diff --git a/.github/Dockerfile b/.github/Dockerfile index ea9be884..7c7f1c75 100644 --- a/.github/Dockerfile +++ b/.github/Dockerfile @@ -4,6 +4,7 @@ LABEL maintainer="mingcheng" COPY . /answer WORKDIR /answer +RUN node -v RUN make install-ui-packages ui && mv ui/build /tmp # stage2 build the main binary within static resource @@ -11,12 +12,14 @@ FROM golang:1.19-alpine AS golang-builder LABEL maintainer="aichy@sf.com" ARG GOPROXY +# ENV GOPROXY ${GOPROXY:-direct} ENV GOPROXY=https://goproxy.io,direct ENV GOPATH /go ENV GOROOT /usr/local/go ENV PACKAGE github.com/answerdev/answer ENV BUILD_DIR ${GOPATH}/src/${PACKAGE} +ENV ANSWER_MODULE ${BUILD_DIR} ARG TAGS="sqlite sqlite_unlock_notify" ENV TAGS "bindata timetzdata $TAGS" @@ -25,9 +28,11 @@ ARG CGO_EXTRA_CFLAGS COPY . ${BUILD_DIR} WORKDIR ${BUILD_DIR} COPY --from=node-builder /tmp/build ${BUILD_DIR}/ui/build -RUN apk --no-cache add build-base git \ - && make clean build \ - && cp answer /usr/bin/answer +RUN apk --no-cache add build-base git bash \ + && make clean build +RUN chmod 755 answer +RUN ["/bin/bash","-c","script/build_plugin.sh"] +RUN cp answer /usr/bin/answer RUN mkdir -p /data/uploads && chmod 777 /data/uploads \ && mkdir -p /data/i18n && cp -r i18n/*.yaml /data/i18n diff --git a/.gitignore b/.gitignore index c762475f..e1f3d8d1 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,6 @@ tmp vendor/ /answer-data/ /answer +/new_answer dist/ diff --git a/Dockerfile b/Dockerfile index abb4e47d..c70786aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,12 +13,14 @@ FROM golang:1.19-alpine AS golang-builder LABEL maintainer="aichy@sf.com" ARG GOPROXY -ENV GOPROXY ${GOPROXY:-direct} +# ENV GOPROXY ${GOPROXY:-direct} +ENV GOPROXY=https://goproxy.io,direct ENV GOPATH /go ENV GOROOT /usr/local/go ENV PACKAGE github.com/answerdev/answer ENV BUILD_DIR ${GOPATH}/src/${PACKAGE} +ENV ANSWER_MODULE ${BUILD_DIR} ARG TAGS="sqlite sqlite_unlock_notify" ENV TAGS "bindata timetzdata $TAGS" @@ -27,9 +29,11 @@ ARG CGO_EXTRA_CFLAGS COPY . ${BUILD_DIR} WORKDIR ${BUILD_DIR} COPY --from=node-builder /tmp/build ${BUILD_DIR}/ui/build -RUN apk --no-cache add build-base git \ - && make clean build \ - && cp answer /usr/bin/answer +RUN apk --no-cache add build-base git bash \ + && make clean build +RUN chmod 755 answer +RUN ["/bin/bash","-c","script/build_plugin.sh"] +RUN cp answer /usr/bin/answer RUN mkdir -p /data/uploads && chmod 777 /data/uploads \ && mkdir -p /data/i18n && cp -r i18n/*.yaml /data/i18n diff --git a/Makefile b/Makefile index 3c69030e..ce3d42c8 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,13 @@ .PHONY: build clean ui -VERSION=1.0.9 +VERSION=1.1.0 BIN=answer DIR_SRC=./cmd/answer DOCKER_CMD=docker GO_ENV=CGO_ENABLED=0 GO111MODULE=on Revision=$(shell git rev-parse --short HEAD) -GO_FLAGS=-ldflags="-X main.Version=$(VERSION) -X 'main.Revision=$(Revision)' -X 'main.Time=`date`' -extldflags -static" +GO_FLAGS=-ldflags="-X github.com/answerdev/answer/cmd.Version=$(VERSION) -X 'github.com/answerdev/answer/cmd.Revision=$(Revision)' -X 'github.com/answerdev/answer/cmd.Time=`date +%s`' -extldflags -static" GO=$(GO_ENV) $(shell which go) build: generate diff --git a/cmd/answer/main.go b/cmd/answer/main.go index 06f8c47b..a1dee485 100644 --- a/cmd/answer/main.go +++ b/cmd/answer/main.go @@ -1,72 +1,12 @@ package main import ( - "os" - "time" - - "github.com/answerdev/answer/internal/base/conf" - "github.com/answerdev/answer/internal/base/constant" - "github.com/answerdev/answer/internal/base/cron" - "github.com/answerdev/answer/internal/cli" - "github.com/answerdev/answer/internal/schema" - "github.com/gin-gonic/gin" - "github.com/segmentfault/pacman" - "github.com/segmentfault/pacman/contrib/log/zap" - "github.com/segmentfault/pacman/contrib/server/http" - "github.com/segmentfault/pacman/log" -) - -// go build -ldflags "-X main.Version=x.y.z" -var ( - // Name is the name of the project - Name = "answer" - // Version is the version of the project - Version = "0.0.0" - // Revision is the git short commit revision number - Revision = "" - // Time is the build time of the project - Time = "" - // log level - logLevel = os.Getenv("LOG_LEVEL") - // log path - logPath = os.Getenv("LOG_PATH") + answercmd "github.com/answerdev/answer/cmd" ) // @securityDefinitions.apikey ApiKeyAuth // @in header // @name Authorization func main() { - log.SetLogger(zap.NewLogger( - log.ParseLevel(logLevel), zap.WithName("answer"), zap.WithPath(logPath), zap.WithCallerFullPath())) - Execute() -} - -func runApp() { - c, err := conf.ReadConfig(cli.GetConfigFilePath()) - if err != nil { - panic(err) - } - conf.GetPathIgnoreList() - app, cleanup, err := initApplication( - c.Debug, c.Server, c.Data.Database, c.Data.Cache, c.I18n, c.Swaggerui, c.ServiceConfig, log.GetLogger()) - if err != nil { - panic(err) - } - constant.Version = Version - constant.Revision = Revision - schema.AppStartTime = time.Now() - - defer cleanup() - if err := app.Run(); err != nil { - panic(err) - } -} - -func newApplication(serverConf *conf.Server, server *gin.Engine, manager *cron.ScheduledTaskManager) *pacman.Application { - manager.Run() - return pacman.NewApp( - pacman.WithName(Name), - pacman.WithVersion(Version), - pacman.WithServer(http.NewServer(server, serverConf.HTTP.Addr)), - ) + answercmd.Main() } diff --git a/cmd/answer/command.go b/cmd/command.go similarity index 75% rename from cmd/answer/command.go rename to cmd/command.go index 1748f4b7..bfcb1414 100644 --- a/cmd/answer/command.go +++ b/cmd/command.go @@ -1,13 +1,15 @@ -package main +package answercmd import ( "fmt" "os" + "strings" "github.com/answerdev/answer/internal/base/conf" "github.com/answerdev/answer/internal/cli" "github.com/answerdev/answer/internal/install" "github.com/answerdev/answer/internal/migrations" + "github.com/answerdev/answer/plugin" "github.com/spf13/cobra" ) @@ -16,6 +18,10 @@ var ( dataDirPath string // dumpDataPath dump data path dumpDataPath string + // plugins needed to build in answer application + buildWithPlugins []string + // build output path + buildOutput string ) func init() { @@ -25,7 +31,11 @@ func init() { dumpCmd.Flags().StringVarP(&dumpDataPath, "path", "p", "./", "dump data path, eg: -p ./dump/data/") - for _, cmd := range []*cobra.Command{initCmd, checkCmd, runCmd, dumpCmd, upgradeCmd} { + buildCmd.Flags().StringSliceVarP(&buildWithPlugins, "with", "w", []string{}, "plugins needed to build") + + buildCmd.Flags().StringVarP(&buildOutput, "output", "o", "", "build output path") + + for _, cmd := range []*cobra.Command{initCmd, checkCmd, runCmd, dumpCmd, upgradeCmd, buildCmd, pluginCmd} { rootCmd.AddCommand(cmd) } } @@ -160,10 +170,44 @@ To run answer, use: fmt.Println("check environment all done") }, } + + // buildCmd used to build another answer with plugins + buildCmd = &cobra.Command{ + Use: "build", + Short: "used to 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")) + err := cli.BuildNewAnswer(buildOutput, buildWithPlugins, cli.OriginalAnswerInfo{ + Version: Version, + Revision: Revision, + Time: Time, + }) + if err != nil { + fmt.Printf("build failed %v", err) + } else { + fmt.Printf("build new answer successfully %s\n", buildOutput) + } + }, + } + + // 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`, + Run: func(_ *cobra.Command, _ []string) { + _ = plugin.CallBase(func(base plugin.Base) error { + info := base.Info() + fmt.Printf("%s[%s] made by %s\n", info.SlugName, info.Version, info.Author) + return nil + }) + }, + } ) // Execute adds all child commands to the root command and sets flags appropriately. -// This is called by main.main(). It only needs to happen once to the rootCmd. +// This is called by main(). It only needs to happen once to the rootCmd. func Execute() { err := rootCmd.Execute() if err != nil { diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 00000000..192832ad --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,75 @@ +package answercmd + +import ( + "fmt" + "os" + "time" + + "github.com/answerdev/answer/internal/base/conf" + "github.com/answerdev/answer/internal/base/constant" + "github.com/answerdev/answer/internal/base/cron" + "github.com/answerdev/answer/internal/cli" + "github.com/answerdev/answer/internal/schema" + "github.com/gin-gonic/gin" + "github.com/segmentfault/pacman" + "github.com/segmentfault/pacman/contrib/log/zap" + "github.com/segmentfault/pacman/contrib/server/http" + "github.com/segmentfault/pacman/log" +) + +// go build -ldflags "-X github.com/answerdev/answer/cmd.Version=x.y.z" +var ( + // Name is the name of the project + Name = "answer" + // Version is the version of the project + Version = "0.0.0" + // Revision is the git short commit revision number + Revision = "-" + // Time is the build time of the project + Time = "-" + // log level + logLevel = os.Getenv("LOG_LEVEL") + // log path + logPath = os.Getenv("LOG_PATH") +) + +// Main +// @securityDefinitions.apikey ApiKeyAuth +// @in header +// @name Authorization +func Main() { + log.SetLogger(zap.NewLogger( + log.ParseLevel(logLevel), zap.WithName("answer"), zap.WithPath(logPath), zap.WithCallerFullPath())) + Execute() +} + +func runApp() { + c, err := conf.ReadConfig(cli.GetConfigFilePath()) + if err != nil { + panic(err) + } + conf.GetPathIgnoreList() + app, cleanup, err := initApplication( + c.Debug, c.Server, c.Data.Database, c.Data.Cache, c.I18n, c.Swaggerui, c.ServiceConfig, log.GetLogger()) + if err != nil { + panic(err) + } + constant.Version = Version + constant.Revision = Revision + schema.AppStartTime = time.Now() + fmt.Println("answer Version:", constant.Version, " Revision:", constant.Revision) + + defer cleanup() + if err := app.Run(); err != nil { + panic(err) + } +} + +func newApplication(serverConf *conf.Server, server *gin.Engine, manager *cron.ScheduledTaskManager) *pacman.Application { + manager.Run() + return pacman.NewApp( + pacman.WithName(Name), + pacman.WithVersion(Version), + pacman.WithServer(http.NewServer(server, serverConf.HTTP.Addr)), + ) +} diff --git a/cmd/answer/wire.go b/cmd/wire.go similarity index 98% rename from cmd/answer/wire.go rename to cmd/wire.go index 90d85635..e8357abd 100644 --- a/cmd/answer/wire.go +++ b/cmd/wire.go @@ -3,7 +3,7 @@ // The build tag makes sure the stub is not built in the final build. -package main +package answercmd import ( "github.com/answerdev/answer/internal/base/conf" diff --git a/cmd/answer/wire_gen.go b/cmd/wire_gen.go similarity index 90% rename from cmd/answer/wire_gen.go rename to cmd/wire_gen.go index bbb6e1ec..e88de43f 100644 --- a/cmd/answer/wire_gen.go +++ b/cmd/wire_gen.go @@ -4,7 +4,7 @@ //go:build !wireinject // +build !wireinject -package main +package answercmd import ( "github.com/answerdev/answer/internal/base/conf" @@ -28,6 +28,7 @@ import ( "github.com/answerdev/answer/internal/repo/export" "github.com/answerdev/answer/internal/repo/meta" "github.com/answerdev/answer/internal/repo/notification" + "github.com/answerdev/answer/internal/repo/plugin_config" "github.com/answerdev/answer/internal/repo/question" "github.com/answerdev/answer/internal/repo/rank" "github.com/answerdev/answer/internal/repo/reason" @@ -40,6 +41,7 @@ import ( "github.com/answerdev/answer/internal/repo/tag_common" "github.com/answerdev/answer/internal/repo/unique" "github.com/answerdev/answer/internal/repo/user" + "github.com/answerdev/answer/internal/repo/user_external_login" "github.com/answerdev/answer/internal/router" "github.com/answerdev/answer/internal/service" "github.com/answerdev/answer/internal/service/action" @@ -57,6 +59,7 @@ import ( notification2 "github.com/answerdev/answer/internal/service/notification" "github.com/answerdev/answer/internal/service/notification_common" "github.com/answerdev/answer/internal/service/object_info" + "github.com/answerdev/answer/internal/service/plugin_common" "github.com/answerdev/answer/internal/service/question_common" rank2 "github.com/answerdev/answer/internal/service/rank" reason2 "github.com/answerdev/answer/internal/service/reason" @@ -74,6 +77,7 @@ import ( "github.com/answerdev/answer/internal/service/uploader" "github.com/answerdev/answer/internal/service/user_admin" "github.com/answerdev/answer/internal/service/user_common" + user_external_login2 "github.com/answerdev/answer/internal/service/user_external_login" "github.com/segmentfault/pacman" "github.com/segmentfault/pacman/log" ) @@ -117,8 +121,10 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, roleRepo := role.NewRoleRepo(dataData) roleService := role2.NewRoleService(roleRepo) userRoleRelService := role2.NewUserRoleRelService(userRoleRelRepo, roleService) - userCommon := usercommon.NewUserCommon(userRepo) - userService := service.NewUserService(userRepo, userActiveActivityRepo, activityRepo, emailService, authService, serviceConf, siteInfoCommonService, userRoleRelService, userCommon) + userCommon := usercommon.NewUserCommon(userRepo, userRoleRelService, authService) + userExternalLoginRepo := user_external_login.NewUserExternalLoginRepo(dataData) + userExternalLoginService := user_external_login2.NewUserExternalLoginService(userRepo, userCommon, userExternalLoginRepo, emailService, siteInfoCommonService, userActiveActivityRepo) + userService := service.NewUserService(userRepo, userActiveActivityRepo, activityRepo, emailService, authService, serviceConf, siteInfoCommonService, userRoleRelService, userCommon, userExternalLoginService) captchaRepo := captcha.NewCaptchaRepo(dataData) captchaService := action.NewCaptchaService(captchaRepo) uploaderService := uploader.NewUploaderService(serviceConf, siteInfoCommonService) @@ -187,7 +193,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) + siteInfoService := siteinfo.NewSiteInfoService(siteInfoRepo, siteInfoCommonService, emailService, tagCommonService, configRepo) siteInfoController := controller_admin.NewSiteInfoController(siteInfoService) siteinfoController := controller.NewSiteinfoController(siteInfoCommonService) notificationRepo := notification.NewNotificationRepo(dataData) @@ -202,7 +208,10 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, activityService := activity2.NewActivityService(activityActivityRepo, userCommon, activityCommon, tagCommonService, objService, commentCommonService, revisionService, metaService) activityController := controller.NewActivityController(activityCommon, activityService) roleController := controller_admin.NewRoleController(roleService) - answerAPIRouter := router.NewAnswerAPIRouter(langController, userController, commentController, reportController, voteController, tagController, followController, collectionController, questionController, answerController, searchController, revisionController, rankController, controller_adminReportController, userAdminController, reasonController, themeController, siteInfoController, siteinfoController, notificationController, dashboardController, uploadController, activityController, roleController) + pluginConfigRepo := plugin_config.NewPluginConfigRepo(dataData) + pluginCommonService := plugin_common.NewPluginCommonService(pluginConfigRepo, configRepo) + pluginController := controller_admin.NewPluginController(pluginCommonService) + answerAPIRouter := router.NewAnswerAPIRouter(langController, userController, commentController, reportController, voteController, tagController, followController, collectionController, questionController, answerController, searchController, revisionController, rankController, controller_adminReportController, userAdminController, reasonController, themeController, siteInfoController, siteinfoController, notificationController, dashboardController, uploadController, activityController, roleController, pluginController) swaggerRouter := router.NewSwaggerRouter(swaggerConf) uiRouter := router.NewUIRouter(siteinfoController, siteInfoCommonService) authUserMiddleware := middleware.NewAuthUserMiddleware(authService, siteInfoCommonService) @@ -210,7 +219,11 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, templateRenderController := templaterender.NewTemplateRenderController(questionService, userService, tagService, answerService, commentService, dataData, siteInfoCommonService) templateController := controller.NewTemplateController(templateRenderController, siteInfoCommonService) templateRouter := router.NewTemplateRouter(templateController, templateRenderController, siteInfoController) - ginEngine := server.NewHTTPServer(debug, staticRouter, answerAPIRouter, swaggerRouter, uiRouter, authUserMiddleware, avatarMiddleware, templateRouter) + connectorController := controller.NewConnectorController(siteInfoCommonService, emailService, userExternalLoginService) + userCenterLoginService := user_external_login2.NewUserCenterLoginService(userRepo, userCommon, userExternalLoginRepo, userActiveActivityRepo, siteInfoCommonService) + userCenterController := controller.NewUserCenterController(userCenterLoginService, siteInfoCommonService) + pluginAPIRouter := router.NewPluginAPIRouter(connectorController, userCenterController) + ginEngine := server.NewHTTPServer(debug, staticRouter, answerAPIRouter, swaggerRouter, uiRouter, authUserMiddleware, avatarMiddleware, templateRouter, pluginAPIRouter) scheduledTaskManager := cron.NewScheduledTaskManager(siteInfoCommonService, questionService) application := newApplication(serverConf, ginEngine, scheduledTaskManager) return application, func() { diff --git a/configs/path_ignore.yaml b/configs/path_ignore.yaml index 4ecb1237..03d308a9 100644 --- a/configs/path_ignore.yaml +++ b/configs/path_ignore.yaml @@ -1,5 +1,6 @@ # url path reserves the keywords list users: + - unsubscribe - settings - login - register @@ -9,5 +10,7 @@ users: - account-activation - confirm-new-email - account-suspended + - confirm-email + - auth-landing questions: - ask diff --git a/docs/docs.go b/docs/docs.go index f0954b62..3c593476 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -183,6 +183,185 @@ const docTemplate = `{ } } }, + "/answer/admin/api/plugin/config": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get plugin config", + "produces": [ + "application/json" + ], + "tags": [ + "AdminPlugin" + ], + "summary": "get plugin config", + "parameters": [ + { + "type": "string", + "description": "plugin_slug_name", + "name": "plugin_slug_name", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetPluginConfigResp" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update plugin config", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AdminPlugin" + ], + "summary": "update plugin config", + "parameters": [ + { + "description": "UpdatePluginConfigReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.UpdatePluginConfigReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/plugin/status": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update plugin status", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AdminPlugin" + ], + "summary": "update plugin status", + "parameters": [ + { + "description": "UpdatePluginStatusReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.UpdatePluginStatusReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/plugins": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get plugin list", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AdminPlugin" + ], + "summary": "get plugin list", + "parameters": [ + { + "type": "string", + "description": "status: active/inactive", + "name": "status", + "in": "query" + }, + { + "type": "boolean", + "description": "have config", + "name": "have_config", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetPluginListResp" + } + } + } + } + ] + } + } + } + } + }, "/answer/admin/api/question/page": { "get": { "security": [ @@ -483,6 +662,77 @@ const docTemplate = `{ } } }, + "/answer/admin/api/setting/privileges": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "GetPrivilegesConfig get privileges config", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "GetPrivilegesConfig get privileges config", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetPrivilegesConfigResp" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update privileges config", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "update privileges config", + "parameters": [ + { + "description": "config", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.UpdatePrivilegesConfigReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, "/answer/admin/api/setting/smtp": { "get": { "security": [ @@ -1122,6 +1372,77 @@ const docTemplate = `{ } } }, + "/answer/admin/api/siteinfo/users": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get site user config", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "get site user config", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.SiteUsersResp" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update site info config about users", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "update site info config about users", + "parameters": [ + { + "description": "users info", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.SiteUsersReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, "/answer/admin/api/siteinfo/write": { "get": { "security": [ @@ -2117,6 +2438,171 @@ const docTemplate = `{ } } }, + "/answer/api/v1/connector/binding/email": { + "post": { + "description": "external login binding user send email", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "PluginConnector" + ], + "summary": "external login binding user send email", + "parameters": [ + { + "description": "external login binding user send email", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.ExternalLoginBindingUserSendEmailReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.ExternalLoginBindingUserSendEmailResp" + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/connector/info": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get all enabled connectors", + "produces": [ + "application/json" + ], + "tags": [ + "PluginConnector" + ], + "summary": "get all enabled connectors", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ConnectorInfoResp" + } + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/connector/user/info": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get all connectors info about user", + "produces": [ + "application/json" + ], + "tags": [ + "PluginConnector" + ], + "summary": "get all connectors info about user", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ConnectorUserInfoResp" + } + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/connector/user/unbinding": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "unbind external user login", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "PluginConnector" + ], + "summary": "unbind external user login", + "parameters": [ + { + "description": "ExternalLoginUnbindingReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.ExternalLoginUnbindingReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, "/answer/api/v1/file": { "post": { "security": [ @@ -2517,7 +3003,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "UserAnswerList", + "description": "list personal answers", "consumes": [ "application/json" ], @@ -2525,9 +3011,9 @@ const docTemplate = `{ "application/json" ], "tags": [ - "api-answer" + "Personal" ], - "summary": "UserAnswerList", + "summary": "list personal answers", "parameters": [ { "type": "string", @@ -2559,8 +3045,8 @@ const docTemplate = `{ { "type": "string", "default": "20", - "description": "pagesize", - "name": "pagesize", + "description": "page_size", + "name": "page_size", "in": "query", "required": true } @@ -2582,7 +3068,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "UserCollectionList", + "description": "list personal collections", "consumes": [ "application/json" ], @@ -2592,7 +3078,7 @@ const docTemplate = `{ "tags": [ "Collection" ], - "summary": "UserCollectionList", + "summary": "list personal collections", "parameters": [ { "type": "string", @@ -2605,8 +3091,8 @@ const docTemplate = `{ { "type": "string", "default": "20", - "description": "pagesize", - "name": "pagesize", + "description": "page_size", + "name": "page_size", "in": "query", "required": true } @@ -4803,12 +5289,12 @@ const docTemplate = `{ "summary": "UserModifyPassWord", "parameters": [ { - "description": "UserModifyPassWordRequest", + "description": "UserModifyPasswordReq", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.UserModifyPassWordRequest" + "$ref": "#/definitions/schema.UserModifyPasswordReq" } } ], @@ -5324,7 +5810,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "UserList", + "description": "list personal questions", "consumes": [ "application/json" ], @@ -5332,9 +5818,9 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Question" + "Personal" ], - "summary": "UserList", + "summary": "list personal questions", "parameters": [ { "type": "string", @@ -5366,8 +5852,8 @@ const docTemplate = `{ { "type": "string", "default": "20", - "description": "pagesize", - "name": "pagesize", + "description": "page_size", + "name": "page_size", "in": "query", "required": true } @@ -5404,6 +5890,20 @@ const docTemplate = `{ } }, "definitions": { + "constant.Privilege": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "label": { + "type": "string" + }, + "value": { + "type": "integer" + } + } + }, "handler.RespBody": { "type": "object", "properties": { @@ -5844,6 +6344,151 @@ const docTemplate = `{ } } }, + "schema.ConfigField": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ConfigFieldOption" + } + }, + "required": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "ui_options": { + "$ref": "#/definitions/schema.ConfigFieldUIOptions" + }, + "value": {} + } + }, + "schema.ConfigFieldOption": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "schema.ConfigFieldUIOptions": { + "type": "object", + "properties": { + "action": { + "$ref": "#/definitions/schema.UIOptionAction" + }, + "input_type": { + "type": "string" + }, + "label": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "rows": { + "type": "string" + }, + "text": { + "type": "string" + }, + "variant": { + "type": "string" + } + } + }, + "schema.ConnectorInfoResp": { + "type": "object", + "properties": { + "icon": { + "type": "string" + }, + "link": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "schema.ConnectorUserInfoResp": { + "type": "object", + "properties": { + "binding": { + "type": "boolean" + }, + "external_id": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "link": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "schema.ExternalLoginBindingUserSendEmailReq": { + "type": "object", + "required": [ + "binding_key", + "email" + ], + "properties": { + "binding_key": { + "type": "string", + "maxLength": 100 + }, + "email": { + "type": "string", + "maxLength": 512 + }, + "must": { + "description": "If must is true, whatever email if exists, try to bind user.\nIf must is false, when email exist, will only be prompted with a warning.", + "type": "boolean" + } + } + }, + "schema.ExternalLoginBindingUserSendEmailResp": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "email_exist_and_must_be_confirmed": { + "type": "boolean" + } + } + }, + "schema.ExternalLoginUnbindingReq": { + "type": "object", + "required": [ + "external_id" + ], + "properties": { + "external_id": { + "type": "string", + "maxLength": 128 + } + } + }, "schema.FollowReq": { "type": "object", "required": [ @@ -6120,6 +6765,69 @@ const docTemplate = `{ } } }, + "schema.GetPluginConfigResp": { + "type": "object", + "properties": { + "config_fields": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ConfigField" + } + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug_name": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "schema.GetPluginListResp": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "have_config": { + "type": "boolean" + }, + "link": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug_name": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "schema.GetPrivilegesConfigResp": { + "type": "object", + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.PrivilegeOption" + } + }, + "selected_level": { + "type": "integer" + } + } + }, "schema.GetRankPersonalWithPageResp": { "type": "object", "properties": { @@ -6556,6 +7264,10 @@ const docTemplate = `{ "description": "follow count", "type": "integer" }, + "have_password": { + "description": "user have password", + "type": "boolean" + }, "id": { "description": "user id", "type": "string" @@ -6656,6 +7368,9 @@ const docTemplate = `{ "description": "follow count", "type": "integer" }, + "have_password": { + "type": "boolean" + }, "id": { "description": "user id", "type": "string" @@ -6761,6 +7476,17 @@ const docTemplate = `{ } } }, + "schema.LoadingAction": { + "type": "object", + "properties": { + "state": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, "schema.NotificationClearIDRequest": { "type": "object", "properties": { @@ -6778,6 +7504,17 @@ const docTemplate = `{ } } }, + "schema.OnCompleteAction": { + "type": "object", + "properties": { + "refresh_form_config": { + "type": "boolean" + }, + "toast_return_message": { + "type": "boolean" + } + } + }, "schema.OperationQuestionReq": { "type": "object", "required": [ @@ -6815,6 +7552,23 @@ const docTemplate = `{ } } }, + "schema.PrivilegeOption": { + "type": "object", + "properties": { + "level": { + "type": "integer" + }, + "level_desc": { + "type": "string" + }, + "privileges": { + "type": "array", + "items": { + "$ref": "#/definitions/constant.Privilege" + } + } + } + }, "schema.QuestionAdd": { "type": "object", "required": [ @@ -6882,6 +7636,10 @@ const docTemplate = `{ "schema.QuestionPageReq": { "type": "object", "properties": { + "inDays": { + "type": "integer", + "minimum": 1 + }, "orderCond": { "type": "string", "enum": [ @@ -7264,6 +8022,10 @@ const docTemplate = `{ "custom_header": { "type": "string", "maxLength": 65536 + }, + "custom_sidebar": { + "type": "string", + "maxLength": 65536 } } }, @@ -7285,6 +8047,10 @@ const docTemplate = `{ "custom_header": { "type": "string", "maxLength": 65536 + }, + "custom_sidebar": { + "type": "string", + "maxLength": 65536 } } }, @@ -7372,6 +8138,9 @@ const docTemplate = `{ "site_seo": { "$ref": "#/definitions/schema.SiteSeoReq" }, + "site_users": { + "$ref": "#/definitions/schema.SiteUsersResp" + }, "theme": { "$ref": "#/definitions/schema.SiteThemeResp" }, @@ -7383,18 +8152,10 @@ const docTemplate = `{ "schema.SiteInterfaceReq": { "type": "object", "required": [ - "default_avatar", "language", "time_zone" ], "properties": { - "default_avatar": { - "type": "string", - "enum": [ - "system", - "gravatar" - ] - }, "language": { "type": "string", "maxLength": 128 @@ -7408,18 +8169,10 @@ const docTemplate = `{ "schema.SiteInterfaceResp": { "type": "object", "required": [ - "default_avatar", "language", "time_zone" ], "properties": { - "default_avatar": { - "type": "string", - "enum": [ - "system", - "gravatar" - ] - }, "language": { "type": "string", "maxLength": 128 @@ -7467,6 +8220,15 @@ const docTemplate = `{ "schema.SiteLoginReq": { "type": "object", "properties": { + "allow_email_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "allow_email_registrations": { + "type": "boolean" + }, "allow_new_registrations": { "type": "boolean" }, @@ -7478,6 +8240,15 @@ const docTemplate = `{ "schema.SiteLoginResp": { "type": "object", "properties": { + "allow_email_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "allow_email_registrations": { + "type": "boolean" + }, "allow_new_registrations": { "type": "boolean" }, @@ -7554,6 +8325,72 @@ const docTemplate = `{ } } }, + "schema.SiteUsersReq": { + "type": "object", + "required": [ + "default_avatar" + ], + "properties": { + "allow_update_avatar": { + "type": "boolean" + }, + "allow_update_bio": { + "type": "boolean" + }, + "allow_update_display_name": { + "type": "boolean" + }, + "allow_update_location": { + "type": "boolean" + }, + "allow_update_username": { + "type": "boolean" + }, + "allow_update_website": { + "type": "boolean" + }, + "default_avatar": { + "type": "string", + "enum": [ + "system", + "gravatar" + ] + } + } + }, + "schema.SiteUsersResp": { + "type": "object", + "required": [ + "default_avatar" + ], + "properties": { + "allow_update_avatar": { + "type": "boolean" + }, + "allow_update_bio": { + "type": "boolean" + }, + "allow_update_display_name": { + "type": "boolean" + }, + "allow_update_location": { + "type": "boolean" + }, + "allow_update_username": { + "type": "boolean" + }, + "allow_update_website": { + "type": "boolean" + }, + "default_avatar": { + "type": "string", + "enum": [ + "system", + "gravatar" + ] + } + } + }, "schema.SiteWriteReq": { "type": "object", "properties": { @@ -7666,6 +8503,23 @@ const docTemplate = `{ } } }, + "schema.UIOptionAction": { + "type": "object", + "properties": { + "loading": { + "$ref": "#/definitions/schema.LoadingAction" + }, + "method": { + "type": "string" + }, + "on_complete": { + "$ref": "#/definitions/schema.OnCompleteAction" + }, + "url": { + "type": "string" + } + } + }, "schema.UnreviewedRevisionInfoInfo": { "type": "object", "properties": { @@ -7722,9 +8576,6 @@ const docTemplate = `{ }, "schema.UpdateInfoRequest": { "type": "object", - "required": [ - "display_name" - ], "properties": { "avatar": { "description": "avatar", @@ -7757,6 +8608,50 @@ const docTemplate = `{ } } }, + "schema.UpdatePluginConfigReq": { + "type": "object", + "required": [ + "plugin_slug_name" + ], + "properties": { + "config_fields": { + "type": "object", + "additionalProperties": {} + }, + "plugin_slug_name": { + "type": "string", + "maxLength": 100 + } + } + }, + "schema.UpdatePluginStatusReq": { + "type": "object", + "required": [ + "plugin_slug_name" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "plugin_slug_name": { + "type": "string", + "maxLength": 100 + } + } + }, + "schema.UpdatePrivilegesConfigReq": { + "type": "object", + "required": [ + "level" + ], + "properties": { + "level": { + "type": "integer", + "maximum": 3, + "minimum": 1 + } + } + }, "schema.UpdateSMTPConfigReq": { "type": "object", "properties": { @@ -7976,6 +8871,11 @@ const docTemplate = `{ "e_mail": { "type": "string", "maxLength": 500 + }, + "pass": { + "type": "string", + "maxLength": 32, + "minLength": 8 } } }, @@ -8019,16 +8919,21 @@ const docTemplate = `{ } } }, - "schema.UserModifyPassWordRequest": { + "schema.UserModifyPasswordReq": { "type": "object", + "required": [ + "pass" + ], "properties": { "old_pass": { - "description": "old password", - "type": "string" + "type": "string", + "maxLength": 32, + "minLength": 8 }, "pass": { - "description": "password", - "type": "string" + "type": "string", + "maxLength": 32, + "minLength": 8 } } }, diff --git a/docs/swagger.json b/docs/swagger.json index 124e77ba..963d6fe7 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -171,6 +171,185 @@ } } }, + "/answer/admin/api/plugin/config": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get plugin config", + "produces": [ + "application/json" + ], + "tags": [ + "AdminPlugin" + ], + "summary": "get plugin config", + "parameters": [ + { + "type": "string", + "description": "plugin_slug_name", + "name": "plugin_slug_name", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetPluginConfigResp" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update plugin config", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AdminPlugin" + ], + "summary": "update plugin config", + "parameters": [ + { + "description": "UpdatePluginConfigReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.UpdatePluginConfigReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/plugin/status": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update plugin status", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AdminPlugin" + ], + "summary": "update plugin status", + "parameters": [ + { + "description": "UpdatePluginStatusReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.UpdatePluginStatusReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/plugins": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get plugin list", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AdminPlugin" + ], + "summary": "get plugin list", + "parameters": [ + { + "type": "string", + "description": "status: active/inactive", + "name": "status", + "in": "query" + }, + { + "type": "boolean", + "description": "have config", + "name": "have_config", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetPluginListResp" + } + } + } + } + ] + } + } + } + } + }, "/answer/admin/api/question/page": { "get": { "security": [ @@ -471,6 +650,77 @@ } } }, + "/answer/admin/api/setting/privileges": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "GetPrivilegesConfig get privileges config", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "GetPrivilegesConfig get privileges config", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetPrivilegesConfigResp" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update privileges config", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "update privileges config", + "parameters": [ + { + "description": "config", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.UpdatePrivilegesConfigReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, "/answer/admin/api/setting/smtp": { "get": { "security": [ @@ -1110,6 +1360,77 @@ } } }, + "/answer/admin/api/siteinfo/users": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get site user config", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "get site user config", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.SiteUsersResp" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update site info config about users", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "update site info config about users", + "parameters": [ + { + "description": "users info", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.SiteUsersReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, "/answer/admin/api/siteinfo/write": { "get": { "security": [ @@ -2105,6 +2426,171 @@ } } }, + "/answer/api/v1/connector/binding/email": { + "post": { + "description": "external login binding user send email", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "PluginConnector" + ], + "summary": "external login binding user send email", + "parameters": [ + { + "description": "external login binding user send email", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.ExternalLoginBindingUserSendEmailReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.ExternalLoginBindingUserSendEmailResp" + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/connector/info": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get all enabled connectors", + "produces": [ + "application/json" + ], + "tags": [ + "PluginConnector" + ], + "summary": "get all enabled connectors", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ConnectorInfoResp" + } + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/connector/user/info": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get all connectors info about user", + "produces": [ + "application/json" + ], + "tags": [ + "PluginConnector" + ], + "summary": "get all connectors info about user", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ConnectorUserInfoResp" + } + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/connector/user/unbinding": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "unbind external user login", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "PluginConnector" + ], + "summary": "unbind external user login", + "parameters": [ + { + "description": "ExternalLoginUnbindingReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.ExternalLoginUnbindingReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, "/answer/api/v1/file": { "post": { "security": [ @@ -2505,7 +2991,7 @@ "ApiKeyAuth": [] } ], - "description": "UserAnswerList", + "description": "list personal answers", "consumes": [ "application/json" ], @@ -2513,9 +2999,9 @@ "application/json" ], "tags": [ - "api-answer" + "Personal" ], - "summary": "UserAnswerList", + "summary": "list personal answers", "parameters": [ { "type": "string", @@ -2547,8 +3033,8 @@ { "type": "string", "default": "20", - "description": "pagesize", - "name": "pagesize", + "description": "page_size", + "name": "page_size", "in": "query", "required": true } @@ -2570,7 +3056,7 @@ "ApiKeyAuth": [] } ], - "description": "UserCollectionList", + "description": "list personal collections", "consumes": [ "application/json" ], @@ -2580,7 +3066,7 @@ "tags": [ "Collection" ], - "summary": "UserCollectionList", + "summary": "list personal collections", "parameters": [ { "type": "string", @@ -2593,8 +3079,8 @@ { "type": "string", "default": "20", - "description": "pagesize", - "name": "pagesize", + "description": "page_size", + "name": "page_size", "in": "query", "required": true } @@ -4791,12 +5277,12 @@ "summary": "UserModifyPassWord", "parameters": [ { - "description": "UserModifyPassWordRequest", + "description": "UserModifyPasswordReq", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.UserModifyPassWordRequest" + "$ref": "#/definitions/schema.UserModifyPasswordReq" } } ], @@ -5312,7 +5798,7 @@ "ApiKeyAuth": [] } ], - "description": "UserList", + "description": "list personal questions", "consumes": [ "application/json" ], @@ -5320,9 +5806,9 @@ "application/json" ], "tags": [ - "Question" + "Personal" ], - "summary": "UserList", + "summary": "list personal questions", "parameters": [ { "type": "string", @@ -5354,8 +5840,8 @@ { "type": "string", "default": "20", - "description": "pagesize", - "name": "pagesize", + "description": "page_size", + "name": "page_size", "in": "query", "required": true } @@ -5392,6 +5878,20 @@ } }, "definitions": { + "constant.Privilege": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "label": { + "type": "string" + }, + "value": { + "type": "integer" + } + } + }, "handler.RespBody": { "type": "object", "properties": { @@ -5832,6 +6332,151 @@ } } }, + "schema.ConfigField": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ConfigFieldOption" + } + }, + "required": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "ui_options": { + "$ref": "#/definitions/schema.ConfigFieldUIOptions" + }, + "value": {} + } + }, + "schema.ConfigFieldOption": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "schema.ConfigFieldUIOptions": { + "type": "object", + "properties": { + "action": { + "$ref": "#/definitions/schema.UIOptionAction" + }, + "input_type": { + "type": "string" + }, + "label": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "rows": { + "type": "string" + }, + "text": { + "type": "string" + }, + "variant": { + "type": "string" + } + } + }, + "schema.ConnectorInfoResp": { + "type": "object", + "properties": { + "icon": { + "type": "string" + }, + "link": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "schema.ConnectorUserInfoResp": { + "type": "object", + "properties": { + "binding": { + "type": "boolean" + }, + "external_id": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "link": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "schema.ExternalLoginBindingUserSendEmailReq": { + "type": "object", + "required": [ + "binding_key", + "email" + ], + "properties": { + "binding_key": { + "type": "string", + "maxLength": 100 + }, + "email": { + "type": "string", + "maxLength": 512 + }, + "must": { + "description": "If must is true, whatever email if exists, try to bind user.\nIf must is false, when email exist, will only be prompted with a warning.", + "type": "boolean" + } + } + }, + "schema.ExternalLoginBindingUserSendEmailResp": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "email_exist_and_must_be_confirmed": { + "type": "boolean" + } + } + }, + "schema.ExternalLoginUnbindingReq": { + "type": "object", + "required": [ + "external_id" + ], + "properties": { + "external_id": { + "type": "string", + "maxLength": 128 + } + } + }, "schema.FollowReq": { "type": "object", "required": [ @@ -6108,6 +6753,69 @@ } } }, + "schema.GetPluginConfigResp": { + "type": "object", + "properties": { + "config_fields": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ConfigField" + } + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug_name": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "schema.GetPluginListResp": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "have_config": { + "type": "boolean" + }, + "link": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug_name": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "schema.GetPrivilegesConfigResp": { + "type": "object", + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.PrivilegeOption" + } + }, + "selected_level": { + "type": "integer" + } + } + }, "schema.GetRankPersonalWithPageResp": { "type": "object", "properties": { @@ -6544,6 +7252,10 @@ "description": "follow count", "type": "integer" }, + "have_password": { + "description": "user have password", + "type": "boolean" + }, "id": { "description": "user id", "type": "string" @@ -6644,6 +7356,9 @@ "description": "follow count", "type": "integer" }, + "have_password": { + "type": "boolean" + }, "id": { "description": "user id", "type": "string" @@ -6749,6 +7464,17 @@ } } }, + "schema.LoadingAction": { + "type": "object", + "properties": { + "state": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, "schema.NotificationClearIDRequest": { "type": "object", "properties": { @@ -6766,6 +7492,17 @@ } } }, + "schema.OnCompleteAction": { + "type": "object", + "properties": { + "refresh_form_config": { + "type": "boolean" + }, + "toast_return_message": { + "type": "boolean" + } + } + }, "schema.OperationQuestionReq": { "type": "object", "required": [ @@ -6803,6 +7540,23 @@ } } }, + "schema.PrivilegeOption": { + "type": "object", + "properties": { + "level": { + "type": "integer" + }, + "level_desc": { + "type": "string" + }, + "privileges": { + "type": "array", + "items": { + "$ref": "#/definitions/constant.Privilege" + } + } + } + }, "schema.QuestionAdd": { "type": "object", "required": [ @@ -6870,6 +7624,10 @@ "schema.QuestionPageReq": { "type": "object", "properties": { + "inDays": { + "type": "integer", + "minimum": 1 + }, "orderCond": { "type": "string", "enum": [ @@ -7252,6 +8010,10 @@ "custom_header": { "type": "string", "maxLength": 65536 + }, + "custom_sidebar": { + "type": "string", + "maxLength": 65536 } } }, @@ -7273,6 +8035,10 @@ "custom_header": { "type": "string", "maxLength": 65536 + }, + "custom_sidebar": { + "type": "string", + "maxLength": 65536 } } }, @@ -7360,6 +8126,9 @@ "site_seo": { "$ref": "#/definitions/schema.SiteSeoReq" }, + "site_users": { + "$ref": "#/definitions/schema.SiteUsersResp" + }, "theme": { "$ref": "#/definitions/schema.SiteThemeResp" }, @@ -7371,18 +8140,10 @@ "schema.SiteInterfaceReq": { "type": "object", "required": [ - "default_avatar", "language", "time_zone" ], "properties": { - "default_avatar": { - "type": "string", - "enum": [ - "system", - "gravatar" - ] - }, "language": { "type": "string", "maxLength": 128 @@ -7396,18 +8157,10 @@ "schema.SiteInterfaceResp": { "type": "object", "required": [ - "default_avatar", "language", "time_zone" ], "properties": { - "default_avatar": { - "type": "string", - "enum": [ - "system", - "gravatar" - ] - }, "language": { "type": "string", "maxLength": 128 @@ -7455,6 +8208,15 @@ "schema.SiteLoginReq": { "type": "object", "properties": { + "allow_email_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "allow_email_registrations": { + "type": "boolean" + }, "allow_new_registrations": { "type": "boolean" }, @@ -7466,6 +8228,15 @@ "schema.SiteLoginResp": { "type": "object", "properties": { + "allow_email_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "allow_email_registrations": { + "type": "boolean" + }, "allow_new_registrations": { "type": "boolean" }, @@ -7542,6 +8313,72 @@ } } }, + "schema.SiteUsersReq": { + "type": "object", + "required": [ + "default_avatar" + ], + "properties": { + "allow_update_avatar": { + "type": "boolean" + }, + "allow_update_bio": { + "type": "boolean" + }, + "allow_update_display_name": { + "type": "boolean" + }, + "allow_update_location": { + "type": "boolean" + }, + "allow_update_username": { + "type": "boolean" + }, + "allow_update_website": { + "type": "boolean" + }, + "default_avatar": { + "type": "string", + "enum": [ + "system", + "gravatar" + ] + } + } + }, + "schema.SiteUsersResp": { + "type": "object", + "required": [ + "default_avatar" + ], + "properties": { + "allow_update_avatar": { + "type": "boolean" + }, + "allow_update_bio": { + "type": "boolean" + }, + "allow_update_display_name": { + "type": "boolean" + }, + "allow_update_location": { + "type": "boolean" + }, + "allow_update_username": { + "type": "boolean" + }, + "allow_update_website": { + "type": "boolean" + }, + "default_avatar": { + "type": "string", + "enum": [ + "system", + "gravatar" + ] + } + } + }, "schema.SiteWriteReq": { "type": "object", "properties": { @@ -7654,6 +8491,23 @@ } } }, + "schema.UIOptionAction": { + "type": "object", + "properties": { + "loading": { + "$ref": "#/definitions/schema.LoadingAction" + }, + "method": { + "type": "string" + }, + "on_complete": { + "$ref": "#/definitions/schema.OnCompleteAction" + }, + "url": { + "type": "string" + } + } + }, "schema.UnreviewedRevisionInfoInfo": { "type": "object", "properties": { @@ -7710,9 +8564,6 @@ }, "schema.UpdateInfoRequest": { "type": "object", - "required": [ - "display_name" - ], "properties": { "avatar": { "description": "avatar", @@ -7745,6 +8596,50 @@ } } }, + "schema.UpdatePluginConfigReq": { + "type": "object", + "required": [ + "plugin_slug_name" + ], + "properties": { + "config_fields": { + "type": "object", + "additionalProperties": {} + }, + "plugin_slug_name": { + "type": "string", + "maxLength": 100 + } + } + }, + "schema.UpdatePluginStatusReq": { + "type": "object", + "required": [ + "plugin_slug_name" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "plugin_slug_name": { + "type": "string", + "maxLength": 100 + } + } + }, + "schema.UpdatePrivilegesConfigReq": { + "type": "object", + "required": [ + "level" + ], + "properties": { + "level": { + "type": "integer", + "maximum": 3, + "minimum": 1 + } + } + }, "schema.UpdateSMTPConfigReq": { "type": "object", "properties": { @@ -7964,6 +8859,11 @@ "e_mail": { "type": "string", "maxLength": 500 + }, + "pass": { + "type": "string", + "maxLength": 32, + "minLength": 8 } } }, @@ -8007,16 +8907,21 @@ } } }, - "schema.UserModifyPassWordRequest": { + "schema.UserModifyPasswordReq": { "type": "object", + "required": [ + "pass" + ], "properties": { "old_pass": { - "description": "old password", - "type": "string" + "type": "string", + "maxLength": 32, + "minLength": 8 }, "pass": { - "description": "password", - "type": "string" + "type": "string", + "maxLength": 32, + "minLength": 8 } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 902d037f..cb011241 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,4 +1,13 @@ definitions: + constant.Privilege: + properties: + key: + type: string + label: + type: string + value: + type: integer + type: object handler.RespBody: properties: code: @@ -305,6 +314,104 @@ definitions: switch: type: boolean type: object + schema.ConfigField: + properties: + description: + type: string + name: + type: string + options: + items: + $ref: '#/definitions/schema.ConfigFieldOption' + type: array + required: + type: boolean + title: + type: string + type: + type: string + ui_options: + $ref: '#/definitions/schema.ConfigFieldUIOptions' + value: {} + type: object + schema.ConfigFieldOption: + properties: + label: + type: string + value: + type: string + type: object + schema.ConfigFieldUIOptions: + properties: + action: + $ref: '#/definitions/schema.UIOptionAction' + input_type: + type: string + label: + type: string + placeholder: + type: string + rows: + type: string + text: + type: string + variant: + type: string + type: object + schema.ConnectorInfoResp: + properties: + icon: + type: string + link: + type: string + name: + type: string + type: object + schema.ConnectorUserInfoResp: + properties: + binding: + type: boolean + external_id: + type: string + icon: + type: string + link: + type: string + name: + type: string + type: object + schema.ExternalLoginBindingUserSendEmailReq: + properties: + binding_key: + maxLength: 100 + type: string + email: + maxLength: 512 + type: string + must: + description: |- + If must is true, whatever email if exists, try to bind user. + If must is false, when email exist, will only be prompted with a warning. + type: boolean + required: + - binding_key + - email + type: object + schema.ExternalLoginBindingUserSendEmailResp: + properties: + access_token: + type: string + email_exist_and_must_be_confirmed: + type: boolean + type: object + schema.ExternalLoginUnbindingReq: + properties: + external_id: + maxLength: 128 + type: string + required: + - external_id + type: object schema.FollowReq: properties: is_cancel: @@ -507,6 +614,47 @@ definitions: info: $ref: '#/definitions/schema.GetOtherUserInfoByUsernameResp' type: object + schema.GetPluginConfigResp: + properties: + config_fields: + items: + $ref: '#/definitions/schema.ConfigField' + type: array + description: + type: string + name: + type: string + slug_name: + type: string + version: + type: string + type: object + schema.GetPluginListResp: + properties: + description: + type: string + enabled: + type: boolean + have_config: + type: boolean + link: + type: string + name: + type: string + slug_name: + type: string + version: + type: string + type: object + schema.GetPrivilegesConfigResp: + properties: + options: + items: + $ref: '#/definitions/schema.PrivilegeOption' + type: array + selected_level: + type: integer + type: object schema.GetRankPersonalWithPageResp: properties: answer_id: @@ -820,6 +968,9 @@ definitions: follow_count: description: follow count type: integer + have_password: + description: user have password + type: boolean id: description: user id type: string @@ -894,6 +1045,8 @@ definitions: follow_count: description: follow count type: integer + have_password: + type: boolean id: description: user id type: string @@ -972,6 +1125,13 @@ definitions: description: vote type type: string type: object + schema.LoadingAction: + properties: + state: + type: string + text: + type: string + type: object schema.NotificationClearIDRequest: properties: id: @@ -983,6 +1143,13 @@ definitions: description: inbox achievement type: string type: object + schema.OnCompleteAction: + properties: + refresh_form_config: + type: boolean + toast_return_message: + type: boolean + type: object schema.OperationQuestionReq: properties: id: @@ -1007,6 +1174,17 @@ definitions: content: type: string type: object + schema.PrivilegeOption: + properties: + level: + type: integer + level_desc: + type: string + privileges: + items: + $ref: '#/definitions/constant.Privilege' + type: array + type: object schema.QuestionAdd: properties: content: @@ -1058,6 +1236,9 @@ definitions: type: object schema.QuestionPageReq: properties: + inDays: + minimum: 1 + type: integer orderCond: enum: - newest @@ -1325,6 +1506,9 @@ definitions: custom_header: maxLength: 65536 type: string + custom_sidebar: + maxLength: 65536 + type: string type: object schema.SiteCustomCssHTMLResp: properties: @@ -1340,6 +1524,9 @@ definitions: custom_header: maxLength: 65536 type: string + custom_sidebar: + maxLength: 65536 + type: string type: object schema.SiteGeneralReq: properties: @@ -1401,6 +1588,8 @@ definitions: type: string site_seo: $ref: '#/definitions/schema.SiteSeoReq' + site_users: + $ref: '#/definitions/schema.SiteUsersResp' theme: $ref: '#/definitions/schema.SiteThemeResp' version: @@ -1408,11 +1597,6 @@ definitions: type: object schema.SiteInterfaceReq: properties: - default_avatar: - enum: - - system - - gravatar - type: string language: maxLength: 128 type: string @@ -1420,17 +1604,11 @@ definitions: maxLength: 128 type: string required: - - default_avatar - language - time_zone type: object schema.SiteInterfaceResp: properties: - default_avatar: - enum: - - system - - gravatar - type: string language: maxLength: 128 type: string @@ -1438,7 +1616,6 @@ definitions: maxLength: 128 type: string required: - - default_avatar - language - time_zone type: object @@ -1466,6 +1643,12 @@ definitions: type: object schema.SiteLoginReq: properties: + allow_email_domains: + items: + type: string + type: array + allow_email_registrations: + type: boolean allow_new_registrations: type: boolean login_required: @@ -1473,6 +1656,12 @@ definitions: type: object schema.SiteLoginResp: properties: + allow_email_domains: + items: + type: string + type: array + allow_email_registrations: + type: boolean allow_new_registrations: type: boolean login_required: @@ -1525,6 +1714,50 @@ definitions: $ref: '#/definitions/schema.ThemeOption' type: array type: object + schema.SiteUsersReq: + properties: + allow_update_avatar: + type: boolean + allow_update_bio: + type: boolean + allow_update_display_name: + type: boolean + allow_update_location: + type: boolean + allow_update_username: + type: boolean + allow_update_website: + type: boolean + default_avatar: + enum: + - system + - gravatar + type: string + required: + - default_avatar + type: object + schema.SiteUsersResp: + properties: + allow_update_avatar: + type: boolean + allow_update_bio: + type: boolean + allow_update_display_name: + type: boolean + allow_update_location: + type: boolean + allow_update_username: + type: boolean + allow_update_website: + type: boolean + default_avatar: + enum: + - system + - gravatar + type: string + required: + - default_avatar + type: object schema.SiteWriteReq: properties: recommend_tags: @@ -1603,6 +1836,17 @@ definitions: value: type: string type: object + schema.UIOptionAction: + properties: + loading: + $ref: '#/definitions/schema.LoadingAction' + method: + type: string + on_complete: + $ref: '#/definitions/schema.OnCompleteAction' + url: + type: string + type: object schema.UnreviewedRevisionInfoInfo: properties: content: @@ -1665,8 +1909,36 @@ definitions: description: website maxLength: 500 type: string + type: object + schema.UpdatePluginConfigReq: + properties: + config_fields: + additionalProperties: {} + type: object + plugin_slug_name: + maxLength: 100 + type: string required: - - display_name + - plugin_slug_name + type: object + schema.UpdatePluginStatusReq: + properties: + enabled: + type: boolean + plugin_slug_name: + maxLength: 100 + type: string + required: + - plugin_slug_name + type: object + schema.UpdatePrivilegesConfigReq: + properties: + level: + maximum: 3 + minimum: 1 + type: integer + required: + - level type: object schema.UpdateSMTPConfigReq: properties: @@ -1824,6 +2096,10 @@ definitions: e_mail: maxLength: 500 type: string + pass: + maxLength: 32 + minLength: 8 + type: string required: - e_mail type: object @@ -1856,14 +2132,18 @@ definitions: - e_mail - pass type: object - schema.UserModifyPassWordRequest: + schema.UserModifyPasswordReq: properties: old_pass: - description: old password + maxLength: 32 + minLength: 8 type: string pass: - description: password + maxLength: 32 + minLength: 8 type: string + required: + - pass type: object schema.UserNoticeSetRequest: properties: @@ -2104,6 +2384,112 @@ paths: summary: Get language options tags: - Lang + /answer/admin/api/plugin/config: + get: + description: get plugin config + parameters: + - description: plugin_slug_name + in: query + name: plugin_slug_name + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + $ref: '#/definitions/schema.GetPluginConfigResp' + type: object + security: + - ApiKeyAuth: [] + summary: get plugin config + tags: + - AdminPlugin + put: + consumes: + - application/json + description: update plugin config + parameters: + - description: UpdatePluginConfigReq + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.UpdatePluginConfigReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + security: + - ApiKeyAuth: [] + summary: update plugin config + tags: + - AdminPlugin + /answer/admin/api/plugin/status: + put: + consumes: + - application/json + description: update plugin status + parameters: + - description: UpdatePluginStatusReq + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.UpdatePluginStatusReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + security: + - ApiKeyAuth: [] + summary: update plugin status + tags: + - AdminPlugin + /answer/admin/api/plugins: + get: + consumes: + - application/json + description: get plugin list + parameters: + - description: 'status: active/inactive' + in: query + name: status + type: string + - description: have config + in: query + name: have_config + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + items: + $ref: '#/definitions/schema.GetPluginListResp' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: get plugin list + tags: + - AdminPlugin /answer/admin/api/question/page: get: consumes: @@ -2294,6 +2680,47 @@ paths: summary: get role list tags: - admin + /answer/admin/api/setting/privileges: + get: + description: GetPrivilegesConfig get privileges config + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + $ref: '#/definitions/schema.GetPrivilegesConfigResp' + type: object + security: + - ApiKeyAuth: [] + summary: GetPrivilegesConfig get privileges config + tags: + - admin + put: + description: update privileges config + parameters: + - description: config + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.UpdatePrivilegesConfigReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + security: + - ApiKeyAuth: [] + summary: update privileges config + tags: + - admin /answer/admin/api/setting/smtp: get: description: GetSMTPConfig get smtp config @@ -2663,6 +3090,47 @@ paths: summary: update site custom css html config tags: - admin + /answer/admin/api/siteinfo/users: + get: + description: get site user config + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + $ref: '#/definitions/schema.SiteUsersResp' + type: object + security: + - ApiKeyAuth: [] + summary: get site user config + tags: + - admin + put: + description: update site info config about users + parameters: + - description: users info + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.SiteUsersReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + security: + - ApiKeyAuth: [] + summary: update site info config about users + tags: + - admin /answer/admin/api/siteinfo/write: get: description: get site interface @@ -3263,6 +3731,101 @@ paths: summary: get comment page tags: - Comment + /answer/api/v1/connector/binding/email: + post: + consumes: + - application/json + description: external login binding user send email + parameters: + - description: external login binding user send email + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.ExternalLoginBindingUserSendEmailReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + $ref: '#/definitions/schema.ExternalLoginBindingUserSendEmailResp' + type: object + summary: external login binding user send email + tags: + - PluginConnector + /answer/api/v1/connector/info: + get: + description: get all enabled connectors + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + items: + $ref: '#/definitions/schema.ConnectorInfoResp' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: get all enabled connectors + tags: + - PluginConnector + /answer/api/v1/connector/user/info: + get: + description: get all connectors info about user + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + items: + $ref: '#/definitions/schema.ConnectorUserInfoResp' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: get all connectors info about user + tags: + - PluginConnector + /answer/api/v1/connector/user/unbinding: + delete: + consumes: + - application/json + description: unbind external user login + parameters: + - description: ExternalLoginUnbindingReq + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.ExternalLoginUnbindingReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + security: + - ApiKeyAuth: [] + summary: unbind external user login + tags: + - PluginConnector /answer/api/v1/file: post: consumes: @@ -3509,7 +4072,7 @@ paths: get: consumes: - application/json - description: UserAnswerList + description: list personal answers parameters: - default: string description: username @@ -3532,9 +4095,9 @@ paths: required: true type: string - default: "20" - description: pagesize + description: page_size in: query - name: pagesize + name: page_size required: true type: string produces: @@ -3546,14 +4109,14 @@ paths: $ref: '#/definitions/handler.RespBody' security: - ApiKeyAuth: [] - summary: UserAnswerList + summary: list personal answers tags: - - api-answer + - Personal /answer/api/v1/personal/collection/page: get: consumes: - application/json - description: UserCollectionList + description: list personal collections parameters: - default: "0" description: page @@ -3562,9 +4125,9 @@ paths: required: true type: string - default: "20" - description: pagesize + description: page_size in: query - name: pagesize + name: page_size required: true type: string produces: @@ -3576,7 +4139,7 @@ paths: $ref: '#/definitions/handler.RespBody' security: - ApiKeyAuth: [] - summary: UserCollectionList + summary: list personal collections tags: - Collection /answer/api/v1/personal/comment/page: @@ -4893,12 +5456,12 @@ paths: - application/json description: UserModifyPassWord parameters: - - description: UserModifyPassWordRequest + - description: UserModifyPasswordReq in: body name: data required: true schema: - $ref: '#/definitions/schema.UserModifyPassWordRequest' + $ref: '#/definitions/schema.UserModifyPasswordReq' produces: - application/json responses: @@ -5210,7 +5773,7 @@ paths: get: consumes: - application/json - description: UserList + description: list personal questions parameters: - default: string description: username @@ -5233,9 +5796,9 @@ paths: required: true type: string - default: "20" - description: pagesize + description: page_size in: query - name: pagesize + name: page_size required: true type: string produces: @@ -5247,9 +5810,9 @@ paths: $ref: '#/definitions/handler.RespBody' security: - ApiKeyAuth: [] - summary: UserList + summary: list personal questions tags: - - Question + - Personal /robots.txt: get: description: get site robots information diff --git a/go.mod b/go.mod index 6f600f4c..6e1cdef3 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.18 require ( github.com/Chain-Zhang/pinyin v0.1.3 + github.com/Masterminds/semver/v3 v3.1.1 github.com/anargu/gin-brotli v0.0.0-20220116052358-12bf532d5267 github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d github.com/bwmarrin/snowflake v0.3.0 @@ -80,6 +81,7 @@ require ( github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/geo v0.0.0-20190812012225-f41920e961ce // indirect github.com/golang/snappy v0.0.4 // indirect + github.com/google/go-cmp v0.5.9 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/gorilla/css v1.0.0 // indirect github.com/gosimple/unidecode v1.0.1 // indirect @@ -146,3 +148,5 @@ require ( modernc.org/token v1.0.0 // indirect sigs.k8s.io/yaml v1.3.0 // indirect ) + +replace lukechampine.com/uint128 v1.1.1 => github.com/aichy126/uint128 v1.1.1 diff --git a/go.sum b/go.sum index 02402218..ee2d6183 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,7 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/LinkinStars/go-i18n/v2 v2.2.2 h1:ZfjpzbW13dv6btv3RALKZkpN9A+7K1JA//2QcNeWaxU= github.com/LinkinStars/go-i18n/v2 v2.2.2/go.mod h1:hLglSJ4/3M0Y7ZVcoEJI+OwqkglHCA32DdjuJJR2LbM= +github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= @@ -64,6 +65,8 @@ github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMx github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= github.com/agiledragon/gomonkey/v2 v2.3.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY= +github.com/aichy126/uint128 v1.1.1 h1:xH1bCWDzq7Ebm4lpXCeIiWco0VWi7UmiKkvTQSWBmb0= +github.com/aichy126/uint128 v1.1.1/go.mod h1:Hke/MPGXUxOl0OXHoNcVesBL4N+XalHEJ9e1jaIbl8o= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -287,7 +290,8 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -1202,8 +1206,6 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -lukechampine.com/uint128 v1.1.1 h1:pnxCASz787iMf+02ssImqk6OLt+Z5QHMoZyUXR4z6JU= -lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= modernc.org/cc/v3 v3.33.6/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= modernc.org/cc/v3 v3.33.9/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= modernc.org/cc/v3 v3.33.11/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 5a53438f..ee8d7088 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -12,6 +12,8 @@ backend: other: Unauthorized. database_error: other: Data server error. + forbidden_error: + other: Forbidden. action: report: other: Flag @@ -23,6 +25,8 @@ backend: other: Close reopen: other: Reopen + forbidden_error: + other: Forbidden. pin: other: Pin hide: @@ -46,6 +50,58 @@ backend: other: Have the full power to access the site. moderator: other: Has access to all posts except admin settings. + privilege: + level_1: + description: + other: Level 1 (less reputation required for private team, group) + level_2: + description: + other: Level 2 (low reputation required for startup community) + level_3: + description: + other: Level 3 (high reputation required for mature community) + rank_question_add_label: + other: Ask question + rank_answer_add_label: + other: Write answer + rank_comment_add_label: + other: Write comment + rank_report_add_label: + other: Flag + rank_comment_vote_up_label: + other: Upvote comment + rank_link_url_limit_label: + other: Post more than 2 links at a time + rank_question_vote_up_label: + other: Upvote question + rank_answer_vote_up_label: + other: Upvote answer + rank_question_vote_down_label: + other: Downvote question + rank_answer_vote_down_label: + other: Downvote answer + rank_tag_add_label: + other: Create new tag + rank_tag_edit_label: + other: Edit tag description (need to review) + rank_question_edit_label: + other: Edit other's question (need to review) + rank_answer_edit_label: + other: Edit other's answer (need to review) + rank_question_edit_without_review_label: + other: Edit other's question without review + rank_answer_edit_without_review_label: + other: Edit other's answer without review + rank_question_audit_label: + other: Review question edits + rank_answer_audit_label: + other: Review answer edits + rank_tag_audit_label: + other: Review tag edits + rank_tag_edit_without_review_label: + other: Edit tag description without review + rank_tag_synonym_label: + other: Manage tag synonyms email: other: Email password: @@ -83,6 +139,8 @@ backend: other: Email should be verified. verify_url_expired: other: Email verified URL has expired, please resend the email. + illegal_email_domain_error: + other: Email is not allowed from that email domain. Please use another one. lang: not_found: other: Language file not found. @@ -155,6 +213,8 @@ backend: no_permission: other: No permission to Revision. user: + external_login_unbinding_forbidden: + other: Please set a login password for your account before you remove this login. email_or_password_wrong: other: other: Email and password do not match. @@ -172,6 +232,10 @@ backend: other: You cannot modify your role. not_allowed_registration: other: Currently the site is not open for registration + access_denied: + other: Access denied + page_access_denied: + other: You do not have access to this page. config: read_config_failed: other: Read config failed @@ -277,6 +341,16 @@ backend: other: Your answer has been deleted your_comment_was_deleted: other: Your comment has been deleted + up_voted_question: + other: upvoted question + down_voted_question: + other: downvoted question + up_voted_answer: + other: upvoted answer + down_voted_answer: + other: downvoted answer + up_voted_comment: + other: upvoted comment # The following fields are used for interface presentation(Front-end) ui: @@ -322,6 +396,7 @@ ui: upgrade: Answer Upgrade maintenance: Website Maintenance users: Users + oauth_callback: Processing http_404: HTTP Error 404 http_50X: HTTP Error 500 http_403: HTTP Error 403 @@ -331,11 +406,13 @@ ui: achievement: Achievements all_read: Mark all as read show_more: Show more + someone: Someone suspended: title: Your Account has been Suspended until_time: "Your account was suspended until {{ time }}." forever: This user was suspended forever. end: You don't meet a community guideline. + contact_us: Contact us editor: blockquote: text: Blockquote @@ -547,7 +624,7 @@ ui: tip_answer: >- Use comments to reply to other users or notify them of changes. If you are adding new information, edit your post instead of commenting. - tip_vote: It adds something useful to the post + tip_vote: It adds something useful to the post edit_answer: title: Edit Answer default_reason: Edit answer @@ -658,7 +735,6 @@ ui: msg: empty: Cannot be empty. login: - page_title: Welcome to {{site_name}} login_to_continue: Log in to continue info_sign: Don't have an account? <1>Sign up info_login: Already have an account? <1>Log in @@ -669,6 +745,7 @@ ui: msg: empty: Name cannot be empty. range: Name up to 30 characters. + character: 'Must use the character set "a-z", "0-9", " - . _"' email: label: Email msg: @@ -689,7 +766,6 @@ ui: msg: empty: Email cannot be empty. change_email: - page_title: Welcome to {{site_name}} btn_cancel: Cancel btn_update: Update email address send_success: >- @@ -699,6 +775,17 @@ ui: label: New Email msg: empty: Email cannot be empty. + oauth_bind_email: + subtitle: Add a recovery email to your account. + btn_update: Update email address + email: + label: Email + msg: + empty: Email cannot be empty. + modal_title: Email already existes. + modal_content: This email address already registered. Are you sure you want to connect to the existing account? + modal_cancel: Change email + modal_confirm: Connect to the existing account password_reset: page_title: Password Reset btn_name: Reset my password @@ -719,6 +806,7 @@ ui: label: Confirm New Password settings: page_title: Settings + goto_modify: Go to Modify nav: profile: Profile notification: Notifications @@ -768,8 +856,11 @@ ui: We've sent an email to that address. Please follow the confirmation instructions. email: - label: Email - msg: Email cannot be empty. + label: New Email + msg: New Email cannot be empty. + pass: + label: Current Password + msg: Password cannot be empty. password_title: Password current_pass: label: Current Password @@ -786,6 +877,13 @@ ui: lang: label: Interface Language text: User interface language. It will change when you refresh the page. + my_logins: + title: My Logins + label: Log in or sign up on this site using these accounts. + modal_title: Remove Login + modal_content: Are you sure you want to remove this login from your account? + modal_confirm_btn: Remove + remove_success: Removed successfully toast: update: update success update_password: Password changed successfully. @@ -809,8 +907,8 @@ ui: closed_in: Closed in show_exist: Show existing question. useful: Useful - question_useful: It is useful and clear - question_un_useful: It is unclear or not useful + question_useful: It is useful and clear + question_un_useful: It is unclear or not useful answer_useful: It is useful answer_un_useful: It is not useful answers: @@ -873,6 +971,10 @@ ui: skip: Skip discard_draft: Discard draft pinned: Pinned + all: All + question: Question + answer: Answer + comment: Comment search: title: Search Results keywords: Keywords @@ -907,7 +1009,6 @@ ui: modal_confirm: title: Error... account_result: - page_title: Welcome to {{site_name}} success: Your new account is confirmed; you will be redirected to the home page. link: Continue to homepage invalid: >- @@ -1034,6 +1135,7 @@ ui: admin_name: label: Name msg: Name cannot be empty. + character: 'Must use the character set "a-z", "0-9", " - . _"' admin_password: label: Password text: >- @@ -1095,8 +1197,20 @@ ui: seo: SEO customize: Customize themes: Themes - css-html: CSS/HTML + css_html: CSS/HTML login: Login + privileges: Privileges + plugins: Plugins + installed_plugins: Installed Plugins + website_welcome: Welcome to {{site_name}} + plugins: + login: Login + qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in. + login_failed_email_tip: Login failed, please allow this app to access your email information before try again. + oauth: + connect: Connect with {{ auth_name }} + remove: Remove {{ auth_name }} + admin: admin_header: title: Admin @@ -1139,6 +1253,7 @@ ui: pending: Pending completed: Completed flagged: Flagged + flagged_type: Flagged {{ type }} created: Created action: Action review: Review @@ -1289,9 +1404,6 @@ ui: label: Timezone msg: Timezone cannot be empty. text: Choose a city in the same timezone as you. - avatar: - label: Default Avatar - text: For users without a custom avatar of their own. smtp: page_title: SMTP from_email: @@ -1401,16 +1513,67 @@ ui: footer: label: Footer text: This will insert before . + sidebar: + label: Sidebar + text: This will insert in sidebar. login: page_title: Login membership: title: Membership label: Allow new registrations text: Turn off to prevent anyone from creating a new account. + email_registration: + title: Email registration + label: Allow email registration + text: Turn off to prevent anyone creating new account through email. + allowed_email_domains: + title: Allowed email domains + text: Email domains that users must register accounts with. One domain per line. Ignored when empty. private: title: Private label: Login required text: Only logged in users can access this community. + installed_plugins: + title: Installed Plugins + filter: + all: All + active: Active + inactive: Inactive + outdated: Outdated + plugins: + label: Plugins + text: Select an existing plugin. + name: Name + version: Version + status: Status + action: Action + deactivate: Deactivate + activate: Activate + settings: Settings + settings_users: + title: Users + avatar: + label: Default Avatar + text: For users without a custom avatar of their own. + profile_editable: + title: Profile Editable + allow_update_display_name: + label: Allow users to change their display name + allow_update_username: + label: Allow users to change their username + allow_update_avatar: + label: Allow users to change their profile image + allow_update_bio: + label: Allow users to change their about me + allow_update_website: + label: Allow users to change their website + allow_update_location: + label: Allow users to change their location + privilege: + title: Privileges + level: + label: Reputation required level + text: Choose the reputation required for the privileges form: optional: (optional) @@ -1418,6 +1581,8 @@ ui: invalid: is invalid btn_submit: Save not_found_props: "Required property {{ key }} not found." + select: Select + page_review: review: Review proposed: proposed diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml index ba79b21c..778026af 100644 --- a/i18n/zh_CN.yaml +++ b/i18n/zh_CN.yaml @@ -45,6 +45,58 @@ backend: other: 拥有管理网站的全部权限。 moderator: other: 拥有访问除管理员设置以外的所有权限。 + privilege: + level_1: + description: + other: 等级1(创业社区所需的声望最低) + level_2: + description: + other: 等级2(创业社区所需的声望较低) + level_3: + description: + other: 等级3(成熟社区所需的声望较高) + rank_question_add_label: + other: 提问 + rank_answer_add_label: + other: 回答问题 + rank_comment_add_label: + other: 发表评论 + rank_report_add_label: + other: 举报 + rank_comment_vote_up_label: + other: 评论点赞 + rank_link_url_limit_label: + other: 一次发布超过两个链接 + rank_question_vote_up_label: + other: 问题点赞 + rank_answer_vote_up_label: + other: 答案点赞 + rank_question_vote_down_label: + other: 问题点踩 + rank_answer_vote_down_label: + other: 答案点踩 + rank_tag_add_label: + other: 创建新标签 + rank_tag_edit_label: + other: 编辑标签描述(需要审核) + rank_question_edit_label: + other: 编辑他人提问(需要审核) + rank_answer_edit_label: + other: 编辑他人回答(需要审核) + rank_question_edit_without_review_label: + other: 编辑他人提问(无需审核) + rank_answer_edit_without_review_label: + other: 编辑他人回答(无需审核) + rank_question_audit_label: + other: 审核问题编辑 + rank_answer_audit_label: + other: 审核答案编辑 + rank_tag_audit_label: + other: 审核标签编辑 + rank_tag_edit_without_review_label: + other: 编辑标签描述(无需审核) + rank_tag_synonym_label: + other: 管理标签同义词 email: other: 邮箱 password: @@ -82,6 +134,8 @@ backend: other: 邮箱需要验证。 verify_url_expired: other: 邮箱验证的网址已过期,请重新发送邮件。 + illegal_email_domain_error: + other: 该域名的邮箱无法使用。请尝试更换其他邮箱。 lang: not_found: other: 语言未找到 @@ -171,6 +225,10 @@ backend: other: 您不能修改自己的角色。 not_allowed_registration: other: 目前该站点未开放注册 + access_denied: + other: 访问被拒绝 + page_access_denied: + other: 你没有权限进入这个页面。 config: read_config_failed: other: 读取配置失败 @@ -271,6 +329,16 @@ backend: other: 你的答案已被删除 your_comment_was_deleted: other: 你的评论已被删除 + up_voted_question: + other: 赞了问题 + down_voted_question: + other: 踩了问题 + up_voted_answer: + other: 赞了答案 + down_voted_answer: + other: 踩了答案 + up_voted_comment: + other: 赞了评论 #The following fields are used for interface presentation(Front-end) ui: how_to_format: @@ -316,6 +384,7 @@ ui: achievement: 成就 all_read: 全部标记为已读 show_more: 显示更多 + someone: 有人 suspended: title: 账号已封禁 until_time: "你的账号被封禁至{{ time }}。" @@ -690,6 +759,7 @@ ui: label: 确认新密码 settings: page_title: 设置 + goto_modify: 前往修改 nav: profile: 我的资料 notification: 通知 @@ -836,6 +906,10 @@ ui: skip: 略过 discard_draft: 丢弃草稿 pinned: 已置顶 + all: 所有 + question: 问题 + answer: 回答 + comment: 评论 search: title: 搜索结果 keywords: 关键词 @@ -1053,6 +1127,16 @@ ui: themes: 主题 css-html: CSS/HTML login: 登录 + plugins: 插件 + installed_plugins: 插件列表 + website_welcome: 欢迎来到 {{site_name}} + plugins: + login: 登录 + qrcode_login_tip: 请使用 {{ agentName }} 扫描二维码登录 + login_failed_email_tip: 登录失败, 请允许该应用程序访问您的电子邮件信息,然后再试一次。 + oauth: + connect: 连接到 {{ auth_name }} + remove: 解绑 {{ auth_name }} admin: admin_header: title: 后台管理 @@ -1095,6 +1179,7 @@ ui: pending: 等待处理 completed: 已完成 flagged: 被举报内容 + flagged_type: 被举报的{{ type }} created: 创建于 action: 操作 review: 审查 @@ -1365,6 +1450,47 @@ ui: title: 非公开的 label: 需要登录 text: 只有登录用户才能访问这个社区。 + installed_plugins: + title: 插件列表 + filter: + all: 全部 + active: 启用 + inactive: 未启用 + outdated: 已过期 + plugins: + label: 插件 + text: 选择一个插件 + name: 插件名称 + version: 插件版本 + status: 状态 + action: 操作 + deactivate: 停用 + activate: 启用 + settings: 设置 + settings_users: + title: 用户 + avatar: + label: 默认头像 + text: 未设置自定义头像的用户所展示的头像。 + profile_editable: + title: 可编辑的个人资料 + allow_update_display_name: + label: 允许用户更改显示名称 + allow_update_username: + label: 允许用户更改用户名 + allow_update_avatar: + label: 允许用户更改头像 + allow_update_bio: + label: 允许用户更改自我介绍 + allow_update_website: + label: 允许用户更改个人网站 + allow_update_location: + label: 允许用户更改所在地 + privilege: + title: 声望权限 + level: + label: 所需声望等级 + text: 选择所需的声望等级以获取权限 form: optional: (选填) empty: 不能为空 diff --git a/internal/base/constant/config_key.go b/internal/base/constant/config_key.go new file mode 100644 index 00000000..a184badf --- /dev/null +++ b/internal/base/constant/config_key.go @@ -0,0 +1,5 @@ +package constant + +const ( + PluginStatus = "plugin.status" +) diff --git a/internal/base/constant/connector.go b/internal/base/constant/connector.go new file mode 100644 index 00000000..fcf181f4 --- /dev/null +++ b/internal/base/constant/connector.go @@ -0,0 +1,8 @@ +package constant + +import "time" + +const ( + ConnectorUserExternalInfoCacheKey = "answer:connector:" + ConnectorUserExternalInfoCacheTime = 10 * time.Minute +) diff --git a/internal/base/constant/constant.go b/internal/base/constant/constant.go index a71d1a9c..b921c9f6 100644 --- a/internal/base/constant/constant.go +++ b/internal/base/constant/constant.go @@ -66,6 +66,8 @@ const ( SiteTypeLogin = "login" SiteTypeCustomCssHTML = "css-html" SiteTypeTheme = "theme" + SiteTypePrivileges = "privileges" + SiteTypeUsers = "users" ) func ExistInPathIgnore(name string) bool { diff --git a/internal/base/constant/notification.go b/internal/base/constant/notification.go index 83856f64..8f8568a8 100644 --- a/internal/base/constant/notification.go +++ b/internal/base/constant/notification.go @@ -1,28 +1,38 @@ package constant const ( - // UpdateQuestion update question - UpdateQuestion = "notification.action.update_question" - // AnswerTheQuestion answer the question - AnswerTheQuestion = "notification.action.answer_the_question" - // UpdateAnswer update answer - UpdateAnswer = "notification.action.update_answer" - // AcceptAnswer accept answer - AcceptAnswer = "notification.action.accept_answer" - // CommentQuestion comment question - CommentQuestion = "notification.action.comment_question" - // CommentAnswer comment answer - CommentAnswer = "notification.action.comment_answer" - // ReplyToYou reply to you - ReplyToYou = "notification.action.reply_to_you" - // MentionYou mention you - MentionYou = "notification.action.mention_you" - // YourQuestionIsClosed your question is closed - YourQuestionIsClosed = "notification.action.your_question_is_closed" - // YourQuestionWasDeleted your question was deleted - YourQuestionWasDeleted = "notification.action.your_question_was_deleted" - // YourAnswerWasDeleted your answer was deleted - YourAnswerWasDeleted = "notification.action.your_answer_was_deleted" - // YourCommentWasDeleted your comment was deleted - YourCommentWasDeleted = "notification.action.your_comment_was_deleted" + // NotificationUpdateQuestion update question + NotificationUpdateQuestion = "notification.action.update_question" + // NotificationAnswerTheQuestion answer the question + NotificationAnswerTheQuestion = "notification.action.answer_the_question" + // NotificationUpVotedTheQuestion up voted the question + NotificationUpVotedTheQuestion = "notification.action.up_voted_question" + // NotificationDownVotedTheQuestion down voted the question + NotificationDownVotedTheQuestion = "notification.action.down_voted_question" + // NotificationUpdateAnswer update answer + NotificationUpdateAnswer = "notification.action.update_answer" + // NotificationAcceptAnswer accept answer + NotificationAcceptAnswer = "notification.action.accept_answer" + // NotificationUpVotedTheAnswer up voted the answer + NotificationUpVotedTheAnswer = "notification.action.up_voted_answer" + // NotificationDownVotedTheAnswer down voted the answer + NotificationDownVotedTheAnswer = "notification.action.down_voted_answer" + // NotificationCommentQuestion comment question + NotificationCommentQuestion = "notification.action.comment_question" + // NotificationCommentAnswer comment answer + NotificationCommentAnswer = "notification.action.comment_answer" + // NotificationUpVotedTheComment up voted the comment + NotificationUpVotedTheComment = "notification.action.up_voted_comment" + // NotificationReplyToYou reply to you + NotificationReplyToYou = "notification.action.reply_to_you" + // NotificationMentionYou mention you + NotificationMentionYou = "notification.action.mention_you" + // NotificationYourQuestionIsClosed your question is closed + NotificationYourQuestionIsClosed = "notification.action.your_question_is_closed" + // NotificationYourQuestionWasDeleted your question was deleted + NotificationYourQuestionWasDeleted = "notification.action.your_question_was_deleted" + // NotificationYourAnswerWasDeleted your answer was deleted + NotificationYourAnswerWasDeleted = "notification.action.your_answer_was_deleted" + // NotificationYourCommentWasDeleted your comment was deleted + NotificationYourCommentWasDeleted = "notification.action.your_comment_was_deleted" ) diff --git a/internal/base/constant/rank.go b/internal/base/constant/rank.go new file mode 100644 index 00000000..f1cbf255 --- /dev/null +++ b/internal/base/constant/rank.go @@ -0,0 +1,70 @@ +package constant + +import "github.com/answerdev/answer/internal/base/reason" + +type Privilege struct { + Key string `json:"key"` + Label string `json:"label"` + Value int `json:"value"` +} + +const ( + RankQuestionAddKey = "rank.question.add" + RankQuestionEditKey = "rank.question.edit" + RankQuestionDeleteKey = "rank.question.delete" + RankQuestionVoteUpKey = "rank.question.vote_up" + RankQuestionVoteDownKey = "rank.question.vote_down" + RankAnswerAddKey = "rank.answer.add" + RankAnswerEditKey = "rank.answer.edit" + RankAnswerDeleteKey = "rank.answer.delete" + RankAnswerAcceptKey = "rank.answer.accept" + RankAnswerVoteUpKey = "rank.answer.vote_up" + RankAnswerVoteDownKey = "rank.answer.vote_down" + RankCommentAddKey = "rank.comment.add" + RankCommentEditKey = "rank.comment.edit" + RankCommentDeleteKey = "rank.comment.delete" + RankReportAddKey = "rank.report.add" + RankTagAddKey = "rank.tag.add" + RankTagEditKey = "rank.tag.edit" + RankTagDeleteKey = "rank.tag.delete" + RankTagSynonymKey = "rank.tag.synonym" + RankLinkUrlLimitKey = "rank.link.url_limit" + RankVoteDetailKey = "rank.vote.detail" + RankCommentVoteUpKey = "rank.comment.vote_up" + RankCommentVoteDownKey = "rank.comment.vote_down" + RankQuestionEditWithoutReviewKey = "rank.question.edit_without_review" + RankAnswerEditWithoutReviewKey = "rank.answer.edit_without_review" + RankTagEditWithoutReviewKey = "rank.tag.edit_without_review" + RankAnswerAuditKey = "rank.answer.audit" + RankQuestionAuditKey = "rank.question.audit" + RankTagAuditKey = "rank.tag.audit" + RankQuestionCloseKey = "rank.question.close" + RankQuestionReopenKey = "rank.question.reopen" + RankTagUseReservedTagKey = "rank.tag.use_reserved_tag" +) + +var ( + RankAllPrivileges = []*Privilege{ + {Label: reason.RankQuestionAddLabel, Key: RankQuestionAddKey}, + {Label: reason.RankAnswerAddLabel, Key: RankAnswerAddKey}, + {Label: reason.RankCommentAddLabel, Key: RankCommentAddKey}, + {Label: reason.RankReportAddLabel, Key: RankReportAddKey}, + {Label: reason.RankCommentVoteUpLabel, Key: RankCommentVoteUpKey}, + {Label: reason.RankLinkUrlLimitLabel, Key: RankLinkUrlLimitKey}, + {Label: reason.RankQuestionVoteUpLabel, Key: RankQuestionVoteUpKey}, + {Label: reason.RankAnswerVoteUpLabel, Key: RankAnswerVoteUpKey}, + {Label: reason.RankQuestionVoteDownLabel, Key: RankQuestionVoteDownKey}, + {Label: reason.RankAnswerVoteDownLabel, Key: RankAnswerVoteDownKey}, + {Label: reason.RankTagAddLabel, Key: RankTagAddKey}, + {Label: reason.RankTagEditLabel, Key: RankTagEditKey}, + {Label: reason.RankQuestionEditLabel, Key: RankQuestionEditKey}, + {Label: reason.RankAnswerEditLabel, Key: RankAnswerEditKey}, + {Label: reason.RankQuestionEditWithoutReviewLabel, Key: RankQuestionEditWithoutReviewKey}, + {Label: reason.RankAnswerEditWithoutReviewLabel, Key: RankAnswerEditWithoutReviewKey}, + {Label: reason.RankQuestionAuditLabel, Key: RankQuestionAuditKey}, + {Label: reason.RankAnswerAuditLabel, Key: RankAnswerAuditKey}, + {Label: reason.RankTagAuditLabel, Key: RankTagAuditKey}, + {Label: reason.RankTagEditWithoutReviewLabel, Key: RankTagEditWithoutReviewKey}, + {Label: reason.RankTagSynonymLabel, Key: RankTagSynonymKey}, + } +) diff --git a/internal/base/constant/site_info.go b/internal/base/constant/site_info.go index dced604f..e6b8cd99 100644 --- a/internal/base/constant/site_info.go +++ b/internal/base/constant/site_info.go @@ -1,5 +1,6 @@ package constant var ( - DefaultAvatar = "system" + DefaultAvatar = "system" + DefaultSiteURL = "" ) diff --git a/internal/base/data/data.go b/internal/base/data/data.go index 0a16659e..113d8d48 100644 --- a/internal/base/data/data.go +++ b/internal/base/data/data.go @@ -5,6 +5,7 @@ import ( "time" "github.com/answerdev/answer/pkg/dir" + "github.com/answerdev/answer/plugin" _ "github.com/go-sql-driver/mysql" _ "github.com/lib/pq" "github.com/segmentfault/pacman/cache" @@ -76,6 +77,15 @@ func NewDB(debug bool, dataConf *Database) (*xorm.Engine, error) { // NewCache new cache instance func NewCache(c *CacheConf) (cache.Cache, func(), error) { + var pluginCache plugin.Cache + _ = plugin.CallCache(func(fn plugin.Cache) error { + pluginCache = fn + return nil + }) + if pluginCache != nil { + return pluginCache, func() {}, nil + } + // TODO What cache type should be initialized according to the configuration file memCache := memory.NewCache() diff --git a/internal/base/middleware/header.go b/internal/base/middleware/header.go new file mode 100644 index 00000000..ee5e3435 --- /dev/null +++ b/internal/base/middleware/header.go @@ -0,0 +1,15 @@ +package middleware + +import ( + "strings" + + "github.com/gin-gonic/gin" +) + +func HeadersByRequestURI() gin.HandlerFunc { + return func(c *gin.Context) { + if strings.HasPrefix(c.Request.RequestURI, "/static/") { + c.Header("cache-control", "public, max-age=31536000") + } + } +} diff --git a/internal/base/middleware/user_center_plugin_auth.go b/internal/base/middleware/user_center_plugin_auth.go new file mode 100644 index 00000000..bf19c319 --- /dev/null +++ b/internal/base/middleware/user_center_plugin_auth.go @@ -0,0 +1,23 @@ +package middleware + +import ( + "github.com/answerdev/answer/internal/base/handler" + "github.com/answerdev/answer/internal/base/reason" + "github.com/answerdev/answer/plugin" + "github.com/gin-gonic/gin" + "github.com/segmentfault/pacman/errors" +) + +// BanAPIForUserCenter ban api for user center +func BanAPIForUserCenter(ctx *gin.Context) { + uc, ok := plugin.GetUserCenter() + if !ok { + return + } + if !uc.Description().EnabledOriginalUserSystem { + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) + ctx.Abort() + return + } + ctx.Next() +} diff --git a/internal/base/reason/privilege.go b/internal/base/reason/privilege.go new file mode 100644 index 00000000..76d4e7bb --- /dev/null +++ b/internal/base/reason/privilege.go @@ -0,0 +1,29 @@ +package reason + +const ( + PrivilegeLevel1Desc = "privilege.level_1.description" + PrivilegeLevel2Desc = "privilege.level_2.description" + PrivilegeLevel3Desc = "privilege.level_3.description" + + RankQuestionAddLabel = "privilege.rank_question_add_label" + RankAnswerAddLabel = "privilege.rank_answer_add_label" + RankCommentAddLabel = "privilege.rank_comment_add_label" + RankReportAddLabel = "privilege.rank_report_add_label" + RankCommentVoteUpLabel = "privilege.rank_comment_vote_up_label" + RankLinkUrlLimitLabel = "privilege.rank_link_url_limit_label" + RankQuestionVoteUpLabel = "privilege.rank_question_vote_up_label" + RankAnswerVoteUpLabel = "privilege.rank_answer_vote_up_label" + RankQuestionVoteDownLabel = "privilege.rank_question_vote_down_label" + RankAnswerVoteDownLabel = "privilege.rank_answer_vote_down_label" + RankTagAddLabel = "privilege.rank_tag_add_label" + RankTagEditLabel = "privilege.rank_tag_edit_label" + RankQuestionEditLabel = "privilege.rank_question_edit_label" + RankAnswerEditLabel = "privilege.rank_answer_edit_label" + RankQuestionEditWithoutReviewLabel = "privilege.rank_question_edit_without_review_label" + RankAnswerEditWithoutReviewLabel = "privilege.rank_answer_edit_without_review_label" + RankQuestionAuditLabel = "privilege.rank_question_audit_label" + RankAnswerAuditLabel = "privilege.rank_answer_audit_label" + RankTagAuditLabel = "privilege.rank_tag_audit_label" + RankTagEditWithoutReviewLabel = "privilege.rank_tag_edit_without_review_label" + RankTagSynonymLabel = "privilege.rank_tag_synonym_label" +) diff --git a/internal/base/reason/reason.go b/internal/base/reason/reason.go index 5f9e316a..3c257944 100644 --- a/internal/base/reason/reason.go +++ b/internal/base/reason/reason.go @@ -11,63 +11,69 @@ const ( UnauthorizedError = "base.unauthorized_error" // DatabaseError database error DatabaseError = "base.database_error" + // ForbiddenError forbidden error + ForbiddenError = "base.forbidden_error" ) const ( - EmailOrPasswordWrong = "error.object.email_or_password_incorrect" - CommentNotFound = "error.comment.not_found" - CommentCannotEditAfterDeadline = "error.comment.cannot_edit_after_deadline" - QuestionNotFound = "error.question.not_found" - QuestionCannotDeleted = "error.question.cannot_deleted" - QuestionCannotClose = "error.question.cannot_close" - QuestionCannotUpdate = "error.question.cannot_update" - QuestionAlreadyDeleted = "error.question.already_deleted" - AnswerNotFound = "error.answer.not_found" - AnswerCannotDeleted = "error.answer.cannot_deleted" - AnswerCannotUpdate = "error.answer.cannot_update" - AnswerCannotAddByClosedQuestion = "error.answer.question_closed_cannot_add" - CommentEditWithoutPermission = "error.comment.edit_without_permission" - DisallowVote = "error.object.disallow_vote" - DisallowFollow = "error.object.disallow_follow" - DisallowVoteYourSelf = "error.object.disallow_vote_your_self" - CaptchaVerificationFailed = "error.object.captcha_verification_failed" - OldPasswordVerificationFailed = "error.object.old_password_verification_failed" - NewPasswordSameAsPreviousSetting = "error.object.new_password_same_as_previous_setting" - UserNotFound = "error.user.not_found" - UsernameInvalid = "error.user.username_invalid" - UsernameDuplicate = "error.user.username_duplicate" - UserSetAvatar = "error.user.set_avatar" - EmailDuplicate = "error.email.duplicate" - EmailVerifyURLExpired = "error.email.verify_url_expired" - EmailNeedToBeVerified = "error.email.need_to_be_verified" - UserSuspended = "error.user.suspended" - ObjectNotFound = "error.object.not_found" - TagNotFound = "error.tag.not_found" - TagNotContainSynonym = "error.tag.not_contain_synonym_tags" - TagCannotUpdate = "error.tag.cannot_update" - TagIsUsedCannotDelete = "error.tag.is_used_cannot_delete" - TagAlreadyExist = "error.tag.already_exist" - RankFailToMeetTheCondition = "error.rank.fail_to_meet_the_condition" - VoteRankFailToMeetTheCondition = "error.rank.vote_fail_to_meet_the_condition" - ThemeNotFound = "error.theme.not_found" - LangNotFound = "error.lang.not_found" - ReportHandleFailed = "error.report.handle_failed" - ReportNotFound = "error.report.not_found" - ReadConfigFailed = "error.config.read_config_failed" - DatabaseConnectionFailed = "error.database.connection_failed" - InstallCreateTableFailed = "error.database.create_table_failed" - InstallConfigFailed = "error.install.create_config_failed" - SiteInfoNotFound = "error.site_info.not_found" - UploadFileSourceUnsupported = "error.upload.source_unsupported" - UploadFileUnsupportedFileFormat = "error.upload.unsupported_file_format" - RecommendTagNotExist = "error.tag.recommend_tag_not_found" - RecommendTagEnter = "error.tag.recommend_tag_enter" - RevisionReviewUnderway = "error.revision.review_underway" - RevisionNoPermission = "error.revision.no_permission" - UserCannotUpdateYourRole = "error.user.cannot_update_your_role" - TagCannotSetSynonymAsItself = "error.tag.cannot_set_synonym_as_itself" - NotAllowedRegistration = "error.user.not_allowed_registration" - SMTPConfigFromNameCannotBeEmail = "error.smtp.config_from_name_cannot_be_email" - AdminCannotUpdateTheirPassword = "error.admin.cannot_update_their_password" - AdminCannotModifySelfStatus = "error.admin.cannot_modify_self_status" + EmailOrPasswordWrong = "error.object.email_or_password_incorrect" + CommentNotFound = "error.comment.not_found" + CommentCannotEditAfterDeadline = "error.comment.cannot_edit_after_deadline" + QuestionNotFound = "error.question.not_found" + QuestionCannotDeleted = "error.question.cannot_deleted" + QuestionCannotClose = "error.question.cannot_close" + QuestionCannotUpdate = "error.question.cannot_update" + QuestionAlreadyDeleted = "error.question.already_deleted" + AnswerNotFound = "error.answer.not_found" + AnswerCannotDeleted = "error.answer.cannot_deleted" + AnswerCannotUpdate = "error.answer.cannot_update" + AnswerCannotAddByClosedQuestion = "error.answer.question_closed_cannot_add" + CommentEditWithoutPermission = "error.comment.edit_without_permission" + DisallowVote = "error.object.disallow_vote" + DisallowFollow = "error.object.disallow_follow" + DisallowVoteYourSelf = "error.object.disallow_vote_your_self" + CaptchaVerificationFailed = "error.object.captcha_verification_failed" + OldPasswordVerificationFailed = "error.object.old_password_verification_failed" + NewPasswordSameAsPreviousSetting = "error.object.new_password_same_as_previous_setting" + UserNotFound = "error.user.not_found" + UsernameInvalid = "error.user.username_invalid" + UsernameDuplicate = "error.user.username_duplicate" + UserSetAvatar = "error.user.set_avatar" + EmailDuplicate = "error.email.duplicate" + EmailVerifyURLExpired = "error.email.verify_url_expired" + EmailNeedToBeVerified = "error.email.need_to_be_verified" + EmailIllegalDomainError = "error.email.illegal_email_domain_error" + UserSuspended = "error.user.suspended" + ObjectNotFound = "error.object.not_found" + TagNotFound = "error.tag.not_found" + TagNotContainSynonym = "error.tag.not_contain_synonym_tags" + TagCannotUpdate = "error.tag.cannot_update" + TagIsUsedCannotDelete = "error.tag.is_used_cannot_delete" + TagAlreadyExist = "error.tag.already_exist" + RankFailToMeetTheCondition = "error.rank.fail_to_meet_the_condition" + VoteRankFailToMeetTheCondition = "error.rank.vote_fail_to_meet_the_condition" + ThemeNotFound = "error.theme.not_found" + LangNotFound = "error.lang.not_found" + ReportHandleFailed = "error.report.handle_failed" + ReportNotFound = "error.report.not_found" + ReadConfigFailed = "error.config.read_config_failed" + DatabaseConnectionFailed = "error.database.connection_failed" + InstallCreateTableFailed = "error.database.create_table_failed" + InstallConfigFailed = "error.install.create_config_failed" + SiteInfoNotFound = "error.site_info.not_found" + UploadFileSourceUnsupported = "error.upload.source_unsupported" + UploadFileUnsupportedFileFormat = "error.upload.unsupported_file_format" + RecommendTagNotExist = "error.tag.recommend_tag_not_found" + RecommendTagEnter = "error.tag.recommend_tag_enter" + RevisionReviewUnderway = "error.revision.review_underway" + RevisionNoPermission = "error.revision.no_permission" + UserCannotUpdateYourRole = "error.user.cannot_update_your_role" + TagCannotSetSynonymAsItself = "error.tag.cannot_set_synonym_as_itself" + NotAllowedRegistration = "error.user.not_allowed_registration" + SMTPConfigFromNameCannotBeEmail = "error.smtp.config_from_name_cannot_be_email" + AdminCannotUpdateTheirPassword = "error.admin.cannot_update_their_password" + AdminCannotModifySelfStatus = "error.admin.cannot_modify_self_status" + UserExternalLoginUnbindingForbidden = "error.user.external_login_unbinding_forbidden" + UserAccessDenied = "error.user.access_denied" + UserPageAccessDenied = "error.user.page_access_denied" ) diff --git a/internal/base/server/http.go b/internal/base/server/http.go index 1b699bce..1c79dbee 100644 --- a/internal/base/server/http.go +++ b/internal/base/server/http.go @@ -7,6 +7,7 @@ import ( brotli "github.com/anargu/gin-brotli" "github.com/answerdev/answer/internal/base/middleware" "github.com/answerdev/answer/internal/router" + "github.com/answerdev/answer/plugin" "github.com/answerdev/answer/ui" "github.com/gin-gonic/gin" ) @@ -20,6 +21,7 @@ func NewHTTPServer(debug bool, authUserMiddleware *middleware.AuthUserMiddleware, avatarMiddleware *middleware.AvatarMiddleware, templateRouter *router.TemplateRouter, + pluginAPIRouter *router.PluginAPIRouter, ) *gin.Engine { if debug { @@ -34,7 +36,7 @@ func NewHTTPServer(debug bool, html, _ := fs.Sub(ui.Template, "template") htmlTemplate := template.Must(template.New("").Funcs(funcMap).ParseFS(html, "*")) r.SetHTMLTemplate(htmlTemplate) - + r.Use(middleware.HeadersByRequestURI()) viewRouter.Register(r) rootGroup := r.Group("") @@ -62,5 +64,17 @@ func NewHTTPServer(debug bool, answerRouter.RegisterAnswerAdminAPIRouter(adminauthV1) templateRouter.RegisterTemplateRouter(rootGroup) + + // plugin routes + pluginAPIRouter.RegisterUnAuthConnectorRouter(mustUnAuthV1) + pluginAPIRouter.RegisterAuthUserConnectorRouter(authV1) + pluginAPIRouter.RegisterAuthAdminConnectorRouter(adminauthV1) + + _ = plugin.CallAgent(func(agent plugin.Agent) error { + agent.RegisterUnAuthRouter(mustUnAuthV1) + agent.RegisterAuthUserRouter(authV1) + agent.RegisterAuthAdminRouter(adminauthV1) + return nil + }) return r } diff --git a/internal/base/translator/provider.go b/internal/base/translator/provider.go index 1504a25f..c6a5a36f 100644 --- a/internal/base/translator/provider.go +++ b/internal/base/translator/provider.go @@ -59,6 +59,7 @@ func NewTranslator(c *I18n) (tr i18n.Translator, err error) { originalTr := struct { Backend map[string]map[string]interface{} `yaml:"backend"` UI map[string]interface{} `yaml:"ui"` + Plugin map[string]interface{} `yaml:"plugin"` }{} if err = yaml.Unmarshal(buf, &originalTr); err != nil { return nil, err @@ -69,6 +70,7 @@ func NewTranslator(c *I18n) (tr i18n.Translator, err error) { } translation["backend"] = originalTr.Backend translation["ui"] = originalTr.UI + translation["plugin"] = originalTr.Plugin content, err := yaml.Marshal(translation) if err != nil { @@ -120,6 +122,9 @@ func CheckLanguageIsValid(lang string) bool { // Tr use language to translate data. If this language translation is not available, return default english translation. func Tr(lang i18n.Language, data string) string { + if GlobalTrans == nil { + return data + } translation := GlobalTrans.Tr(lang, data) if translation == data { return GlobalTrans.Tr(i18n.DefaultLanguage, data) diff --git a/internal/cli/build.go b/internal/cli/build.go new file mode 100644 index 00000000..b6c74f96 --- /dev/null +++ b/internal/cli/build.go @@ -0,0 +1,358 @@ +package cli + +import ( + "bytes" + "embed" + "fmt" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + "text/template" + + "github.com/Masterminds/semver/v3" + "github.com/answerdev/answer/pkg/dir" + "github.com/answerdev/answer/pkg/writer" + "github.com/answerdev/answer/ui" + "github.com/segmentfault/pacman/log" + "gopkg.in/yaml.v3" +) + +const ( + mainGoTpl = `package main + +import ( + answercmd "github.com/answerdev/answer/cmd" + + // remote plugins + {{- range .remote_plugins}} + _ "{{.}}" + {{- end}} + + // local plugins + {{- range .local_plugins}} + _ "answer/{{.}}" + {{- end}} +) + +func main() { + answercmd.Main() +} +` + goModTpl = `module answer + +go 1.19 +` +) + +type answerBuilder struct { + buildingMaterial *buildingMaterial + BuildError error +} + +type buildingMaterial struct { + answerModuleReplacement string + plugins []*pluginInfo + outputPath string + tmpDir string + originalAnswerInfo OriginalAnswerInfo +} + +type OriginalAnswerInfo struct { + Version string + Revision string + Time string +} + +type pluginInfo struct { + // Name of the plugin e.g. github.com/answerdev/github-connector + Name string + // Path to the plugin. If path exist, read plugin from local filesystem + Path string + // Version of the plugin + Version string +} + +func newAnswerBuilder(outputPath string, plugins []string, originalAnswerInfo OriginalAnswerInfo) *answerBuilder { + material := &buildingMaterial{originalAnswerInfo: originalAnswerInfo} + parentDir, _ := filepath.Abs(".") + material.tmpDir, _ = os.MkdirTemp(parentDir, "answer_build") + if len(outputPath) == 0 { + outputPath = filepath.Join(parentDir, "new_answer") + } + material.outputPath = outputPath + material.plugins = formatPlugins(plugins) + material.answerModuleReplacement = os.Getenv("ANSWER_MODULE") + return &answerBuilder{ + buildingMaterial: material, + } +} + +func (a *answerBuilder) DoTask(task func(b *buildingMaterial) error) { + if a.BuildError != nil { + return + } + a.BuildError = task(a.buildingMaterial) +} + +// BuildNewAnswer builds a new answer with specified plugins +func BuildNewAnswer(outputPath string, plugins []string, originalAnswerInfo OriginalAnswerInfo) (err error) { + builder := newAnswerBuilder(outputPath, plugins, originalAnswerInfo) + builder.DoTask(createMainGoFile) + builder.DoTask(downloadGoModFile) + builder.DoTask(mergeI18nFiles) + builder.DoTask(replaceNecessaryFile) + builder.DoTask(buildBinary) + builder.DoTask(cleanByproduct) + return builder.BuildError +} + +func formatPlugins(plugins []string) (formatted []*pluginInfo) { + for _, plugin := range plugins { + plugin = strings.TrimSpace(plugin) + // plugin description like this 'github.com/answerdev/github-connector@latest=/local/path' + info := &pluginInfo{} + plugin, info.Path, _ = strings.Cut(plugin, "=") + info.Name, info.Version, _ = strings.Cut(plugin, "@") + formatted = append(formatted, info) + } + return formatted +} + +func createMainGoFile(b *buildingMaterial) (err error) { + fmt.Printf("[build] tmp dir: %s\n", b.tmpDir) + err = dir.CreateDirIfNotExist(b.tmpDir) + if err != nil { + return err + } + + var ( + remotePlugins []string + ) + for _, p := range b.plugins { + remotePlugins = append(remotePlugins, versionedModulePath(p.Name, p.Version)) + } + + mainGoFile := &bytes.Buffer{} + tmpl, err := template.New("main").Parse(mainGoTpl) + if err != nil { + return err + } + err = tmpl.Execute(mainGoFile, map[string]any{ + "remote_plugins": remotePlugins, + }) + if err != nil { + return err + } + + err = writer.WriteFile(filepath.Join(b.tmpDir, "main.go"), mainGoFile.String()) + if err != nil { + return err + } + + err = writer.WriteFile(filepath.Join(b.tmpDir, "go.mod"), goModTpl) + if err != nil { + return err + } + + for _, p := range b.plugins { + if len(p.Path) == 0 { + continue + } + replacement := fmt.Sprintf("%s@v%s=%s", p.Name, p.Version, p.Path) + err = b.newExecCmd("go", "mod", "edit", "-replace", replacement).Run() + if err != nil { + return err + } + } + return +} + +func downloadGoModFile(b *buildingMaterial) (err error) { + // If user specify a module replacement, use it. Otherwise, use the latest version. + if len(b.answerModuleReplacement) > 0 { + replacement := fmt.Sprintf("%s=%s", "github.com/answerdev/answer", b.answerModuleReplacement) + err = b.newExecCmd("go", "mod", "edit", "-replace", replacement).Run() + if err != nil { + return err + } + } + + err = b.newExecCmd("go", "mod", "tidy").Run() + if err != nil { + return err + } + + err = b.newExecCmd("go", "mod", "vendor").Run() + if err != nil { + return err + } + return +} + +func replaceNecessaryFile(b *buildingMaterial) (err error) { + fmt.Printf("try to replace ui build directory\n") + uiBuildDir := filepath.Join(b.tmpDir, "vendor/github.com/answerdev/answer/ui") + err = copyDirEntries(ui.Build, ".", uiBuildDir) + return err +} + +func mergeI18nFiles(b *buildingMaterial) (err error) { + fmt.Printf("try to merge i18n files\n") + + type YamlPluginContent struct { + Plugin map[string]any `yaml:"plugin"` + } + + pluginAllTranslations := make(map[string]*YamlPluginContent) + for _, plugin := range b.plugins { + i18nDir := filepath.Join(b.tmpDir, fmt.Sprintf("vendor/%s/i18n", plugin.Name)) + fmt.Println("i18n dir: ", i18nDir) + if !dir.CheckDirExist(i18nDir) { + continue + } + + entries, err := os.ReadDir(i18nDir) + if err != nil { + return err + } + + for _, file := range entries { + // ignore directory + if file.IsDir() { + continue + } + // ignore non-YAML file + if filepath.Ext(file.Name()) != ".yaml" { + continue + } + buf, err := os.ReadFile(filepath.Join(i18nDir, file.Name())) + if err != nil { + log.Debugf("read translation file failed: %s %s", file.Name(), err) + continue + } + + translation := &YamlPluginContent{} + if err = yaml.Unmarshal(buf, translation); err != nil { + log.Debugf("unmarshal translation file failed: %s %s", file.Name(), err) + continue + } + + if pluginAllTranslations[file.Name()] == nil { + pluginAllTranslations[file.Name()] = &YamlPluginContent{Plugin: make(map[string]any)} + } + for k, v := range translation.Plugin { + pluginAllTranslations[file.Name()].Plugin[k] = v + } + } + } + + originalI18nDir := filepath.Join(b.tmpDir, "vendor/github.com/answerdev/answer/i18n") + entries, err := os.ReadDir(originalI18nDir) + if err != nil { + return err + } + + for _, file := range entries { + // ignore directory + if file.IsDir() { + continue + } + // ignore non-YAML file + filename := file.Name() + if filepath.Ext(filename) != ".yaml" && filename != "i18n.yaml" { + continue + } + + // if plugin don't have this translation file, ignore it + if pluginAllTranslations[filename] == nil { + continue + } + + out, _ := yaml.Marshal(pluginAllTranslations[filename]) + + buf, err := os.OpenFile(filepath.Join(originalI18nDir, filename), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Debugf("read translation file failed: %s %s", filename, err) + continue + } + + _, _ = buf.WriteString("\n") + _, _ = buf.Write(out) + _ = buf.Close() + } + return err +} + +func copyDirEntries(sourceFs embed.FS, sourceDir string, targetDir string) (err error) { + entries, err := ui.Build.ReadDir(sourceDir) + if err != nil { + return err + } + + err = dir.CreateDirIfNotExist(targetDir) + if err != nil { + return err + } + for _, entry := range entries { + if entry.IsDir() { + err = copyDirEntries(sourceFs, filepath.Join(sourceDir, entry.Name()), filepath.Join(targetDir, entry.Name())) + if err != nil { + return err + } + continue + } + file, err := sourceFs.ReadFile(filepath.Join(sourceDir, entry.Name())) + if err != nil { + return err + } + filename := filepath.Join(targetDir, entry.Name()) + err = os.WriteFile(filename, file, 0666) + if err != nil { + return err + } + } + return nil +} + +func buildBinary(b *buildingMaterial) (err error) { + versionInfo := b.originalAnswerInfo + cmdPkg := "github.com/answerdev/answer/cmd" + ldflags := fmt.Sprintf("-X %s.Version=%s -X %s.Revision=%s -X %s.Time=%s", + cmdPkg, versionInfo.Version, cmdPkg, versionInfo.Revision, cmdPkg, versionInfo.Time) + err = b.newExecCmd("go", "build", + "-ldflags", ldflags, "-o", b.outputPath, ".").Run() + if err != nil { + return err + } + return +} + +func cleanByproduct(b *buildingMaterial) (err error) { + return os.RemoveAll(b.tmpDir) +} + +func (b *buildingMaterial) newExecCmd(command string, args ...string) *exec.Cmd { + cmd := exec.Command(command, args...) + fmt.Println(cmd.Args) + cmd.Dir = b.tmpDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd +} + +func versionedModulePath(modulePath, moduleVersion string) string { + if moduleVersion == "" { + return modulePath + } + ver, err := semver.StrictNewVersion(strings.TrimPrefix(moduleVersion, "v")) + if err != nil { + return modulePath + } + major := ver.Major() + if major > 1 { + modulePath += fmt.Sprintf("/v%d", major) + } + return path.Clean(modulePath) +} diff --git a/internal/controller/connector_controller.go b/internal/controller/connector_controller.go new file mode 100644 index 00000000..bb6c37ad --- /dev/null +++ b/internal/controller/connector_controller.go @@ -0,0 +1,254 @@ +package controller + +import ( + "fmt" + "net/http" + + "github.com/answerdev/answer/internal/base/handler" + "github.com/answerdev/answer/internal/base/middleware" + "github.com/answerdev/answer/internal/schema" + "github.com/answerdev/answer/internal/service/export" + "github.com/answerdev/answer/internal/service/siteinfo_common" + "github.com/answerdev/answer/internal/service/user_external_login" + "github.com/answerdev/answer/plugin" + "github.com/gin-gonic/gin" + "github.com/segmentfault/pacman/log" +) + +const ( + commonRouterPrefix = "/answer/api/v1" + ConnectorLoginRouterPrefix = "/connector/login/" + ConnectorRedirectRouterPrefix = "/connector/redirect/" +) + +// ConnectorController comment controller +type ConnectorController struct { + siteInfoService *siteinfo_common.SiteInfoCommonService + userExternalService *user_external_login.UserExternalLoginService + emailService *export.EmailService +} + +// NewConnectorController new controller +func NewConnectorController( + siteInfoService *siteinfo_common.SiteInfoCommonService, + emailService *export.EmailService, + userExternalService *user_external_login.UserExternalLoginService, +) *ConnectorController { + return &ConnectorController{ + siteInfoService: siteInfoService, + userExternalService: userExternalService, + emailService: emailService, + } +} + +// ConnectorLoginDispatcher dispatch connector login request to specific connector by slug name +// We can't register specific router for each connector when application start, because the plugin status will be changed by admin. +// If the plugin is disabled, the router should be unavailable. +func (cc *ConnectorController) ConnectorLoginDispatcher(ctx *gin.Context) { + slugName := ctx.Param("name") + var c plugin.Connector + _ = plugin.CallConnector(func(connector plugin.Connector) error { + if connector.ConnectorSlugName() == slugName { + c = connector + } + return nil + }) + if c == nil { + log.Errorf("connector %s not found", slugName) + ctx.Redirect(http.StatusFound, "/50x") + return + } + cc.ConnectorLogin(c)(ctx) +} + +func (cc *ConnectorController) ConnectorRedirectDispatcher(ctx *gin.Context) { + slugName := ctx.Param("name") + var c plugin.Connector + _ = plugin.CallConnector(func(connector plugin.Connector) error { + if connector.ConnectorSlugName() == slugName { + c = connector + } + return nil + }) + if c == nil { + log.Errorf("connector %s not found", slugName) + ctx.Redirect(http.StatusFound, "/50x") + return + } + cc.ConnectorRedirect(c)(ctx) +} + +func (cc *ConnectorController) ConnectorLogin(connector plugin.Connector) (fn func(ctx *gin.Context)) { + return func(ctx *gin.Context) { + general, err := cc.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Error(err) + ctx.Redirect(http.StatusFound, "/50x") + return + } + + receiverURL := fmt.Sprintf("%s%s%s%s", general.SiteUrl, + commonRouterPrefix, ConnectorRedirectRouterPrefix, connector.ConnectorSlugName()) + redirectURL := connector.ConnectorSender(ctx, receiverURL) + if len(redirectURL) > 0 { + ctx.Redirect(http.StatusFound, redirectURL) + } + return + } +} + +func (cc *ConnectorController) ConnectorRedirect(connector plugin.Connector) (fn func(ctx *gin.Context)) { + return func(ctx *gin.Context) { + siteGeneral, err := cc.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Errorf("get site info failed: %v", err) + ctx.Redirect(http.StatusFound, "/50x") + return + } + receiverURL := fmt.Sprintf("%s%s%s%s", siteGeneral.SiteUrl, + commonRouterPrefix, ConnectorRedirectRouterPrefix, connector.ConnectorSlugName()) + userInfo, err := connector.ConnectorReceiver(ctx, receiverURL) + if err != nil { + log.Errorf("connector received failed: %v", err) + ctx.Redirect(http.StatusFound, "/50x") + return + } + log.Debugf("connector received: %+v", userInfo) + u := &schema.ExternalLoginUserInfoCache{ + Provider: connector.ConnectorSlugName(), + ExternalID: userInfo.ExternalID, + DisplayName: userInfo.DisplayName, + Username: userInfo.Username, + Email: userInfo.Email, + Avatar: userInfo.Avatar, + MetaInfo: userInfo.MetaInfo, + } + resp, err := cc.userExternalService.ExternalLogin(ctx, u) + if err != nil { + log.Errorf("external login failed: %v", err) + ctx.Redirect(http.StatusFound, "/50x") + return + } + if len(resp.AccessToken) > 0 { + ctx.Redirect(http.StatusFound, fmt.Sprintf("%s/users/auth-landing?access_token=%s", + siteGeneral.SiteUrl, resp.AccessToken)) + } else { + ctx.Redirect(http.StatusFound, fmt.Sprintf("%s/users/confirm-email?binding_key=%s", + siteGeneral.SiteUrl, resp.BindingKey)) + } + } +} + +// ConnectorsInfo get all enabled connectors +// @Summary get all enabled connectors +// @Description get all enabled connectors +// @Tags PluginConnector +// @Security ApiKeyAuth +// @Produce json +// @Success 200 {object} handler.RespBody{data=[]schema.ConnectorInfoResp} +// @Router /answer/api/v1/connector/info [get] +func (cc *ConnectorController) ConnectorsInfo(ctx *gin.Context) { + general, err := cc.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + + resp := make([]*schema.ConnectorInfoResp, 0) + _ = plugin.CallConnector(func(fn plugin.Connector) error { + connectorName := fn.ConnectorName() + resp = append(resp, &schema.ConnectorInfoResp{ + Name: connectorName.Translate(ctx), + Icon: fn.ConnectorLogoSVG(), + Link: fmt.Sprintf("%s%s%s%s", general.SiteUrl, + commonRouterPrefix, ConnectorLoginRouterPrefix, fn.ConnectorSlugName()), + }) + return nil + }) + handler.HandleResponse(ctx, nil, resp) +} + +// ExternalLoginBindingUserSendEmail external login binding user send email +// @Summary external login binding user send email +// @Description external login binding user send email +// @Tags PluginConnector +// @Accept json +// @Produce json +// @Param data body schema.ExternalLoginBindingUserSendEmailReq true "external login binding user send email" +// @Success 200 {object} handler.RespBody{data=schema.ExternalLoginBindingUserSendEmailResp} +// @Router /answer/api/v1/connector/binding/email [post] +func (cc *ConnectorController) ExternalLoginBindingUserSendEmail(ctx *gin.Context) { + req := &schema.ExternalLoginBindingUserSendEmailReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + resp, err := cc.userExternalService.ExternalLoginBindingUserSendEmail(ctx, req) + handler.HandleResponse(ctx, err, resp) +} + +// ConnectorsUserInfo get all connectors info about user +// @Summary get all connectors info about user +// @Description get all connectors info about user +// @Tags PluginConnector +// @Security ApiKeyAuth +// @Produce json +// @Success 200 {object} handler.RespBody{data=[]schema.ConnectorUserInfoResp} +// @Router /answer/api/v1/connector/user/info [get] +func (cc *ConnectorController) ConnectorsUserInfo(ctx *gin.Context) { + general, err := cc.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + + userID := middleware.GetLoginUserIDFromContext(ctx) + + userInfoList, err := cc.userExternalService.GetExternalLoginUserInfoList(ctx, userID) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + userExternalLoginMapping := make(map[string]string) + for _, userInfo := range userInfoList { + userExternalLoginMapping[userInfo.Provider] = userInfo.ExternalID + } + + resp := make([]*schema.ConnectorUserInfoResp, 0) + _ = plugin.CallConnector(func(fn plugin.Connector) error { + externalID := userExternalLoginMapping[fn.ConnectorSlugName()] + connectorName := fn.ConnectorName() + resp = append(resp, &schema.ConnectorUserInfoResp{ + Name: connectorName.Translate(ctx), + Icon: fn.ConnectorLogoSVG(), + Link: fmt.Sprintf("%s%s%s%s", general.SiteUrl, + commonRouterPrefix, ConnectorLoginRouterPrefix, fn.ConnectorSlugName()), + Binding: len(externalID) > 0, + ExternalID: externalID, + }) + return nil + }) + handler.HandleResponse(ctx, nil, resp) +} + +// ExternalLoginUnbinding unbind external user login +// @Summary unbind external user login +// @Description unbind external user login +// @Tags PluginConnector +// @Security ApiKeyAuth +// @Accept json +// @Produce json +// @Param data body schema.ExternalLoginUnbindingReq true "ExternalLoginUnbindingReq" +// @Success 200 {object} handler.RespBody{} +// @Router /answer/api/v1/connector/user/unbinding [delete] +func (cc *ConnectorController) ExternalLoginUnbinding(ctx *gin.Context) { + req := &schema.ExternalLoginUnbindingReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + + resp, err := cc.userExternalService.ExternalLoginUnbinding(ctx, req) + handler.HandleResponse(ctx, err, resp) +} diff --git a/internal/controller/controller.go b/internal/controller/controller.go index fcbca143..a5daabba 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -24,4 +24,6 @@ var ProviderSetController = wire.NewSet( NewUploadController, NewActivityController, NewTemplateController, + NewConnectorController, + NewUserCenterController, ) diff --git a/internal/controller/plugin_user_center_controller.go b/internal/controller/plugin_user_center_controller.go new file mode 100644 index 00000000..024a0692 --- /dev/null +++ b/internal/controller/plugin_user_center_controller.go @@ -0,0 +1,196 @@ +package controller + +import ( + "fmt" + "net/http" + + "github.com/answerdev/answer/internal/base/handler" + "github.com/answerdev/answer/internal/base/middleware" + "github.com/answerdev/answer/internal/schema" + "github.com/answerdev/answer/internal/service/siteinfo_common" + "github.com/answerdev/answer/internal/service/user_external_login" + "github.com/answerdev/answer/plugin" + "github.com/gin-gonic/gin" + "github.com/segmentfault/pacman/log" +) + +const ( + UserCenterLoginRouter = "/user-center/login/redirect" + UserCenterSignUpRedirectRouter = "/user-center/sign-up/redirect" +) + +// UserCenterController comment controller +type UserCenterController struct { + userCenterLoginService *user_external_login.UserCenterLoginService + siteInfoService *siteinfo_common.SiteInfoCommonService +} + +// NewUserCenterController new controller +func NewUserCenterController( + userCenterLoginService *user_external_login.UserCenterLoginService, + siteInfoService *siteinfo_common.SiteInfoCommonService, +) *UserCenterController { + return &UserCenterController{ + userCenterLoginService: userCenterLoginService, + siteInfoService: siteInfoService, + } +} + +// UserCenterAgent get user center agent info +func (uc *UserCenterController) UserCenterAgent(ctx *gin.Context) { + resp := &schema.UserCenterAgentResp{} + resp.Enabled = plugin.UserCenterEnabled() + if !resp.Enabled { + handler.HandleResponse(ctx, nil, resp) + return + } + siteGeneral, err := uc.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Errorf("get site info failed: %v", err) + ctx.Redirect(http.StatusFound, "/50x") + return + } + + resp.AgentInfo = &schema.AgentInfo{} + resp.AgentInfo.LoginRedirectURL = fmt.Sprintf("%s%s%s", siteGeneral.SiteUrl, + commonRouterPrefix, UserCenterLoginRouter) + resp.AgentInfo.SignUpRedirectURL = fmt.Sprintf("%s%s%s", siteGeneral.SiteUrl, + commonRouterPrefix, UserCenterSignUpRedirectRouter) + + _ = plugin.CallUserCenter(func(uc plugin.UserCenter) error { + info := uc.Description() + resp.AgentInfo.Name = info.Name + resp.AgentInfo.DisplayName = info.DisplayName.Translate(ctx) + resp.AgentInfo.Icon = info.Icon + resp.AgentInfo.Url = info.Url + resp.AgentInfo.ControlCenterItems = make([]*schema.ControlCenter, 0) + resp.AgentInfo.EnabledOriginalUserSystem = info.EnabledOriginalUserSystem + items := uc.ControlCenterItems() + for _, item := range items { + resp.AgentInfo.ControlCenterItems = append(resp.AgentInfo.ControlCenterItems, &schema.ControlCenter{ + Name: item.Name, + Label: item.Label, + Url: item.Url, + }) + } + return nil + }) + + handler.HandleResponse(ctx, nil, resp) +} + +// UserCenterPersonalBranding get user center personal user info +func (uc *UserCenterController) UserCenterPersonalBranding(ctx *gin.Context) { + req := &schema.GetOtherUserInfoByUsernameReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + resp, err := uc.userCenterLoginService.UserCenterPersonalBranding(ctx, req.Username) + handler.HandleResponse(ctx, err, resp) +} + +func (uc *UserCenterController) UserCenterLoginRedirect(ctx *gin.Context) { + var redirectURL string + _ = plugin.CallUserCenter(func(userCenter plugin.UserCenter) error { + info := userCenter.Description() + redirectURL = info.LoginRedirectURL + return nil + }) + ctx.Redirect(http.StatusFound, redirectURL) +} + +func (uc *UserCenterController) UserCenterSignUpRedirect(ctx *gin.Context) { + var redirectURL string + _ = plugin.CallUserCenter(func(userCenter plugin.UserCenter) error { + info := userCenter.Description() + redirectURL = info.LoginRedirectURL + return nil + }) + ctx.Redirect(http.StatusFound, redirectURL) +} + +func (uc *UserCenterController) UserCenterLoginCallback(ctx *gin.Context) { + siteGeneral, err := uc.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Errorf("get site info failed: %v", err) + ctx.Redirect(http.StatusFound, "/50x") + return + } + + userCenter, ok := plugin.GetUserCenter() + if !ok { + ctx.Redirect(http.StatusFound, "/404") + return + } + userInfo, err := userCenter.LoginCallback(ctx) + if err != nil { + log.Error(err) + if !ctx.IsAborted() { + ctx.Redirect(http.StatusFound, "/50x") + } + return + } + + resp, err := uc.userCenterLoginService.ExternalLogin(ctx, userCenter, userInfo) + if err != nil { + log.Errorf("external login failed: %v", err) + ctx.Redirect(http.StatusFound, "/50x") + return + } + if len(resp.ErrMsg) > 0 { + ctx.Redirect(http.StatusFound, fmt.Sprintf("/50x?title=%s&msg=%s", resp.ErrTitle, resp.ErrMsg)) + return + } + userCenter.AfterLogin(userInfo.ExternalID, resp.AccessToken) + ctx.Redirect(http.StatusFound, fmt.Sprintf("%s/users/auth-landing?access_token=%s", + siteGeneral.SiteUrl, resp.AccessToken)) +} + +func (uc *UserCenterController) UserCenterSignUpCallback(ctx *gin.Context) { + siteGeneral, err := uc.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Errorf("get site info failed: %v", err) + ctx.Redirect(http.StatusFound, "/50x") + return + } + + userCenter, ok := plugin.GetUserCenter() + if !ok { + ctx.Redirect(http.StatusFound, "/404") + return + } + userInfo, err := userCenter.SignUpCallback(ctx) + if err != nil { + log.Error(err) + ctx.Redirect(http.StatusFound, "/50x") + return + } + + resp, err := uc.userCenterLoginService.ExternalLogin(ctx, userCenter, userInfo) + if err != nil { + log.Errorf("external login failed: %v", err) + ctx.Redirect(http.StatusFound, "/50x") + return + } + if len(resp.ErrMsg) > 0 { + ctx.Redirect(http.StatusFound, fmt.Sprintf("/50x?title=%s&msg=%s", resp.ErrTitle, resp.ErrMsg)) + return + } + userCenter.AfterLogin(userInfo.ExternalID, resp.AccessToken) + ctx.Redirect(http.StatusFound, fmt.Sprintf("%s/users/auth-landing?access_token=%s", + siteGeneral.SiteUrl, resp.AccessToken)) +} + +// UserCenterUserSettings user center user settings +func (uc *UserCenterController) UserCenterUserSettings(ctx *gin.Context) { + userID := middleware.GetLoginUserIDFromContext(ctx) + resp, err := uc.userCenterLoginService.UserCenterUserSettings(ctx, userID) + handler.HandleResponse(ctx, err, resp) +} + +// UserCenterAdminFunctionAgent user center admin function agent +func (uc *UserCenterController) UserCenterAdminFunctionAgent(ctx *gin.Context) { + resp, err := uc.userCenterLoginService.UserCenterAdminFunctionAgent(ctx) + handler.HandleResponse(ctx, err, resp) +} diff --git a/internal/controller/question_controller.go b/internal/controller/question_controller.go index 6b474670..e72a692e 100644 --- a/internal/controller/question_controller.go +++ b/internal/controller/question_controller.go @@ -11,7 +11,6 @@ import ( "github.com/answerdev/answer/internal/service" "github.com/answerdev/answer/internal/service/permission" "github.com/answerdev/answer/internal/service/rank" - "github.com/answerdev/answer/pkg/converter" "github.com/answerdev/answer/pkg/uid" "github.com/gin-gonic/gin" "github.com/jinzhu/copier" @@ -552,84 +551,75 @@ func (qc *QuestionController) UserTop(ctx *gin.Context) { }) } -// UserList godoc -// @Summary UserList -// @Description UserList -// @Tags Question +// PersonalQuestionPage list personal questions +// @Summary list personal questions +// @Description list personal questions +// @Tags Personal // @Accept json // @Produce json // @Security ApiKeyAuth // @Param username query string true "username" default(string) // @Param order query string true "order" Enums(newest,score) // @Param page query string true "page" default(0) -// @Param pagesize query string true "pagesize" default(20) +// @Param page_size query string true "page_size" default(20) // @Success 200 {object} handler.RespBody // @Router /personal/question/page [get] -func (qc *QuestionController) UserList(ctx *gin.Context) { - userName := ctx.Query("username") - order := ctx.Query("order") - pageStr := ctx.Query("page") - pageSizeStr := ctx.Query("pagesize") - page := converter.StringToInt(pageStr) - pageSize := converter.StringToInt(pageSizeStr) - userID := middleware.GetLoginUserIDFromContext(ctx) - questionList, count, err := qc.questionService.SearchUserList(ctx, userName, order, page, pageSize, userID) - handler.HandleResponse(ctx, err, gin.H{ - "list": questionList, - "count": count, - }) +func (qc *QuestionController) PersonalQuestionPage(ctx *gin.Context) { + req := &schema.PersonalQuestionPageReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx) + resp, err := qc.questionService.PersonalQuestionPage(ctx, req) + handler.HandleResponse(ctx, err, resp) } -// UserAnswerList godoc -// @Summary UserAnswerList -// @Description UserAnswerList -// @Tags api-answer +// PersonalAnswerPage list personal answers +// @Summary list personal answers +// @Description list personal answers +// @Tags Personal // @Accept json // @Produce json // @Security ApiKeyAuth // @Param username query string true "username" default(string) // @Param order query string true "order" Enums(newest,score) // @Param page query string true "page" default(0) -// @Param pagesize query string true "pagesize" default(20) +// @Param page_size query string true "page_size" default(20) // @Success 200 {object} handler.RespBody // @Router /answer/api/v1/personal/answer/page [get] -func (qc *QuestionController) UserAnswerList(ctx *gin.Context) { - userName := ctx.Query("username") - order := ctx.Query("order") - pageStr := ctx.Query("page") - pageSizeStr := ctx.Query("pagesize") - page := converter.StringToInt(pageStr) - pageSize := converter.StringToInt(pageSizeStr) - userID := middleware.GetLoginUserIDFromContext(ctx) - questionList, count, err := qc.questionService.SearchUserAnswerList(ctx, userName, order, page, pageSize, userID) - handler.HandleResponse(ctx, err, gin.H{ - "list": questionList, - "count": count, - }) +func (qc *QuestionController) PersonalAnswerPage(ctx *gin.Context) { + req := &schema.PersonalAnswerPageReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx) + resp, err := qc.questionService.PersonalAnswerPage(ctx, req) + handler.HandleResponse(ctx, err, resp) } -// UserCollectionList godoc -// @Summary UserCollectionList -// @Description UserCollectionList +// PersonalCollectionPage list personal collections +// @Summary list personal collections +// @Description list personal collections // @Tags Collection // @Accept json // @Produce json // @Security ApiKeyAuth // @Param page query string true "page" default(0) -// @Param pagesize query string true "pagesize" default(20) +// @Param page_size query string true "page_size" default(20) // @Success 200 {object} handler.RespBody // @Router /answer/api/v1/personal/collection/page [get] -func (qc *QuestionController) UserCollectionList(ctx *gin.Context) { - pageStr := ctx.Query("page") - pageSizeStr := ctx.Query("pagesize") - page := converter.StringToInt(pageStr) - pageSize := converter.StringToInt(pageSizeStr) - userID := middleware.GetLoginUserIDFromContext(ctx) - questionList, count, err := qc.questionService.SearchUserCollectionList(ctx, page, pageSize, userID) - handler.HandleResponse(ctx, err, gin.H{ - "list": questionList, - "count": count, - }) +func (qc *QuestionController) PersonalCollectionPage(ctx *gin.Context) { + req := &schema.PersonalCollectionPageReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + + resp, err := qc.questionService.PersonalCollectionPage(ctx, req) + handler.HandleResponse(ctx, err, resp) } // AdminSearchList godoc @@ -678,6 +668,9 @@ func (qc *QuestionController) AdminSearchAnswerList(ctx *gin.Context) { return } req.QuestionID = uid.DeShortID(req.QuestionID) + if req.QuestionID == "0" { + req.QuestionID = "" + } userID := middleware.GetLoginUserIDFromContext(ctx) questionList, count, err := qc.questionService.AdminSearchAnswerList(ctx, req, userID) handler.HandleResponse(ctx, err, gin.H{ diff --git a/internal/controller/siteinfo_controller.go b/internal/controller/siteinfo_controller.go index b0ec1315..f815bdb9 100644 --- a/internal/controller/siteinfo_controller.go +++ b/internal/controller/siteinfo_controller.go @@ -64,6 +64,10 @@ func (sc *SiteinfoController) GetSiteInfo(ctx *gin.Context) { if err != nil { log.Error(err) } + resp.SiteUsers, err = sc.siteInfoService.GetSiteUsers(ctx) + if err != nil { + log.Error(err) + } handler.HandleResponse(ctx, nil, resp) } diff --git a/internal/controller/user_controller.go b/internal/controller/user_controller.go index 4fb7f8a6..8c90ce6b 100644 --- a/internal/controller/user_controller.go +++ b/internal/controller/user_controller.go @@ -13,6 +13,7 @@ import ( "github.com/answerdev/answer/internal/service/export" "github.com/answerdev/answer/internal/service/siteinfo_common" "github.com/answerdev/answer/internal/service/uploader" + "github.com/answerdev/answer/pkg/checker" "github.com/gin-gonic/gin" "github.com/segmentfault/pacman/errors" "github.com/segmentfault/pacman/log" @@ -183,9 +184,9 @@ func (uc *UserController) UseRePassWord(ctx *gin.Context) { return } - resp, err := uc.userService.UseRePassword(ctx, req) + err := uc.userService.UpdatePasswordWhenForgot(ctx, req) uc.actionService.ActionRecordDel(ctx, schema.ActionRecordTypeFindPass, ctx.ClientIP()) - handler.HandleResponse(ctx, err, resp) + handler.HandleResponse(ctx, err, nil) } // UserLogout user logout @@ -223,7 +224,7 @@ func (uc *UserController) UserRegisterByEmail(ctx *gin.Context) { handler.HandleResponse(ctx, err, nil) return } - if !siteInfo.AllowNewRegistrations { + if !siteInfo.AllowNewRegistrations || !siteInfo.AllowEmailRegistrations { handler.HandleResponse(ctx, errors.BadRequest(reason.NotAllowedRegistration), nil) return } @@ -232,6 +233,10 @@ func (uc *UserController) UserRegisterByEmail(ctx *gin.Context) { if handler.BindAndCheck(ctx, req) { return } + if !checker.EmailInAllowEmailDomain(req.Email, siteInfo.AllowEmailDomains) { + handler.HandleResponse(ctx, errors.BadRequest(reason.EmailIllegalDomainError), nil) + return + } req.IP = ctx.ClientIP() captchaPass := uc.actionService.UserRegisterVerifyCaptcha(ctx, req.CaptchaID, req.CaptchaCode) if !captchaPass { @@ -334,15 +339,16 @@ func (uc *UserController) UserVerifyEmailSend(ctx *gin.Context) { // @Accept json // @Produce json // @Security ApiKeyAuth -// @Param data body schema.UserModifyPassWordRequest true "UserModifyPassWordRequest" +// @Param data body schema.UserModifyPasswordReq true "UserModifyPasswordReq" // @Success 200 {object} handler.RespBody // @Router /answer/api/v1/user/password [put] func (uc *UserController) UserModifyPassWord(ctx *gin.Context) { - req := &schema.UserModifyPassWordRequest{} + req := &schema.UserModifyPasswordReq{} if handler.BindAndCheck(ctx, req) { return } req.UserID = middleware.GetLoginUserIDFromContext(ctx) + req.AccessToken = middleware.ExtractToken(ctx) oldPassVerification, err := uc.userService.UserModifyPassWordVerification(ctx, req) if err != nil { @@ -488,6 +494,16 @@ func (uc *UserController) UserChangeEmailSendCode(ctx *gin.Context) { handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil) return } + // check whether email allow register or not + siteInfo, err := uc.siteInfoCommonService.GetSiteLogin(ctx) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + if !checker.EmailInAllowEmailDomain(req.Email, siteInfo.AllowEmailDomains) { + handler.HandleResponse(ctx, errors.BadRequest(reason.EmailIllegalDomainError), nil) + return + } captchaPass := uc.actionService.ActionRecordVerifyCaptcha(ctx, schema.ActionRecordTypeEmail, ctx.ClientIP(), req.CaptchaID, req.CaptchaCode) if !captchaPass { diff --git a/internal/controller_admin/controller.go b/internal/controller_admin/controller.go index 885d59e0..03c2a331 100644 --- a/internal/controller_admin/controller.go +++ b/internal/controller_admin/controller.go @@ -9,4 +9,5 @@ var ProviderSetController = wire.NewSet( NewThemeController, NewSiteInfoController, NewRoleController, + NewPluginController, ) diff --git a/internal/controller_admin/plugin_controller.go b/internal/controller_admin/plugin_controller.go new file mode 100644 index 00000000..e565f2bd --- /dev/null +++ b/internal/controller_admin/plugin_controller.go @@ -0,0 +1,184 @@ +package controller_admin + +import ( + "encoding/json" + + "github.com/answerdev/answer/internal/base/handler" + "github.com/answerdev/answer/internal/schema" + "github.com/answerdev/answer/internal/service/plugin_common" + "github.com/answerdev/answer/plugin" + "github.com/gin-gonic/gin" +) + +// PluginController role controller +type PluginController struct { + PluginCommonService *plugin_common.PluginCommonService +} + +// NewPluginController new controller +func NewPluginController(PluginCommonService *plugin_common.PluginCommonService) *PluginController { + return &PluginController{PluginCommonService: PluginCommonService} +} + +// GetPluginList get plugin list +// @Summary get plugin list +// @Description get plugin list +// @Tags AdminPlugin +// @Security ApiKeyAuth +// @Accept json +// @Produce json +// @Param status query string false "status: active/inactive" +// @Param have_config query boolean false "have config" +// @Success 200 {object} handler.RespBody{data=[]schema.GetPluginListResp} +// @Router /answer/admin/api/plugins [get] +func (pc *PluginController) GetPluginList(ctx *gin.Context) { + req := &schema.GetPluginListReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + pluginConfigMapping := make(map[string]bool) + _ = plugin.CallConfig(func(fn plugin.Config) error { + if len(fn.ConfigFields()) > 0 { + pluginConfigMapping[fn.Info().SlugName] = true + } + return nil + }) + + resp := make([]*schema.GetPluginListResp, 0) + _ = plugin.CallBase(func(base plugin.Base) error { + info := base.Info() + resp = append(resp, &schema.GetPluginListResp{ + Name: info.Name.Translate(ctx), + SlugName: info.SlugName, + Description: info.Description.Translate(ctx), + Version: info.Version, + Enabled: plugin.StatusManager.IsEnabled(info.SlugName), + HaveConfig: pluginConfigMapping[info.SlugName], + Link: info.Link, + }) + return nil + }) + + if len(req.Status) > 0 { + resp = pc.filterPluginByStatus(resp, req.Status) + } + if req.HaveConfig { + resp = pc.filterNoConfigPlugin(resp) + } + handler.HandleResponse(ctx, nil, resp) +} + +func (pc *PluginController) filterNoConfigPlugin(list []*schema.GetPluginListResp) []*schema.GetPluginListResp { + resp := make([]*schema.GetPluginListResp, 0) + for _, t := range list { + if t.HaveConfig { + resp = append(resp, t) + } + } + return resp +} + +func (pc *PluginController) filterPluginByStatus(list []*schema.GetPluginListResp, status schema.PluginStatus, +) []*schema.GetPluginListResp { + resp := make([]*schema.GetPluginListResp, 0) + for _, t := range list { + if status == schema.PluginStatusActive && t.Enabled { + resp = append(resp, t) + } else if status == schema.PluginStatusInactive && !t.Enabled { + resp = append(resp, t) + } + } + return resp +} + +// UpdatePluginStatus update plugin status +// @Summary update plugin status +// @Description update plugin status +// @Tags AdminPlugin +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param data body schema.UpdatePluginStatusReq true "UpdatePluginStatusReq" +// @Success 200 {object} handler.RespBody +// @Router /answer/admin/api/plugin/status [put] +func (pc *PluginController) UpdatePluginStatus(ctx *gin.Context) { + req := &schema.UpdatePluginStatusReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + plugin.StatusManager.Enable(req.PluginSlugName, req.Enabled) + err := pc.PluginCommonService.UpdatePluginStatus(ctx) + handler.HandleResponse(ctx, err, nil) +} + +// GetPluginConfig get plugin config +// @Summary get plugin config +// @Description get plugin config +// @Tags AdminPlugin +// @Security ApiKeyAuth +// @Produce json +// @Param plugin_slug_name query string true "plugin_slug_name" +// @Success 200 {object} handler.RespBody{data=schema.GetPluginConfigResp} +// @Router /answer/admin/api/plugin/config [get] +func (pc *PluginController) GetPluginConfig(ctx *gin.Context) { + req := &schema.GetPluginConfigReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + resp := &schema.GetPluginConfigResp{} + _ = plugin.CallBase(func(base plugin.Base) error { + if base.Info().SlugName != req.PluginSlugName { + return nil + } + info := base.Info() + resp.Name = info.Name.Translate(ctx) + resp.SlugName = info.SlugName + resp.Description = info.Description.Translate(ctx) + resp.Version = info.Version + return nil + }) + + _ = plugin.CallConfig(func(fn plugin.Config) error { + if fn.Info().SlugName != req.PluginSlugName { + return nil + } + resp.SetConfigFields(ctx, fn.ConfigFields()) + return nil + }) + handler.HandleResponse(ctx, nil, resp) +} + +// UpdatePluginConfig update plugin config +// @Summary update plugin config +// @Description update plugin config +// @Tags AdminPlugin +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param data body schema.UpdatePluginConfigReq true "UpdatePluginConfigReq" +// @Success 200 {object} handler.RespBody +// @Router /answer/admin/api/plugin/config [put] +func (pc *PluginController) UpdatePluginConfig(ctx *gin.Context) { + req := &schema.UpdatePluginConfigReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + configFields, _ := json.Marshal(req.ConfigFields) + err := plugin.CallConfig(func(fn plugin.Config) error { + if fn.Info().SlugName == req.PluginSlugName { + return fn.ConfigReceiver(configFields) + } + return nil + }) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + + err = pc.PluginCommonService.UpdatePluginConfig(ctx, req) + handler.HandleResponse(ctx, err, nil) +} diff --git a/internal/controller_admin/siteinfo_controller.go b/internal/controller_admin/siteinfo_controller.go index d799fe5b..c29c5b72 100644 --- a/internal/controller_admin/siteinfo_controller.go +++ b/internal/controller_admin/siteinfo_controller.go @@ -139,6 +139,19 @@ func (sc *SiteInfoController) GetSiteTheme(ctx *gin.Context) { handler.HandleResponse(ctx, err, resp) } +// GetSiteUsers get site user config +// @Summary get site user config +// @Description get site user config +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Success 200 {object} handler.RespBody{data=schema.SiteUsersResp} +// @Router /answer/admin/api/siteinfo/users [get] +func (sc *SiteInfoController) GetSiteUsers(ctx *gin.Context) { + resp, err := sc.siteInfoService.GetSiteUsers(ctx) + handler.HandleResponse(ctx, err, resp) +} + // GetRobots get site robots information // @Summary get site robots information // @Description get site robots information @@ -336,6 +349,24 @@ func (sc *SiteInfoController) SaveSiteTheme(ctx *gin.Context) { handler.HandleResponse(ctx, err, nil) } +// UpdateSiteUsers update site config about users +// @Summary update site info config about users +// @Description update site info config about users +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Param data body schema.SiteUsersReq true "users info" +// @Success 200 {object} handler.RespBody{} +// @Router /answer/admin/api/siteinfo/users [put] +func (sc *SiteInfoController) UpdateSiteUsers(ctx *gin.Context) { + req := &schema.SiteUsersReq{} + if handler.BindAndCheck(ctx, req) { + return + } + err := sc.siteInfoService.SaveSiteUsers(ctx, req) + handler.HandleResponse(ctx, err, nil) +} + // GetSMTPConfig get smtp config // @Summary GetSMTPConfig get smtp config // @Description GetSMTPConfig get smtp config @@ -366,3 +397,34 @@ func (sc *SiteInfoController) UpdateSMTPConfig(ctx *gin.Context) { err := sc.siteInfoService.UpdateSMTPConfig(ctx, req) handler.HandleResponse(ctx, err, nil) } + +// GetPrivilegesConfig get privileges config +// @Summary GetPrivilegesConfig get privileges config +// @Description GetPrivilegesConfig get privileges config +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Success 200 {object} handler.RespBody{data=schema.GetPrivilegesConfigResp} +// @Router /answer/admin/api/setting/privileges [get] +func (sc *SiteInfoController) GetPrivilegesConfig(ctx *gin.Context) { + resp, err := sc.siteInfoService.GetPrivilegesConfig(ctx) + handler.HandleResponse(ctx, err, resp) +} + +// UpdatePrivilegesConfig update privileges config +// @Summary update privileges config +// @Description update privileges config +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Param data body schema.UpdatePrivilegesConfigReq true "config" +// @Success 200 {object} handler.RespBody{} +// @Router /answer/admin/api/setting/privileges [put] +func (sc *SiteInfoController) UpdatePrivilegesConfig(ctx *gin.Context) { + req := &schema.UpdatePrivilegesConfigReq{} + if handler.BindAndCheck(ctx, req) { + return + } + err := sc.siteInfoService.UpdatePrivilegesConfig(ctx, req) + handler.HandleResponse(ctx, err, nil) +} diff --git a/internal/controller_admin/user_backyard_controller.go b/internal/controller_admin/user_backyard_controller.go index eaa06f73..cb8fe7d8 100644 --- a/internal/controller_admin/user_backyard_controller.go +++ b/internal/controller_admin/user_backyard_controller.go @@ -3,9 +3,12 @@ package controller_admin import ( "github.com/answerdev/answer/internal/base/handler" "github.com/answerdev/answer/internal/base/middleware" + "github.com/answerdev/answer/internal/base/reason" "github.com/answerdev/answer/internal/schema" "github.com/answerdev/answer/internal/service/user_admin" + "github.com/answerdev/answer/plugin" "github.com/gin-gonic/gin" + "github.com/segmentfault/pacman/errors" ) // UserAdminController user controller @@ -29,6 +32,10 @@ func NewUserAdminController(userService *user_admin.UserAdminService) *UserAdmin // @Success 200 {object} handler.RespBody // @Router /answer/admin/api/user/status [put] func (uc *UserAdminController) UpdateUserStatus(ctx *gin.Context) { + if u, ok := plugin.GetUserCenter(); ok && u.Description().UserStatusAgentEnabled { + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) + return + } req := &schema.UpdateUserStatusReq{} if handler.BindAndCheck(ctx, req) { return diff --git a/internal/entity/auth_user_entity.go b/internal/entity/auth_user_entity.go index a417cd2a..79d24fb1 100644 --- a/internal/entity/auth_user_entity.go +++ b/internal/entity/auth_user_entity.go @@ -6,4 +6,5 @@ type UserCacheInfo struct { UserStatus int `json:"user_status"` EmailStatus int `json:"email_status"` RoleID int `json:"role_id"` + ExternalID string `json:"external_id"` } diff --git a/internal/entity/plugin_config_entity.go b/internal/entity/plugin_config_entity.go new file mode 100644 index 00000000..f8260069 --- /dev/null +++ b/internal/entity/plugin_config_entity.go @@ -0,0 +1,13 @@ +package entity + +// PluginConfig plugin config +type PluginConfig struct { + ID int `xorm:"not null pk autoincr INT(11) id"` + PluginSlugName string `xorm:"unique VARCHAR(128) plugin_slug_name"` + Value string `xorm:"TEXT value"` +} + +// TableName config table name +func (PluginConfig) TableName() string { + return "plugin_config" +} diff --git a/internal/entity/user_external_login_entity.go b/internal/entity/user_external_login_entity.go new file mode 100644 index 00000000..b4a73fed --- /dev/null +++ b/internal/entity/user_external_login_entity.go @@ -0,0 +1,19 @@ +package entity + +import "time" + +// UserExternalLogin user external login +type UserExternalLogin struct { + ID int64 `xorm:"not null pk autoincr BIGINT(20) id"` + CreatedAt time.Time `xorm:"created TIMESTAMP created_at"` + UpdatedAt time.Time `xorm:"updated TIMESTAMP updated_at"` + UserID string `xorm:"not null default 0 BIGINT(20) user_id"` + Provider string `xorm:"not null default '' VARCHAR(100) provider"` + ExternalID string `xorm:"not null default '' VARCHAR(128) external_id"` + MetaInfo string `xorm:"TEXT meta_info"` +} + +// TableName table name +func (UserExternalLogin) TableName() string { + return "user_external_login" +} diff --git a/internal/migrations/init.go b/internal/migrations/init.go index 93bd0eec..d47014f9 100644 --- a/internal/migrations/init.go +++ b/internal/migrations/init.go @@ -50,6 +50,8 @@ var tables = []interface{}{ &entity.RolePowerRel{}, &entity.Power{}, &entity.UserRoleRel{}, + &entity.PluginConfig{}, + &entity.UserExternalLogin{}, } // InitDB init db @@ -112,9 +114,8 @@ func initAdminUser(engine *xorm.Engine) error { func initSiteInfo(engine *xorm.Engine, language, siteName, siteURL, contactEmail string) error { interfaceData := map[string]string{ - "logo": "", - "theme": "black", - "language": language, + "language": language, + "time_zone": "UTC", } interfaceDataBytes, _ := json.Marshal(interfaceData) _, err := engine.InsertOne(&entity.SiteInfo{ @@ -142,8 +143,9 @@ func initSiteInfo(engine *xorm.Engine, language, siteName, siteURL, contactEmail } loginConfig := map[string]bool{ - "allow_new_registrations": true, - "login_required": false, + "allow_new_registrations": true, + "allow_email_registrations": true, + "login_required": false, } loginConfigDataBytes, _ := json.Marshal(loginConfig) _, err = engine.InsertOne(&entity.SiteInfo{ @@ -177,6 +179,25 @@ func initSiteInfo(engine *xorm.Engine, language, siteName, siteURL, contactEmail if err != nil { return err } + + usersData := map[string]any{ + "default_avatar": "gravatar", + "allow_update_display_name": true, + "allow_update_username": true, + "allow_update_avatar": true, + "allow_update_bio": true, + "allow_update_website": true, + "allow_update_location": true, + } + usersDataBytes, _ := json.Marshal(usersData) + _, err = engine.InsertOne(&entity.SiteInfo{ + Type: "users", + Content: string(usersDataBytes), + Status: 1, + }) + if err != nil { + return err + } return err } @@ -345,10 +366,14 @@ func initConfigTable(engine *xorm.Engine) error { {ID: 116, Key: "rank.question.reopen", Value: `-1`}, {ID: 117, Key: "rank.tag.use_reserved_tag", Value: `-1`}, {ID: 118, Key: "plugin.status", Value: `{}`}, - {ID: 119, Key: "question.pin", Value: `-1`}, - {ID: 120, Key: "question.unpin", Value: `-1`}, - {ID: 121, Key: "question.show", Value: `-1`}, - {ID: 122, Key: "question.hide", Value: `-1`}, + {ID: 119, Key: "question.pin", Value: `0`}, + {ID: 120, Key: "question.unpin", Value: `0`}, + {ID: 121, Key: "question.show", Value: `0`}, + {ID: 122, Key: "question.hide", Value: `0`}, + {ID: 123, Key: "rank.question.pin", Value: `-1`}, + {ID: 124, Key: "rank.question.unpin", Value: `-1`}, + {ID: 125, Key: "rank.question.show", Value: `-1`}, + {ID: 126, Key: "rank.question.hide", Value: `-1`}, } _, err := engine.Insert(defaultConfigTable) return err diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go index 787c72f4..20e07eb2 100644 --- a/internal/migrations/migrations.go +++ b/internal/migrations/migrations.go @@ -58,6 +58,10 @@ var migrations = []Migration{ NewMigration("add new answer notification", addNewAnswerNotification, true), NewMigration("add user pin hide features", addRolePinAndHideFeatures, true), NewMigration("update accept answer rank", updateAcceptAnswerRank, true), + NewMigration("add plugin", addPlugin, false), + NewMigration("update user pin hide features", updateRolePinAndHideFeatures, true), + NewMigration("update question post time", updateQuestionPostTime, true), + NewMigration("add login limitations", addLoginLimitations, true), } // GetCurrentDBVersion returns the current db version diff --git a/internal/migrations/v10.go b/internal/migrations/v10.go new file mode 100644 index 00000000..7ad0c990 --- /dev/null +++ b/internal/migrations/v10.go @@ -0,0 +1,69 @@ +package migrations + +import ( + "encoding/json" + "fmt" + + "github.com/answerdev/answer/internal/base/constant" + "github.com/answerdev/answer/internal/entity" + "github.com/answerdev/answer/internal/schema" + "github.com/tidwall/gjson" + "xorm.io/xorm" +) + +func addLoginLimitations(x *xorm.Engine) error { + loginSiteInfo := &entity.SiteInfo{ + Type: constant.SiteTypeLogin, + } + exist, err := x.Get(loginSiteInfo) + if err != nil { + return fmt.Errorf("get config failed: %w", err) + } + if exist { + content := &schema.SiteLoginReq{} + _ = json.Unmarshal([]byte(loginSiteInfo.Content), content) + content.AllowEmailRegistrations = true + content.AllowEmailDomains = make([]string, 0) + _, err = x.ID(loginSiteInfo.ID).Cols("content").Update(loginSiteInfo) + if err != nil { + return fmt.Errorf("update site info failed: %w", err) + } + } + + interfaceSiteInfo := &entity.SiteInfo{ + Type: constant.SiteTypeInterface, + } + exist, err = x.Get(interfaceSiteInfo) + if err != nil { + return fmt.Errorf("get config failed: %w", err) + } + siteUsers := &schema.SiteUsersReq{ + AllowUpdateDisplayName: true, + AllowUpdateUsername: true, + AllowUpdateAvatar: true, + AllowUpdateBio: true, + AllowUpdateWebsite: true, + AllowUpdateLocation: true, + } + if exist { + siteUsers.DefaultAvatar = gjson.Get(interfaceSiteInfo.Content, "default_avatar").String() + } + data, _ := json.Marshal(siteUsers) + + exist, err = x.Get(&entity.SiteInfo{Type: constant.SiteTypeUsers}) + if err != nil { + return fmt.Errorf("get config failed: %w", err) + } + if !exist { + usersSiteInfo := &entity.SiteInfo{ + Type: constant.SiteTypeUsers, + Content: string(data), + Status: 1, + } + _, err = x.InsertOne(usersSiteInfo) + if err != nil { + return fmt.Errorf("insert site info failed: %w", err) + } + } + return nil +} diff --git a/internal/migrations/v11.go b/internal/migrations/v11.go new file mode 100644 index 00000000..5d9b8725 --- /dev/null +++ b/internal/migrations/v11.go @@ -0,0 +1,42 @@ +package migrations + +import ( + "fmt" + + "github.com/answerdev/answer/internal/entity" + "github.com/segmentfault/pacman/log" + "xorm.io/xorm" +) + +func updateRolePinAndHideFeatures(x *xorm.Engine) error { + + defaultConfigTable := []*entity.Config{ + {ID: 119, Key: "question.pin", Value: `0`}, + {ID: 120, Key: "question.unpin", Value: `0`}, + {ID: 121, Key: "question.show", Value: `0`}, + {ID: 122, Key: "question.hide", Value: `0`}, + {ID: 123, Key: "rank.question.pin", Value: `-1`}, + {ID: 124, Key: "rank.question.unpin", Value: `-1`}, + {ID: 125, Key: "rank.question.show", Value: `-1`}, + {ID: 126, Key: "rank.question.hide", Value: `-1`}, + } + for _, c := range defaultConfigTable { + exist, err := x.Get(&entity.Config{ID: c.ID}) + if err != nil { + return fmt.Errorf("get config failed: %w", err) + } + if exist { + if _, err = x.Update(c, &entity.Config{ID: c.ID}); err != nil { + log.Errorf("update %+v config failed: %s", c, err) + return fmt.Errorf("update config failed: %w", err) + } + continue + } + if _, err = x.Insert(&entity.Config{ID: c.ID, Key: c.Key, Value: c.Value}); err != nil { + log.Errorf("insert %+v config failed: %s", c, err) + return fmt.Errorf("add config failed: %w", err) + } + } + + return nil +} diff --git a/internal/migrations/v12.go b/internal/migrations/v12.go new file mode 100644 index 00000000..34b6c0c4 --- /dev/null +++ b/internal/migrations/v12.go @@ -0,0 +1,33 @@ +package migrations + +import ( + "fmt" + + "github.com/answerdev/answer/internal/entity" + "github.com/segmentfault/pacman/log" + "xorm.io/xorm" +) + +func updateQuestionPostTime(x *xorm.Engine) error { + questionList := make([]entity.Question, 0) + err := x.Find(&questionList, &entity.Question{}) + if err != nil { + return fmt.Errorf("get questions failed: %w", err) + } + for _, item := range questionList { + if item.PostUpdateTime.IsZero() { + if !item.UpdatedAt.IsZero() { + item.PostUpdateTime = item.UpdatedAt + } else if !item.CreatedAt.IsZero() { + item.PostUpdateTime = item.CreatedAt + } + if _, err = x.Update(item, &entity.Question{ID: item.ID}); err != nil { + log.Errorf("update %+v config failed: %s", item, err) + return fmt.Errorf("update question failed: %w", err) + } + } + + } + + return nil +} diff --git a/internal/migrations/v7.go b/internal/migrations/v7.go new file mode 100644 index 00000000..1770a80d --- /dev/null +++ b/internal/migrations/v7.go @@ -0,0 +1,30 @@ +package migrations + +import ( + "fmt" + + "github.com/answerdev/answer/internal/entity" + "github.com/segmentfault/pacman/log" + "xorm.io/xorm" +) + +func addPlugin(x *xorm.Engine) error { + defaultConfigTable := []*entity.Config{ + {ID: 118, Key: "plugin.status", Value: `{}`}, + } + for _, c := range defaultConfigTable { + exist, err := x.Get(&entity.Config{ID: c.ID, Key: c.Key}) + if err != nil { + return fmt.Errorf("get config failed: %w", err) + } + if exist { + continue + } + if _, err = x.Insert(&entity.Config{ID: c.ID, Key: c.Key, Value: c.Value}); err != nil { + log.Errorf("insert %+v config failed: %s", c, err) + return fmt.Errorf("add config failed: %w", err) + } + } + + return x.Sync(new(entity.PluginConfig), new(entity.UserExternalLogin)) +} diff --git a/internal/migrations/v8.go b/internal/migrations/v8.go index faac7f21..0b3674c3 100644 --- a/internal/migrations/v8.go +++ b/internal/migrations/v8.go @@ -63,18 +63,22 @@ func addRolePinAndHideFeatures(x *xorm.Engine) error { } defaultConfigTable := []*entity.Config{ - {ID: 119, Key: "question.pin", Value: `-1`}, - {ID: 120, Key: "question.unpin", Value: `-1`}, - {ID: 121, Key: "question.show", Value: `-1`}, - {ID: 122, Key: "question.hide", Value: `-1`}, + {ID: 119, Key: "question.pin", Value: `0`}, + {ID: 120, Key: "question.unpin", Value: `0`}, + {ID: 121, Key: "question.show", Value: `0`}, + {ID: 122, Key: "question.hide", Value: `0`}, + {ID: 123, Key: "rank.question.pin", Value: `-1`}, + {ID: 124, Key: "rank.question.unpin", Value: `-1`}, + {ID: 125, Key: "rank.question.show", Value: `-1`}, + {ID: 126, Key: "rank.question.hide", Value: `-1`}, } for _, c := range defaultConfigTable { - exist, err := x.Get(&entity.Config{ID: c.ID, Key: c.Key}) + exist, err := x.Get(&entity.Config{ID: c.ID}) if err != nil { return fmt.Errorf("get config failed: %w", err) } if exist { - if _, err = x.Update(c, &entity.Config{ID: c.ID, Key: c.Key}); err != nil { + if _, err = x.Update(c, &entity.Config{ID: c.ID}); err != nil { log.Errorf("update %+v config failed: %s", c, err) return fmt.Errorf("update config failed: %w", err) } diff --git a/internal/repo/activity/answer_repo.go b/internal/repo/activity/answer_repo.go index cb0d25f2..02e9c511 100644 --- a/internal/repo/activity/answer_repo.go +++ b/internal/repo/activity/answer_repo.go @@ -202,7 +202,9 @@ func (ar *AnswerActivityRepo) AcceptAnswer(ctx context.Context, msg.TriggerUserID = questionUserID msg.ObjectType = constant.AnswerObjectType } - notice_queue.AddNotification(msg) + if msg.TriggerUserID != msg.ReceiverUserID { + notice_queue.AddNotification(msg) + } } for _, act := range addActivityList { @@ -214,7 +216,7 @@ func (ar *AnswerActivityRepo) AcceptAnswer(ctx context.Context, if act.UserID != questionUserID { msg.TriggerUserID = questionUserID msg.ObjectType = constant.AnswerObjectType - msg.NotificationAction = constant.AcceptAnswer + msg.NotificationAction = constant.NotificationAcceptAnswer notice_queue.AddNotification(msg) } } diff --git a/internal/repo/activity/vote_repo.go b/internal/repo/activity/vote_repo.go index bb2f1ef0..1303f3c7 100644 --- a/internal/repo/activity/vote_repo.go +++ b/internal/repo/activity/vote_repo.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "github.com/answerdev/answer/internal/base/constant" "github.com/answerdev/answer/pkg/converter" "github.com/answerdev/answer/internal/base/pager" @@ -70,7 +71,9 @@ var LimitDownActions = map[string][]string{ func (vr *VoteRepo) vote(ctx context.Context, objectID string, userID, objectUserID string, actions []string) (resp *schema.VoteResp, err error) { resp = &schema.VoteResp{} - notificationUserIDs := make([]string, 0) + achievementNotificationUserIDs := make([]string, 0) + sendInboxNotification := false + upVote := false _, err = vr.data.DB.Transaction(func(session *xorm.Session) (result any, err error) { result = nil for _, action := range actions { @@ -127,7 +130,7 @@ func (vr *VoteRepo) vote(ctx context.Context, objectID string, userID, objectUse if isReachStandard { insertActivity.Rank = 0 } - notificationUserIDs = append(notificationUserIDs, activityUserID) + achievementNotificationUserIDs = append(achievementNotificationUserIDs, activityUserID) } if has { @@ -142,13 +145,17 @@ func (vr *VoteRepo) vote(ctx context.Context, objectID string, userID, objectUse if err != nil { return nil, err } + sendInboxNotification = true } // update votes - if action == "vote_down" || action == "vote_up" { + if action == constant.ActVoteDown || action == constant.ActVoteUp { votes := 1 - if action == "vote_down" { + if action == constant.ActVoteDown { + upVote = false votes = -1 + } else { + upVote = true } err = vr.updateVotes(ctx, session, objectID, votes) if err != nil { @@ -165,9 +172,12 @@ func (vr *VoteRepo) vote(ctx context.Context, objectID string, userID, objectUse resp, err = vr.GetVoteResultByObjectId(ctx, objectID) resp.VoteStatus = vr.voteCommon.GetVoteStatus(ctx, objectID, userID) - for _, activityUserID := range notificationUserIDs { + for _, activityUserID := range achievementNotificationUserIDs { vr.sendNotification(ctx, activityUserID, objectUserID, objectID) } + if sendInboxNotification { + vr.sendVoteInboxNotification(userID, objectUserID, objectID, upVote) + } return } @@ -441,3 +451,40 @@ func (vr *VoteRepo) sendNotification(ctx context.Context, activityUserID, object } notice_queue.AddNotification(msg) } + +func (vr *VoteRepo) sendVoteInboxNotification(triggerUserID, receiverUserID, objectID string, upvote bool) { + if triggerUserID == receiverUserID { + return + } + objectType, _ := obj.GetObjectTypeStrByObjectID(objectID) + + msg := &schema.NotificationMsg{ + TriggerUserID: triggerUserID, + ReceiverUserID: receiverUserID, + Type: schema.NotificationTypeInbox, + ObjectID: objectID, + ObjectType: objectType, + } + if objectType == constant.QuestionObjectType { + if upvote { + msg.NotificationAction = constant.NotificationUpVotedTheQuestion + } else { + msg.NotificationAction = constant.NotificationDownVotedTheQuestion + } + } + if objectType == constant.AnswerObjectType { + if upvote { + msg.NotificationAction = constant.NotificationUpVotedTheAnswer + } else { + msg.NotificationAction = constant.NotificationDownVotedTheAnswer + } + } + if objectType == constant.CommentObjectType { + if upvote { + msg.NotificationAction = constant.NotificationUpVotedTheComment + } + } + if len(msg.NotificationAction) > 0 { + notice_queue.AddNotification(msg) + } +} diff --git a/internal/repo/auth/auth.go b/internal/repo/auth/auth.go index bcc51212..5cd808a7 100644 --- a/internal/repo/auth/auth.go +++ b/internal/repo/auth/auth.go @@ -148,7 +148,7 @@ func (ar *authRepo) AddUserTokenMapping(ctx context.Context, userID, accessToken } // RemoveUserTokens Log out all users under this user id -func (ar *authRepo) RemoveUserTokens(ctx context.Context, userID string) { +func (ar *authRepo) RemoveUserTokens(ctx context.Context, userID string, remainToken string) { key := constant.UserTokenMappingCacheKey + userID resp, _ := ar.data.Cache.GetString(ctx, key) mapping := make(map[string]bool, 0) @@ -158,6 +158,9 @@ func (ar *authRepo) RemoveUserTokens(ctx context.Context, userID string) { } for token := range mapping { + if token == remainToken { + continue + } if err := ar.RemoveUserCacheInfo(ctx, token); err != nil { log.Error(err) } else { diff --git a/internal/repo/plugin_config/plugin_config_repo.go b/internal/repo/plugin_config/plugin_config_repo.go new file mode 100644 index 00000000..d615577f --- /dev/null +++ b/internal/repo/plugin_config/plugin_config_repo.go @@ -0,0 +1,49 @@ +package plugin_config + +import ( + "context" + + "github.com/answerdev/answer/internal/base/data" + "github.com/answerdev/answer/internal/base/reason" + "github.com/answerdev/answer/internal/entity" + "github.com/answerdev/answer/internal/service/plugin_common" + "github.com/segmentfault/pacman/errors" +) + +type pluginConfigRepo struct { + data *data.Data +} + +// NewPluginConfigRepo new repository +func NewPluginConfigRepo(data *data.Data) plugin_common.PluginConfigRepo { + return &pluginConfigRepo{ + data: data, + } +} + +func (ur *pluginConfigRepo) SavePluginConfig(ctx context.Context, pluginSlugName, configValue string) (err error) { + old := &entity.PluginConfig{PluginSlugName: pluginSlugName} + exist, err := ur.data.DB.Get(old) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + if exist { + old.Value = configValue + _, err = ur.data.DB.ID(old.ID).Update(old) + } else { + _, err = ur.data.DB.InsertOne(&entity.PluginConfig{PluginSlugName: pluginSlugName, Value: configValue}) + } + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return nil +} + +func (ur *pluginConfigRepo) GetPluginConfigAll(ctx context.Context) (pluginConfigs []*entity.PluginConfig, err error) { + pluginConfigs = make([]*entity.PluginConfig, 0) + err = ur.data.DB.Find(&pluginConfigs) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return pluginConfigs, err +} diff --git a/internal/repo/provider.go b/internal/repo/provider.go index bc18d3da..b0dc6f8a 100644 --- a/internal/repo/provider.go +++ b/internal/repo/provider.go @@ -14,6 +14,7 @@ import ( "github.com/answerdev/answer/internal/repo/export" "github.com/answerdev/answer/internal/repo/meta" "github.com/answerdev/answer/internal/repo/notification" + "github.com/answerdev/answer/internal/repo/plugin_config" "github.com/answerdev/answer/internal/repo/question" "github.com/answerdev/answer/internal/repo/rank" "github.com/answerdev/answer/internal/repo/reason" @@ -26,6 +27,7 @@ import ( "github.com/answerdev/answer/internal/repo/tag_common" "github.com/answerdev/answer/internal/repo/unique" "github.com/answerdev/answer/internal/repo/user" + "github.com/answerdev/answer/internal/repo/user_external_login" "github.com/google/wire" ) @@ -72,4 +74,6 @@ var ProviderSetRepo = wire.NewSet( role.NewUserRoleRelRepo, role.NewRolePowerRelRepo, role.NewPowerRepo, + user_external_login.NewUserExternalLoginRepo, + plugin_config.NewPluginConfigRepo, ) diff --git a/internal/repo/question/question_repo.go b/internal/repo/question/question_repo.go index 4e44dad8..d4f592c4 100644 --- a/internal/repo/question/question_repo.go +++ b/internal/repo/question/question_repo.go @@ -273,7 +273,7 @@ func (qr *questionRepo) GetQuestionIDsPage(ctx context.Context, page, pageSize i } // GetQuestionPage query question page -func (qr *questionRepo) GetQuestionPage(ctx context.Context, page, pageSize int, userID, tagID, orderCond string) ( +func (qr *questionRepo) GetQuestionPage(ctx context.Context, page, pageSize int, userID, tagID, orderCond string, inDays int) ( questionList []*entity.Question, total int64, err error) { questionList = make([]*entity.Question, 0) @@ -289,6 +289,9 @@ func (qr *questionRepo) GetQuestionPage(ctx context.Context, page, pageSize int, } else { session.And("question.show = ?", entity.QuestionShow) } + if inDays > 0 { + session.And("question.created_at > ?", time.Now().AddDate(0, 0, -inDays)) + } switch orderCond { case "newest": diff --git a/internal/repo/rank/user_rank_repo.go b/internal/repo/rank/user_rank_repo.go index 65174999..462f999e 100644 --- a/internal/repo/rank/user_rank_repo.go +++ b/internal/repo/rank/user_rank_repo.go @@ -9,6 +9,7 @@ import ( "github.com/answerdev/answer/internal/entity" "github.com/answerdev/answer/internal/service/config" "github.com/answerdev/answer/internal/service/rank" + "github.com/answerdev/answer/plugin" "github.com/jinzhu/now" "github.com/segmentfault/pacman/errors" "github.com/segmentfault/pacman/log" @@ -36,6 +37,10 @@ func NewUserRankRepo(data *data.Data, configRepo config.ConfigRepo) rank.UserRan func (ur *UserRankRepo) TriggerUserRank(ctx context.Context, session *xorm.Session, userID string, deltaRank int, activityType int, ) (isReachStandard bool, err error) { + // IMPORTANT: If user center enabled the rank agent, then we should not change user rank. + if plugin.RankAgentEnabled() { + return false, nil + } if deltaRank == 0 { return false, nil } diff --git a/internal/repo/tag_common/tag_common_repo.go b/internal/repo/tag_common/tag_common_repo.go index 02e1c6ae..b12810e0 100644 --- a/internal/repo/tag_common/tag_common_repo.go +++ b/internal/repo/tag_common/tag_common_repo.go @@ -46,7 +46,7 @@ func (tr *tagCommonRepo) GetTagListByIDs(ctx context.Context, ids []string) (tag // GetTagBySlugName get tag by slug name func (tr *tagCommonRepo) GetTagBySlugName(ctx context.Context, slugName string) (tagInfo *entity.Tag, exist bool, err error) { tagInfo = &entity.Tag{} - session := tr.data.DB.Where("slug_name = LOWER(?)", slugName) + session := tr.data.DB.Where("LOWER(slug_name) = ?", slugName) session.Where(builder.Eq{"status": entity.TagStatusAvailable}) exist, err = session.Get(tagInfo) if err != nil { diff --git a/internal/repo/user/user_backyard_repo.go b/internal/repo/user/user_backyard_repo.go index 1b6cb2f1..cebb2112 100644 --- a/internal/repo/user/user_backyard_repo.go +++ b/internal/repo/user/user_backyard_repo.go @@ -86,6 +86,13 @@ func (ur *userAdminRepo) GetUserInfo(ctx context.Context, userID string) (user * if err != nil { return nil, false, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } + if !exist { + return + } + err = tryToDecorateUserInfoFromUserCenter(ctx, ur.data, user) + if err != nil { + return nil, false, err + } return } @@ -96,6 +103,14 @@ func (ur *userAdminRepo) GetUserInfoByEmail(ctx context.Context, email string) ( Where("status != ?", entity.UserStatusDeleted).Get(userInfo) if err != nil { err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + return + } + if !exist { + return + } + err = tryToDecorateUserInfoFromUserCenter(ctx, ur.data, user) + if err != nil { + return nil, false, err } return } @@ -127,6 +142,8 @@ func (ur *userAdminRepo) GetUserPage(ctx context.Context, page, pageSize int, us total, err = pager.Help(page, pageSize, &users, user, session) if err != nil { err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + return } + tryToDecorateUserListFromUserCenter(ctx, ur.data, users) return } diff --git a/internal/repo/user/user_repo.go b/internal/repo/user/user_repo.go index 18bebbfa..53c46ca0 100644 --- a/internal/repo/user/user_repo.go +++ b/internal/repo/user/user_repo.go @@ -7,9 +7,14 @@ import ( "github.com/answerdev/answer/internal/base/data" "github.com/answerdev/answer/internal/base/reason" "github.com/answerdev/answer/internal/entity" + "github.com/answerdev/answer/internal/schema" "github.com/answerdev/answer/internal/service/config" usercommon "github.com/answerdev/answer/internal/service/user_common" + "github.com/answerdev/answer/pkg/converter" + "github.com/answerdev/answer/plugin" "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" + "xorm.io/xorm" ) // userRepo user repository @@ -28,10 +33,21 @@ func NewUserRepo(data *data.Data, configRepo config.ConfigRepo) usercommon.UserR // AddUser add user func (ur *userRepo) AddUser(ctx context.Context, user *entity.User) (err error) { - _, err = ur.data.DB.Insert(user) - if err != nil { - err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() - } + _, err = ur.data.DB.Transaction(func(session *xorm.Session) (interface{}, error) { + userInfo := &entity.User{} + exist, err := session.Where("username = ?", user.Username).Get(userInfo) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + if exist { + return nil, errors.InternalServer(reason.UsernameDuplicate) + } + _, err = session.Insert(user) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return nil, nil + }) return } @@ -145,6 +161,11 @@ func (ur *userRepo) GetByUserID(ctx context.Context, userID string) (userInfo *e exist, err = ur.data.DB.Where("id = ?", userID).Get(userInfo) if err != nil { err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + return + } + err = tryToDecorateUserInfoFromUserCenter(ctx, ur.data, userInfo) + if err != nil { + return nil, false, err } return } @@ -155,6 +176,7 @@ func (ur *userRepo) BatchGetByID(ctx context.Context, ids []string) ([]*entity.U if err != nil { return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } + tryToDecorateUserListFromUserCenter(ctx, ur.data, list) return list, nil } @@ -164,6 +186,11 @@ func (ur *userRepo) GetByUsername(ctx context.Context, username string) (userInf exist, err = ur.data.DB.Where("username = ?", username).Get(userInfo) if err != nil { err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + return + } + err = tryToDecorateUserInfoFromUserCenter(ctx, ur.data, userInfo) + if err != nil { + return nil, false, err } return } @@ -179,11 +206,121 @@ func (ur *userRepo) GetByEmail(ctx context.Context, email string) (userInfo *ent return } -func (vr *userRepo) GetUserCount(ctx context.Context) (count int64, err error) { +func (ur *userRepo) GetUserCount(ctx context.Context) (count int64, err error) { list := make([]*entity.User, 0) - count, err = vr.data.DB.Where("mail_status =?", entity.EmailStatusAvailable).And("status =?", entity.UserStatusAvailable).FindAndCount(&list) + count, err = ur.data.DB.Where("mail_status =?", entity.EmailStatusAvailable).And("status =?", entity.UserStatusAvailable).FindAndCount(&list) if err != nil { return count, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } return } + +func tryToDecorateUserInfoFromUserCenter(ctx context.Context, data *data.Data, original *entity.User) (err error) { + if original == nil { + return nil + } + uc, ok := plugin.GetUserCenter() + if !ok { + return nil + } + + userInfo := &entity.UserExternalLogin{} + session := data.DB.Where("user_id = ?", original.ID) + session.Where("provider = ?", uc.Info().SlugName) + exist, err := session.Get(userInfo) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + if !exist { + return nil + } + + userCenterBasicUserInfo, err := uc.UserInfo(userInfo.ExternalID) + if err != nil { + log.Error(err) + return errors.BadRequest(reason.UserNotFound).WithError(err).WithStack() + } + + // In general, usernames should be guaranteed unique by the User Center plugin, so there are no inconsistencies. + if original.Username != userCenterBasicUserInfo.Username { + log.Warnf("user %s username is inconsistent with user center", original.ID) + } + decorateByUserCenterUser(original, userCenterBasicUserInfo) + return nil +} + +func tryToDecorateUserListFromUserCenter(ctx context.Context, data *data.Data, original []*entity.User) { + uc, ok := plugin.GetUserCenter() + if !ok { + return + } + + ids := make([]string, 0) + originalUserIDMapping := make(map[string]*entity.User, 0) + for _, user := range original { + originalUserIDMapping[user.ID] = user + ids = append(ids, user.ID) + } + + userExternalLoginList := make([]*entity.UserExternalLogin, 0) + session := data.DB.Where("provider = ?", uc.Info().SlugName) + session.In("user_id", ids) + err := session.Find(&userExternalLoginList) + if err != nil { + log.Error(err) + return + } + + userExternalIDs := make([]string, 0) + originalExternalIDMapping := make(map[string]*entity.User, 0) + for _, u := range userExternalLoginList { + originalExternalIDMapping[u.ExternalID] = originalUserIDMapping[u.UserID] + userExternalIDs = append(userExternalIDs, u.ExternalID) + } + if len(userExternalIDs) == 0 { + return + } + + ucUsers, err := uc.UserList(userExternalIDs) + if err != nil { + log.Errorf("get user list from user center failed: %v, %v", err, userExternalIDs) + return + } + + for _, ucUser := range ucUsers { + decorateByUserCenterUser(originalExternalIDMapping[ucUser.ExternalID], ucUser) + } +} + +func decorateByUserCenterUser(original *entity.User, ucUser *plugin.UserCenterBasicUserInfo) { + if original == nil || ucUser == nil { + return + } + // In general, usernames should be guaranteed unique by the User Center plugin, so there are no inconsistencies. + if original.Username != ucUser.Username { + log.Warnf("user %s username is inconsistent with user center", original.ID) + } + if len(ucUser.DisplayName) > 0 { + original.DisplayName = ucUser.DisplayName + } + if len(ucUser.Email) > 0 { + original.EMail = ucUser.Email + } + if len(ucUser.Avatar) > 0 { + original.Avatar = schema.CustomAvatar(ucUser.Avatar).ToJsonString() + } + if len(ucUser.Mobile) > 0 { + original.Mobile = ucUser.Mobile + } + if len(ucUser.Bio) > 0 { + original.BioHTML = converter.Markdown2HTML(ucUser.Bio) + original.BioHTML + } + + // If plugin enable rank agent, use rank from user center. + if plugin.RankAgentEnabled() { + original.Rank = ucUser.Rank + } + if ucUser.Status != plugin.UserStatusAvailable { + original.Status = int(ucUser.Status) + } +} diff --git a/internal/repo/user_external_login/user_external_login_repo.go b/internal/repo/user_external_login/user_external_login_repo.go new file mode 100644 index 00000000..f7be790e --- /dev/null +++ b/internal/repo/user_external_login/user_external_login_repo.go @@ -0,0 +1,94 @@ +package user_external_login + +import ( + "context" + "encoding/json" + + "github.com/answerdev/answer/internal/base/constant" + "github.com/answerdev/answer/internal/base/data" + "github.com/answerdev/answer/internal/base/reason" + "github.com/answerdev/answer/internal/entity" + "github.com/answerdev/answer/internal/schema" + "github.com/answerdev/answer/internal/service/user_external_login" + "github.com/segmentfault/pacman/errors" +) + +type userExternalLoginRepo struct { + data *data.Data +} + +// NewUserExternalLoginRepo new repository +func NewUserExternalLoginRepo(data *data.Data) user_external_login.UserExternalLoginRepo { + return &userExternalLoginRepo{ + data: data, + } +} + +// AddUserExternalLogin add external login information +func (ur *userExternalLoginRepo) AddUserExternalLogin(ctx context.Context, user *entity.UserExternalLogin) (err error) { + _, err = ur.data.DB.Insert(user) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// UpdateInfo update user info +func (ur *userExternalLoginRepo) UpdateInfo(ctx context.Context, userInfo *entity.UserExternalLogin) (err error) { + _, err = ur.data.DB.ID(userInfo.ID).Update(userInfo) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// GetByExternalID get by external ID +func (ur *userExternalLoginRepo) GetByExternalID(ctx context.Context, provider, externalID string) ( + userInfo *entity.UserExternalLogin, exist bool, err error) { + userInfo = &entity.UserExternalLogin{} + exist, err = ur.data.DB.Where("external_id = ?", externalID).Where("provider = ?", provider).Get(userInfo) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// GetUserExternalLoginList get by external ID +func (ur *userExternalLoginRepo) GetUserExternalLoginList(ctx context.Context, userID string) ( + resp []*entity.UserExternalLogin, err error) { + resp = make([]*entity.UserExternalLogin, 0) + err = ur.data.DB.Where("user_id = ?", userID).Find(&resp) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// DeleteUserExternalLogin delete external user login info +func (ur *userExternalLoginRepo) DeleteUserExternalLogin(ctx context.Context, userID, externalID string) (err error) { + cond := &entity.UserExternalLogin{} + _, err = ur.data.DB.Where("user_id = ? AND external_id = ?", userID, externalID).Delete(cond) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// SetCacheUserExternalLoginInfo cache user info for external login +func (ur *userExternalLoginRepo) SetCacheUserExternalLoginInfo( + ctx context.Context, key string, info *schema.ExternalLoginUserInfoCache) (err error) { + cacheData, _ := json.Marshal(info) + return ur.data.Cache.SetString(ctx, constant.ConnectorUserExternalInfoCacheKey+key, + string(cacheData), constant.ConnectorUserExternalInfoCacheTime) +} + +// GetCacheUserExternalLoginInfo cache user info for external login +func (ur *userExternalLoginRepo) GetCacheUserExternalLoginInfo( + ctx context.Context, key string) (info *schema.ExternalLoginUserInfoCache, err error) { + res, err := ur.data.Cache.GetString(ctx, constant.ConnectorUserExternalInfoCacheKey+key) + if err != nil { + return info, err + } + _ = json.Unmarshal([]byte(res), &info) + return info, nil +} diff --git a/internal/router/answer_api_router.go b/internal/router/answer_api_router.go index 3f79d2f2..a7c2dab9 100644 --- a/internal/router/answer_api_router.go +++ b/internal/router/answer_api_router.go @@ -1,6 +1,7 @@ package router import ( + "github.com/answerdev/answer/internal/base/middleware" "github.com/answerdev/answer/internal/controller" "github.com/answerdev/answer/internal/controller_admin" "github.com/gin-gonic/gin" @@ -31,6 +32,7 @@ type AnswerAPIRouter struct { uploadController *controller.UploadController activityController *controller.ActivityController roleController *controller_admin.RoleController + pluginController *controller_admin.PluginController } func NewAnswerAPIRouter( @@ -58,6 +60,7 @@ func NewAnswerAPIRouter( uploadController *controller.UploadController, activityController *controller.ActivityController, roleController *controller_admin.RoleController, + pluginController *controller_admin.PluginController, ) *AnswerAPIRouter { return &AnswerAPIRouter{ langController: langController, @@ -84,6 +87,7 @@ func NewAnswerAPIRouter( uploadController: uploadController, activityController: activityController, roleController: roleController, + pluginController: pluginController, } } @@ -97,37 +101,38 @@ func (a *AnswerAPIRouter) RegisterMustUnAuthAnswerAPIRouter(r *gin.RouterGroup) r.GET("/siteinfo/legal", a.siteinfoController.GetSiteLegalInfo) // user - r.POST("/user/login/email", a.userController.UserEmailLogin) - r.POST("/user/register/email", a.userController.UserRegisterByEmail) - r.GET("/user/register/captcha", a.userController.UserRegisterCaptcha) - r.POST("/user/email/verification", a.userController.UserVerifyEmail) - r.PUT("/user/email", a.userController.UserChangeEmailVerify) - r.GET("/user/action/record", a.userController.ActionRecord) - r.POST("/user/password/reset", a.userController.RetrievePassWord) - r.POST("/user/password/replacement", a.userController.UseRePassWord) r.GET("/user/info", a.userController.GetUserInfoByUserID) - r.PUT("/user/email/notification", a.userController.UserUnsubscribeEmailNotification) + routerGroup := r.Group("", middleware.BanAPIForUserCenter) + routerGroup.POST("/user/login/email", a.userController.UserEmailLogin) + routerGroup.POST("/user/register/email", a.userController.UserRegisterByEmail) + routerGroup.GET("/user/register/captcha", a.userController.UserRegisterCaptcha) + routerGroup.POST("/user/email/verification", a.userController.UserVerifyEmail) + routerGroup.PUT("/user/email", a.userController.UserChangeEmailVerify) + routerGroup.GET("/user/action/record", a.userController.ActionRecord) + routerGroup.POST("/user/password/reset", a.userController.RetrievePassWord) + routerGroup.POST("/user/password/replacement", a.userController.UseRePassWord) + routerGroup.PUT("/user/email/notification", a.userController.UserUnsubscribeEmailNotification) } func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) { // user r.GET("/user/logout", a.userController.UserLogout) - r.POST("/user/email/change/code", a.userController.UserChangeEmailSendCode) - r.POST("/user/email/verification/send", a.userController.UserVerifyEmailSend) + r.POST("/user/email/change/code", middleware.BanAPIForUserCenter, a.userController.UserChangeEmailSendCode) + r.POST("/user/email/verification/send", middleware.BanAPIForUserCenter, a.userController.UserVerifyEmailSend) r.GET("/personal/user/info", a.userController.GetOtherUserInfoByUsername) r.GET("/user/ranking", a.userController.UserRanking) //answer r.GET("/answer/info", a.answerController.Get) r.GET("/answer/page", a.answerController.AnswerList) - r.GET("/personal/answer/page", a.questionController.UserAnswerList) + r.GET("/personal/answer/page", a.questionController.PersonalAnswerPage) //question r.GET("/question/info", a.questionController.GetQuestion) r.GET("/question/page", a.questionController.QuestionPage) r.GET("/question/similar/tag", a.questionController.SimilarQuestion) r.GET("/personal/qa/top", a.questionController.UserTop) - r.GET("/personal/question/page", a.questionController.UserList) + r.GET("/personal/question/page", a.questionController.PersonalQuestionPage) // comment r.GET("/comment/page", a.commentController.GetCommentWithPage) @@ -182,7 +187,7 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) { // collection r.POST("/collection/switch", a.collectionController.CollectionSwitch) - r.GET("/personal/collection/page", a.questionController.UserCollectionList) + r.GET("/personal/collection/page", a.questionController.PersonalCollectionPage) // question r.POST("/question", a.questionController.AddQuestion) @@ -201,7 +206,7 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) { r.DELETE("/answer", a.answerController.RemoveAnswer) // user - r.PUT("/user/password", a.userController.UserModifyPassWord) + r.PUT("/user/password", middleware.BanAPIForUserCenter, a.userController.UserModifyPassWord) r.PUT("/user/info", a.userController.UserUpdateInfo) r.PUT("/user/interface", a.userController.UserUpdateInterface) r.POST("/user/notice/set", a.userController.UserNoticeSet) @@ -257,29 +262,39 @@ func (a *AnswerAPIRouter) RegisterAnswerAdminAPIRouter(r *gin.RouterGroup) { // siteinfo r.GET("/siteinfo/general", a.siteInfoController.GetGeneral) - r.GET("/siteinfo/interface", a.siteInfoController.GetInterface) - r.GET("/siteinfo/branding", a.siteInfoController.GetSiteBranding) - r.GET("/siteinfo/write", a.siteInfoController.GetSiteWrite) - r.GET("/siteinfo/legal", a.siteInfoController.GetSiteLegal) - r.GET("/siteinfo/seo", a.siteInfoController.GetSeo) - r.GET("/siteinfo/login", a.siteInfoController.GetSiteLogin) - r.GET("/siteinfo/custom-css-html", a.siteInfoController.GetSiteCustomCssHTML) - r.GET("/siteinfo/theme", a.siteInfoController.GetSiteTheme) r.PUT("/siteinfo/general", a.siteInfoController.UpdateGeneral) + r.GET("/siteinfo/interface", a.siteInfoController.GetInterface) r.PUT("/siteinfo/interface", a.siteInfoController.UpdateInterface) + r.GET("/siteinfo/branding", a.siteInfoController.GetSiteBranding) r.PUT("/siteinfo/branding", a.siteInfoController.UpdateBranding) + r.GET("/siteinfo/write", a.siteInfoController.GetSiteWrite) r.PUT("/siteinfo/write", a.siteInfoController.UpdateSiteWrite) + r.GET("/siteinfo/legal", a.siteInfoController.GetSiteLegal) r.PUT("/siteinfo/legal", a.siteInfoController.UpdateSiteLegal) - r.PUT("/siteinfo/login", a.siteInfoController.UpdateSiteLogin) - r.PUT("/siteinfo/custom-css-html", a.siteInfoController.UpdateSiteCustomCssHTML) - r.PUT("/siteinfo/theme", a.siteInfoController.SaveSiteTheme) + r.GET("/siteinfo/seo", a.siteInfoController.GetSeo) r.PUT("/siteinfo/seo", a.siteInfoController.UpdateSeo) + r.GET("/siteinfo/login", a.siteInfoController.GetSiteLogin) + r.PUT("/siteinfo/login", a.siteInfoController.UpdateSiteLogin) + r.GET("/siteinfo/custom-css-html", a.siteInfoController.GetSiteCustomCssHTML) + r.PUT("/siteinfo/custom-css-html", a.siteInfoController.UpdateSiteCustomCssHTML) + r.GET("/siteinfo/theme", a.siteInfoController.GetSiteTheme) + r.PUT("/siteinfo/theme", a.siteInfoController.SaveSiteTheme) + r.GET("/siteinfo/users", a.siteInfoController.GetSiteUsers) + r.PUT("/siteinfo/users", a.siteInfoController.UpdateSiteUsers) r.GET("/setting/smtp", a.siteInfoController.GetSMTPConfig) r.PUT("/setting/smtp", a.siteInfoController.UpdateSMTPConfig) + r.GET("/setting/privileges", a.siteInfoController.GetPrivilegesConfig) + r.PUT("/setting/privileges", a.siteInfoController.UpdatePrivilegesConfig) // dashboard r.GET("/dashboard", a.dashboardController.DashboardInfo) // roles r.GET("/roles", a.roleController.GetRoleList) + + // plugin + r.GET("/plugins", a.pluginController.GetPluginList) + r.PUT("/plugin/status", a.pluginController.UpdatePluginStatus) + r.GET("/plugin/config", a.pluginController.GetPluginConfig) + r.PUT("/plugin/config", a.pluginController.UpdatePluginConfig) } diff --git a/internal/router/plugin_api_router.go b/internal/router/plugin_api_router.go new file mode 100644 index 00000000..981b34c1 --- /dev/null +++ b/internal/router/plugin_api_router.go @@ -0,0 +1,50 @@ +package router + +import ( + "github.com/answerdev/answer/internal/controller" + "github.com/gin-gonic/gin" +) + +type PluginAPIRouter struct { + connectorController *controller.ConnectorController + userCenterController *controller.UserCenterController +} + +func NewPluginAPIRouter( + connectorController *controller.ConnectorController, + userCenterController *controller.UserCenterController, +) *PluginAPIRouter { + return &PluginAPIRouter{ + connectorController: connectorController, + userCenterController: userCenterController, + } +} + +func (pr *PluginAPIRouter) RegisterUnAuthConnectorRouter(r *gin.RouterGroup) { + // connector plugin + connectorController := pr.connectorController + r.GET(controller.ConnectorLoginRouterPrefix+":name", connectorController.ConnectorLoginDispatcher) + r.GET(controller.ConnectorRedirectRouterPrefix+":name", connectorController.ConnectorRedirectDispatcher) + r.GET("/connector/info", connectorController.ConnectorsInfo) + r.POST("/connector/binding/email", connectorController.ExternalLoginBindingUserSendEmail) + + // user center plugin + r.GET("/user-center/agent", pr.userCenterController.UserCenterAgent) + r.GET("/user-center/personal/branding", pr.userCenterController.UserCenterPersonalBranding) + r.GET(controller.UserCenterLoginRouter, pr.userCenterController.UserCenterLoginRedirect) + r.GET(controller.UserCenterSignUpRedirectRouter, pr.userCenterController.UserCenterSignUpRedirect) + r.GET("/user-center/login/callback", pr.userCenterController.UserCenterLoginCallback) + r.GET("/user-center/sign-up/callback", pr.userCenterController.UserCenterSignUpCallback) +} + +func (pr *PluginAPIRouter) RegisterAuthUserConnectorRouter(r *gin.RouterGroup) { + connectorController := pr.connectorController + r.GET("/connector/user/info", connectorController.ConnectorsUserInfo) + r.DELETE("/connector/user/unbinding", connectorController.ExternalLoginUnbinding) + + r.GET("/user-center/user/settings", pr.userCenterController.UserCenterUserSettings) +} + +func (pr *PluginAPIRouter) RegisterAuthAdminConnectorRouter(r *gin.RouterGroup) { + r.GET("/user-center/agent", pr.userCenterController.UserCenterAdminFunctionAgent) +} diff --git a/internal/router/provider.go b/internal/router/provider.go index f705c973..c3665314 100644 --- a/internal/router/provider.go +++ b/internal/router/provider.go @@ -3,4 +3,11 @@ package router import "github.com/google/wire" // ProviderSetRouter is providers. -var ProviderSetRouter = wire.NewSet(NewAnswerAPIRouter, NewSwaggerRouter, NewStaticRouter, NewUIRouter, NewTemplateRouter) +var ProviderSetRouter = wire.NewSet( + NewAnswerAPIRouter, + NewSwaggerRouter, + NewStaticRouter, + NewUIRouter, + NewTemplateRouter, + NewPluginAPIRouter, +) diff --git a/internal/schema/connector_schema.go b/internal/schema/connector_schema.go new file mode 100644 index 00000000..e9631ecc --- /dev/null +++ b/internal/schema/connector_schema.go @@ -0,0 +1,15 @@ +package schema + +type ConnectorInfoResp struct { + Name string `json:"name"` + Icon string `json:"icon"` + Link string `json:"link"` +} + +type ConnectorUserInfoResp struct { + Name string `json:"name"` + Icon string `json:"icon"` + Link string `json:"link"` + Binding bool `json:"binding"` + ExternalID string `json:"external_id"` +} diff --git a/internal/schema/email_template.go b/internal/schema/email_template.go index 16e438c8..94918331 100644 --- a/internal/schema/email_template.go +++ b/internal/schema/email_template.go @@ -3,18 +3,21 @@ package schema import "encoding/json" const ( - AccountActivationSourceType SourceType = "account-activation" - PasswordResetSourceType SourceType = "password-reset" - ConfirmNewEmailSourceType SourceType = "password-reset" - UnsubscribeSourceType SourceType = "unsubscribe" + AccountActivationSourceType EmailSourceType = "account-activation" + PasswordResetSourceType EmailSourceType = "password-reset" + ConfirmNewEmailSourceType EmailSourceType = "password-reset" + UnsubscribeSourceType EmailSourceType = "unsubscribe" + BindingSourceType EmailSourceType = "binding" ) -type SourceType string +type EmailSourceType string type EmailCodeContent struct { - SourceType SourceType `json:"source_type"` - Email string `json:"e_mail"` - UserID string `json:"user_id"` + SourceType EmailSourceType `json:"source_type"` + Email string `json:"e_mail"` + UserID string `json:"user_id"` + // Used for third-party login account binding + BindingKey string `json:"binding_key"` } func (r *EmailCodeContent) ToJSONString() string { diff --git a/internal/schema/plugin_admin_schema.go b/internal/schema/plugin_admin_schema.go new file mode 100644 index 00000000..7684e264 --- /dev/null +++ b/internal/schema/plugin_admin_schema.go @@ -0,0 +1,141 @@ +package schema + +import ( + "github.com/answerdev/answer/plugin" + "github.com/gin-gonic/gin" +) + +const ( + PluginStatusActive PluginStatus = "active" + PluginStatusInactive PluginStatus = "inactive" +) + +type PluginStatus string + +type GetPluginListReq struct { + Status PluginStatus `form:"status"` + HaveConfig bool `form:"have_config"` +} + +type GetPluginListResp struct { + Name string `json:"name"` + SlugName string `json:"slug_name"` + Description string `json:"description"` + Version string `json:"version"` + Enabled bool `json:"enabled"` + HaveConfig bool `json:"have_config"` + Link string `json:"link"` +} + +type UpdatePluginStatusReq struct { + PluginSlugName string `validate:"required,gt=1,lte=100" json:"plugin_slug_name"` + Enabled bool `json:"enabled"` +} + +type GetPluginConfigReq struct { + PluginSlugName string `validate:"required,gt=1,lte=100" form:"plugin_slug_name"` +} + +type GetPluginConfigResp struct { + Name string `json:"name"` + SlugName string `json:"slug_name"` + Description string `json:"description"` + Version string `json:"version"` + ConfigFields []ConfigField `json:"config_fields"` +} + +func (g *GetPluginConfigResp) SetConfigFields(ctx *gin.Context, fields []plugin.ConfigField) { + for _, field := range fields { + configField := ConfigField{ + Name: field.Name, + Type: string(field.Type), + Title: field.Title.Translate(ctx), + Description: field.Description.Translate(ctx), + Required: field.Required, + Value: field.Value, + UIOptions: ConfigFieldUIOptions{ + Rows: field.UIOptions.Rows, + InputType: string(field.UIOptions.InputType), + Variant: field.UIOptions.Variant, + }, + } + configField.UIOptions.Placeholder = field.UIOptions.Placeholder.Translate(ctx) + configField.UIOptions.Label = field.UIOptions.Label.Translate(ctx) + configField.UIOptions.Text = field.UIOptions.Text.Translate(ctx) + if field.UIOptions.Action != nil { + uiOptionAction := &UIOptionAction{ + Url: field.UIOptions.Action.Url, + Method: field.UIOptions.Action.Method, + } + if field.UIOptions.Action.Loading != nil { + uiOptionAction.Loading = &LoadingAction{ + Text: field.UIOptions.Action.Loading.Text.Translate(ctx), + State: string(field.UIOptions.Action.Loading.State), + } + } + if field.UIOptions.Action.OnComplete != nil { + uiOptionAction.OnCompleteAction = &OnCompleteAction{ + ToastReturnMessage: field.UIOptions.Action.OnComplete.ToastReturnMessage, + RefreshFormConfig: field.UIOptions.Action.OnComplete.RefreshFormConfig, + } + } + configField.UIOptions.Action = uiOptionAction + } + + for _, option := range field.Options { + configField.Options = append(configField.Options, ConfigFieldOption{ + Label: option.Label.Translate(ctx), + Value: option.Value, + }) + } + g.ConfigFields = append(g.ConfigFields, configField) + } +} + +type ConfigField struct { + Name string `json:"name"` + Type string `json:"type"` + Title string `json:"title"` + Description string `json:"description"` + Required bool `json:"required"` + Value any `json:"value"` + UIOptions ConfigFieldUIOptions `json:"ui_options"` + Options []ConfigFieldOption `json:"options,omitempty"` +} + +type ConfigFieldUIOptions struct { + Placeholder string `json:"placeholder,omitempty"` + Rows string `json:"rows,omitempty"` + InputType string `json:"input_type,omitempty"` + Label string `json:"label,omitempty"` + Action *UIOptionAction `json:"action,omitempty"` + Variant string `json:"variant,omitempty"` + Text string `json:"text,omitempty"` +} + +type ConfigFieldOption struct { + Label string `json:"label"` + Value string `json:"value"` +} + +type UIOptionAction struct { + Url string `json:"url"` + Method string `json:"method,omitempty"` + Loading *LoadingAction `json:"loading,omitempty"` + OnCompleteAction *OnCompleteAction `json:"on_complete,omitempty"` +} + +type LoadingAction struct { + Text string `json:"text"` + State string `json:"state"` +} + +type OnCompleteAction struct { + ToastReturnMessage bool `json:"toast_return_message"` + RefreshFormConfig bool `json:"refresh_form_config"` +} + +type UpdatePluginConfigReq struct { + PluginSlugName string `validate:"required,gt=1,lte=100" json:"plugin_slug_name"` + ConfigFields map[string]any `json:"config_fields"` +} diff --git a/internal/schema/plugin_user_center.go b/internal/schema/plugin_user_center.go new file mode 100644 index 00000000..9a5f65a8 --- /dev/null +++ b/internal/schema/plugin_user_center.go @@ -0,0 +1,35 @@ +package schema + +type UserCenterAgentResp struct { + Enabled bool `json:"enabled"` + AgentInfo *AgentInfo `json:"agent_info"` +} + +type AgentInfo struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + Icon string `json:"icon"` + Url string `json:"url"` + LoginRedirectURL string `json:"login_redirect_url"` + SignUpRedirectURL string `json:"sign_up_redirect_url"` + ControlCenterItems []*ControlCenter `json:"control_center"` + EnabledOriginalUserSystem bool `json:"enabled_original_user_system"` +} + +type ControlCenter struct { + Name string `json:"name"` + Label string `json:"label"` + Url string `json:"url"` +} + +type UserCenterPersonalBranding struct { + Enabled bool `json:"enabled"` + PersonalBranding []*PersonalBranding `json:"personal_branding"` +} + +type PersonalBranding struct { + Icon string `json:"icon"` + Name string `json:"name"` + Label string `json:"label"` + Url string `json:"url"` +} diff --git a/internal/schema/question_schema.go b/internal/schema/question_schema.go index b86dcff9..60af4f14 100644 --- a/internal/schema/question_schema.go +++ b/internal/schema/question_schema.go @@ -297,6 +297,7 @@ type QuestionPageReq struct { OrderCond string `validate:"omitempty,oneof=newest active frequent score unanswered" form:"order"` Tag string `validate:"omitempty,gt=0,lte=100" form:"tag"` Username string `validate:"omitempty,gt=0,lte=100" form:"username"` + InDays int `validate:"omitempty,min=1" form:"in_days"` LoginUserID string `json:"-"` UserIDBeSearched string `json:"-"` @@ -374,3 +375,25 @@ type SiteMapQuestionInfo struct { Title string `json:"title"` UpdateTime string `json:"time"` } + +type PersonalQuestionPageReq struct { + Page int `validate:"omitempty,min=1" form:"page"` + PageSize int `validate:"omitempty,min=1" form:"page_size"` + OrderCond string `validate:"omitempty,oneof=newest active frequent score unanswered" form:"order"` + Username string `validate:"omitempty,gt=0,lte=100" form:"username"` + LoginUserID string `json:"-"` +} + +type PersonalAnswerPageReq struct { + Page int `validate:"omitempty,min=1" form:"page"` + PageSize int `validate:"omitempty,min=1" form:"page_size"` + OrderCond string `validate:"omitempty,oneof=newest active frequent score unanswered" form:"order"` + Username string `validate:"omitempty,gt=0,lte=100" form:"username"` + LoginUserID string `json:"-"` +} + +type PersonalCollectionPageReq struct { + Page int `validate:"omitempty,min=1" form:"page"` + PageSize int `validate:"omitempty,min=1" form:"page_size"` + UserID string `json:"-"` +} diff --git a/internal/schema/siteinfo_schema.go b/internal/schema/siteinfo_schema.go index 4f786009..f0b659e3 100644 --- a/internal/schema/siteinfo_schema.go +++ b/internal/schema/siteinfo_schema.go @@ -6,6 +6,7 @@ import ( "net/mail" "net/url" + "github.com/answerdev/answer/internal/base/constant" "github.com/answerdev/answer/internal/base/handler" "github.com/answerdev/answer/internal/base/reason" "github.com/answerdev/answer/internal/base/translator" @@ -42,9 +43,8 @@ func (r *SiteGeneralReq) FormatSiteUrl() { // SiteInterfaceReq site interface request type SiteInterfaceReq struct { - Language string `validate:"required,gt=1,lte=128" form:"language" json:"language"` - TimeZone string `validate:"required,gt=1,lte=128" form:"time_zone" json:"time_zone"` - DefaultAvatar string `validate:"required,oneof=system gravatar" form:"default_avatar" json:"default_avatar"` + Language string `validate:"required,gt=1,lte=128" form:"language" json:"language"` + TimeZone string `validate:"required,gt=1,lte=128" form:"time_zone" json:"time_zone"` } // SiteBrandingReq site branding request @@ -92,18 +92,32 @@ type GetSiteLegalInfoResp struct { PrivacyPolicyParsedText string `json:"privacy_policy_parsed_text,omitempty"` } +// SiteUsersReq site users config request +type SiteUsersReq struct { + DefaultAvatar string `validate:"required,oneof=system gravatar" form:"default_avatar" json:"default_avatar"` + AllowUpdateDisplayName bool `form:"allow_update_display_name" json:"allow_update_display_name"` + AllowUpdateUsername bool `form:"allow_update_username" json:"allow_update_username"` + AllowUpdateAvatar bool `form:"allow_update_avatar" json:"allow_update_avatar"` + AllowUpdateBio bool `form:"allow_update_bio" json:"allow_update_bio"` + AllowUpdateWebsite bool `form:"allow_update_website" json:"allow_update_website"` + AllowUpdateLocation bool `form:"allow_update_location" json:"allow_update_location"` +} + // SiteLoginReq site login request type SiteLoginReq struct { - AllowNewRegistrations bool `json:"allow_new_registrations"` - LoginRequired bool `json:"login_required"` + AllowNewRegistrations bool `json:"allow_new_registrations"` + AllowEmailRegistrations bool `json:"allow_email_registrations"` + LoginRequired bool `json:"login_required"` + AllowEmailDomains []string `json:"allow_email_domains"` } // SiteCustomCssHTMLReq site custom css html type SiteCustomCssHTMLReq struct { - CustomHead string `validate:"omitempty,gt=0,lte=65536" json:"custom_head"` - CustomCss string `validate:"omitempty,gt=0,lte=65536" json:"custom_css"` - CustomHeader string `validate:"omitempty,gt=0,lte=65536" json:"custom_header"` - CustomFooter string `validate:"omitempty,gt=0,lte=65536" json:"custom_footer"` + CustomHead string `validate:"omitempty,gt=0,lte=65536" json:"custom_head"` + CustomCss string `validate:"omitempty,gt=0,lte=65536" json:"custom_css"` + CustomHeader string `validate:"omitempty,gt=0,lte=65536" json:"custom_header"` + CustomFooter string `validate:"omitempty,gt=0,lte=65536" json:"custom_footer"` + CustomSideBar string `validate:"omitempty,gt=0,lte=65536" json:"custom_sidebar"` } // SiteThemeReq site theme config @@ -127,6 +141,9 @@ type SiteLoginResp SiteLoginReq // SiteCustomCssHTMLResp site custom css html response type SiteCustomCssHTMLResp SiteCustomCssHTMLReq +// SiteUsersResp site users response +type SiteUsersResp SiteUsersReq + // SiteThemeResp site theme response type SiteThemeResp struct { ThemeOptions []*ThemeOption `json:"theme_options"` @@ -169,6 +186,7 @@ type SiteInfoResp struct { Theme *SiteThemeResp `json:"theme"` CustomCssHtml *SiteCustomCssHTMLResp `json:"custom_css_html"` SiteSeo *SiteSeoReq `json:"site_seo"` + SiteUsers *SiteUsersResp `json:"site_users"` Version string `json:"version"` Revision string `json:"revision"` } @@ -235,3 +253,85 @@ type GetManifestJsonResp struct { ThemeColor string `json:"theme_color"` BackgroundColor string `json:"background_color"` } + +const ( + // PrivilegeLevel1 low + PrivilegeLevel1 PrivilegeLevel = 1 + // PrivilegeLevel2 medium + PrivilegeLevel2 PrivilegeLevel = 2 + // PrivilegeLevel3 high + PrivilegeLevel3 PrivilegeLevel = 3 +) + +type PrivilegeLevel int + +// GetPrivilegesConfigResp get privileges config response +type GetPrivilegesConfigResp struct { + Options []*PrivilegeOption `json:"options"` + SelectedLevel PrivilegeLevel `json:"selected_level"` +} + +// PrivilegeOption privilege option +type PrivilegeOption struct { + Level PrivilegeLevel `json:"level"` + LevelDesc string `json:"level_desc"` + Privileges []*constant.Privilege `json:"privileges"` +} + +// UpdatePrivilegesConfigReq update privileges config request +type UpdatePrivilegesConfigReq struct { + Level PrivilegeLevel `validate:"required,min=1,max=3" json:"level"` +} + +var ( + DefaultPrivilegeOptions []*PrivilegeOption + privilegeOptionsLevelMapping = map[string][]int{ + constant.RankQuestionAddKey: {1, 1, 1}, + constant.RankAnswerAddKey: {1, 1, 1}, + constant.RankCommentAddKey: {1, 1, 1}, + constant.RankReportAddKey: {1, 1, 1}, + constant.RankCommentVoteUpKey: {1, 1, 1}, + constant.RankLinkUrlLimitKey: {1, 10, 10}, + constant.RankQuestionVoteUpKey: {1, 1, 15}, + constant.RankAnswerVoteUpKey: {1, 1, 15}, + constant.RankQuestionVoteDownKey: {125, 125, 125}, + constant.RankAnswerVoteDownKey: {125, 125, 125}, + constant.RankTagAddKey: {1, 750, 1500}, + constant.RankTagEditKey: {1, 50, 100}, + constant.RankQuestionEditKey: {1, 100, 200}, + constant.RankAnswerEditKey: {1, 100, 200}, + constant.RankQuestionEditWithoutReviewKey: {1, 1000, 2000}, + constant.RankAnswerEditWithoutReviewKey: {1, 1000, 2000}, + constant.RankQuestionAuditKey: {1, 1000, 2000}, + constant.RankAnswerAuditKey: {1, 1000, 2000}, + constant.RankTagAuditKey: {1, 2500, 5000}, + constant.RankTagEditWithoutReviewKey: {1, 10000, 20000}, + constant.RankTagSynonymKey: {1, 10000, 20000}, + } +) + +func init() { + DefaultPrivilegeOptions = append(DefaultPrivilegeOptions, &PrivilegeOption{ + Level: PrivilegeLevel1, + LevelDesc: reason.PrivilegeLevel1Desc, + }, &PrivilegeOption{ + Level: PrivilegeLevel2, + LevelDesc: reason.PrivilegeLevel2Desc, + }, &PrivilegeOption{ + Level: PrivilegeLevel3, + LevelDesc: reason.PrivilegeLevel3Desc, + }) + + for _, option := range DefaultPrivilegeOptions { + for _, privilege := range constant.RankAllPrivileges { + if len(privilegeOptionsLevelMapping[privilege.Key]) == 0 { + continue + } + option.Privileges = append(option.Privileges, &constant.Privilege{ + Label: privilege.Label, + Value: privilegeOptionsLevelMapping[privilege.Key][option.Level-1], + Key: privilege.Key, + }) + } + } +} diff --git a/internal/schema/user_external_login_schema.go b/internal/schema/user_external_login_schema.go new file mode 100644 index 00000000..56b5fc5e --- /dev/null +++ b/internal/schema/user_external_login_schema.go @@ -0,0 +1,81 @@ +package schema + +// UserExternalLoginResp user external login resp +type UserExternalLoginResp struct { + BindingKey string `json:"binding_key"` + AccessToken string `json:"access_token"` + // ErrMsg error message, if not empty, means login failed and this message should be displayed. + ErrMsg string `json:"-"` + ErrTitle string `json:"-"` +} + +// ExternalLoginBindingUserSendEmailReq external login binding user request +type ExternalLoginBindingUserSendEmailReq struct { + BindingKey string `validate:"required,gt=1,lte=100" json:"binding_key"` + Email string `validate:"required,gt=1,lte=512,email" json:"email"` + // If must is true, whatever email if exists, try to bind user. + // If must is false, when email exist, will only be prompted with a warning. + Must bool `json:"must"` +} + +// ExternalLoginBindingUserSendEmailResp external login binding user response +type ExternalLoginBindingUserSendEmailResp struct { + EmailExistAndMustBeConfirmed bool `json:"email_exist_and_must_be_confirmed"` + AccessToken string `json:"access_token"` +} + +// ExternalLoginBindingUserReq external login binding user request +type ExternalLoginBindingUserReq struct { + Code string `validate:"required,gt=0,lte=500" json:"code"` + Content string `json:"-"` +} + +// ExternalLoginBindingUserResp external login binding user response +type ExternalLoginBindingUserResp struct { + AccessToken string `json:"access_token"` +} + +// ExternalLoginUserInfoCache external login user info +type ExternalLoginUserInfoCache struct { + // Third party identification + // e.g. facebook, twitter, instagram + Provider string + // required. The unique user ID provided by the third-party login + ExternalID string + // optional. This name is used preferentially during registration + DisplayName string + // optional. This username is used preferentially during registration + Username string + // optional. If email exist will bind the existing user + Email string + // optional. The avatar URL provided by the third-party login platform + Avatar string + // optional. The original user information provided by the third-party login platform + MetaInfo string + // optional. The bio provided by the third-party login platform + Bio string +} + +// ExternalLoginUnbindingReq external login unbinding user +type ExternalLoginUnbindingReq struct { + ExternalID string `validate:"required,gt=0,lte=128" json:"external_id"` + UserID string `json:"-"` +} + +// UserCenterUserSettingsResp user center user info response +type UserCenterUserSettingsResp struct { + ProfileSettingAgent UserSettingAgent `json:"profile_setting_agent"` + AccountSettingAgent UserSettingAgent `json:"account_setting_agent"` +} + +type UserCenterAdminFunctionAgentResp struct { + AllowCreateUser bool `json:"allow_create_user"` + AllowUpdateUserStatus bool `json:"allow_update_user_status"` + AllowUpdateUserPassword bool `json:"allow_update_user_password"` + AllowUpdateUserRole bool `json:"allow_update_user_role"` +} + +type UserSettingAgent struct { + Enabled bool `json:"enabled"` + RedirectURL string `json:"redirect_url"` +} diff --git a/internal/schema/user_schema.go b/internal/schema/user_schema.go index 64e01974..f1f2bc2f 100644 --- a/internal/schema/user_schema.go +++ b/internal/schema/user_schema.go @@ -4,14 +4,12 @@ import ( "encoding/json" "github.com/answerdev/answer/internal/base/constant" - "github.com/answerdev/answer/internal/base/reason" "github.com/answerdev/answer/internal/base/validator" "github.com/answerdev/answer/internal/entity" "github.com/answerdev/answer/pkg/checker" "github.com/answerdev/answer/pkg/converter" "github.com/answerdev/answer/pkg/gravatar" "github.com/jinzhu/copier" - "github.com/segmentfault/pacman/errors" ) // UserVerifyEmailReq user verify email request @@ -72,6 +70,8 @@ type GetUserResp struct { RoleID int `json:"role_id"` // user status Status string `json:"status"` + // user have password + HavePassword bool `json:"have_password"` } func (r *GetUserResp) GetFromUserEntity(userInfo *entity.User) { @@ -83,11 +83,13 @@ func (r *GetUserResp) GetFromUserEntity(userInfo *entity.User) { if ok { r.Status = statusShow } + r.HavePassword = len(userInfo.Pass) > 0 } type GetUserToSetShowResp struct { *GetUserResp - Avatar *AvatarInfo `json:"avatar"` + Avatar *AvatarInfo `json:"avatar"` + HavePassword bool `json:"have_password"` } func (r *GetUserToSetShowResp) GetFromUserEntity(userInfo *entity.User) { @@ -108,6 +110,12 @@ func (r *GetUserToSetShowResp) GetFromUserEntity(userInfo *entity.User) { r.Avatar = avatarInfo } +const ( + AvatarTypeDefault = "default" + AvatarTypeGravatar = "gravatar" + AvatarTypeCustom = "custom" +) + func FormatAvatarInfo(avatarJson, email string) (res string) { defer func() { if constant.DefaultAvatar == "gravatar" && len(res) == 0 { @@ -124,15 +132,22 @@ func FormatAvatarInfo(avatarJson, email string) (res string) { return "" } switch avatarInfo.Type { - case "gravatar": + case AvatarTypeGravatar: return avatarInfo.Gravatar - case "custom": + case AvatarTypeCustom: return avatarInfo.Custom default: return "" } } +func CustomAvatar(url string) *AvatarInfo { + return &AvatarInfo{ + Type: AvatarTypeCustom, + Custom: url, + } +} + // GetUserStatusResp get user status info type GetUserStatusResp struct { // user status @@ -260,14 +275,14 @@ func (u *UserRegisterReq) Check() (errFields []*validator.FormErrorField, err er return nil, nil } -// UserModifyPassWordRequest -type UserModifyPassWordRequest struct { - UserID string `json:"-" ` // user_id - OldPass string `json:"old_pass" ` // old password - Pass string `json:"pass" ` // password +type UserModifyPasswordReq struct { + OldPass string `validate:"omitempty,gte=8,lte=32" json:"old_pass"` + Pass string `validate:"required,gte=8,lte=32" json:"pass"` + UserID string `json:"-"` + AccessToken string `json:"-"` } -func (u *UserModifyPassWordRequest) Check() (errFields []*validator.FormErrorField, err error) { +func (u *UserModifyPasswordReq) Check() (errFields []*validator.FormErrorField, err error) { // TODO i18n err = checker.CheckPassword(8, 32, 0, u.Pass) if err != nil { @@ -283,7 +298,7 @@ func (u *UserModifyPassWordRequest) Check() (errFields []*validator.FormErrorFie type UpdateInfoRequest struct { // display_name - DisplayName string `validate:"required,gt=0,lte=30" json:"display_name"` + DisplayName string `validate:"omitempty,gt=0,lte=30" json:"display_name"` // username Username string `validate:"omitempty,gt=3,lte=30" json:"username"` // avatar @@ -306,17 +321,12 @@ type AvatarInfo struct { Custom string `validate:"omitempty,gt=0,lte=200" json:"custom"` } +func (a *AvatarInfo) ToJsonString() string { + data, _ := json.Marshal(a) + return string(data) +} + func (req *UpdateInfoRequest) Check() (errFields []*validator.FormErrorField, err error) { - if len(req.Username) > 0 { - if checker.IsInvalidUsername(req.Username) { - errField := &validator.FormErrorField{ - ErrorField: "username", - ErrorMsg: reason.UsernameInvalid, - } - errFields = append(errFields, errField) - return errFields, errors.BadRequest(reason.UsernameInvalid) - } - } req.BioHTML = converter.Markdown2BasicHTML(req.Bio) return nil, nil } @@ -399,6 +409,7 @@ type GetOtherUserInfoResp struct { type UserChangeEmailSendCodeReq struct { UserVerifyEmailSendReq Email string `validate:"required,email,gt=0,lte=500" json:"e_mail"` + Pass string `validate:"omitempty,gte=8,lte=32" json:"pass"` UserID string `json:"-"` } diff --git a/internal/service/activity/activity.go b/internal/service/activity/activity.go index f971583b..1688de42 100644 --- a/internal/service/activity/activity.go +++ b/internal/service/activity/activity.go @@ -3,6 +3,7 @@ package activity import ( "context" "encoding/json" + "fmt" "strings" "github.com/answerdev/answer/internal/base/constant" @@ -111,7 +112,11 @@ func (as *ActivityService) GetObjectTimeline(ctx context.Context, req *schema.Ge item.Username = "N/A" item.UserDisplayName = "N/A" } else { - item.UserID = act.UserID + if act.TriggerUserID > 0 { + item.UserID = fmt.Sprintf("%d", act.TriggerUserID) + } else { + item.UserID = act.UserID + } } item.Comment = as.getTimelineActivityComment(ctx, item.ObjectID, item.ObjectType, item.ActivityType, item.RevisionID) diff --git a/internal/service/answer_service.go b/internal/service/answer_service.go index 39433b81..2fa5c261 100644 --- a/internal/service/answer_service.go +++ b/internal/service/answer_service.go @@ -476,7 +476,7 @@ func (as *AnswerService) AdminSetAnswerStatus(ctx context.Context, req *schema.A msg.ReceiverUserID = answerInfo.UserID msg.TriggerUserID = answerInfo.UserID msg.ObjectType = constant.AnswerObjectType - msg.NotificationAction = constant.YourAnswerWasDeleted + msg.NotificationAction = constant.NotificationYourAnswerWasDeleted notice_queue.AddNotification(msg) return nil @@ -566,7 +566,7 @@ func (as *AnswerService) notificationUpdateAnswer(ctx context.Context, questionU ObjectID: answerID, } msg.ObjectType = constant.AnswerObjectType - msg.NotificationAction = constant.UpdateAnswer + msg.NotificationAction = constant.NotificationUpdateAnswer notice_queue.AddNotification(msg) } @@ -583,7 +583,7 @@ func (as *AnswerService) notificationAnswerTheQuestion(ctx context.Context, ObjectID: answerID, } msg.ObjectType = constant.AnswerObjectType - msg.NotificationAction = constant.AnswerTheQuestion + msg.NotificationAction = constant.NotificationAnswerTheQuestion notice_queue.AddNotification(msg) userInfo, exist, err := as.userRepo.GetByUserID(ctx, questionUserID) diff --git a/internal/service/auth/auth.go b/internal/service/auth/auth.go index 618ef5bd..9053a507 100644 --- a/internal/service/auth/auth.go +++ b/internal/service/auth/auth.go @@ -5,6 +5,7 @@ import ( "github.com/answerdev/answer/internal/entity" "github.com/answerdev/answer/pkg/token" + "github.com/answerdev/answer/plugin" "github.com/segmentfault/pacman/log" ) @@ -20,7 +21,7 @@ type AuthRepo interface { SetAdminUserCacheInfo(ctx context.Context, accessToken string, userInfo *entity.UserCacheInfo) error RemoveAdminUserCacheInfo(ctx context.Context, accessToken string) (err error) AddUserTokenMapping(ctx context.Context, userID, accessToken string) (err error) - RemoveUserTokens(ctx context.Context, userID string) + RemoveUserTokens(ctx context.Context, userID string, remainToken string) } // AuthService kit service @@ -42,7 +43,6 @@ func (as *AuthService) GetUserCacheInfo(ctx context.Context, accessToken string) } cacheInfo, _ := as.authRepo.GetUserStatus(ctx, userCacheInfo.UserID) if cacheInfo != nil { - log.Debugf("user status updated: %+v", cacheInfo) userCacheInfo.UserStatus = cacheInfo.UserStatus userCacheInfo.EmailStatus = cacheInfo.EmailStatus userCacheInfo.RoleID = cacheInfo.RoleID @@ -52,6 +52,14 @@ func (as *AuthService) GetUserCacheInfo(ctx context.Context, accessToken string) return nil, err } } + + // try to get user status from user center + uc, ok := plugin.GetUserCenter() + if ok && len(userCacheInfo.ExternalID) > 0 { + if userStatus := uc.UserStatus(userCacheInfo.ExternalID); userStatus != plugin.UserStatusAvailable { + userCacheInfo.UserStatus = int(userStatus) + } + } return userCacheInfo, nil } @@ -85,9 +93,14 @@ func (as *AuthService) AddUserTokenMapping(ctx context.Context, userID, accessTo return as.authRepo.AddUserTokenMapping(ctx, userID, accessToken) } -// RemoveUserTokens Log out all users under this user id -func (as *AuthService) RemoveUserTokens(ctx context.Context, userID string) { - as.authRepo.RemoveUserTokens(ctx, userID) +// RemoveUserAllTokens Log out all users under this user id +func (as *AuthService) RemoveUserAllTokens(ctx context.Context, userID string) { + as.authRepo.RemoveUserTokens(ctx, userID, "") +} + +// RemoveTokensExceptCurrentUser remove all tokens except the current user +func (as *AuthService) RemoveTokensExceptCurrentUser(ctx context.Context, userID string, accessToken string) { + as.authRepo.RemoveUserTokens(ctx, userID, accessToken) } //Admin diff --git a/internal/service/comment/comment_service.go b/internal/service/comment/comment_service.go index 1b65c48d..654ee9f3 100644 --- a/internal/service/comment/comment_service.go +++ b/internal/service/comment/comment_service.go @@ -471,7 +471,7 @@ func (cs *CommentService) notificationQuestionComment(ctx context.Context, quest ObjectID: commentID, } msg.ObjectType = constant.CommentObjectType - msg.NotificationAction = constant.CommentQuestion + msg.NotificationAction = constant.NotificationCommentQuestion notice_queue.AddNotification(msg) receiverUserInfo, exist, err := cs.userRepo.GetByUserID(ctx, questionUserID) @@ -526,7 +526,7 @@ func (cs *CommentService) notificationAnswerComment(ctx context.Context, ObjectID: commentID, } msg.ObjectType = constant.CommentObjectType - msg.NotificationAction = constant.CommentAnswer + msg.NotificationAction = constant.NotificationCommentAnswer notice_queue.AddNotification(msg) receiverUserInfo, exist, err := cs.userRepo.GetByUserID(ctx, answerUserID) @@ -578,7 +578,7 @@ func (cs *CommentService) notificationCommentReply(ctx context.Context, replyUse ObjectID: commentID, } msg.ObjectType = constant.CommentObjectType - msg.NotificationAction = constant.ReplyToYou + msg.NotificationAction = constant.NotificationReplyToYou notice_queue.AddNotification(msg) } @@ -599,7 +599,7 @@ func (cs *CommentService) notificationMention( ObjectID: commentID, } msg.ObjectType = constant.CommentObjectType - msg.NotificationAction = constant.MentionYou + msg.NotificationAction = constant.NotificationMentionYou notice_queue.AddNotification(msg) alreadyNotifiedUserIDs = append(alreadyNotifiedUserIDs, userInfo.ID) } diff --git a/internal/service/export/email_service.go b/internal/service/export/email_service.go index 4ae086e9..4fc9a2fa 100644 --- a/internal/service/export/email_service.go +++ b/internal/service/export/email_service.go @@ -162,39 +162,30 @@ func (es *EmailService) GetSiteGeneral(ctx context.Context) (resp schema.SiteGen } func (es *EmailService) RegisterTemplate(ctx context.Context, registerUrl string) (title, body string, err error) { - ec, err := es.GetEmailConfig() - if err != nil { - return - } - siteinfo, err := es.GetSiteGeneral(ctx) + emailConfig, err := es.GetEmailConfig() if err != nil { return } + siteInfo, err := es.GetSiteGeneral(ctx) + if err != nil { + return + } templateData := RegisterTemplateData{ - SiteName: siteinfo.Name, RegisterUrl: registerUrl, - } - tmpl, err := template.New("register_title").Parse(ec.RegisterTitle) - if err != nil { - return "", "", err - } - titleBuf := &bytes.Buffer{} - bodyBuf := &bytes.Buffer{} - err = tmpl.Execute(titleBuf, templateData) - if err != nil { - return "", "", err + SiteName: siteInfo.Name, + RegisterUrl: registerUrl, } - tmpl, err = template.New("register_body").Parse(ec.RegisterBody) + title, err = es.parseTemplateData(emailConfig.RegisterTitle, templateData) if err != nil { - return "", "", err - } - err = tmpl.Execute(bodyBuf, templateData) - if err != nil { - return "", "", err + return "", "", fmt.Errorf("email template parse error: %s", err) } - return titleBuf.String(), bodyBuf.String(), nil + body, err = es.parseTemplateData(emailConfig.RegisterBody, templateData) + if err != nil { + return "", "", fmt.Errorf("email template parse error: %s", err) + } + return title, body, nil } func (es *EmailService) PassResetTemplate(ctx context.Context, passResetUrl string) (title, body string, err error) { diff --git a/internal/service/notification/notification_service.go b/internal/service/notification/notification_service.go index 6f5e5b0e..3a8cce13 100644 --- a/internal/service/notification/notification_service.go +++ b/internal/service/notification/notification_service.go @@ -7,14 +7,15 @@ import ( "github.com/answerdev/answer/internal/base/constant" "github.com/answerdev/answer/internal/base/data" + "github.com/answerdev/answer/internal/base/handler" "github.com/answerdev/answer/internal/base/pager" "github.com/answerdev/answer/internal/base/translator" + "github.com/answerdev/answer/internal/entity" "github.com/answerdev/answer/internal/schema" notficationcommon "github.com/answerdev/answer/internal/service/notification_common" "github.com/answerdev/answer/internal/service/revision_common" "github.com/answerdev/answer/pkg/uid" "github.com/jinzhu/copier" - "github.com/segmentfault/pacman/i18n" "github.com/segmentfault/pacman/log" ) @@ -127,35 +128,47 @@ func (ns *NotificationService) GetNotificationPage(ctx context.Context, searchCo if err != nil { return nil, err } + resp, err = ns.formatNotificationPage(ctx, notifications) + if err != nil { + return nil, err + } + return pager.NewPageModel(total, resp), nil +} + +func (ns *NotificationService) formatNotificationPage(ctx context.Context, notifications []*entity.Notification) ( + resp []*schema.NotificationContent, err error) { + lang := handler.GetLangByCtx(ctx) for _, notificationInfo := range notifications { item := &schema.NotificationContent{} - err := json.Unmarshal([]byte(notificationInfo.Content), item) - if err != nil { + if err := json.Unmarshal([]byte(notificationInfo.Content), item); err != nil { log.Error("NotificationContent Unmarshal Error", err.Error()) continue } - lang, _ := ctx.Value(constant.AcceptLanguageFlag).(i18n.Language) - item.NotificationAction = translator.Tr(lang, item.NotificationAction) - item.ID = notificationInfo.ID - item.UpdateTime = notificationInfo.UpdatedAt.Unix() - if notificationInfo.IsRead == schema.NotificationRead { - item.IsRead = true + // If notification is downvote, the user info is not needed. + if item.NotificationAction == constant.NotificationDownVotedTheQuestion || + item.NotificationAction == constant.NotificationDownVotedTheAnswer { + item.UserInfo = nil } - answerID, ok := item.ObjectInfo.ObjectMap["answer"] - if ok { + + item.ID = notificationInfo.ID + item.NotificationAction = translator.Tr(lang, item.NotificationAction) + item.UpdateTime = notificationInfo.UpdatedAt.Unix() + item.IsRead = notificationInfo.IsRead == schema.NotificationRead + + if answerID, ok := item.ObjectInfo.ObjectMap["answer"]; ok { if item.ObjectInfo.ObjectID == answerID { item.ObjectInfo.ObjectID = uid.EnShortID(item.ObjectInfo.ObjectMap["answer"]) } item.ObjectInfo.ObjectMap["answer"] = uid.EnShortID(item.ObjectInfo.ObjectMap["answer"]) } - questionID, ok := item.ObjectInfo.ObjectMap["question"] - if ok { + if questionID, ok := item.ObjectInfo.ObjectMap["question"]; ok { if item.ObjectInfo.ObjectID == questionID { item.ObjectInfo.ObjectID = uid.EnShortID(item.ObjectInfo.ObjectMap["question"]) } item.ObjectInfo.ObjectMap["question"] = uid.EnShortID(item.ObjectInfo.ObjectMap["question"]) } + resp = append(resp, item) } - return pager.NewPageModel(total, resp), nil + return resp, nil } diff --git a/internal/service/notification_common/notification.go b/internal/service/notification_common/notification.go index eb148cd4..5deda1de 100644 --- a/internal/service/notification_common/notification.go +++ b/internal/service/notification_common/notification.go @@ -15,6 +15,7 @@ import ( "github.com/answerdev/answer/internal/service/object_info" usercommon "github.com/answerdev/answer/internal/service/user_common" "github.com/answerdev/answer/pkg/uid" + "github.com/answerdev/answer/plugin" "github.com/goccy/go-json" "github.com/jinzhu/copier" "github.com/segmentfault/pacman/errors" @@ -82,7 +83,9 @@ func (ns *NotificationCommon) HandleNotification() { // ObjectInfo.ObjectID // ObjectInfo.ObjectType func (ns *NotificationCommon) AddNotification(ctx context.Context, msg *schema.NotificationMsg) error { - + if msg.Type == schema.NotificationTypeAchievement && plugin.RankAgentEnabled() { + return nil + } req := &schema.NotificationContent{ TriggerUserID: msg.TriggerUserID, ReceiverUserID: msg.ReceiverUserID, @@ -190,10 +193,10 @@ func (ns *NotificationCommon) SendNotificationToAllFollower(ctx context.Context, if msg.NoNeedPushAllFollow { return } - if msg.NotificationAction != constant.UpdateQuestion && - msg.NotificationAction != constant.AnswerTheQuestion && - msg.NotificationAction != constant.UpdateAnswer && - msg.NotificationAction != constant.AcceptAnswer { + if msg.NotificationAction != constant.NotificationUpdateQuestion && + msg.NotificationAction != constant.NotificationAnswerTheQuestion && + msg.NotificationAction != constant.NotificationUpdateAnswer && + msg.NotificationAction != constant.NotificationAcceptAnswer { return } condObjectID := msg.ObjectID diff --git a/internal/service/permission/permission_name.go b/internal/service/permission/permission_name.go index 1d49ce3f..9262e569 100644 --- a/internal/service/permission/permission_name.go +++ b/internal/service/permission/permission_name.go @@ -10,10 +10,10 @@ const ( QuestionReopen = "question.reopen" QuestionVoteUp = "question.vote_up" QuestionVoteDown = "question.vote_down" - QuestionPin = "question.pin" //Top the question - QuestionUnPin = "question.unpin" //untop the question - QuestionHide = "question.hide" //hide the question - QuestionShow = "question.show" //show the question + QuestionPin = "question.pin" + QuestionUnPin = "question.unpin" + QuestionHide = "question.hide" + QuestionShow = "question.show" AnswerAdd = "answer.add" AnswerEdit = "answer.edit" AnswerEditWithoutReview = "answer.edit_without_review" diff --git a/internal/service/plugin_common/plugin_common_service.go b/internal/service/plugin_common/plugin_common_service.go new file mode 100644 index 00000000..c8f7d799 --- /dev/null +++ b/internal/service/plugin_common/plugin_common_service.go @@ -0,0 +1,80 @@ +package plugin_common + +import ( + "context" + "encoding/json" + + "github.com/answerdev/answer/internal/base/constant" + "github.com/answerdev/answer/internal/base/reason" + "github.com/answerdev/answer/internal/entity" + "github.com/answerdev/answer/internal/schema" + "github.com/answerdev/answer/internal/service/config" + "github.com/answerdev/answer/plugin" + "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" +) + +type PluginConfigRepo interface { + SavePluginConfig(ctx context.Context, pluginSlugName, configValue string) (err error) + GetPluginConfigAll(ctx context.Context) (pluginConfigs []*entity.PluginConfig, err error) +} + +// PluginCommonService user service +type PluginCommonService struct { + configRepo config.ConfigRepo + pluginConfigRepo PluginConfigRepo +} + +// NewPluginCommonService new report service +func NewPluginCommonService( + pluginConfigRepo PluginConfigRepo, + configRepo config.ConfigRepo) *PluginCommonService { + + // init plugin status + pluginStatus, err := configRepo.GetString(constant.PluginStatus) + if err != nil { + log.Error(err) + } else { + if err := plugin.StatusManager.UnmarshalJSON([]byte(pluginStatus)); err != nil { + log.Error(err) + } + } + + // init plugin config + pluginConfigs, err := pluginConfigRepo.GetPluginConfigAll(context.Background()) + if err != nil { + log.Error(err) + } else { + for _, pluginConfig := range pluginConfigs { + err := plugin.CallConfig(func(fn plugin.Config) error { + if fn.Info().SlugName == pluginConfig.PluginSlugName { + return fn.ConfigReceiver([]byte(pluginConfig.Value)) + } + return nil + }) + if err != nil { + log.Errorf("parse plugin config failed: %s %v", pluginConfig.PluginSlugName, err) + } + } + } + + return &PluginCommonService{ + configRepo: configRepo, + pluginConfigRepo: pluginConfigRepo, + } +} + +// UpdatePluginStatus update plugin status +func (ps *PluginCommonService) UpdatePluginStatus(ctx context.Context) (err error) { + content, err := plugin.StatusManager.MarshalJSON() + if err != nil { + return errors.InternalServer(reason.UnknownError).WithError(err) + } + return ps.configRepo.SetConfig(constant.PluginStatus, string(content)) +} + +// UpdatePluginConfig update plugin config +func (ps *PluginCommonService) UpdatePluginConfig(ctx context.Context, req *schema.UpdatePluginConfigReq) (err error) { + configValue, _ := json.Marshal(req.ConfigFields) + return ps.pluginConfigRepo.SavePluginConfig(ctx, req.PluginSlugName, string(configValue)) +} diff --git a/internal/service/provider.go b/internal/service/provider.go index 65923136..8273866c 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -16,6 +16,7 @@ import ( "github.com/answerdev/answer/internal/service/notification" notficationcommon "github.com/answerdev/answer/internal/service/notification_common" "github.com/answerdev/answer/internal/service/object_info" + "github.com/answerdev/answer/internal/service/plugin_common" questioncommon "github.com/answerdev/answer/internal/service/question_common" "github.com/answerdev/answer/internal/service/rank" "github.com/answerdev/answer/internal/service/reason" @@ -32,6 +33,7 @@ import ( "github.com/answerdev/answer/internal/service/uploader" "github.com/answerdev/answer/internal/service/user_admin" usercommon "github.com/answerdev/answer/internal/service/user_common" + "github.com/answerdev/answer/internal/service/user_external_login" "github.com/google/wire" ) @@ -79,4 +81,7 @@ var ProviderSetService = wire.NewSet( role.NewRoleService, role.NewUserRoleRelService, role.NewRolePowerRelService, + user_external_login.NewUserExternalLoginService, + user_external_login.NewUserCenterLoginService, + plugin_common.NewPluginCommonService, ) diff --git a/internal/service/question_common/question.go b/internal/service/question_common/question.go index 2ca0549a..46a76067 100644 --- a/internal/service/question_common/question.go +++ b/internal/service/question_common/question.go @@ -32,7 +32,7 @@ type QuestionRepo interface { UpdateQuestion(ctx context.Context, question *entity.Question, Cols []string) (err error) GetQuestion(ctx context.Context, id string) (question *entity.Question, exist bool, err error) GetQuestionList(ctx context.Context, question *entity.Question) (questions []*entity.Question, err error) - GetQuestionPage(ctx context.Context, page, pageSize int, userID, tagID, orderCond string) ( + GetQuestionPage(ctx context.Context, page, pageSize int, userID, tagID, orderCond string, inDays int) ( questionList []*entity.Question, total int64, err error) UpdateQuestionStatus(ctx context.Context, question *entity.Question) (err error) UpdateQuestionStatusWithOutUpdateTime(ctx context.Context, question *entity.Question) (err error) diff --git a/internal/service/question_service.go b/internal/service/question_service.go index 52ec1e33..b47ffba9 100644 --- a/internal/service/question_service.go +++ b/internal/service/question_service.go @@ -10,6 +10,7 @@ import ( "github.com/answerdev/answer/internal/base/constant" "github.com/answerdev/answer/internal/base/data" "github.com/answerdev/answer/internal/base/handler" + "github.com/answerdev/answer/internal/base/pager" "github.com/answerdev/answer/internal/base/reason" "github.com/answerdev/answer/internal/base/translator" "github.com/answerdev/answer/internal/base/validator" @@ -270,6 +271,7 @@ func (qs *QuestionService) AddQuestion(ctx context.Context, req *schema.Question question.Status = entity.QuestionStatusAvailable question.RevisionID = "0" question.CreatedAt = now + question.PostUpdateTime = now question.Pin = entity.QuestionUnPin question.Show = entity.QuestionShow //question.UpdatedAt = nil @@ -742,70 +744,74 @@ func (qs *QuestionService) CheckChangeReservedTag(ctx context.Context, oldobject return qs.tagCommon.CheckChangeReservedTag(ctx, oldobjectTagData, objectTagData) } -func (qs *QuestionService) SearchUserList(ctx context.Context, userName, order string, page, pageSize int, loginUserID string) ([]*schema.UserQuestionInfo, int64, error) { - userlist := make([]*schema.UserQuestionInfo, 0) +// PersonalQuestionPage get question list by user +func (qs *QuestionService) PersonalQuestionPage(ctx context.Context, req *schema.PersonalQuestionPageReq) ( + pageModel *pager.PageModel, err error) { - userinfo, Exist, err := qs.userCommon.GetUserBasicInfoByUserName(ctx, userName) + userinfo, exist, err := qs.userCommon.GetUserBasicInfoByUserName(ctx, req.Username) if err != nil { - return userlist, 0, err + return nil, err } - if !Exist { - return userlist, 0, nil + if !exist { + return nil, errors.BadRequest(reason.UserNotFound) } search := &schema.QuestionPageReq{} - search.OrderCond = order - search.Page = page - search.PageSize = pageSize + search.OrderCond = req.OrderCond + search.Page = req.Page + search.PageSize = req.PageSize search.UserIDBeSearched = userinfo.ID - search.LoginUserID = loginUserID - questionlist, count, err := qs.GetQuestionPage(ctx, search) + search.LoginUserID = req.LoginUserID + questionList, total, err := qs.GetQuestionPage(ctx, search) if err != nil { - return userlist, 0, err + return nil, err } - for _, item := range questionlist { + userQuestionInfoList := make([]*schema.UserQuestionInfo, 0) + for _, item := range questionList { info := &schema.UserQuestionInfo{} _ = copier.Copy(info, item) status, ok := entity.AdminQuestionSearchStatusIntToString[item.Status] if ok { info.Status = status } - userlist = append(userlist, info) + userQuestionInfoList = append(userQuestionInfoList, info) } - return userlist, count, nil + return pager.NewPageModel(total, userQuestionInfoList), nil } -func (qs *QuestionService) SearchUserAnswerList(ctx context.Context, userName, order string, page, pageSize int, loginUserID string) ([]*schema.UserAnswerInfo, int64, error) { - answerlist := make([]*schema.AnswerInfo, 0) - userAnswerlist := make([]*schema.UserAnswerInfo, 0) - userinfo, Exist, err := qs.userCommon.GetUserBasicInfoByUserName(ctx, userName) +func (qs *QuestionService) PersonalAnswerPage(ctx context.Context, req *schema.PersonalAnswerPageReq) ( + pageModel *pager.PageModel, err error) { + userinfo, exist, err := qs.userCommon.GetUserBasicInfoByUserName(ctx, req.Username) if err != nil { - return userAnswerlist, 0, err + return nil, err } - if !Exist { - return userAnswerlist, 0, nil + if !exist { + return nil, errors.BadRequest(reason.UserNotFound) } answersearch := &entity.AnswerSearch{} answersearch.UserID = userinfo.ID - answersearch.PageSize = pageSize - answersearch.Page = page - if order == "newest" { + answersearch.PageSize = req.PageSize + answersearch.Page = req.Page + if req.OrderCond == "newest" { answersearch.Order = entity.AnswerSearchOrderByTime } else { answersearch.Order = entity.AnswerSearchOrderByDefault } questionIDs := make([]string, 0) - answerList, count, err := qs.questioncommon.AnswerCommon.Search(ctx, answersearch) + answerList, total, err := qs.questioncommon.AnswerCommon.Search(ctx, answersearch) if err != nil { - return userAnswerlist, count, err + return nil, err } + + answerlist := make([]*schema.AnswerInfo, 0) + userAnswerlist := make([]*schema.UserAnswerInfo, 0) for _, item := range answerList { answerinfo := qs.questioncommon.AnswerCommon.ShowFormat(ctx, item) answerlist = append(answerlist, answerinfo) questionIDs = append(questionIDs, uid.DeShortID(item.QuestionID)) } - questionMaps, err := qs.questioncommon.FindInfoByID(ctx, questionIDs, loginUserID) + questionMaps, err := qs.questioncommon.FindInfoByID(ctx, questionIDs, req.LoginUserID) if err != nil { - return userAnswerlist, count, err + return nil, err } for _, item := range answerlist { @@ -822,34 +828,29 @@ func (qs *QuestionService) SearchUserAnswerList(ctx context.Context, userName, o } } - return userAnswerlist, count, nil + return pager.NewPageModel(total, userAnswerlist), nil } -func (qs *QuestionService) SearchUserCollectionList(ctx context.Context, page, pageSize int, loginUserID string) ([]*schema.QuestionInfo, int64, error) { +// PersonalCollectionPage get collection list by user +func (qs *QuestionService) PersonalCollectionPage(ctx context.Context, req *schema.PersonalCollectionPageReq) ( + pageModel *pager.PageModel, err error) { list := make([]*schema.QuestionInfo, 0) - userinfo, Exist, err := qs.userCommon.GetUserBasicInfoByID(ctx, loginUserID) - if err != nil { - return list, 0, err - } - if !Exist { - return list, 0, nil - } collectionSearch := &entity.CollectionSearch{} - collectionSearch.UserID = userinfo.ID - collectionSearch.Page = page - collectionSearch.PageSize = pageSize - collectionlist, count, err := qs.collectionCommon.SearchList(ctx, collectionSearch) + collectionSearch.UserID = req.UserID + collectionSearch.Page = req.Page + collectionSearch.PageSize = req.PageSize + collectionList, total, err := qs.collectionCommon.SearchList(ctx, collectionSearch) if err != nil { - return list, 0, err + return nil, err } questionIDs := make([]string, 0) - for _, item := range collectionlist { + for _, item := range collectionList { questionIDs = append(questionIDs, item.ObjectID) } - questionMaps, err := qs.questioncommon.FindInfoByID(ctx, questionIDs, loginUserID) + questionMaps, err := qs.questioncommon.FindInfoByID(ctx, questionIDs, req.UserID) if err != nil { - return list, count, err + return nil, err } for _, id := range questionIDs { _, ok := questionMaps[uid.EnShortID(id)] @@ -862,7 +863,7 @@ func (qs *QuestionService) SearchUserCollectionList(ctx context.Context, page, p } } - return list, count, nil + return pager.NewPageModel(total, list), nil } func (qs *QuestionService) SearchUserTopList(ctx context.Context, userName string, loginUserID string) ([]*schema.UserQuestionInfo, []*schema.UserAnswerInfo, error) { @@ -1010,7 +1011,7 @@ func (qs *QuestionService) GetQuestionPage(ctx context.Context, req *schema.Ques } questionList, total, err := qs.questionRepo.GetQuestionPage(ctx, req.Page, req.PageSize, - req.UserIDBeSearched, req.TagID, req.OrderCond) + req.UserIDBeSearched, req.TagID, req.OrderCond, req.InDays) if err != nil { return nil, 0, err } @@ -1072,7 +1073,7 @@ func (qs *QuestionService) AdminSetQuestionStatus(ctx context.Context, questionI msg.ReceiverUserID = questionInfo.UserID msg.TriggerUserID = questionInfo.UserID msg.ObjectType = constant.QuestionObjectType - msg.NotificationAction = constant.YourQuestionWasDeleted + msg.NotificationAction = constant.NotificationYourQuestionWasDeleted notice_queue.AddNotification(msg) return nil } diff --git a/internal/service/rank/rank_service.go b/internal/service/rank/rank_service.go index b079bed5..534913d8 100644 --- a/internal/service/rank/rank_service.go +++ b/internal/service/rank/rank_service.go @@ -16,6 +16,7 @@ import ( usercommon "github.com/answerdev/answer/internal/service/user_common" "github.com/answerdev/answer/pkg/htmltext" "github.com/answerdev/answer/pkg/uid" + "github.com/answerdev/answer/plugin" "github.com/segmentfault/pacman/errors" "github.com/segmentfault/pacman/log" "xorm.io/xorm" @@ -228,6 +229,9 @@ func (rs *RankService) checkUserRank(ctx context.Context, userID string, userRan // GetRankPersonalWithPage get personal comment list page func (rs *RankService) GetRankPersonalWithPage(ctx context.Context, req *schema.GetRankPersonalWithPageReq) ( pageModel *pager.PageModel, err error) { + if plugin.RankAgentEnabled() { + return pager.NewPageModel(0, []string{}), nil + } if len(req.Username) > 0 { userInfo, exist, err := rs.userCommon.GetUserBasicInfoByUserName(ctx, req.Username) if err != nil { diff --git a/internal/service/report_handle_admin/report_handle.go b/internal/service/report_handle_admin/report_handle.go index c638228a..a7a57272 100644 --- a/internal/service/report_handle_admin/report_handle.go +++ b/internal/service/report_handle_admin/report_handle.go @@ -66,7 +66,7 @@ func (rh *ReportHandle) HandleObject(ctx context.Context, reported *entity.Repor switch req.FlaggedType { case reasonDelete: err = rh.commentRepo.RemoveComment(ctx, objectID) - rh.sendNotification(ctx, reportedUserID, objectID, constant.YourCommentWasDeleted) + rh.sendNotification(ctx, reportedUserID, objectID, constant.NotificationYourCommentWasDeleted) } } return diff --git a/internal/service/revision_service.go b/internal/service/revision_service.go index 3e8c3605..20d3c9ca 100644 --- a/internal/service/revision_service.go +++ b/internal/service/revision_service.go @@ -209,7 +209,7 @@ func (rs *RevisionService) revisionAuditAnswer(ctx context.Context, revisionitem ObjectID: answerinfo.ID, } msg.ObjectType = constant.AnswerObjectType - msg.NotificationAction = constant.UpdateAnswer + msg.NotificationAction = constant.NotificationUpdateAnswer notice_queue.AddNotification(msg) activity_queue.AddActivity(&schema.ActivityMsg{ diff --git a/internal/service/siteinfo/siteinfo_service.go b/internal/service/siteinfo/siteinfo_service.go index d41f4302..986beb78 100644 --- a/internal/service/siteinfo/siteinfo_service.go +++ b/internal/service/siteinfo/siteinfo_service.go @@ -3,12 +3,15 @@ package siteinfo import ( "context" "encoding/json" + "fmt" "github.com/answerdev/answer/internal/base/constant" + "github.com/answerdev/answer/internal/base/handler" "github.com/answerdev/answer/internal/base/reason" "github.com/answerdev/answer/internal/base/translator" "github.com/answerdev/answer/internal/entity" "github.com/answerdev/answer/internal/schema" + "github.com/answerdev/answer/internal/service/config" "github.com/answerdev/answer/internal/service/export" "github.com/answerdev/answer/internal/service/siteinfo_common" tagcommon "github.com/answerdev/answer/internal/service/tag_common" @@ -23,19 +26,23 @@ type SiteInfoService struct { siteInfoCommonService *siteinfo_common.SiteInfoCommonService emailService *export.EmailService tagCommonService *tagcommon.TagCommonService + configRepo config.ConfigRepo } func NewSiteInfoService( siteInfoRepo siteinfo_common.SiteInfoRepo, siteInfoCommonService *siteinfo_common.SiteInfoCommonService, emailService *export.EmailService, - tagCommonService *tagcommon.TagCommonService) *SiteInfoService { - - resp, err := siteInfoCommonService.GetSiteInterface(context.Background()) - if err != nil { - log.Error(err) - } else { - constant.DefaultAvatar = resp.DefaultAvatar + tagCommonService *tagcommon.TagCommonService, + configRepo config.ConfigRepo, +) *SiteInfoService { + usersSiteInfo, _ := siteInfoCommonService.GetSiteUsers(context.Background()) + if usersSiteInfo != nil { + constant.DefaultAvatar = usersSiteInfo.DefaultAvatar + } + generalSiteInfo, _ := siteInfoCommonService.GetSiteGeneral(context.Background()) + if generalSiteInfo != nil { + constant.DefaultSiteURL = generalSiteInfo.SiteUrl } return &SiteInfoService{ @@ -43,6 +50,7 @@ func NewSiteInfoService( siteInfoCommonService: siteInfoCommonService, emailService: emailService, tagCommonService: tagCommonService, + configRepo: configRepo, } } @@ -61,6 +69,11 @@ func (s *SiteInfoService) GetSiteBranding(ctx context.Context) (resp *schema.Sit return s.siteInfoCommonService.GetSiteBranding(ctx) } +// GetSiteUsers get site info about users +func (s *SiteInfoService) GetSiteUsers(ctx context.Context) (resp *schema.SiteUsersResp, err error) { + return s.siteInfoCommonService.GetSiteUsers(ctx) +} + // GetSiteWrite get site info write func (s *SiteInfoService) GetSiteWrite(ctx context.Context) (resp *schema.SiteWriteResp, err error) { resp = &schema.SiteWriteResp{} @@ -106,45 +119,32 @@ func (s *SiteInfoService) GetSiteTheme(ctx context.Context) (resp *schema.SiteTh func (s *SiteInfoService) SaveSiteGeneral(ctx context.Context, req schema.SiteGeneralReq) (err error) { req.FormatSiteUrl() - var ( - siteType = "general" - content []byte - ) - content, _ = json.Marshal(req) - - data := entity.SiteInfo{ - Type: siteType, + content, _ := json.Marshal(req) + data := &entity.SiteInfo{ + Type: constant.SiteTypeGeneral, Content: string(content), + Status: 1, + } + err = s.siteInfoRepo.SaveByType(ctx, constant.SiteTypeGeneral, data) + if err == nil { + constant.DefaultSiteURL = req.SiteUrl } - - err = s.siteInfoRepo.SaveByType(ctx, siteType, &data) return } func (s *SiteInfoService) SaveSiteInterface(ctx context.Context, req schema.SiteInterfaceReq) (err error) { - var ( - siteType = "interface" - content []byte - ) - // check language if !translator.CheckLanguageIsValid(req.Language) { err = errors.BadRequest(reason.LangNotFound) return } - content, _ = json.Marshal(req) - + content, _ := json.Marshal(req) data := entity.SiteInfo{ - Type: siteType, + Type: constant.SiteTypeInterface, Content: string(content), } - - err = s.siteInfoRepo.SaveByType(ctx, siteType, &data) - if err == nil { - constant.DefaultAvatar = req.DefaultAvatar - } - return + return s.siteInfoRepo.SaveByType(ctx, constant.SiteTypeInterface, &data) } // SaveSiteBranding save site branding information @@ -218,6 +218,21 @@ func (s *SiteInfoService) SaveSiteTheme(ctx context.Context, req *schema.SiteThe return s.siteInfoRepo.SaveByType(ctx, constant.SiteTypeTheme, data) } +// SaveSiteUsers save site users +func (s *SiteInfoService) SaveSiteUsers(ctx context.Context, req *schema.SiteUsersReq) (err error) { + content, _ := json.Marshal(req) + data := &entity.SiteInfo{ + Type: constant.SiteTypeUsers, + Content: string(content), + Status: 1, + } + err = s.siteInfoRepo.SaveByType(ctx, constant.SiteTypeUsers, data) + if err == nil { + constant.DefaultAvatar = req.DefaultAvatar + } + return err +} + // GetSMTPConfig get smtp config func (s *SiteInfoService) GetSMTPConfig(ctx context.Context) ( resp *schema.GetSMTPConfigResp, err error, @@ -253,8 +268,11 @@ func (s *SiteInfoService) UpdateSMTPConfig(ctx context.Context, req *schema.Upda return } -func (s *SiteInfoService) GetSeo(ctx context.Context) (resp *schema.SiteSeoResp, err error) { - resp = &schema.SiteSeoResp{} +func (s *SiteInfoService) GetSeo(ctx context.Context) (resp *schema.SiteSeoReq, err error) { + resp = &schema.SiteSeoReq{} + if err = s.siteInfoCommonService.GetSiteInfoByType(ctx, constant.SiteTypeSeo, resp); err != nil { + return resp, err + } loginConfig, err := s.GetSiteLogin(ctx) if err != nil { log.Error(err) @@ -265,17 +283,6 @@ func (s *SiteInfoService) GetSeo(ctx context.Context) (resp *schema.SiteSeoResp, resp.Robots = "User-agent: *\nDisallow: /" return resp, nil } - - resp = &schema.SiteSeoResp{} - siteInfo, exist, err := s.siteInfoRepo.GetByType(ctx, constant.SiteTypeSeo) - if err != nil { - log.Error(err) - return resp, nil - } - if !exist { - return resp, nil - } - _ = json.Unmarshal([]byte(siteInfo.Content), resp) return resp, nil } @@ -302,3 +309,71 @@ func (s *SiteInfoService) SaveSeo(ctx context.Context, req schema.SiteSeoReq) (e } return } + +func (s *SiteInfoService) GetPrivilegesConfig(ctx context.Context) (resp *schema.GetPrivilegesConfigResp, err error) { + privilege := &schema.UpdatePrivilegesConfigReq{} + if err = s.siteInfoCommonService.GetSiteInfoByType(ctx, constant.SiteTypePrivileges, privilege); err != nil { + return nil, err + } + resp = &schema.GetPrivilegesConfigResp{ + Options: s.translatePrivilegeOptions(ctx), + SelectedLevel: schema.PrivilegeLevel3, + } + if privilege != nil && privilege.Level > 0 { + resp.SelectedLevel = privilege.Level + } + return resp, nil +} + +func (s *SiteInfoService) translatePrivilegeOptions(ctx context.Context) (options []*schema.PrivilegeOption) { + la := handler.GetLangByCtx(ctx) + for _, option := range schema.DefaultPrivilegeOptions { + op := &schema.PrivilegeOption{ + Level: option.Level, + LevelDesc: translator.Tr(la, option.LevelDesc), + } + for _, privilege := range option.Privileges { + op.Privileges = append(op.Privileges, &constant.Privilege{ + Key: privilege.Key, + Label: translator.Tr(la, privilege.Label), + Value: privilege.Value, + }) + } + options = append(options, op) + } + return +} + +func (s *SiteInfoService) UpdatePrivilegesConfig(ctx context.Context, req *schema.UpdatePrivilegesConfigReq) (err error) { + var chooseOption *schema.PrivilegeOption + for _, option := range schema.DefaultPrivilegeOptions { + if option.Level == req.Level { + chooseOption = option + break + } + } + if chooseOption == nil { + return nil + } + + // update site info that user choose which privilege level + content, _ := json.Marshal(req) + data := &entity.SiteInfo{ + Type: constant.SiteTypePrivileges, + Content: string(content), + Status: 1, + } + err = s.siteInfoRepo.SaveByType(ctx, constant.SiteTypePrivileges, data) + if err != nil { + return err + } + + // update privilege in config + for _, privilege := range chooseOption.Privileges { + err = s.configRepo.SetConfig(privilege.Key, fmt.Sprintf("%d", privilege.Value)) + if err != nil { + return err + } + } + return +} diff --git a/internal/service/siteinfo_common/siteinfo_service.go b/internal/service/siteinfo_common/siteinfo_service.go index b2a89643..7e2934a3 100644 --- a/internal/service/siteinfo_common/siteinfo_service.go +++ b/internal/service/siteinfo_common/siteinfo_service.go @@ -43,7 +43,7 @@ func NewSiteInfoCommonService(siteInfoRepo SiteInfoRepo) *SiteInfoCommonService // GetSiteGeneral get site info general func (s *SiteInfoCommonService) GetSiteGeneral(ctx context.Context) (resp *schema.SiteGeneralResp, err error) { resp = &schema.SiteGeneralResp{} - if err = s.getSiteInfoByType(ctx, constant.SiteTypeGeneral, resp); err != nil { + if err = s.GetSiteInfoByType(ctx, constant.SiteTypeGeneral, resp); err != nil { return nil, err } return resp, nil @@ -52,7 +52,7 @@ func (s *SiteInfoCommonService) GetSiteGeneral(ctx context.Context) (resp *schem // GetSiteInterface get site info interface func (s *SiteInfoCommonService) GetSiteInterface(ctx context.Context) (resp *schema.SiteInterfaceResp, err error) { resp = &schema.SiteInterfaceResp{} - if err = s.getSiteInfoByType(ctx, constant.SiteTypeInterface, resp); err != nil { + if err = s.GetSiteInfoByType(ctx, constant.SiteTypeInterface, resp); err != nil { return nil, err } return resp, nil @@ -61,7 +61,16 @@ func (s *SiteInfoCommonService) GetSiteInterface(ctx context.Context) (resp *sch // GetSiteBranding get site info branding func (s *SiteInfoCommonService) GetSiteBranding(ctx context.Context) (resp *schema.SiteBrandingResp, err error) { resp = &schema.SiteBrandingResp{} - if err = s.getSiteInfoByType(ctx, constant.SiteTypeBranding, resp); err != nil { + if err = s.GetSiteInfoByType(ctx, constant.SiteTypeBranding, resp); err != nil { + return nil, err + } + return resp, nil +} + +// GetSiteUsers get site info about users +func (s *SiteInfoCommonService) GetSiteUsers(ctx context.Context) (resp *schema.SiteUsersResp, err error) { + resp = &schema.SiteUsersResp{} + if err = s.GetSiteInfoByType(ctx, constant.SiteTypeUsers, resp); err != nil { return nil, err } return resp, nil @@ -70,7 +79,7 @@ func (s *SiteInfoCommonService) GetSiteBranding(ctx context.Context) (resp *sche // GetSiteWrite get site info write func (s *SiteInfoCommonService) GetSiteWrite(ctx context.Context) (resp *schema.SiteWriteResp, err error) { resp = &schema.SiteWriteResp{} - if err = s.getSiteInfoByType(ctx, constant.SiteTypeWrite, resp); err != nil { + if err = s.GetSiteInfoByType(ctx, constant.SiteTypeWrite, resp); err != nil { return nil, err } return resp, nil @@ -79,7 +88,7 @@ func (s *SiteInfoCommonService) GetSiteWrite(ctx context.Context) (resp *schema. // GetSiteLegal get site info write func (s *SiteInfoCommonService) GetSiteLegal(ctx context.Context) (resp *schema.SiteLegalResp, err error) { resp = &schema.SiteLegalResp{} - if err = s.getSiteInfoByType(ctx, constant.SiteTypeLegal, resp); err != nil { + if err = s.GetSiteInfoByType(ctx, constant.SiteTypeLegal, resp); err != nil { return nil, err } return resp, nil @@ -88,7 +97,7 @@ func (s *SiteInfoCommonService) GetSiteLegal(ctx context.Context) (resp *schema. // GetSiteLogin get site login config func (s *SiteInfoCommonService) GetSiteLogin(ctx context.Context) (resp *schema.SiteLoginResp, err error) { resp = &schema.SiteLoginResp{} - if err = s.getSiteInfoByType(ctx, constant.SiteTypeLogin, resp); err != nil { + if err = s.GetSiteInfoByType(ctx, constant.SiteTypeLogin, resp); err != nil { return nil, err } return resp, nil @@ -97,7 +106,7 @@ func (s *SiteInfoCommonService) GetSiteLogin(ctx context.Context) (resp *schema. // GetSiteCustomCssHTML get site custom css html config func (s *SiteInfoCommonService) GetSiteCustomCssHTML(ctx context.Context) (resp *schema.SiteCustomCssHTMLResp, err error) { resp = &schema.SiteCustomCssHTMLResp{} - if err = s.getSiteInfoByType(ctx, constant.SiteTypeCustomCssHTML, resp); err != nil { + if err = s.GetSiteInfoByType(ctx, constant.SiteTypeCustomCssHTML, resp); err != nil { return nil, err } return resp, nil @@ -108,7 +117,7 @@ func (s *SiteInfoCommonService) GetSiteTheme(ctx context.Context) (resp *schema. resp = &schema.SiteThemeResp{ ThemeOptions: schema.GetThemeOptions, } - if err = s.getSiteInfoByType(ctx, constant.SiteTypeTheme, resp); err != nil { + if err = s.GetSiteInfoByType(ctx, constant.SiteTypeTheme, resp); err != nil { return nil, err } resp.TrTheme(ctx) @@ -118,13 +127,13 @@ func (s *SiteInfoCommonService) GetSiteTheme(ctx context.Context) (resp *schema. // GetSiteSeo get site seo func (s *SiteInfoCommonService) GetSiteSeo(ctx context.Context) (resp *schema.SiteSeoReq, err error) { resp = &schema.SiteSeoReq{} - if err = s.getSiteInfoByType(ctx, constant.SiteTypeSeo, resp); err != nil { + if err = s.GetSiteInfoByType(ctx, constant.SiteTypeSeo, resp); err != nil { return nil, err } return resp, nil } -func (s *SiteInfoCommonService) getSiteInfoByType(ctx context.Context, siteType string, resp interface{}) (err error) { +func (s *SiteInfoCommonService) GetSiteInfoByType(ctx context.Context, siteType string, resp interface{}) (err error) { siteInfo, exist, err := s.siteInfoRepo.GetByType(ctx, siteType) if err != nil { return err diff --git a/internal/service/uploader/upload.go b/internal/service/uploader/upload.go index 980e82e2..3de12ff2 100644 --- a/internal/service/uploader/upload.go +++ b/internal/service/uploader/upload.go @@ -18,10 +18,12 @@ import ( "github.com/answerdev/answer/pkg/checker" "github.com/answerdev/answer/pkg/dir" "github.com/answerdev/answer/pkg/uid" + "github.com/answerdev/answer/plugin" "github.com/disintegration/imaging" "github.com/gin-gonic/gin" exifremove "github.com/scottleedavis/go-exif-remove" "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" ) const ( @@ -72,6 +74,14 @@ func NewUploaderService(serviceConfig *service_config.ServiceConfig, // UploadAvatarFile upload avatar file func (us *UploaderService) UploadAvatarFile(ctx *gin.Context) (url string, err error) { + url, err = us.tryToUploadByPlugin(ctx, plugin.UserAvatar) + if err != nil { + return "", err + } + if len(url) > 0 { + return url, nil + } + // max size ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, 5*1024*1024) _, file, err := ctx.Request.FormFile("file") @@ -143,6 +153,14 @@ func (us *UploaderService) AvatarThumbFile(ctx *gin.Context, uploadPath, fileNam func (us *UploaderService) UploadPostFile(ctx *gin.Context) ( url string, err error) { + url, err = us.tryToUploadByPlugin(ctx, plugin.UserAvatar) + if err != nil { + return "", err + } + if len(url) > 0 { + return url, nil + } + // max size ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, 10*1024*1024) _, file, err := ctx.Request.FormFile("file") @@ -161,6 +179,14 @@ func (us *UploaderService) UploadPostFile(ctx *gin.Context) ( func (us *UploaderService) UploadBrandingFile(ctx *gin.Context) ( url string, err error) { + url, err = us.tryToUploadByPlugin(ctx, plugin.UserAvatar) + if err != nil { + return "", err + } + if len(url) > 0 { + return url, nil + } + // max size ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, 10*1024*1024) _, file, err := ctx.Request.FormFile("file") @@ -204,6 +230,21 @@ func (us *UploaderService) uploadFile(ctx *gin.Context, file *multipart.FileHead return url, nil } +func (us *UploaderService) tryToUploadByPlugin(ctx *gin.Context, source plugin.UploadSource) ( + url string, err error) { + _ = plugin.CallStorage(func(fn plugin.Storage) error { + resp := fn.UploadFile(ctx, source) + if resp.OriginalError != nil { + log.Errorf("upload file by plugin failed, err: %v", resp.OriginalError) + err = errors.BadRequest("").WithMsg(resp.DisplayErrorMsg.Translate(ctx)).WithError(err) + } else { + url = resp.FullURL + } + return nil + }) + return url, err +} + func Dexif(filepath string, destpath string) error { img, err := ioutil.ReadFile(filepath) if err != nil { diff --git a/internal/service/user_admin/user_backyard.go b/internal/service/user_admin/user_backyard.go index 6897d8d2..a98086ae 100644 --- a/internal/service/user_admin/user_backyard.go +++ b/internal/service/user_admin/user_backyard.go @@ -116,7 +116,7 @@ func (us *UserAdminService) UpdateUserRole(ctx context.Context, req *schema.Upda return err } - us.authService.RemoveUserTokens(ctx, req.UserID) + us.authService.RemoveUserAllTokens(ctx, req.UserID) return } @@ -179,7 +179,7 @@ func (us *UserAdminService) UpdateUserPassword(ctx context.Context, req *schema. return err } // logout this user - us.authService.RemoveUserTokens(ctx, req.UserID) + us.authService.RemoveUserAllTokens(ctx, req.UserID) return } diff --git a/internal/service/user_common/user.go b/internal/service/user_common/user.go index 97b31b70..c5786746 100644 --- a/internal/service/user_common/user.go +++ b/internal/service/user_common/user.go @@ -2,16 +2,18 @@ package usercommon import ( "context" - "encoding/hex" - "math/rand" "strings" "github.com/Chain-Zhang/pinyin" "github.com/answerdev/answer/internal/base/reason" "github.com/answerdev/answer/internal/entity" "github.com/answerdev/answer/internal/schema" + "github.com/answerdev/answer/internal/service/auth" + "github.com/answerdev/answer/internal/service/role" "github.com/answerdev/answer/pkg/checker" + "github.com/answerdev/answer/pkg/random" "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" ) type UserRepo interface { @@ -36,14 +38,20 @@ type UserRepo interface { // UserCommon user service type UserCommon struct { - userRepo UserRepo + userRepo UserRepo + userRoleService *role.UserRoleRelService + authService *auth.AuthService } func NewUserCommon( userRepo UserRepo, + userRoleService *role.UserRoleRelService, + authService *auth.AuthService, ) *UserCommon { return &UserCommon{ - userRepo: userRepo, + userRepo: userRepo, + userRoleService: userRoleService, + authService: authService, } } @@ -139,9 +147,34 @@ func (us *UserCommon) MakeUsername(ctx context.Context, displayName string) (use if !has { break } - bytes := make([]byte, 2) - _, _ = rand.Read(bytes) - suffix = hex.EncodeToString(bytes) + suffix = random.UsernameSuffix() } return username + suffix, nil } + +func (us *UserCommon) CacheLoginUserInfo(ctx context.Context, userID string, userStatus, emailStatus int, externalID string) ( + accessToken string, userCacheInfo *entity.UserCacheInfo, err error) { + roleID, err := us.userRoleService.GetUserRole(ctx, userID) + if err != nil { + log.Error(err) + } + + userCacheInfo = &entity.UserCacheInfo{ + UserID: userID, + EmailStatus: emailStatus, + UserStatus: userStatus, + RoleID: roleID, + ExternalID: externalID, + } + + accessToken, err = us.authService.SetUserCacheInfo(ctx, userCacheInfo) + if err != nil { + return "", nil, err + } + if userCacheInfo.RoleID == role.RoleAdminID { + if err = us.authService.SetAdminUserCacheInfo(ctx, accessToken, &entity.UserCacheInfo{UserID: userID}); err != nil { + return "", nil, err + } + } + return accessToken, userCacheInfo, nil +} diff --git a/internal/service/user_external_login/user_center_login_service.go b/internal/service/user_external_login/user_center_login_service.go new file mode 100644 index 00000000..14200298 --- /dev/null +++ b/internal/service/user_external_login/user_center_login_service.go @@ -0,0 +1,277 @@ +package user_external_login + +import ( + "context" + "encoding/json" + "time" + + "github.com/answerdev/answer/internal/base/handler" + "github.com/answerdev/answer/internal/base/reason" + "github.com/answerdev/answer/internal/base/translator" + "github.com/answerdev/answer/internal/entity" + "github.com/answerdev/answer/internal/schema" + "github.com/answerdev/answer/internal/service/activity" + "github.com/answerdev/answer/internal/service/siteinfo_common" + usercommon "github.com/answerdev/answer/internal/service/user_common" + "github.com/answerdev/answer/pkg/checker" + "github.com/answerdev/answer/pkg/converter" + "github.com/answerdev/answer/pkg/random" + "github.com/answerdev/answer/plugin" + "github.com/segmentfault/pacman/log" +) + +// UserCenterLoginService user external login service +type UserCenterLoginService struct { + userRepo usercommon.UserRepo + userExternalLoginRepo UserExternalLoginRepo + userCommonService *usercommon.UserCommon + userActivity activity.UserActiveActivityRepo + siteInfoCommonService *siteinfo_common.SiteInfoCommonService +} + +// NewUserCenterLoginService new user external login service +func NewUserCenterLoginService( + userRepo usercommon.UserRepo, + userCommonService *usercommon.UserCommon, + userExternalLoginRepo UserExternalLoginRepo, + userActivity activity.UserActiveActivityRepo, + siteInfoCommonService *siteinfo_common.SiteInfoCommonService, +) *UserCenterLoginService { + return &UserCenterLoginService{ + userRepo: userRepo, + userCommonService: userCommonService, + userExternalLoginRepo: userExternalLoginRepo, + userActivity: userActivity, + siteInfoCommonService: siteInfoCommonService, + } +} + +func (us *UserCenterLoginService) ExternalLogin( + ctx context.Context, userCenter plugin.UserCenter, basicUserInfo *plugin.UserCenterBasicUserInfo) ( + resp *schema.UserExternalLoginResp, err error) { + + if len(basicUserInfo.Email) > 0 { + // check whether site allow register or not + siteInfo, err := us.siteInfoCommonService.GetSiteLogin(ctx) + if err != nil { + return nil, err + } + if !checker.EmailInAllowEmailDomain(basicUserInfo.Email, siteInfo.AllowEmailDomains) { + log.Debugf("email domain not allowed: %s", basicUserInfo.Email) + return &schema.UserExternalLoginResp{ + ErrTitle: translator.Tr(handler.GetLangByCtx(ctx), reason.UserAccessDenied), + ErrMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.EmailIllegalDomainError), + }, nil + } + } + + oldExternalLoginUserInfo, exist, err := us.userExternalLoginRepo.GetByExternalID(ctx, + userCenter.Info().SlugName, basicUserInfo.ExternalID) + if err != nil { + return nil, err + } + if exist { + // if user is already a member, login directly + oldUserInfo, exist, err := us.userRepo.GetByUserID(ctx, oldExternalLoginUserInfo.UserID) + if err != nil { + return nil, err + } + if exist { + // if user is deleted, do not allow login + if oldUserInfo.Status == entity.UserStatusDeleted { + return &schema.UserExternalLoginResp{ + ErrTitle: translator.Tr(handler.GetLangByCtx(ctx), reason.UserAccessDenied), + ErrMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.UserPageAccessDenied), + }, nil + } + if err := us.userRepo.UpdateLastLoginDate(ctx, oldUserInfo.ID); err != nil { + log.Errorf("update user last login date failed: %v", err) + } + accessToken, _, err := us.userCommonService.CacheLoginUserInfo( + ctx, oldUserInfo.ID, oldUserInfo.MailStatus, oldUserInfo.Status, oldExternalLoginUserInfo.ExternalID) + return &schema.UserExternalLoginResp{AccessToken: accessToken}, err + } + } + + // cache external user info, waiting for user enter email address. + if userCenter.Description().MustAuthEmailEnabled && len(basicUserInfo.Email) == 0 { + return &schema.UserExternalLoginResp{ErrMsg: "Requires authorized email to login"}, nil + } + + oldUserInfo, err := us.registerNewUser(ctx, userCenter.Info().SlugName, basicUserInfo) + if err != nil { + return nil, err + } + + us.activeUser(ctx, oldUserInfo) + + accessToken, _, err := us.userCommonService.CacheLoginUserInfo( + ctx, oldUserInfo.ID, oldUserInfo.MailStatus, oldUserInfo.Status, oldExternalLoginUserInfo.ExternalID) + return &schema.UserExternalLoginResp{AccessToken: accessToken}, err +} + +func (us *UserCenterLoginService) registerNewUser(ctx context.Context, provider string, + basicUserInfo *plugin.UserCenterBasicUserInfo) (userInfo *entity.User, err error) { + userInfo = &entity.User{} + userInfo.EMail = basicUserInfo.Email + userInfo.DisplayName = basicUserInfo.DisplayName + + userInfo.Username, err = us.userCommonService.MakeUsername(ctx, basicUserInfo.Username) + if err != nil { + log.Error(err) + userInfo.Username = random.Username() + } + + if len(basicUserInfo.Avatar) > 0 { + avatarInfo := &schema.AvatarInfo{ + Type: schema.AvatarTypeCustom, + Custom: basicUserInfo.Avatar, + } + avatar, _ := json.Marshal(avatarInfo) + userInfo.Avatar = string(avatar) + } + + userInfo.MailStatus = entity.EmailStatusAvailable + userInfo.Status = entity.UserStatusAvailable + userInfo.LastLoginDate = time.Now() + userInfo.Bio = basicUserInfo.Bio + userInfo.BioHTML = converter.Markdown2HTML(basicUserInfo.Bio) + err = us.userRepo.AddUser(ctx, userInfo) + if err != nil { + return nil, err + } + + metaInfo, _ := json.Marshal(basicUserInfo) + newExternalUserInfo := &entity.UserExternalLogin{ + UserID: userInfo.ID, + Provider: provider, + ExternalID: basicUserInfo.ExternalID, + MetaInfo: string(metaInfo), + } + err = us.userExternalLoginRepo.AddUserExternalLogin(ctx, newExternalUserInfo) + + return userInfo, nil +} + +func (us *UserCenterLoginService) activeUser(ctx context.Context, oldUserInfo *entity.User) { + if err := us.userActivity.UserActive(ctx, oldUserInfo.ID); err != nil { + log.Error(err) + } +} + +func (us *UserCenterLoginService) UserCenterUserSettings(ctx context.Context, userID string) ( + resp *schema.UserCenterUserSettingsResp, err error) { + resp = &schema.UserCenterUserSettingsResp{} + + userCenter, ok := plugin.GetUserCenter() + if !ok { + return resp, nil + } + + // get external login info + externalLoginList, err := us.userExternalLoginRepo.GetUserExternalLoginList(ctx, userID) + if err != nil { + return nil, err + } + var externalInfo *entity.UserExternalLogin + for _, t := range externalLoginList { + if t.Provider == userCenter.Info().SlugName { + externalInfo = t + } + } + if externalInfo == nil { + return resp, nil + } + + settings, err := userCenter.UserSettings(externalInfo.ExternalID) + if err != nil { + log.Error(err) + return resp, nil + } + + if len(settings.AccountSettingRedirectURL) > 0 { + resp.AccountSettingAgent = schema.UserSettingAgent{ + Enabled: true, + RedirectURL: settings.AccountSettingRedirectURL, + } + } + if len(settings.ProfileSettingRedirectURL) > 0 { + resp.ProfileSettingAgent = schema.UserSettingAgent{ + Enabled: true, + RedirectURL: settings.ProfileSettingRedirectURL, + } + } + return resp, nil +} + +// UserCenterAdminFunctionAgent Check in the backend administration interface if the user-related functions +// are turned off due to turning on the User Center plugin. +func (us *UserCenterLoginService) UserCenterAdminFunctionAgent(ctx context.Context) ( + resp *schema.UserCenterAdminFunctionAgentResp, err error) { + resp = &schema.UserCenterAdminFunctionAgentResp{ + AllowCreateUser: true, + AllowUpdateUserStatus: true, + AllowUpdateUserPassword: true, + AllowUpdateUserRole: true, + } + userCenter, ok := plugin.GetUserCenter() + if !ok { + return + } + desc := userCenter.Description() + // If user status agent is enabled, admin can not update user status in answer. + resp.AllowUpdateUserStatus = !desc.UserStatusAgentEnabled + + // If original user system is enabled, admin can update user password and role in answer. + resp.AllowUpdateUserPassword = desc.EnabledOriginalUserSystem + resp.AllowUpdateUserRole = desc.EnabledOriginalUserSystem + resp.AllowCreateUser = desc.EnabledOriginalUserSystem + return resp, nil +} + +func (us *UserCenterLoginService) UserCenterPersonalBranding(ctx context.Context, username string) ( + resp *schema.UserCenterPersonalBranding, err error) { + resp = &schema.UserCenterPersonalBranding{ + PersonalBranding: make([]*schema.PersonalBranding, 0), + } + userCenter, ok := plugin.GetUserCenter() + if !ok { + return + } + + userInfo, exist, err := us.userRepo.GetByUsername(ctx, username) + if err != nil { + return nil, err + } + if !exist { + return resp, nil + } + + // get external login info + externalLoginList, err := us.userExternalLoginRepo.GetUserExternalLoginList(ctx, userInfo.ID) + if err != nil { + return nil, err + } + var externalInfo *entity.UserExternalLogin + for _, t := range externalLoginList { + if t.Provider == userCenter.Info().SlugName { + externalInfo = t + } + } + if externalInfo == nil { + return resp, nil + } + + resp.Enabled = true + branding := userCenter.PersonalBranding(externalInfo.ExternalID) + + for _, t := range branding { + resp.PersonalBranding = append(resp.PersonalBranding, &schema.PersonalBranding{ + Icon: t.Icon, + Name: t.Name, + Label: t.Label, + Url: t.Url, + }) + } + return resp, nil +} diff --git a/internal/service/user_external_login/user_external_login_service.go b/internal/service/user_external_login/user_external_login_service.go new file mode 100644 index 00000000..912d9135 --- /dev/null +++ b/internal/service/user_external_login/user_external_login_service.go @@ -0,0 +1,351 @@ +package user_external_login + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/answerdev/answer/internal/base/reason" + "github.com/answerdev/answer/internal/entity" + "github.com/answerdev/answer/internal/schema" + "github.com/answerdev/answer/internal/service/activity" + "github.com/answerdev/answer/internal/service/export" + "github.com/answerdev/answer/internal/service/siteinfo_common" + usercommon "github.com/answerdev/answer/internal/service/user_common" + "github.com/answerdev/answer/pkg/random" + "github.com/answerdev/answer/pkg/token" + "github.com/answerdev/answer/plugin" + "github.com/google/uuid" + "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" +) + +type UserExternalLoginRepo interface { + AddUserExternalLogin(ctx context.Context, user *entity.UserExternalLogin) (err error) + UpdateInfo(ctx context.Context, userInfo *entity.UserExternalLogin) (err error) + GetByExternalID(ctx context.Context, provider, externalID string) (userInfo *entity.UserExternalLogin, exist bool, err error) + GetUserExternalLoginList(ctx context.Context, userID string) (resp []*entity.UserExternalLogin, err error) + DeleteUserExternalLogin(ctx context.Context, userID, externalID string) (err error) + SetCacheUserExternalLoginInfo(ctx context.Context, key string, info *schema.ExternalLoginUserInfoCache) (err error) + GetCacheUserExternalLoginInfo(ctx context.Context, key string) (info *schema.ExternalLoginUserInfoCache, err error) +} + +// UserExternalLoginService user external login service +type UserExternalLoginService struct { + userRepo usercommon.UserRepo + userExternalLoginRepo UserExternalLoginRepo + userCommonService *usercommon.UserCommon + emailService *export.EmailService + siteInfoCommonService *siteinfo_common.SiteInfoCommonService + userActivity activity.UserActiveActivityRepo +} + +// NewUserExternalLoginService new user external login service +func NewUserExternalLoginService( + userRepo usercommon.UserRepo, + userCommonService *usercommon.UserCommon, + userExternalLoginRepo UserExternalLoginRepo, + emailService *export.EmailService, + siteInfoCommonService *siteinfo_common.SiteInfoCommonService, + userActivity activity.UserActiveActivityRepo, +) *UserExternalLoginService { + return &UserExternalLoginService{ + userRepo: userRepo, + userCommonService: userCommonService, + userExternalLoginRepo: userExternalLoginRepo, + emailService: emailService, + siteInfoCommonService: siteInfoCommonService, + userActivity: userActivity, + } +} + +// ExternalLogin if user is already a member logged in +func (us *UserExternalLoginService) ExternalLogin( + ctx context.Context, externalUserInfo *schema.ExternalLoginUserInfoCache) ( + resp *schema.UserExternalLoginResp, err error) { + oldExternalLoginUserInfo, exist, err := us.userExternalLoginRepo.GetByExternalID(ctx, + externalUserInfo.Provider, externalUserInfo.ExternalID) + if err != nil { + return nil, err + } + if exist { + // if user is already a member, login directly + oldUserInfo, exist, err := us.userRepo.GetByUserID(ctx, oldExternalLoginUserInfo.UserID) + if err != nil { + return nil, err + } + if exist && oldUserInfo.Status != entity.UserStatusDeleted { + if err := us.userRepo.UpdateLastLoginDate(ctx, oldUserInfo.ID); err != nil { + log.Errorf("update user last login date failed: %v", err) + } + newMailStatus, err := us.activeUser(ctx, oldUserInfo, externalUserInfo) + if err != nil { + log.Error(err) + } + accessToken, _, err := us.userCommonService.CacheLoginUserInfo( + ctx, oldUserInfo.ID, newMailStatus, oldUserInfo.Status, oldExternalLoginUserInfo.ExternalID) + return &schema.UserExternalLoginResp{AccessToken: accessToken}, err + } + } + + // cache external user info, waiting for user enter email address. + if len(externalUserInfo.Email) == 0 { + bindingKey := token.GenerateToken() + err = us.userExternalLoginRepo.SetCacheUserExternalLoginInfo(ctx, bindingKey, externalUserInfo) + if err != nil { + return nil, err + } + return &schema.UserExternalLoginResp{BindingKey: bindingKey}, nil + } + + oldUserInfo, exist, err := us.userRepo.GetByEmail(ctx, externalUserInfo.Email) + if err != nil { + return nil, err + } + // if user is not a member, register a new user + if !exist { + oldUserInfo, err = us.registerNewUser(ctx, externalUserInfo) + if err != nil { + return nil, err + } + } + // bind external user info to user + err = us.bindOldUser(ctx, externalUserInfo, oldUserInfo) + if err != nil { + return nil, err + } + + // If user login with external account and email is exist, active user directly. + newMailStatus, err := us.activeUser(ctx, oldUserInfo, externalUserInfo) + if err != nil { + log.Error(err) + } + + accessToken, _, err := us.userCommonService.CacheLoginUserInfo( + ctx, oldUserInfo.ID, newMailStatus, oldUserInfo.Status, oldExternalLoginUserInfo.ExternalID) + return &schema.UserExternalLoginResp{AccessToken: accessToken}, err +} + +func (us *UserExternalLoginService) registerNewUser(ctx context.Context, + externalUserInfo *schema.ExternalLoginUserInfoCache) (userInfo *entity.User, err error) { + userInfo = &entity.User{} + userInfo.EMail = externalUserInfo.Email + userInfo.DisplayName = externalUserInfo.DisplayName + + userInfo.Username, err = us.userCommonService.MakeUsername(ctx, externalUserInfo.Username) + if err != nil { + log.Error(err) + userInfo.Username = random.Username() + } + + if len(externalUserInfo.Avatar) > 0 { + avatarInfo := &schema.AvatarInfo{ + Type: schema.AvatarTypeCustom, + Custom: externalUserInfo.Avatar, + } + avatar, _ := json.Marshal(avatarInfo) + userInfo.Avatar = string(avatar) + } + + userInfo.MailStatus = entity.EmailStatusToBeVerified + userInfo.Status = entity.UserStatusAvailable + userInfo.LastLoginDate = time.Now() + userInfo.Bio = externalUserInfo.Bio + userInfo.BioHTML = externalUserInfo.Bio + err = us.userRepo.AddUser(ctx, userInfo) + if err != nil { + return nil, err + } + return userInfo, nil +} + +func (us *UserExternalLoginService) bindOldUser(ctx context.Context, + externalUserInfo *schema.ExternalLoginUserInfoCache, oldUserInfo *entity.User) (err error) { + oldExternalUserInfo, exist, err := us.userExternalLoginRepo.GetByExternalID(ctx, + externalUserInfo.Provider, + externalUserInfo.ExternalID) + if err != nil { + return err + } + if exist { + oldExternalUserInfo.MetaInfo = externalUserInfo.MetaInfo + oldExternalUserInfo.UserID = oldUserInfo.ID + err = us.userExternalLoginRepo.UpdateInfo(ctx, oldExternalUserInfo) + } else { + newExternalUserInfo := &entity.UserExternalLogin{ + UserID: oldUserInfo.ID, + Provider: externalUserInfo.Provider, + ExternalID: externalUserInfo.ExternalID, + MetaInfo: externalUserInfo.MetaInfo, + } + err = us.userExternalLoginRepo.AddUserExternalLogin(ctx, newExternalUserInfo) + } + return err +} + +func (us *UserExternalLoginService) activeUser(ctx context.Context, oldUserInfo *entity.User, + externalUserInfo *schema.ExternalLoginUserInfoCache) ( + mailStatus int, err error) { + log.Infof("user %s login with external account, try to active email, old status is %d", + oldUserInfo.ID, oldUserInfo.MailStatus) + + // try to active user email + if oldUserInfo.MailStatus == entity.EmailStatusToBeVerified { + err = us.userRepo.UpdateEmailStatus(ctx, oldUserInfo.ID, entity.EmailStatusAvailable) + if err != nil { + return oldUserInfo.MailStatus, err + } + } + + // try to update user avatar + if len(externalUserInfo.Avatar) > 0 && len(schema.FormatAvatarInfo(oldUserInfo.Avatar, oldUserInfo.EMail)) == 0 { + avatarInfo := &schema.AvatarInfo{ + Type: schema.AvatarTypeCustom, + Custom: externalUserInfo.Avatar, + } + avatar, _ := json.Marshal(avatarInfo) + oldUserInfo.Avatar = string(avatar) + err = us.userRepo.UpdateInfo(ctx, oldUserInfo) + if err != nil { + log.Error(err) + } + } + + if err = us.userActivity.UserActive(ctx, oldUserInfo.ID); err != nil { + return oldUserInfo.MailStatus, err + } + return entity.EmailStatusAvailable, nil +} + +// ExternalLoginBindingUserSendEmail Send an email for third-party account login for binding user +func (us *UserExternalLoginService) ExternalLoginBindingUserSendEmail( + ctx context.Context, req *schema.ExternalLoginBindingUserSendEmailReq) ( + resp *schema.ExternalLoginBindingUserSendEmailResp, err error) { + siteGeneral, err := us.siteInfoCommonService.GetSiteGeneral(ctx) + if err != nil { + return nil, err + } + resp = &schema.ExternalLoginBindingUserSendEmailResp{} + externalLoginInfo, err := us.userExternalLoginRepo.GetCacheUserExternalLoginInfo(ctx, req.BindingKey) + if err != nil || len(externalLoginInfo.ExternalID) == 0 { + return nil, errors.BadRequest(reason.UserNotFound) + } + if len(externalLoginInfo.Email) > 0 { + log.Warnf("the binding email has been sent %s", req.BindingKey) + return &schema.ExternalLoginBindingUserSendEmailResp{}, nil + } + + userInfo, exist, err := us.userRepo.GetByEmail(ctx, req.Email) + if err != nil { + return nil, err + } + if exist && !req.Must { + resp.EmailExistAndMustBeConfirmed = true + return resp, nil + } + + if !exist { + externalLoginInfo.Email = req.Email + userInfo, err = us.registerNewUser(ctx, externalLoginInfo) + if err != nil { + return nil, err + } + resp.AccessToken, _, err = us.userCommonService.CacheLoginUserInfo( + ctx, userInfo.ID, userInfo.MailStatus, userInfo.Status, externalLoginInfo.ExternalID) + if err != nil { + log.Error(err) + } + } + err = us.userExternalLoginRepo.SetCacheUserExternalLoginInfo(ctx, req.BindingKey, externalLoginInfo) + if err != nil { + return nil, err + } + + // send bind confirmation email + data := &schema.EmailCodeContent{ + SourceType: schema.BindingSourceType, + Email: req.Email, + UserID: userInfo.ID, + BindingKey: req.BindingKey, + } + code := uuid.NewString() + verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", siteGeneral.SiteUrl, code) + title, body, err := us.emailService.RegisterTemplate(ctx, verifyEmailURL) + if err != nil { + return nil, err + } + go us.emailService.SendAndSaveCode(ctx, userInfo.EMail, title, body, code, data.ToJSONString()) + return resp, nil +} + +// ExternalLoginBindingUser +// The user clicks on the email link of the bound account and requests the API to bind the user officially +func (us *UserExternalLoginService) ExternalLoginBindingUser( + ctx context.Context, bindingKey string, oldUserInfo *entity.User) (err error) { + externalLoginInfo, err := us.userExternalLoginRepo.GetCacheUserExternalLoginInfo(ctx, bindingKey) + if err != nil || len(externalLoginInfo.ExternalID) == 0 { + return errors.BadRequest(reason.UserNotFound) + } + return us.bindOldUser(ctx, externalLoginInfo, oldUserInfo) +} + +// GetExternalLoginUserInfoList get external login user info list +func (us *UserExternalLoginService) GetExternalLoginUserInfoList( + ctx context.Context, userID string) (resp []*entity.UserExternalLogin, err error) { + return us.userExternalLoginRepo.GetUserExternalLoginList(ctx, userID) +} + +// ExternalLoginUnbinding external login unbinding +func (us *UserExternalLoginService) ExternalLoginUnbinding( + ctx context.Context, req *schema.ExternalLoginUnbindingReq) (resp any, err error) { + // If user has only one external login and never set password, he can't unbind it. + userInfo, exist, err := us.userRepo.GetByUserID(ctx, req.UserID) + if err != nil { + return nil, err + } + if !exist { + return nil, errors.BadRequest(reason.UserNotFound) + } + if len(userInfo.Pass) == 0 { + loginList, err := us.userExternalLoginRepo.GetUserExternalLoginList(ctx, req.UserID) + if err != nil { + return nil, err + } + if len(loginList) <= 1 { + return schema.ErrTypeToast, errors.BadRequest(reason.UserExternalLoginUnbindingForbidden) + } + } + + return nil, us.userExternalLoginRepo.DeleteUserExternalLogin(ctx, req.UserID, req.ExternalID) +} + +// CheckUserStatusInUserCenter check user status in user center +func (us *UserExternalLoginService) CheckUserStatusInUserCenter(ctx context.Context, userID string) ( + valid bool, externalID string, err error) { + // If enable user center plugin, user status should be checked by user center + userCenter, ok := plugin.GetUserCenter() + if !ok { + return true, "", nil + } + userInfoList, err := us.GetExternalLoginUserInfoList(ctx, userID) + if err != nil { + return false, "", err + } + var thisUcUserInfo *entity.UserExternalLogin + for _, t := range userInfoList { + if t.Provider == userCenter.Info().SlugName { + thisUcUserInfo = t + break + } + } + // If this user not login by user center, no need to check user status + if thisUcUserInfo == nil { + return true, "", nil + } + userStatus := userCenter.UserStatus(thisUcUserInfo.ExternalID) + if userStatus == plugin.UserStatusDeleted { + return false, thisUcUserInfo.ExternalID, nil + } + return true, thisUcUserInfo.ExternalID, nil +} diff --git a/internal/service/user_service.go b/internal/service/user_service.go index f6a6f093..00e6b191 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -20,7 +20,9 @@ import ( "github.com/answerdev/answer/internal/service/service_config" "github.com/answerdev/answer/internal/service/siteinfo_common" usercommon "github.com/answerdev/answer/internal/service/user_common" + "github.com/answerdev/answer/internal/service/user_external_login" "github.com/answerdev/answer/pkg/checker" + "github.com/answerdev/answer/plugin" "github.com/google/uuid" "github.com/segmentfault/pacman/errors" "github.com/segmentfault/pacman/log" @@ -31,15 +33,16 @@ import ( // UserService user service type UserService struct { - userCommonService *usercommon.UserCommon - userRepo usercommon.UserRepo - userActivity activity.UserActiveActivityRepo - activityRepo activity_common.ActivityRepo - serviceConfig *service_config.ServiceConfig - emailService *export.EmailService - authService *auth.AuthService - siteInfoService *siteinfo_common.SiteInfoCommonService - userRoleService *role.UserRoleRelService + userCommonService *usercommon.UserCommon + userRepo usercommon.UserRepo + userActivity activity.UserActiveActivityRepo + activityRepo activity_common.ActivityRepo + serviceConfig *service_config.ServiceConfig + emailService *export.EmailService + authService *auth.AuthService + siteInfoService *siteinfo_common.SiteInfoCommonService + userRoleService *role.UserRoleRelService + userExternalLoginService *user_external_login.UserExternalLoginService } func NewUserService(userRepo usercommon.UserRepo, @@ -51,22 +54,25 @@ func NewUserService(userRepo usercommon.UserRepo, siteInfoService *siteinfo_common.SiteInfoCommonService, userRoleService *role.UserRoleRelService, userCommonService *usercommon.UserCommon, + userExternalLoginService *user_external_login.UserExternalLoginService, ) *UserService { return &UserService{ - userCommonService: userCommonService, - userRepo: userRepo, - userActivity: userActivity, - activityRepo: activityRepo, - emailService: emailService, - serviceConfig: serviceConfig, - authService: authService, - siteInfoService: siteInfoService, - userRoleService: userRoleService, + userCommonService: userCommonService, + userRepo: userRepo, + userActivity: userActivity, + activityRepo: activityRepo, + emailService: emailService, + serviceConfig: serviceConfig, + authService: authService, + siteInfoService: siteInfoService, + userRoleService: userRoleService, + userExternalLoginService: userExternalLoginService, } } // GetUserInfoByUserID get user info by user id -func (us *UserService) GetUserInfoByUserID(ctx context.Context, token, userID string) (resp *schema.GetUserToSetShowResp, err error) { +func (us *UserService) GetUserInfoByUserID(ctx context.Context, token, userID string) ( + resp *schema.GetUserToSetShowResp, err error) { userInfo, exist, err := us.userRepo.GetByUserID(ctx, userID) if err != nil { return nil, err @@ -74,6 +80,9 @@ func (us *UserService) GetUserInfoByUserID(ctx context.Context, token, userID st if !exist { return nil, errors.BadRequest(reason.UserNotFound) } + if userInfo.Status == entity.UserStatusDeleted { + return nil, errors.Unauthorized(reason.UnauthorizedError) + } roleID, err := us.userRoleService.GetUserRole(ctx, userInfo.ID) if err != nil { log.Error(err) @@ -82,6 +91,7 @@ func (us *UserService) GetUserInfoByUserID(ctx context.Context, token, userID st resp.GetFromUserEntity(userInfo) resp.AccessToken = token resp.RoleID = roleID + resp.HavePassword = len(userInfo.Pass) > 0 return resp, nil } @@ -112,10 +122,17 @@ func (us *UserService) EmailLogin(ctx context.Context, req *schema.UserEmailLogi if !us.verifyPassword(ctx, req.Pass, userInfo.Pass) { return nil, errors.BadRequest(reason.EmailOrPasswordWrong) } + ok, externalID, err := us.userExternalLoginService.CheckUserStatusInUserCenter(ctx, userInfo.ID) + if err != nil { + return nil, err + } + if !ok { + return nil, errors.BadRequest(reason.EmailOrPasswordWrong) + } err = us.userRepo.UpdateLastLoginDate(ctx, userInfo.ID) if err != nil { - log.Error("UpdateLastLoginDate", err.Error()) + log.Errorf("update last login data failed, err: %v", err) } roleID, err := us.userRoleService.GetUserRole(ctx, userInfo.ID) @@ -130,6 +147,7 @@ func (us *UserService) EmailLogin(ctx context.Context, req *schema.UserEmailLogi EmailStatus: userInfo.MailStatus, UserStatus: userInfo.Status, RoleID: roleID, + ExternalID: externalID, } resp.AccessToken, err = us.authService.SetUserCacheInfo(ctx, userCacheInfo) if err != nil { @@ -171,42 +189,43 @@ func (us *UserService) RetrievePassWord(ctx context.Context, req *schema.UserRet return nil } -// UseRePassword -func (us *UserService) UseRePassword(ctx context.Context, req *schema.UserRePassWordRequest) (resp *schema.GetUserResp, err error) { +// UpdatePasswordWhenForgot update user password when user forgot password +func (us *UserService) UpdatePasswordWhenForgot(ctx context.Context, req *schema.UserRePassWordRequest) (err error) { data := &schema.EmailCodeContent{} err = data.FromJSONString(req.Content) if err != nil { - return nil, errors.BadRequest(reason.EmailVerifyURLExpired) + return errors.BadRequest(reason.EmailVerifyURLExpired) } userInfo, exist, err := us.userRepo.GetByEmail(ctx, data.Email) if err != nil { - return nil, err + return err } if !exist { - return nil, errors.BadRequest(reason.UserNotFound) + return errors.BadRequest(reason.UserNotFound) } enpass, err := us.encryptPassword(ctx, req.Pass) if err != nil { - return nil, err + return err } err = us.userRepo.UpdatePass(ctx, userInfo.ID, enpass) if err != nil { - return nil, err + return err } - resp = &schema.GetUserResp{} - return resp, nil + // When the user changes the password, all the current user's tokens are invalid. + us.authService.RemoveUserAllTokens(ctx, userInfo.ID) + return nil } -func (us *UserService) UserModifyPassWordVerification(ctx context.Context, request *schema.UserModifyPassWordRequest) (bool, error) { - userInfo, has, err := us.userRepo.GetByUserID(ctx, request.UserID) +func (us *UserService) UserModifyPassWordVerification(ctx context.Context, req *schema.UserModifyPasswordReq) (bool, error) { + userInfo, has, err := us.userRepo.GetByUserID(ctx, req.UserID) if err != nil { return false, err } if !has { - return false, fmt.Errorf("user does not exist") + return false, errors.BadRequest(reason.UserNotFound) } - isPass := us.verifyPassword(ctx, request.OldPass, userInfo.Pass) + isPass := us.verifyPassword(ctx, req.OldPass, userInfo.Pass) if !isPass { return false, nil } @@ -215,33 +234,56 @@ func (us *UserService) UserModifyPassWordVerification(ctx context.Context, reque } // UserModifyPassword user modify password -func (us *UserService) UserModifyPassword(ctx context.Context, request *schema.UserModifyPassWordRequest) error { - enpass, err := us.encryptPassword(ctx, request.Pass) +func (us *UserService) UserModifyPassword(ctx context.Context, req *schema.UserModifyPasswordReq) error { + enpass, err := us.encryptPassword(ctx, req.Pass) if err != nil { return err } - userInfo, has, err := us.userRepo.GetByUserID(ctx, request.UserID) + userInfo, exist, err := us.userRepo.GetByUserID(ctx, req.UserID) if err != nil { return err } - if !has { - return fmt.Errorf("user does not exist") + if !exist { + return errors.BadRequest(reason.UserNotFound) } - isPass := us.verifyPassword(ctx, request.OldPass, userInfo.Pass) + + isPass := us.verifyPassword(ctx, req.OldPass, userInfo.Pass) if !isPass { - return fmt.Errorf("the old password verification failed") + return errors.BadRequest(reason.OldPasswordVerificationFailed) } err = us.userRepo.UpdatePass(ctx, userInfo.ID, enpass) if err != nil { return err } + + us.authService.RemoveTokensExceptCurrentUser(ctx, userInfo.ID, req.AccessToken) return nil } // UpdateInfo update user info func (us *UserService) UpdateInfo(ctx context.Context, req *schema.UpdateInfoRequest) ( errFields []*validator.FormErrorField, err error) { - if len(req.Username) > 0 { + siteUsers, err := us.siteInfoService.GetSiteUsers(ctx) + if err != nil { + return nil, err + } + + if siteUsers.AllowUpdateUsername && len(req.Username) > 0 { + if checker.IsInvalidUsername(req.Username) { + errFields = append(errFields, &validator.FormErrorField{ + ErrorField: "username", + ErrorMsg: reason.UsernameInvalid, + }) + return errFields, errors.BadRequest(reason.UsernameInvalid) + } + if checker.IsReservedUsername(req.Username) { + errFields = append(errFields, &validator.FormErrorField{ + ErrorField: "username", + ErrorMsg: reason.UsernameInvalid, + }) + return errFields, errors.BadRequest(reason.UsernameInvalid) + } + userInfo, exist, err := us.userRepo.GetByUsername(ctx, req.Username) if err != nil { return nil, err @@ -253,31 +295,57 @@ func (us *UserService) UpdateInfo(ctx context.Context, req *schema.UpdateInfoReq }) return errFields, errors.BadRequest(reason.UsernameDuplicate) } - if checker.IsReservedUsername(req.Username) { - errFields = append(errFields, &validator.FormErrorField{ - ErrorField: "username", - ErrorMsg: reason.UsernameInvalid, - }) - return errFields, errors.BadRequest(reason.UsernameInvalid) - } } - avatar, err := json.Marshal(req.Avatar) + + oldUserInfo, exist, err := us.userRepo.GetByUserID(ctx, req.UserID) if err != nil { - return nil, errors.BadRequest(reason.UserSetAvatar).WithError(err).WithStack() + return nil, err } - userInfo := entity.User{} - userInfo.ID = req.UserID - userInfo.Avatar = string(avatar) - userInfo.DisplayName = req.DisplayName - userInfo.Bio = req.Bio - userInfo.BioHTML = req.BioHTML - userInfo.Location = req.Location - userInfo.Website = req.Website - userInfo.Username = req.Username - err = us.userRepo.UpdateInfo(ctx, &userInfo) + if !exist { + return nil, errors.BadRequest(reason.UserNotFound) + } + + cond := us.formatUserInfoForUpdateInfo(oldUserInfo, req, siteUsers) + err = us.userRepo.UpdateInfo(ctx, cond) return nil, err } +func (us *UserService) formatUserInfoForUpdateInfo( + oldUserInfo *entity.User, req *schema.UpdateInfoRequest, siteUsersConf *schema.SiteUsersResp) *entity.User { + avatar, _ := json.Marshal(req.Avatar) + + userInfo := &entity.User{} + userInfo.DisplayName = oldUserInfo.DisplayName + userInfo.Username = oldUserInfo.Username + userInfo.Avatar = oldUserInfo.Avatar + userInfo.Bio = oldUserInfo.Bio + userInfo.BioHTML = oldUserInfo.BioHTML + userInfo.Website = oldUserInfo.Website + userInfo.Location = oldUserInfo.Location + userInfo.ID = req.UserID + + if len(req.DisplayName) > 0 && siteUsersConf.AllowUpdateDisplayName { + userInfo.DisplayName = req.DisplayName + } + if len(req.Username) > 0 && siteUsersConf.AllowUpdateUsername { + userInfo.Username = req.Username + } + if len(avatar) > 0 && siteUsersConf.AllowUpdateAvatar { + userInfo.Avatar = string(avatar) + } + if siteUsersConf.AllowUpdateBio { + userInfo.Bio = req.Bio + userInfo.BioHTML = req.BioHTML + } + if siteUsersConf.AllowUpdateWebsite { + userInfo.Website = req.Website + } + if siteUsersConf.AllowUpdateLocation { + userInfo.Location = req.Location + } + return userInfo +} + func (us *UserService) UserEmailHas(ctx context.Context, email string) (bool, error) { _, has, err := us.userRepo.GetByEmail(ctx, email) if err != nil { @@ -435,50 +503,48 @@ func (us *UserService) UserVerifyEmail(ctx context.Context, req *schema.UserVeri if !has { return nil, errors.BadRequest(reason.UserNotFound) } - userInfo.MailStatus = entity.EmailStatusAvailable - err = us.userRepo.UpdateEmailStatus(ctx, userInfo.ID, userInfo.MailStatus) - if err != nil { - return nil, err + if userInfo.MailStatus == entity.EmailStatusToBeVerified { + userInfo.MailStatus = entity.EmailStatusAvailable + err = us.userRepo.UpdateEmailStatus(ctx, userInfo.ID, userInfo.MailStatus) + if err != nil { + return nil, err + } } if err = us.userActivity.UserActive(ctx, userInfo.ID); err != nil { log.Error(err) } - roleID, err := us.userRoleService.GetUserRole(ctx, userInfo.ID) + // In the case of three-party login, the associated users are bound + if len(data.BindingKey) > 0 { + err = us.userExternalLoginService.ExternalLoginBindingUser(ctx, data.BindingKey, userInfo) + if err != nil { + return nil, err + } + } + + accessToken, userCacheInfo, err := us.userCommonService.CacheLoginUserInfo( + ctx, userInfo.ID, userInfo.MailStatus, userInfo.Status, "") if err != nil { - log.Error(err) + return nil, err } resp = &schema.GetUserResp{} resp.GetFromUserEntity(userInfo) - userCacheInfo := &entity.UserCacheInfo{ - UserID: userInfo.ID, - EmailStatus: userInfo.MailStatus, - UserStatus: userInfo.Status, - RoleID: roleID, - } - resp.AccessToken, err = us.authService.SetUserCacheInfo(ctx, userCacheInfo) - if err != nil { - return nil, err - } + resp.AccessToken = accessToken // User verified email will update user email status. So user status cache should be updated. if err = us.authService.SetUserStatus(ctx, userCacheInfo); err != nil { return nil, err } - resp.RoleID = userCacheInfo.RoleID - if resp.RoleID == role.RoleAdminID { - err = us.authService.SetAdminUserCacheInfo(ctx, resp.AccessToken, &entity.UserCacheInfo{UserID: userInfo.ID}) - if err != nil { - return nil, err - } - } return resp, nil } // verifyPassword // Compare whether the password is correct -func (us *UserService) verifyPassword(ctx context.Context, LoginPass, UserPass string) bool { - err := bcrypt.CompareHashAndPassword([]byte(UserPass), []byte(LoginPass)) +func (us *UserService) verifyPassword(ctx context.Context, loginPass, userPass string) bool { + if len(loginPass) == 0 && len(userPass) == 0 { + return true + } + err := bcrypt.CompareHashAndPassword([]byte(userPass), []byte(loginPass)) return err == nil } @@ -501,6 +567,15 @@ func (us *UserService) UserChangeEmailSendCode(ctx context.Context, req *schema. return nil, errors.BadRequest(reason.UserNotFound) } + // If user's email already verified, then must verify password first. + if userInfo.MailStatus == entity.EmailStatusAvailable && !us.verifyPassword(ctx, req.Pass, userInfo.Pass) { + resp = append(resp, &validator.FormErrorField{ + ErrorField: "pass", + ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.OldPasswordVerificationFailed), + }) + return resp, errors.BadRequest(reason.OldPasswordVerificationFailed) + } + _, exist, err = us.userRepo.GetByEmail(ctx, req.Email) if err != nil { return nil, err @@ -664,6 +739,9 @@ func (us *UserService) UserUnsubscribeEmailNotification( func (us *UserService) getActivityUserRankStat(ctx context.Context, startTime, endTime time.Time, limit int, userIDExist map[string]bool) (rankStat []*entity.ActivityUserRankStat, userIDs []string, err error) { + if plugin.RankAgentEnabled() { + return make([]*entity.ActivityUserRankStat, 0), make([]string, 0), nil + } rankStat, err = us.activityRepo.GetUsersWhoHasGainedTheMostReputation(ctx, startTime, endTime, limit) if err != nil { return nil, nil, err @@ -683,6 +761,9 @@ func (us *UserService) getActivityUserRankStat(ctx context.Context, startTime, e func (us *UserService) getActivityUserVoteStat(ctx context.Context, startTime, endTime time.Time, limit int, userIDExist map[string]bool) (voteStat []*entity.ActivityUserVoteStat, userIDs []string, err error) { + if plugin.RankAgentEnabled() { + return make([]*entity.ActivityUserVoteStat, 0), make([]string, 0), nil + } voteStat, err = us.activityRepo.GetUsersWhoHasVoteMost(ctx, startTime, endTime, limit) if err != nil { return nil, nil, err diff --git a/internal/service/vote_service.go b/internal/service/vote_service.go index 62c20b64..39d4282d 100644 --- a/internal/service/vote_service.go +++ b/internal/service/vote_service.go @@ -63,12 +63,12 @@ func NewVoteService( } // VoteUp vote up -func (as *VoteService) VoteUp(ctx context.Context, dto *schema.VoteDTO) (voteResp *schema.VoteResp, err error) { +func (vs *VoteService) VoteUp(ctx context.Context, dto *schema.VoteDTO) (voteResp *schema.VoteResp, err error) { voteResp = &schema.VoteResp{} var objectUserID string - objectUserID, err = as.GetObjectUserID(ctx, dto.ObjectID) + objectUserID, err = vs.GetObjectUserID(ctx, dto.ObjectID) if err != nil { return } @@ -80,19 +80,19 @@ func (as *VoteService) VoteUp(ctx context.Context, dto *schema.VoteDTO) (voteRes } if dto.IsCancel { - return as.voteRepo.VoteUpCancel(ctx, dto.ObjectID, dto.UserID, objectUserID) + return vs.voteRepo.VoteUpCancel(ctx, dto.ObjectID, dto.UserID, objectUserID) } else { - return as.voteRepo.VoteUp(ctx, dto.ObjectID, dto.UserID, objectUserID) + return vs.voteRepo.VoteUp(ctx, dto.ObjectID, dto.UserID, objectUserID) } } // VoteDown vote down -func (as *VoteService) VoteDown(ctx context.Context, dto *schema.VoteDTO) (voteResp *schema.VoteResp, err error) { +func (vs *VoteService) VoteDown(ctx context.Context, dto *schema.VoteDTO) (voteResp *schema.VoteResp, err error) { voteResp = &schema.VoteResp{} var objectUserID string - objectUserID, err = as.GetObjectUserID(ctx, dto.ObjectID) + objectUserID, err = vs.GetObjectUserID(ctx, dto.ObjectID) if err != nil { return } @@ -104,9 +104,9 @@ func (as *VoteService) VoteDown(ctx context.Context, dto *schema.VoteDTO) (voteR } if dto.IsCancel { - return as.voteRepo.VoteDownCancel(ctx, dto.ObjectID, dto.UserID, objectUserID) + return vs.voteRepo.VoteDownCancel(ctx, dto.ObjectID, dto.UserID, objectUserID) } else { - return as.voteRepo.VoteDown(ctx, dto.ObjectID, dto.UserID, objectUserID) + return vs.voteRepo.VoteDown(ctx, dto.ObjectID, dto.UserID, objectUserID) } } diff --git a/pkg/checker/email.go b/pkg/checker/email.go new file mode 100644 index 00000000..1c93cc66 --- /dev/null +++ b/pkg/checker/email.go @@ -0,0 +1,17 @@ +package checker + +import "strings" + +func EmailInAllowEmailDomain(email string, allowEmailDomains []string) bool { + if len(allowEmailDomains) == 0 { + return true + } + + for _, domain := range allowEmailDomains { + if strings.HasSuffix(email, domain) { + return true + } + } + + return false +} diff --git a/pkg/converter/markdown.go b/pkg/converter/markdown.go index d066b030..17deb990 100644 --- a/pkg/converter/markdown.go +++ b/pkg/converter/markdown.go @@ -38,6 +38,7 @@ func Markdown2HTML(source string) string { filter.RequireNoFollowOnLinks(false) filter.RequireParseableURLs(false) filter.RequireNoFollowOnFullyQualifiedLinks(false) + filter.AllowElements("kbd") html = filter.Sanitize(html) return html } @@ -46,7 +47,7 @@ func Markdown2HTML(source string) string { func Markdown2BasicHTML(source string) string { content := Markdown2HTML(source) filter := bluemonday.NewPolicy() - filter.AllowElements("p", "b", "br") + filter.AllowElements("p", "b", "br", "strong", "em") filter.AllowAttrs("src").OnElements("img") filter.AddSpaceWhenStrippingTag(true) content = filter.Sanitize(content) @@ -87,7 +88,11 @@ func (r *DangerousHTMLRenderer) renderRawHTML(w util.BufWriter, source []byte, n l := n.Segments.Len() for i := 0; i < l; i++ { segment := n.Segments.At(i) - _, _ = w.Write(r.Filter.SanitizeBytes(segment.Value(source))) + if string(source[segment.Start:segment.Stop]) == "" || string(source[segment.Start:segment.Stop]) == "" { + _, _ = w.Write(segment.Value(source)) + } else { + _, _ = w.Write(r.Filter.SanitizeBytes(segment.Value(source))) + } } return ast.WalkSkipChildren, nil } diff --git a/pkg/random/random_username.go b/pkg/random/random_username.go new file mode 100644 index 00000000..3c8cb1de --- /dev/null +++ b/pkg/random/random_username.go @@ -0,0 +1,18 @@ +package random + +import ( + "encoding/hex" + "math/rand" +) + +func UsernameSuffix() string { + bytes := make([]byte, 2) + _, _ = rand.Read(bytes) + return hex.EncodeToString(bytes) +} + +func Username() string { + bytes := make([]byte, 6) + _, _ = rand.Read(bytes) + return hex.EncodeToString(bytes) +} diff --git a/plugin/agent.go b/plugin/agent.go new file mode 100644 index 00000000..7de90ce5 --- /dev/null +++ b/plugin/agent.go @@ -0,0 +1,24 @@ +package plugin + +import ( + "github.com/answerdev/answer/internal/base/constant" + "github.com/gin-gonic/gin" +) + +type Agent interface { + Base + RegisterUnAuthRouter(r *gin.RouterGroup) + RegisterAuthUserRouter(r *gin.RouterGroup) + RegisterAuthAdminRouter(r *gin.RouterGroup) +} + +var ( + CallAgent, + registerAgent = MakePlugin[Agent](true) +) + +// SiteURL The site url is the domain address of the current site. e.g. http://localhost:8080 +// When some Agent plugins want to redirect to the origin site, it can use this function to get the site url. +func SiteURL() string { + return constant.DefaultSiteURL +} diff --git a/plugin/base.go b/plugin/base.go new file mode 100644 index 00000000..459863b4 --- /dev/null +++ b/plugin/base.go @@ -0,0 +1,23 @@ +package plugin + +// Info presents the plugin information +type Info struct { + Name Translator + SlugName string + Description Translator + Author string + Version string + Link string +} + +// Base is the base plugin +type Base interface { + // Info returns the plugin information + Info() Info +} + +var ( + // CallBase is a function that calls all registered base plugins + CallBase, + registerBase = MakePlugin[Base](true) +) diff --git a/plugin/cache.go b/plugin/cache.go new file mode 100644 index 00000000..993f3fe0 --- /dev/null +++ b/plugin/cache.go @@ -0,0 +1,23 @@ +package plugin + +import ( + "context" + "time" +) + +type Cache interface { + Base + + GetString(ctx context.Context, key string) (string, error) + SetString(ctx context.Context, key, value string, ttl time.Duration) error + GetInt64(ctx context.Context, key string) (int64, error) + SetInt64(ctx context.Context, key string, value int64, ttl time.Duration) error + Del(ctx context.Context, key string) error + Flush(ctx context.Context) error +} + +var ( + // CallCache is a function that calls all registered cache + CallCache, + registerCache = MakePlugin[Cache](false) +) diff --git a/plugin/config.go b/plugin/config.go new file mode 100644 index 00000000..d637d49d --- /dev/null +++ b/plugin/config.go @@ -0,0 +1,103 @@ +package plugin + +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" +) + +const ( + InputTypeText InputType = "text" + InputTypeColor InputType = "color" + InputTypeDate InputType = "date" + InputTypeDatetime InputType = "datetime-local" + InputTypeEmail InputType = "email" + InputTypeMonth InputType = "month" + InputTypeNumber InputType = "number" + InputTypePassword InputType = "password" + InputTypeRange InputType = "range" + InputTypeSearch InputType = "search" + InputTypeTel InputType = "tel" + InputTypeTime InputType = "time" + InputTypeUrl InputType = "url" + InputTypeWeek InputType = "week" +) + +type ConfigField struct { + Name string `json:"name"` + Type ConfigType `json:"type"` + Title Translator `json:"title"` + Description Translator `json:"description"` + Required bool `json:"required"` + Value any `json:"value"` + UIOptions ConfigFieldUIOptions `json:"ui_options"` + Options []ConfigFieldOption `json:"options,omitempty"` +} + +type ConfigFieldUIOptions struct { + Placeholder Translator `json:"placeholder,omitempty"` + Rows string `json:"rows,omitempty"` + InputType InputType `json:"input_type,omitempty"` + Label Translator `json:"label,omitempty"` + Action *UIOptionAction `json:"action,omitempty"` + Variant string `json:"variant,omitempty"` + Text Translator `json:"text,omitempty"` +} + +type ConfigFieldOption struct { + Label Translator `json:"label"` + Value string `json:"value"` +} + +type UIOptionAction struct { + Url string `json:"url"` + Method string `json:"method,omitempty"` + Loading *LoadingAction `json:"loading,omitempty"` + OnComplete *OnCompleteAction `json:"on_complete,omitempty"` +} + +const ( + LoadingActionStateNone LoadingActionType = "none" + LoadingActionStatePending LoadingActionType = "pending" + LoadingActionStateComplete LoadingActionType = "completed" +) + +type LoadingActionType string + +type LoadingAction struct { + Text Translator `json:"text"` + State LoadingActionType `json:"state"` +} + +type OnCompleteAction struct { + ToastReturnMessage bool `json:"toast_return_message"` + RefreshFormConfig bool `json:"refresh_form_config"` +} + +type Config interface { + Base + + // ConfigFields returns the list of config fields + ConfigFields() []ConfigField + + // ConfigReceiver receives the config data, it calls when the config is saved or initialized. + // We recommend to unmarshal the data to a struct, and then use the struct to do something. + // The config is encoded in JSON format. + // It depends on the definition of ConfigFields. + ConfigReceiver(config []byte) error +} + +var ( + // CallConfig is a function that calls all registered config plugins + CallConfig, + registerConfig = MakePlugin[Config](true) +) diff --git a/plugin/connector.go b/plugin/connector.go new file mode 100644 index 00000000..41b8c2fd --- /dev/null +++ b/plugin/connector.go @@ -0,0 +1,49 @@ +package plugin + +type Connector interface { + Base + + // ConnectorLogoSVG presents the logo in svg format + ConnectorLogoSVG() string + + // ConnectorName presents the name of the connector + // e.g. Facebook, Twitter, Instagram + ConnectorName() Translator + + // ConnectorSlugName presents the slug name of the connector + // Please use lowercase and hyphen as the separator + // e.g. facebook, twitter, instagram + ConnectorSlugName() string + + // ConnectorSender presents the sender of the connector + // It handles the start endpoint of the connector + // receiverURL is the whole URL of the receiver + ConnectorSender(ctx *GinContext, receiverURL string) (redirectURL string) + + // ConnectorReceiver presents the receiver of the connector + // It handles the callback endpoint of the connector, and returns the + ConnectorReceiver(ctx *GinContext, receiverURL string) (userInfo ExternalLoginUserInfo, err error) +} + +// ExternalLoginUserInfo external login user info +type ExternalLoginUserInfo struct { + // required. The unique user ID provided by the third-party login + ExternalID string + // optional. This name is used preferentially during registration + DisplayName string + // optional. This username is used preferentially during registration + Username string + // optional. If email exist will bind the existing user + // IMPORTANT: The email must have been verified. If the plugin can't guarantee the email is verified, please leave it empty. + Email string + // optional. The avatar URL provided by the third-party login platform + Avatar string + // optional. The original user information provided by the third-party login platform + MetaInfo string +} + +var ( + // CallConnector is a function that calls all registered connectors + CallConnector, + registerConnector = MakePlugin[Connector](false) +) diff --git a/plugin/filter.go b/plugin/filter.go new file mode 100644 index 00000000..0976efaf --- /dev/null +++ b/plugin/filter.go @@ -0,0 +1,12 @@ +package plugin + +type Filter interface { + Base + FilterText(text string) (err error) +} + +var ( + // CallFilter is a function that calls all registered parsers + CallFilter, + registerFilter = MakePlugin[Filter](false) +) diff --git a/plugin/parser.go b/plugin/parser.go new file mode 100644 index 00000000..a5b80cce --- /dev/null +++ b/plugin/parser.go @@ -0,0 +1,12 @@ +package plugin + +type Parser interface { + Base + Parse(text string) (string, error) +} + +var ( + // CallParser is a function that calls all registered parsers + CallParser, + registerParser = MakePlugin[Parser](false) +) diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 00000000..7f6e4a41 --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,156 @@ +package plugin + +import ( + "encoding/json" + + "github.com/answerdev/answer/internal/base/handler" + "github.com/answerdev/answer/internal/base/translator" + "github.com/gin-gonic/gin" +) + +// GinContext is a wrapper of gin.Context +// We export it to make it easy to use in plugins +type GinContext = gin.Context + +// StatusManager is a manager that manages the status of plugins +// Init Plugins: +// json.Unmarshal([]byte(`{"plugin1": true, "plugin2": false}`), &plugin.StatusManager) +// Dump Status: +// json.Marshal(plugin.StatusManager) +var StatusManager = statusManager{ + status: make(map[string]bool), +} + +// Register registers a plugin +func Register(p Base) { + registerBase(p) + + if _, ok := p.(Config); ok { + registerConfig(p.(Config)) + } + + if _, ok := p.(Connector); ok { + registerConnector(p.(Connector)) + } + + if _, ok := p.(Parser); ok { + registerParser(p.(Parser)) + } + + if _, ok := p.(Filter); ok { + registerFilter(p.(Filter)) + } + + if _, ok := p.(Storage); ok { + registerStorage(p.(Storage)) + } + + if _, ok := p.(Cache); ok { + registerCache(p.(Cache)) + } + + if _, ok := p.(UserCenter); ok { + registerUserCenter(p.(UserCenter)) + } + + if _, ok := p.(Agent); ok { + registerAgent(p.(Agent)) + } +} + +type Stack[T Base] struct { + plugins []T +} + +type RegisterFn[T Base] func(p T) +type Caller[T Base] func(p T) error +type CallFn[T Base] func(fn Caller[T]) error + +// MakePlugin creates a plugin caller and register stack manager +// The parameter super presents if the plugin can be disabled. +// It returns a register function and a caller function +// The register function is used to register a plugin, it will be called in the plugin's init function +// The caller function is used to call all registered plugins +func MakePlugin[T Base](super bool) (CallFn[T], RegisterFn[T]) { + stack := Stack[T]{} + + call := func(fn Caller[T]) error { + for _, p := range stack.plugins { + // If the plugin is disabled, skip it + if !super && !StatusManager.IsEnabled(p.Info().SlugName) { + continue + } + + if err := fn(p); err != nil { + return err + } + } + return nil + } + + register := func(p T) { + for _, plugin := range stack.plugins { + if plugin.Info().SlugName == p.Info().SlugName { + panic("plugin " + p.Info().SlugName + " is already registered") + } + } + stack.plugins = append(stack.plugins, p) + } + + return call, register +} + +type statusManager struct { + status map[string]bool +} + +func (m *statusManager) Enable(name string, enabled bool) { + m.status[name] = enabled +} + +func (m *statusManager) IsEnabled(name string) bool { + if status, ok := m.status[name]; ok { + return status + } + return false +} + +// MarshalJSON implements the json.Marshaler interface. +func (m *statusManager) MarshalJSON() ([]byte, error) { + return json.Marshal(m.status) +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (m *statusManager) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &m.status) +} + +// Translate translates the key to the current language of the context +func Translate(ctx *GinContext, key string) string { + return translator.Tr(handler.GetLang(ctx), key) +} + +// TranslateFn presents a generator of translated string. +// We use it to delegate the translation work outside the plugin. +type TranslateFn func(ctx *GinContext) string + +// Translator contains a function that translates the key to the current language of the context +type Translator struct { + Fn TranslateFn +} + +// MakeTranslator generates a translator from the key +func MakeTranslator(key string) Translator { + t := func(ctx *GinContext) string { + return Translate(ctx, key) + } + return Translator{Fn: t} +} + +// Translate translates the key to the current language of the context +func (t Translator) Translate(ctx *GinContext) string { + if &t == nil || t.Fn == nil { + return "" + } + return t.Fn(ctx) +} diff --git a/plugin/storage.go b/plugin/storage.go new file mode 100644 index 00000000..82dd59d6 --- /dev/null +++ b/plugin/storage.go @@ -0,0 +1,50 @@ +package plugin + +type UploadSource string + +const ( + UserAvatar UploadSource = "user_avatar" + UserPost UploadSource = "user_post" + AdminBranding UploadSource = "admin_branding" +) + +var ( + DefaultFileTypeCheckMapping = map[UploadSource]map[string]bool{ + UserAvatar: { + ".jpg": true, + ".jpeg": true, + ".png": true, + }, + UserPost: { + ".jpg": true, + ".jpeg": true, + ".png": true, + }, + AdminBranding: { + ".ico": true, + }, + } +) + +type UploadFileResponse struct { + // FullURL is the URL that can be used to access the file + FullURL string + // OriginalError is the error returned by the storage plugin. It is used for debugging. + OriginalError error + // DisplayErrorMsg is the error message that will be displayed to the user. + DisplayErrorMsg Translator +} + +type Storage interface { + Base + + // UploadFile uploads a file to storage. + // The file is in the Form of the ctx and the key is "file" + UploadFile(ctx *GinContext, source UploadSource) UploadFileResponse +} + +var ( + // CallStorage is a function that calls all registered storage + CallStorage, + registerStorage = MakePlugin[Storage](false) +) diff --git a/plugin/user_center.go b/plugin/user_center.go new file mode 100644 index 00000000..57061394 --- /dev/null +++ b/plugin/user_center.go @@ -0,0 +1,107 @@ +package plugin + +type UserCenter interface { + Base + // Description returns the description of the user center, including the name, icon, url, etc. + Description() UserCenterDesc + // ControlCenterItems returns the items that will be displayed in the control center + ControlCenterItems() []ControlCenter + // LoginCallback is called when the user center login callback is called + LoginCallback(ctx *GinContext) (userInfo *UserCenterBasicUserInfo, err error) + // SignUpCallback is called when the user center sign up callback is called + SignUpCallback(ctx *GinContext) (userInfo *UserCenterBasicUserInfo, err error) + // UserInfo returns the user information + UserInfo(externalID string) (userInfo *UserCenterBasicUserInfo, err error) + // UserStatus returns the latest user status + UserStatus(externalID string) (userStatus UserStatus) + // UserList returns the user list information + UserList(externalIDs []string) (userInfo []*UserCenterBasicUserInfo, err error) + // UserSettings returns the user settings + UserSettings(externalID string) (userSettings *SettingInfo, err error) + // PersonalBranding returns the personal branding information + PersonalBranding(externalID string) (branding []*PersonalBranding) + // AfterLogin is called after the user logs in + AfterLogin(externalID, accessToken string) +} + +type UserCenterDesc struct { + Name string `json:"name"` + DisplayName Translator `json:"display_name"` + Icon string `json:"icon"` + Url string `json:"url"` + LoginRedirectURL string `json:"login_redirect_url"` + SignUpRedirectURL string `json:"sign_up_redirect_url"` + RankAgentEnabled bool `json:"rank_agent_enabled"` + UserStatusAgentEnabled bool `json:"user_status_agent_enabled"` + MustAuthEmailEnabled bool `json:"must_auth_email_enabled"` + EnabledOriginalUserSystem bool `json:"enabled_original_user_system"` +} + +type UserStatus int + +const ( + UserStatusAvailable UserStatus = 1 + UserStatusSuspended UserStatus = 9 + UserStatusDeleted UserStatus = 10 +) + +type UserCenterBasicUserInfo struct { + ExternalID string `json:"external_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Rank int `json:"rank"` + Avatar string `json:"avatar"` + Mobile string `json:"mobile"` + Bio string `json:"bio"` + Status UserStatus `json:"status"` +} + +type ControlCenter struct { + Name string `json:"name"` + Label string `json:"label"` + Url string `json:"url"` +} + +type SettingInfo struct { + ProfileSettingRedirectURL string `json:"profile_setting_redirect_url"` + AccountSettingRedirectURL string `json:"account_setting_redirect_url"` +} + +type PersonalBranding struct { + Icon string `json:"icon"` + Name string `json:"name"` + Label string `json:"label"` + Url string `json:"url"` +} + +var ( + // CallUserCenter is a function that calls all registered parsers + CallUserCenter, + registerUserCenter = MakePlugin[UserCenter](false) +) + +func UserCenterEnabled() (enabled bool) { + _ = CallUserCenter(func(fn UserCenter) error { + enabled = true + return nil + }) + return +} + +func RankAgentEnabled() (enabled bool) { + _ = CallUserCenter(func(fn UserCenter) error { + enabled = fn.Description().RankAgentEnabled + return nil + }) + return +} + +func GetUserCenter() (uc UserCenter, ok bool) { + _ = CallUserCenter(func(fn UserCenter) error { + uc = fn + ok = true + return nil + }) + return +} diff --git a/script/build_plugin.sh b/script/build_plugin.sh new file mode 100755 index 00000000..89d03cdf --- /dev/null +++ b/script/build_plugin.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -e +echo "begin build plugin" +plugin_file=./script/plugin_list +if [ ! -f "$plugin_file" ]; then + echo "plugin_list is not exist" + exit 0 +fi + +echo "plugin_list exist" +cmd="./answer build " +for repo in `cat $plugin_file` +do + echo ${repo} + cmd=$cmd" --with "${repo} +done + +echo "cmd is "$cmd +$cmd +if [ ! -f "./new_answer" ]; then + echo "new_answer is not exist build failed" + exit 0 +fi +rm answer +mv new_answer answer +./answer plugin \ No newline at end of file diff --git a/script/plugin_list b/script/plugin_list new file mode 100644 index 00000000..a7cfc670 --- /dev/null +++ b/script/plugin_list @@ -0,0 +1 @@ +github.com/answerdev/plugins/connector/basic@latest \ No newline at end of file diff --git a/ui/.eslintrc.js b/ui/.eslintrc.js index 032c2103..f4bf831f 100644 --- a/ui/.eslintrc.js +++ b/ui/.eslintrc.js @@ -36,6 +36,7 @@ module.exports = { 'react/no-unescaped-entities': 'off', 'react/require-default-props': 'off', 'arrow-body-style': 'off', + "global-require": "off", 'react/prop-types': 0, 'react/no-danger': 'off', 'jsx-a11y/no-static-element-interactions': 'off', diff --git a/ui/package.json b/ui/package.json index 772d2f14..52523768 100644 --- a/ui/package.json +++ b/ui/package.json @@ -15,14 +15,13 @@ "dependencies": { "axios": "^0.27.2", "bootstrap": "^5.2.0", - "bootstrap-icons": "1.10.2", + "bootstrap-icons": "1.10.4", "classnames": "^2.3.1", "codemirror": "5.65.0", "color": "^4.2.3", "copy-to-clipboard": "^3.3.2", "dayjs": "^1.11.5", "diff": "^5.1.0", - "emoji-regex": "^10.2.1", "i18next": "^21.9.0", "katex": "^0.16.2", "lodash": "^4.17.21", @@ -30,6 +29,7 @@ "md5": "^2.3.0", "mermaid": "^9.1.7", "next-share": "^0.18.1", + "qrcode": "^1.5.1", "qs": "^6.11.0", "react": "^18.2.0", "react-bootstrap": "^2.5.0", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 416bf92d..aa286c88 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -21,7 +21,7 @@ specifiers: '@typescript-eslint/parser': ^5.33.0 axios: ^0.27.2 bootstrap: ^5.2.0 - bootstrap-icons: 1.10.2 + bootstrap-icons: 1.10.4 classnames: ^2.3.1 codemirror: 5.65.0 color: ^4.2.3 @@ -29,7 +29,6 @@ specifiers: customize-cra: ^1.0.0 dayjs: ^1.11.5 diff: ^5.1.0 - emoji-regex: ^10.2.1 eslint: ^8.0.1 eslint-config-airbnb: ^19.0.4 eslint-config-airbnb-typescript: ^17.0.0 @@ -54,6 +53,7 @@ specifiers: postcss: ^8.0.0 prettier: ^2.7.1 purgecss-webpack-plugin: ^4.1.3 + qrcode: ^1.5.1 qs: ^6.11.0 react: ^18.2.0 react-app-rewired: ^2.2.1 @@ -74,14 +74,13 @@ specifiers: dependencies: axios: 0.27.2 bootstrap: 5.2.1_@popperjs+core@2.11.7 - bootstrap-icons: 1.10.2 + bootstrap-icons: 1.10.4 classnames: 2.3.2 codemirror: 5.65.0 color: 4.2.3 copy-to-clipboard: 3.3.2 dayjs: 1.11.5 diff: 5.1.0 - emoji-regex: 10.2.1 i18next: 21.9.2 katex: 0.16.2 lodash: 4.17.21 @@ -89,6 +88,7 @@ dependencies: md5: 2.3.0 mermaid: 9.1.7 next-share: 0.18.1_lbqamd2wfmenkveygahn4wdfcq + qrcode: 1.5.1 qs: 6.11.0 react: 18.2.0 react-bootstrap: 2.5.0_7ey2zzynotv32rpkwno45fsx4e @@ -109,8 +109,8 @@ devDependencies: '@testing-library/jest-dom': 4.2.4 '@testing-library/react': 13.4.0_biqbaboplfbrettd7655fr4n2y '@testing-library/user-event': 13.5.0_znccgeejomvff3jrsk3ljovfpu - '@types/color': registry.npmjs.org/@types/color/3.0.3 - '@types/dompurify': registry.npmjs.org/@types/dompurify/2.4.0 + '@types/color': 3.0.3 + '@types/dompurify': 2.4.0 '@types/jest': 27.5.2 '@types/lodash': 4.14.185 '@types/marked': 4.0.7 @@ -137,7 +137,7 @@ devDependencies: lint-staged: 13.0.3 postcss: 8.4.16 prettier: 2.7.1 - purgecss-webpack-plugin: 4.1.3_webpack@5.77.0 + purgecss-webpack-plugin: 4.1.3_webpack@5.80.0 react-app-rewired: 2.2.1_react-scripts@5.0.1 react-scripts: 5.0.1_z72bcl2gkg6v3fmxqtnfgirxda sass: 1.54.9 @@ -153,6 +153,13 @@ packages: '@jridgewell/gen-mapping': 0.1.1 '@jridgewell/trace-mapping': 0.3.15 + /@ampproject/remapping/2.2.1: + resolution: {integrity: sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/gen-mapping': 0.3.3 + '@jridgewell/trace-mapping': 0.3.18 + /@apideck/better-ajv-errors/0.3.6_ajv@8.11.0: resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==} engines: {node: '>=10'} @@ -210,7 +217,7 @@ packages: resolution: {integrity: sha512-qt/YV149Jman/6AfmlxJ04LMIu8bMoyl3RB91yTFrxQmgbrSvQMy7cI8Q62FHx1t8wJ8B5fu0UDoLwHAhUo1QA==} engines: {node: '>=6.9.0'} dependencies: - '@ampproject/remapping': 2.2.0 + '@ampproject/remapping': 2.2.1 '@babel/code-frame': 7.21.4 '@babel/generator': 7.21.4 '@babel/helper-compilation-targets': 7.21.4_@babel+core@7.21.4 @@ -254,8 +261,8 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/types': 7.21.4 - '@jridgewell/gen-mapping': 0.3.2 - '@jridgewell/trace-mapping': 0.3.17 + '@jridgewell/gen-mapping': 0.3.3 + '@jridgewell/trace-mapping': 0.3.18 jsesc: 2.5.2 /@babel/helper-annotate-as-pure/7.18.6: @@ -2077,7 +2084,7 @@ packages: collect-v8-coverage: 1.0.1 exit: 0.1.2 glob: 7.2.3 - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 istanbul-lib-coverage: 3.2.0 istanbul-lib-instrument: 5.2.0 istanbul-lib-report: 3.0.0 @@ -2106,7 +2113,7 @@ packages: engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} dependencies: callsites: 3.1.0 - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 source-map: 0.6.1 /@jest/test-result/27.5.1: @@ -2132,7 +2139,7 @@ packages: engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} dependencies: '@jest/test-result': 27.5.1 - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 jest-haste-map: 27.5.1 jest-runtime: 27.5.1 transitivePeerDependencies: @@ -2203,7 +2210,15 @@ packages: dependencies: '@jridgewell/set-array': 1.1.2 '@jridgewell/sourcemap-codec': 1.4.14 - '@jridgewell/trace-mapping': 0.3.15 + '@jridgewell/trace-mapping': 0.3.17 + + /@jridgewell/gen-mapping/0.3.3: + resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/set-array': 1.1.2 + '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping': 0.3.18 /@jridgewell/resolve-uri/3.1.0: resolution: {integrity: sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==} @@ -2217,11 +2232,21 @@ packages: resolution: {integrity: sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==} dependencies: '@jridgewell/gen-mapping': 0.3.2 - '@jridgewell/trace-mapping': 0.3.15 + '@jridgewell/trace-mapping': 0.3.17 + + /@jridgewell/source-map/0.3.3: + resolution: {integrity: sha512-b+fsZXeLYi9fEULmfBrhxn4IrPlINf8fiNarzTof004v3lFdntdwa9PF7vFJqm3mg7s+ScJMxXaE3Acp1irZcg==} + dependencies: + '@jridgewell/gen-mapping': 0.3.3 + '@jridgewell/trace-mapping': 0.3.18 + dev: true /@jridgewell/sourcemap-codec/1.4.14: resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} + /@jridgewell/sourcemap-codec/1.4.15: + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + /@jridgewell/trace-mapping/0.3.15: resolution: {integrity: sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g==} dependencies: @@ -2234,6 +2259,12 @@ packages: '@jridgewell/resolve-uri': 3.1.0 '@jridgewell/sourcemap-codec': 1.4.14 + /@jridgewell/trace-mapping/0.3.18: + resolution: {integrity: sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==} + dependencies: + '@jridgewell/resolve-uri': 3.1.0 + '@jridgewell/sourcemap-codec': 1.4.14 + /@jridgewell/trace-mapping/0.3.9: resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} dependencies: @@ -2637,6 +2668,22 @@ packages: dependencies: '@types/node': 16.11.59 + /@types/color-convert/2.0.0: + resolution: {integrity: sha512-m7GG7IKKGuJUXvkZ1qqG3ChccdIM/qBBo913z+Xft0nKCX4hAU/IxKwZBU4cpRZ7GS5kV4vOblUkILtSShCPXQ==} + dependencies: + '@types/color-name': 1.1.1 + dev: true + + /@types/color-name/1.1.1: + resolution: {integrity: sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==} + dev: true + + /@types/color/3.0.3: + resolution: {integrity: sha512-X//qzJ3d3Zj82J9sC/C18ZY5f43utPbAJ6PhYt/M7uG6etcF6MRpKdN880KBy43B0BMzSfeT96MzrsNjFI3GbA==, registry: https://registry.yarnpkg.com/, tarball: https://registry.yarnpkg.com/@types/color/-/color-3.0.3.tgz} + dependencies: + '@types/color-convert': 2.0.0 + dev: true + /@types/connect-history-api-fallback/1.3.5: resolution: {integrity: sha512-h8QJa8xSb1WD4fpKBDcATDNGXghFj6/3GRWG6dhmRcu0RX1Ubasur2Uvx5aeEwlf0MwblEC2bMzzMQntxnw/Cw==} dependencies: @@ -2648,6 +2695,12 @@ packages: dependencies: '@types/node': 16.11.59 + /@types/dompurify/2.4.0: + resolution: {integrity: sha512-IDBwO5IZhrKvHFUl+clZxgf3hn2b/lU6H1KaBShPkQyGJUQ0xwebezIPSuiyGwfz1UzJWQl4M7BDxtHtCCPlTg==, registry: https://registry.yarnpkg.com/, tarball: https://registry.yarnpkg.com/@types/dompurify/-/dompurify-2.4.0.tgz} + dependencies: + '@types/trusted-types': 2.0.2 + dev: true + /@types/eslint-scope/3.7.4: resolution: {integrity: sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==} dependencies: @@ -2669,6 +2722,10 @@ packages: /@types/estree/1.0.0: resolution: {integrity: sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==} + /@types/estree/1.0.1: + resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} + dev: true + /@types/express-serve-static-core/4.17.31: resolution: {integrity: sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==} dependencies: @@ -2823,6 +2880,9 @@ packages: /@types/stack-utils/2.0.1: resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==} + /@types/trusted-types/2.0.2: + resolution: {integrity: sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==} + /@types/warning/3.0.0: resolution: {integrity: sha512-t/Tvs5qR47OLOr+4E9ckN8AmP2Tf16gWq+/qA4iUGS/OOyHVO8wv2vjJuX8SNOUTJyWb+2t7wJm6cXILFnOROA==} dev: false @@ -2987,15 +3047,34 @@ packages: '@webassemblyjs/helper-numbers': 1.11.1 '@webassemblyjs/helper-wasm-bytecode': 1.11.1 + /@webassemblyjs/ast/1.11.5: + resolution: {integrity: sha512-LHY/GSAZZRpsNQH+/oHqhRQ5FT7eoULcBqgfyTB5nQHogFnK3/7QoN7dLnwSE/JkUAF0SrRuclT7ODqMFtWxxQ==} + dependencies: + '@webassemblyjs/helper-numbers': 1.11.5 + '@webassemblyjs/helper-wasm-bytecode': 1.11.5 + dev: true + /@webassemblyjs/floating-point-hex-parser/1.11.1: resolution: {integrity: sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==} + /@webassemblyjs/floating-point-hex-parser/1.11.5: + resolution: {integrity: sha512-1j1zTIC5EZOtCplMBG/IEwLtUojtwFVwdyVMbL/hwWqbzlQoJsWCOavrdnLkemwNoC/EOwtUFch3fuo+cbcXYQ==} + dev: true + /@webassemblyjs/helper-api-error/1.11.1: resolution: {integrity: sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==} + /@webassemblyjs/helper-api-error/1.11.5: + resolution: {integrity: sha512-L65bDPmfpY0+yFrsgz8b6LhXmbbs38OnwDCf6NpnMUYqa+ENfE5Dq9E42ny0qz/PdR0LJyq/T5YijPnU8AXEpA==} + dev: true + /@webassemblyjs/helper-buffer/1.11.1: resolution: {integrity: sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==} + /@webassemblyjs/helper-buffer/1.11.5: + resolution: {integrity: sha512-fDKo1gstwFFSfacIeH5KfwzjykIE6ldh1iH9Y/8YkAZrhmu4TctqYjSh7t0K2VyDSXOZJ1MLhht/k9IvYGcIxg==} + dev: true + /@webassemblyjs/helper-numbers/1.11.1: resolution: {integrity: sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==} dependencies: @@ -3003,9 +3082,21 @@ packages: '@webassemblyjs/helper-api-error': 1.11.1 '@xtuc/long': 4.2.2 + /@webassemblyjs/helper-numbers/1.11.5: + resolution: {integrity: sha512-DhykHXM0ZABqfIGYNv93A5KKDw/+ywBFnuWybZZWcuzWHfbp21wUfRkbtz7dMGwGgT4iXjWuhRMA2Mzod6W4WA==} + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.11.5 + '@webassemblyjs/helper-api-error': 1.11.5 + '@xtuc/long': 4.2.2 + dev: true + /@webassemblyjs/helper-wasm-bytecode/1.11.1: resolution: {integrity: sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==} + /@webassemblyjs/helper-wasm-bytecode/1.11.5: + resolution: {integrity: sha512-oC4Qa0bNcqnjAowFn7MPCETQgDYytpsfvz4ujZz63Zu/a/v71HeCAAmZsgZ3YVKec3zSPYytG3/PrRCqbtcAvA==} + dev: true + /@webassemblyjs/helper-wasm-section/1.11.1: resolution: {integrity: sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==} dependencies: @@ -3014,19 +3105,44 @@ packages: '@webassemblyjs/helper-wasm-bytecode': 1.11.1 '@webassemblyjs/wasm-gen': 1.11.1 + /@webassemblyjs/helper-wasm-section/1.11.5: + resolution: {integrity: sha512-uEoThA1LN2NA+K3B9wDo3yKlBfVtC6rh0i4/6hvbz071E8gTNZD/pT0MsBf7MeD6KbApMSkaAK0XeKyOZC7CIA==} + dependencies: + '@webassemblyjs/ast': 1.11.5 + '@webassemblyjs/helper-buffer': 1.11.5 + '@webassemblyjs/helper-wasm-bytecode': 1.11.5 + '@webassemblyjs/wasm-gen': 1.11.5 + dev: true + /@webassemblyjs/ieee754/1.11.1: resolution: {integrity: sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==} dependencies: '@xtuc/ieee754': 1.2.0 + /@webassemblyjs/ieee754/1.11.5: + resolution: {integrity: sha512-37aGq6qVL8A8oPbPrSGMBcp38YZFXcHfiROflJn9jxSdSMMM5dS5P/9e2/TpaJuhE+wFrbukN2WI6Hw9MH5acg==} + dependencies: + '@xtuc/ieee754': 1.2.0 + dev: true + /@webassemblyjs/leb128/1.11.1: resolution: {integrity: sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==} dependencies: '@xtuc/long': 4.2.2 + /@webassemblyjs/leb128/1.11.5: + resolution: {integrity: sha512-ajqrRSXaTJoPW+xmkfYN6l8VIeNnR4vBOTQO9HzR7IygoCcKWkICbKFbVTNMjMgMREqXEr0+2M6zukzM47ZUfQ==} + dependencies: + '@xtuc/long': 4.2.2 + dev: true + /@webassemblyjs/utf8/1.11.1: resolution: {integrity: sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==} + /@webassemblyjs/utf8/1.11.5: + resolution: {integrity: sha512-WiOhulHKTZU5UPlRl53gHR8OxdGsSOxqfpqWeA2FmcwBMaoEdz6b2x2si3IwC9/fSPLfe8pBMRTHVMk5nlwnFQ==} + dev: true + /@webassemblyjs/wasm-edit/1.11.1: resolution: {integrity: sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==} dependencies: @@ -3039,6 +3155,19 @@ packages: '@webassemblyjs/wasm-parser': 1.11.1 '@webassemblyjs/wast-printer': 1.11.1 + /@webassemblyjs/wasm-edit/1.11.5: + resolution: {integrity: sha512-C0p9D2fAu3Twwqvygvf42iGCQ4av8MFBLiTb+08SZ4cEdwzWx9QeAHDo1E2k+9s/0w1DM40oflJOpkZ8jW4HCQ==} + dependencies: + '@webassemblyjs/ast': 1.11.5 + '@webassemblyjs/helper-buffer': 1.11.5 + '@webassemblyjs/helper-wasm-bytecode': 1.11.5 + '@webassemblyjs/helper-wasm-section': 1.11.5 + '@webassemblyjs/wasm-gen': 1.11.5 + '@webassemblyjs/wasm-opt': 1.11.5 + '@webassemblyjs/wasm-parser': 1.11.5 + '@webassemblyjs/wast-printer': 1.11.5 + dev: true + /@webassemblyjs/wasm-gen/1.11.1: resolution: {integrity: sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==} dependencies: @@ -3048,6 +3177,16 @@ packages: '@webassemblyjs/leb128': 1.11.1 '@webassemblyjs/utf8': 1.11.1 + /@webassemblyjs/wasm-gen/1.11.5: + resolution: {integrity: sha512-14vteRlRjxLK9eSyYFvw1K8Vv+iPdZU0Aebk3j6oB8TQiQYuO6hj9s4d7qf6f2HJr2khzvNldAFG13CgdkAIfA==} + dependencies: + '@webassemblyjs/ast': 1.11.5 + '@webassemblyjs/helper-wasm-bytecode': 1.11.5 + '@webassemblyjs/ieee754': 1.11.5 + '@webassemblyjs/leb128': 1.11.5 + '@webassemblyjs/utf8': 1.11.5 + dev: true + /@webassemblyjs/wasm-opt/1.11.1: resolution: {integrity: sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==} dependencies: @@ -3056,6 +3195,15 @@ packages: '@webassemblyjs/wasm-gen': 1.11.1 '@webassemblyjs/wasm-parser': 1.11.1 + /@webassemblyjs/wasm-opt/1.11.5: + resolution: {integrity: sha512-tcKwlIXstBQgbKy1MlbDMlXaxpucn42eb17H29rawYLxm5+MsEmgPzeCP8B1Cl69hCice8LeKgZpRUAPtqYPgw==} + dependencies: + '@webassemblyjs/ast': 1.11.5 + '@webassemblyjs/helper-buffer': 1.11.5 + '@webassemblyjs/wasm-gen': 1.11.5 + '@webassemblyjs/wasm-parser': 1.11.5 + dev: true + /@webassemblyjs/wasm-parser/1.11.1: resolution: {integrity: sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==} dependencies: @@ -3066,12 +3214,30 @@ packages: '@webassemblyjs/leb128': 1.11.1 '@webassemblyjs/utf8': 1.11.1 + /@webassemblyjs/wasm-parser/1.11.5: + resolution: {integrity: sha512-SVXUIwsLQlc8srSD7jejsfTU83g7pIGr2YYNb9oHdtldSxaOhvA5xwvIiWIfcX8PlSakgqMXsLpLfbbJ4cBYew==} + dependencies: + '@webassemblyjs/ast': 1.11.5 + '@webassemblyjs/helper-api-error': 1.11.5 + '@webassemblyjs/helper-wasm-bytecode': 1.11.5 + '@webassemblyjs/ieee754': 1.11.5 + '@webassemblyjs/leb128': 1.11.5 + '@webassemblyjs/utf8': 1.11.5 + dev: true + /@webassemblyjs/wast-printer/1.11.1: resolution: {integrity: sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==} dependencies: '@webassemblyjs/ast': 1.11.1 '@xtuc/long': 4.2.2 + /@webassemblyjs/wast-printer/1.11.5: + resolution: {integrity: sha512-f7Pq3wvg3GSPUPzR0F6bmI89Hdb+u9WXrSKc4v+N0aV0q6r42WoF92Jp2jEorBEBRoRNXgjp53nBniDXcqZYPA==} + dependencies: + '@webassemblyjs/ast': 1.11.5 + '@xtuc/long': 4.2.2 + dev: true + /@xtuc/ieee754/1.2.0: resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -3632,8 +3798,8 @@ packages: /boolbase/1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - /bootstrap-icons/1.10.2: - resolution: {integrity: sha512-PTPYadRn1AMGr+QTSxe4ZCc+Wzv9DGZxbi3lNse/dajqV31n2/wl/7NX78ZpkvFgRNmH4ogdIQPQmxAfhEV6nA==} + /bootstrap-icons/1.10.4: + resolution: {integrity: sha512-eI3HyIUmpGKRiRv15FCZccV+2sreGE2NnmH8mtxV/nPOzQVu0sPEj8HhF1MwjJ31IhjF0rgMvtYOX5VqIzcb/A==} dev: false /bootstrap/5.2.1_@popperjs+core@2.11.7: @@ -3679,10 +3845,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true dependencies: - caniuse-lite: 1.0.30001474 - electron-to-chromium: 1.4.350 + caniuse-lite: 1.0.30001481 + electron-to-chromium: 1.4.369 node-releases: 2.0.10 - update-browserslist-db: 1.0.10_browserslist@4.21.5 + update-browserslist-db: 1.0.11_browserslist@4.21.5 /bser/2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} @@ -3758,8 +3924,8 @@ packages: /caniuse-lite/1.0.30001408: resolution: {integrity: sha512-DdUCktgMSM+1ndk9EFMZcavsGszV7zxV9O7MtOHniTa/iyAIwJCF0dFVBdU9SijJbfh29hC9bCs07wu8pjnGJQ==} - /caniuse-lite/1.0.30001474: - resolution: {integrity: sha512-iaIZ8gVrWfemh5DG3T9/YqarVZoYf0r188IjaGwx68j4Pf0SGY6CQkmJUIE+NZHkkecQGohzXmBGEwWDr9aM3Q==} + /caniuse-lite/1.0.30001481: + resolution: {integrity: sha512-KCqHwRnaa1InZBtqXzP98LPg0ajCVujMKjqKDhZEthIpAsJl/YEIa3YvXjGXPVqzZVguccuu7ga9KOE1J9rKPQ==} /case-sensitive-paths-webpack-plugin/2.4.0: resolution: {integrity: sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==} @@ -3857,6 +4023,14 @@ packages: string-width: 5.1.2 dev: true + /cliui/6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + dev: false + /cliui/7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} dependencies: @@ -4209,7 +4383,7 @@ packages: dependencies: boolbase: 1.0.0 css-what: 3.4.2 - domutils: registry.npmjs.org/domutils/1.7.0 + domutils: 1.7.0 nth-check: 1.0.2 /css-select/4.3.0: @@ -4217,8 +4391,8 @@ packages: dependencies: boolbase: 1.0.0 css-what: 6.1.0 - domhandler: registry.npmjs.org/domhandler/4.3.1 - domutils: registry.npmjs.org/domutils/2.8.0 + domhandler: 4.3.1 + domutils: 2.8.0 nth-check: 2.1.1 /css-tree/1.0.0-alpha.37: @@ -4890,7 +5064,6 @@ packages: /decamelize/1.2.0: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} - dev: true /decimal.js/10.4.1: resolution: {integrity: sha512-F29o+vci4DodHYT9UrR5IEbfBw9pE5eSapIJdTqXK5+6hq+t8VRxwQyKlW2i+KDKFkkJQRvFyI/QXD83h8LyQw==} @@ -5004,6 +5177,10 @@ packages: engines: {node: '>=0.3.1'} dev: false + /dijkstrajs/1.0.2: + resolution: {integrity: sha512-QV6PMaHTCNmKSeP6QoXhVTw9snc9VD8MulTT0Bd99Pacp4SS1cjcrYPgBPmibqKVtMJJfqC6XvOXgPMEEPH/fg==} + dev: false + /dir-glob/3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -5050,12 +5227,54 @@ packages: csstype: 3.1.1 dev: false + /dom-serializer/0.2.2: + resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==, registry: https://registry.yarnpkg.com/, tarball: https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz} + dependencies: + domelementtype: 2.3.0 + entities: 2.2.0 + + /dom-serializer/1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==, registry: https://registry.yarnpkg.com/, tarball: https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz} + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + + /domelementtype/1.3.1: + resolution: {integrity: sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==, registry: https://registry.yarnpkg.com/, tarball: https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz} + + /domelementtype/2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==, registry: https://registry.yarnpkg.com/, tarball: https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz} + /domexception/2.0.1: resolution: {integrity: sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==} engines: {node: '>=8'} dependencies: webidl-conversions: 5.0.0 + /domhandler/4.3.1: + resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==, registry: https://registry.yarnpkg.com/, tarball: https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz} + engines: {node: '>= 4'} + dependencies: + domelementtype: 2.3.0 + + /dompurify/2.4.0: + resolution: {integrity: sha512-Be9tbQMZds4a3C6xTmz68NlMfeONA//4dOavl/1rNw50E+/QO0KVpbcU0PcaW0nsQxurXls9ZocqFxk8R2mWEA==, registry: https://registry.yarnpkg.com/, tarball: https://registry.yarnpkg.com/dompurify/-/dompurify-2.4.0.tgz} + dev: false + + /domutils/1.7.0: + resolution: {integrity: sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==, registry: https://registry.yarnpkg.com/, tarball: https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz} + dependencies: + dom-serializer: 0.2.2 + domelementtype: 1.3.1 + + /domutils/2.8.0: + resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==, registry: https://registry.yarnpkg.com/, tarball: https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz} + dependencies: + dom-serializer: 1.4.1 + domelementtype: 2.3.0 + domhandler: 4.3.1 + /dot-case/3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} dependencies: @@ -5096,8 +5315,8 @@ packages: /electron-to-chromium/1.4.256: resolution: {integrity: sha512-x+JnqyluoJv8I0U9gVe+Sk2st8vF0CzMt78SXxuoWCooLLY2k5VerIBdpvG7ql6GKI4dzNnPjmqgDJ76EdaAKw==} - /electron-to-chromium/1.4.350: - resolution: {integrity: sha512-XnXcWpVnOfHZ4C3NPiL+SubeoGV8zc/pg8GEubRtc1dPA/9jKS2vsOPmtClJHhWxUb2RSGC1OBLCbgNUJMtZPw==} + /electron-to-chromium/1.4.369: + resolution: {integrity: sha512-LfxbHXdA/S+qyoTEA4EbhxGjrxx7WK2h6yb5K2v0UCOufUKX+VZaHbl3svlzZfv9sGseym/g3Ne4DpsgRULmqg==} /emittery/0.10.2: resolution: {integrity: sha512-aITqOwnLanpHLNXZJENbOgjUBeHocD+xsSJmNrjovKBW5HbSpW3d1pEls7GFQPUWXiwG9+0P4GtHfEqC/4M0Iw==} @@ -5107,10 +5326,6 @@ packages: resolution: {integrity: sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==} engines: {node: '>=10'} - /emoji-regex/10.2.1: - resolution: {integrity: sha512-97g6QgOk8zlDRdgq1WxwgTMgEWGVAQvB5Fdpgc1MkNy56la5SKP9GsMXKDOdqwn90/41a8yPwIGk1Y6WVbeMQA==} - dev: false - /emoji-regex/8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -5121,6 +5336,10 @@ packages: resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==} engines: {node: '>= 4'} + /encode-utf8/1.0.3: + resolution: {integrity: sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==} + dev: false + /encodeurl/1.0.2: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} engines: {node: '>= 0.8'} @@ -5129,17 +5348,20 @@ packages: resolution: {integrity: sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ==} engines: {node: '>=10.13.0'} dependencies: - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 tapable: 2.2.1 - /enhanced-resolve/5.12.0: - resolution: {integrity: sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==} + /enhanced-resolve/5.13.0: + resolution: {integrity: sha512-eyV8f0y1+bzyfh8xAwW/WTSZpLbjhqc4ne9eGSH4Zo2ejdyiNG9pU6mf9DG8a7+Auk6MFTlNOT4Y2y/9k8GKVg==} engines: {node: '>=10.13.0'} dependencies: graceful-fs: 4.2.11 tapable: 2.2.1 dev: true + /entities/2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==, registry: https://registry.yarnpkg.com/, tarball: https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz} + /error-ex/1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} dependencies: @@ -5184,6 +5406,10 @@ packages: /es-module-lexer/0.9.3: resolution: {integrity: sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==} + /es-module-lexer/1.2.1: + resolution: {integrity: sha512-9978wrXM50Y4rTMmW5kXIC09ZdXQZqkE4mxhwkd8VbzsGkXGPgV4zWuqQJgCEzYngdo2dYDa0l8xhX4fkSwJSg==} + dev: true + /es-shim-unscopables/1.0.0: resolution: {integrity: sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==} dependencies: @@ -6293,6 +6519,14 @@ packages: tapable: 2.2.1 webpack: 5.74.0 + /htmlparser2/6.1.0: + resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==, registry: https://registry.yarnpkg.com/, tarball: https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz} + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + domutils: 2.8.0 + entities: 2.2.0 + /http-deceiver/1.2.7: resolution: {integrity: sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==} @@ -6804,7 +7038,7 @@ packages: ci-info: 3.4.0 deepmerge: 4.2.2 glob: 7.2.3 - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 jest-circus: 27.5.1 jest-environment-jsdom: 27.5.1 jest-environment-node: 27.5.1 @@ -6976,7 +7210,7 @@ packages: '@jest/types': 27.5.1 '@types/stack-utils': 2.0.1 chalk: 4.1.2 - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 micromatch: 4.0.5 pretty-format: 27.5.1 slash: 3.0.0 @@ -7059,7 +7293,7 @@ packages: '@types/node': 16.11.59 chalk: 4.1.2 emittery: 0.8.1 - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 jest-docblock: 27.5.1 jest-environment-jsdom: 27.5.1 jest-environment-node: 27.5.1 @@ -7094,7 +7328,7 @@ packages: collect-v8-coverage: 1.0.1 execa: 5.1.1 glob: 7.2.3 - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 jest-haste-map: 27.5.1 jest-message-util: 27.5.1 jest-mock: 27.5.1 @@ -7112,7 +7346,7 @@ packages: engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} dependencies: '@types/node': 16.11.59 - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 /jest-snapshot/27.5.1: resolution: {integrity: sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==} @@ -7130,7 +7364,7 @@ packages: babel-preset-current-node-syntax: 1.0.1_@babel+core@7.19.1 chalk: 4.1.2 expect: 27.5.1 - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 jest-diff: 27.5.1 jest-get-type: 27.5.1 jest-haste-map: 27.5.1 @@ -7365,7 +7599,7 @@ packages: dependencies: universalify: 2.0.0 optionalDependencies: - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 /jsonp/0.2.1: resolution: {integrity: sha512-pfog5gdDxPdV4eP7Kg87M8/bHgshlZ5pybl+yKxAnCZ5O7lCIn7Ixydj03wOlnDQesky2BPyA91SQ+5Y/mNwzw==} @@ -7681,7 +7915,7 @@ packages: d3: 7.6.1 dagre: 0.8.5 dagre-d3: 0.6.4 - dompurify: registry.npmjs.org/dompurify/2.4.0 + dompurify: 2.4.0 graphlib: 2.1.8 khroma: 2.0.0 moment-mini: 2.24.0 @@ -8177,6 +8411,11 @@ packages: dependencies: find-up: 3.0.0 + /pngjs/5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + dev: false + /postcss-attribute-case-insensitive/5.0.2_postcss@8.4.16: resolution: {integrity: sha512-XIidXV8fDr0kKt28vqki84fRK8VW8eTuIa4PChv2MqKuT6C9UjmSKzen6KaWhWEoYvwxFCa7n/tC1SZ3tyq4SQ==} engines: {node: ^12 || ^14 || >=16} @@ -8983,13 +9222,13 @@ packages: resolution: {integrity: sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==} engines: {node: '>=6'} - /purgecss-webpack-plugin/4.1.3_webpack@5.77.0: + /purgecss-webpack-plugin/4.1.3_webpack@5.80.0: resolution: {integrity: sha512-1OHS0WE935w66FjaFSlV06ycmn3/A8a6Q+iVUmmCYAujQ1HPdX+psMXUhASEW0uF1PYEpOlhMc5ApigVqYK08g==} peerDependencies: webpack: '*' dependencies: purgecss: 4.1.3 - webpack: 5.77.0 + webpack: 5.80.0 webpack-sources: 3.2.3 dev: true @@ -9007,6 +9246,17 @@ packages: resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==} engines: {node: '>=0.6.0', teleport: '>=0.2.0'} + /qrcode/1.5.1: + resolution: {integrity: sha512-nS8NJ1Z3md8uTjKtP+SGGhfqmTCs5flU/xR623oI0JX+Wepz9R8UrRVCTBTJm3qGw3rH6jJ6MUHjkDx15cxSSg==} + engines: {node: '>=10.13.0'} + hasBin: true + dependencies: + dijkstrajs: 1.0.2 + encode-utf8: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + dev: false + /qs/6.10.3: resolution: {integrity: sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==} engines: {node: '>=0.6'} @@ -9478,7 +9728,7 @@ packages: dependencies: css-select: 4.3.0 dom-converter: 0.2.0 - htmlparser2: registry.npmjs.org/htmlparser2/6.1.0 + htmlparser2: 6.1.0 lodash: 4.17.21 strip-ansi: 6.0.1 @@ -9490,6 +9740,10 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + /require-main-filename/2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + dev: false + /requires-port/1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -9703,6 +9957,15 @@ packages: ajv: 6.12.6 ajv-keywords: 3.5.2_ajv@6.12.6 + /schema-utils/3.1.2: + resolution: {integrity: sha512-pvjEHOgWc9OWA/f/DE3ohBWTD6EleVLf7iFUkoSwAxttdBhB9QUebQgxER2kWueOvRJXPHNnyrvvh9eZINB8Eg==} + engines: {node: '>= 10.13.0'} + dependencies: + '@types/json-schema': 7.0.11 + ajv: 6.12.6 + ajv-keywords: 3.5.2_ajv@6.12.6 + dev: true + /schema-utils/4.0.0: resolution: {integrity: sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==} engines: {node: '>= 12.13.0'} @@ -9806,6 +10069,10 @@ packages: transitivePeerDependencies: - supports-color + /set-blocking/2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + dev: false + /setprototypeof/1.1.0: resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==} @@ -10330,7 +10597,7 @@ packages: terser: 5.15.0 webpack: 5.74.0 - /terser-webpack-plugin/5.3.7_webpack@5.77.0: + /terser-webpack-plugin/5.3.7_webpack@5.80.0: resolution: {integrity: sha512-AfKwIktyP7Cu50xNjXF/6Qb5lBNzYaWpU6YfoX3uZicTx0zTy0stDDCsvjDapKsSDvOeWo5MEq4TmdBy2cNoHw==} engines: {node: '>= 10.13.0'} peerDependencies: @@ -10346,12 +10613,12 @@ packages: uglify-js: optional: true dependencies: - '@jridgewell/trace-mapping': 0.3.17 + '@jridgewell/trace-mapping': 0.3.18 jest-worker: 27.5.1 - schema-utils: 3.1.1 + schema-utils: 3.1.2 serialize-javascript: 6.0.1 - terser: 5.16.8 - webpack: 5.77.0 + terser: 5.17.1 + webpack: 5.80.0 dev: true /terser/5.15.0: @@ -10364,12 +10631,12 @@ packages: commander: 2.20.3 source-map-support: 0.5.21 - /terser/5.16.8: - resolution: {integrity: sha512-QI5g1E/ef7d+PsDifb+a6nnVgC4F22Bg6T0xrBrz6iloVB4PUkkunp6V8nzoOOZJIzjWVdAGqCdlKlhLq/TbIA==} + /terser/5.17.1: + resolution: {integrity: sha512-hVl35zClmpisy6oaoKALOpS0rDYLxRFLHhRuDlEGTKey9qHjS1w9GMORjuwIMt70Wan4lwsLYyWDVnWgF+KUEw==} engines: {node: '>=10'} hasBin: true dependencies: - '@jridgewell/source-map': 0.3.2 + '@jridgewell/source-map': 0.3.3 acorn: 8.8.2 commander: 2.20.3 source-map-support: 0.5.21 @@ -10633,8 +10900,8 @@ packages: resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} engines: {node: '>=4'} - /update-browserslist-db/1.0.10_browserslist@4.21.5: - resolution: {integrity: sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==} + /update-browserslist-db/1.0.11_browserslist@4.21.5: + resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -10759,7 +11026,7 @@ packages: engines: {node: '>=10.13.0'} dependencies: glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 /wbuf/1.7.3: resolution: {integrity: sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==} @@ -10903,8 +11170,8 @@ packages: - esbuild - uglify-js - /webpack/5.77.0: - resolution: {integrity: sha512-sbGNjBr5Ya5ss91yzjeJTLKyfiwo5C628AFjEa6WSXcZa4E+F57om3Cc8xLb1Jh0b243AWuSYRf3dn7HVeFQ9Q==} + /webpack/5.80.0: + resolution: {integrity: sha512-OIMiq37XK1rWO8mH9ssfFKZsXg4n6klTEDL7S8/HqbAOBBaiy8ABvXvz0dDCXeEF9gqwxSvVk611zFPjS8hJxA==} engines: {node: '>=10.13.0'} hasBin: true peerDependencies: @@ -10914,16 +11181,16 @@ packages: optional: true dependencies: '@types/eslint-scope': 3.7.4 - '@types/estree': 0.0.51 - '@webassemblyjs/ast': 1.11.1 - '@webassemblyjs/wasm-edit': 1.11.1 - '@webassemblyjs/wasm-parser': 1.11.1 + '@types/estree': 1.0.1 + '@webassemblyjs/ast': 1.11.5 + '@webassemblyjs/wasm-edit': 1.11.5 + '@webassemblyjs/wasm-parser': 1.11.5 acorn: 8.8.2 acorn-import-assertions: 1.8.0_acorn@8.8.2 browserslist: 4.21.5 chrome-trace-event: 1.0.3 - enhanced-resolve: 5.12.0 - es-module-lexer: 0.9.3 + enhanced-resolve: 5.13.0 + es-module-lexer: 1.2.1 eslint-scope: 5.1.1 events: 3.3.0 glob-to-regexp: 0.4.1 @@ -10932,9 +11199,9 @@ packages: loader-runner: 4.3.0 mime-types: 2.1.35 neo-async: 2.6.2 - schema-utils: 3.1.1 + schema-utils: 3.1.2 tapable: 2.2.1 - terser-webpack-plugin: 5.3.7_webpack@5.77.0 + terser-webpack-plugin: 5.3.7_webpack@5.80.0 watchpack: 2.4.0 webpack-sources: 3.2.3 transitivePeerDependencies: @@ -10990,6 +11257,10 @@ packages: is-string: 1.0.7 is-symbol: 1.0.4 + /which-module/2.0.0: + resolution: {integrity: sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==} + dev: false + /which/1.3.1: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true @@ -11150,7 +11421,7 @@ packages: /workbox-window/6.5.4: resolution: {integrity: sha512-HnLZJDwYBE+hpG25AQBO8RUWBJRaCsI9ksQJEp3aCOFCaG5kqaToAYXFRAHxzRluM2cQbGzdQF5rjKPWPA1fug==} dependencies: - '@types/trusted-types': registry.npmjs.org/@types/trusted-types/2.0.2 + '@types/trusted-types': 2.0.2 workbox-core: 6.5.4 /wrap-ansi/6.2.0: @@ -11160,7 +11431,6 @@ packages: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 - dev: true /wrap-ansi/7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} @@ -11215,6 +11485,10 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + /y18n/4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + dev: false + /y18n/5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -11243,6 +11517,14 @@ packages: engines: {node: '>= 14'} dev: true + /yargs-parser/18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + dev: false + /yargs-parser/20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -11252,6 +11534,23 @@ packages: engines: {node: '>=12'} dev: true + /yargs/15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.0 + y18n: 4.0.3 + yargs-parser: 18.1.3 + dev: false + /yargs/16.2.0: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} engines: {node: '>=10'} @@ -11300,111 +11599,3 @@ packages: react: 18.2.0 use-sync-external-store: 1.2.0_react@18.2.0 dev: false - - registry.npmjs.org/@types/color-convert/2.0.0: - resolution: {integrity: sha512-m7GG7IKKGuJUXvkZ1qqG3ChccdIM/qBBo913z+Xft0nKCX4hAU/IxKwZBU4cpRZ7GS5kV4vOblUkILtSShCPXQ==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/@types/color-convert/-/color-convert-2.0.0.tgz} - name: '@types/color-convert' - version: 2.0.0 - dependencies: - '@types/color-name': registry.npmjs.org/@types/color-name/1.1.1 - dev: true - - registry.npmjs.org/@types/color-name/1.1.1: - resolution: {integrity: sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz} - name: '@types/color-name' - version: 1.1.1 - dev: true - - registry.npmjs.org/@types/color/3.0.3: - resolution: {integrity: sha512-X//qzJ3d3Zj82J9sC/C18ZY5f43utPbAJ6PhYt/M7uG6etcF6MRpKdN880KBy43B0BMzSfeT96MzrsNjFI3GbA==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/@types/color/-/color-3.0.3.tgz} - name: '@types/color' - version: 3.0.3 - dependencies: - '@types/color-convert': registry.npmjs.org/@types/color-convert/2.0.0 - dev: true - - registry.npmjs.org/@types/dompurify/2.4.0: - resolution: {integrity: sha512-IDBwO5IZhrKvHFUl+clZxgf3hn2b/lU6H1KaBShPkQyGJUQ0xwebezIPSuiyGwfz1UzJWQl4M7BDxtHtCCPlTg==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/@types/dompurify/-/dompurify-2.4.0.tgz} - name: '@types/dompurify' - version: 2.4.0 - dependencies: - '@types/trusted-types': registry.npmjs.org/@types/trusted-types/2.0.2 - dev: true - - registry.npmjs.org/@types/trusted-types/2.0.2: - resolution: {integrity: sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz} - name: '@types/trusted-types' - version: 2.0.2 - - registry.npmjs.org/dom-serializer/0.2.2: - resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz} - name: dom-serializer - version: 0.2.2 - dependencies: - domelementtype: registry.npmjs.org/domelementtype/2.3.0 - entities: registry.npmjs.org/entities/2.2.0 - - registry.npmjs.org/dom-serializer/1.4.1: - resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz} - name: dom-serializer - version: 1.4.1 - dependencies: - domelementtype: registry.npmjs.org/domelementtype/2.3.0 - domhandler: registry.npmjs.org/domhandler/4.3.1 - entities: registry.npmjs.org/entities/2.2.0 - - registry.npmjs.org/domelementtype/1.3.1: - resolution: {integrity: sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz} - name: domelementtype - version: 1.3.1 - - registry.npmjs.org/domelementtype/2.3.0: - resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz} - name: domelementtype - version: 2.3.0 - - registry.npmjs.org/domhandler/4.3.1: - resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz} - name: domhandler - version: 4.3.1 - engines: {node: '>= 4'} - dependencies: - domelementtype: registry.npmjs.org/domelementtype/2.3.0 - - registry.npmjs.org/dompurify/2.4.0: - resolution: {integrity: sha512-Be9tbQMZds4a3C6xTmz68NlMfeONA//4dOavl/1rNw50E+/QO0KVpbcU0PcaW0nsQxurXls9ZocqFxk8R2mWEA==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/dompurify/-/dompurify-2.4.0.tgz} - name: dompurify - version: 2.4.0 - dev: false - - registry.npmjs.org/domutils/1.7.0: - resolution: {integrity: sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz} - name: domutils - version: 1.7.0 - dependencies: - dom-serializer: registry.npmjs.org/dom-serializer/0.2.2 - domelementtype: registry.npmjs.org/domelementtype/1.3.1 - - registry.npmjs.org/domutils/2.8.0: - resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz} - name: domutils - version: 2.8.0 - dependencies: - dom-serializer: registry.npmjs.org/dom-serializer/1.4.1 - domelementtype: registry.npmjs.org/domelementtype/2.3.0 - domhandler: registry.npmjs.org/domhandler/4.3.1 - - registry.npmjs.org/entities/2.2.0: - resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/entities/-/entities-2.2.0.tgz} - name: entities - version: 2.2.0 - - registry.npmjs.org/htmlparser2/6.1.0: - resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==, registry: https://registry.yarnpkg.com/, tarball: https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz} - name: htmlparser2 - version: 6.1.0 - dependencies: - domelementtype: registry.npmjs.org/domelementtype/2.3.0 - domhandler: registry.npmjs.org/domhandler/4.3.1 - domutils: registry.npmjs.org/domutils/2.8.0 - entities: registry.npmjs.org/entities/2.2.0 diff --git a/ui/public/index.html b/ui/public/index.html index cb72d38a..b7bce50c 100644 --- a/ui/public/index.html +++ b/ui/public/index.html @@ -2,8 +2,8 @@ - - + + diff --git a/ui/src/assets/images/carousel-wecom-1.jpg b/ui/src/assets/images/carousel-wecom-1.jpg new file mode 100644 index 00000000..4dac6292 Binary files /dev/null and b/ui/src/assets/images/carousel-wecom-1.jpg differ diff --git a/ui/src/assets/images/carousel-wecom-2.jpg b/ui/src/assets/images/carousel-wecom-2.jpg new file mode 100644 index 00000000..618db5af Binary files /dev/null and b/ui/src/assets/images/carousel-wecom-2.jpg differ diff --git a/ui/src/assets/images/carousel-wecom-3.jpg b/ui/src/assets/images/carousel-wecom-3.jpg new file mode 100644 index 00000000..c8c7abb4 Binary files /dev/null and b/ui/src/assets/images/carousel-wecom-3.jpg differ diff --git a/ui/src/assets/images/carousel-wecom-4.jpg b/ui/src/assets/images/carousel-wecom-4.jpg new file mode 100644 index 00000000..7f581fb4 Binary files /dev/null and b/ui/src/assets/images/carousel-wecom-4.jpg differ diff --git a/ui/src/assets/images/carousel-wecom-5.jpg b/ui/src/assets/images/carousel-wecom-5.jpg new file mode 100644 index 00000000..e4068ebe Binary files /dev/null and b/ui/src/assets/images/carousel-wecom-5.jpg differ diff --git a/ui/src/common/constants.ts b/ui/src/common/constants.ts index 705e74c1..35643bda 100644 --- a/ui/src/common/constants.ts +++ b/ui/src/common/constants.ts @@ -2,25 +2,18 @@ export const DEFAULT_SITE_NAME = 'Answer'; export const DEFAULT_LANG = 'en_US'; export const CURRENT_LANG_STORAGE_KEY = '_a_lang_'; export const LANG_RESOURCE_STORAGE_KEY = '_a_lang_r_'; -export const LOGGED_USER_STORAGE_KEY = '_a_lui_'; export const LOGGED_TOKEN_STORAGE_KEY = '_a_ltk_'; export const REDIRECT_PATH_STORAGE_KEY = '_a_rp_'; export const CAPTCHA_CODE_STORAGE_KEY = '_a_captcha_'; export const DRAFT_QUESTION_STORAGE_KEY = '_a_dq_'; export const DRAFT_ANSWER_STORAGE_KEY = '_a_da_'; export const DRAFT_TIMESIGH_STORAGE_KEY = '|_a_t_s_|'; - -export const IGNORE_PATH_LIST = [ - '/users/login', - '/users/register', - '/users/account-recovery', - '/users/change-email', - '/users/password-reset', - '/users/account-activation', - '/users/account-activation/success', - '/users/account-activation/failed', - '/users/confirm-new-email', -]; +export const USER_AGENT_NAMES = { + SegmentFault: 'SegmentFault', + WeChat: 'WeChat', + WeCom: 'WeCom', + DingTalk: 'DingTalk', +}; export const ADMIN_LIST_STATUS = { // normal; @@ -75,7 +68,8 @@ export const ADMIN_NAV_MENUS = [ name: 'themes', }, { - name: 'css-html', + name: 'css_html', + path: 'css-html', }, ], }, @@ -90,12 +84,21 @@ export const ADMIN_NAV_MENUS = [ { name: 'write' }, { name: 'seo' }, { name: 'login' }, + { name: 'users', path: 'settings-users' }, + { name: 'privileges' }, + ], + }, + { + name: 'plugins', + children: [ + { + name: 'installed_plugins', + path: 'installed-plugins', + }, ], }, ]; -export const ADMIN_LEGAL_MENUS = [{ name: 'tos' }, { name: 'privacy' }]; - export const TIMEZONES = [ { label: 'Africa', @@ -585,7 +588,7 @@ export const TIMEZONES = [ options: [{ value: 'UTC', label: 'UTC' }], }, ]; -export const DEFAULT_TIMEZONE = 'UTC+0'; +export const DEFAULT_TIMEZONE = 'UTC'; export const TIMELINE_NORMAL_ACTIVITY_TYPE = [ 'undeleted', diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 7d51a3c8..ae2e44db 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -136,6 +136,7 @@ export interface UserInfoRes extends UserInfoBase { mail_status: number; language: string; e_mail?: string; + have_password: boolean; [prop: string]: any; } @@ -243,6 +244,7 @@ export type QuestionOrderBy = export interface QueryQuestionsReq extends Paging { order: QuestionOrderBy; tag?: string; + in_days?: number; } export type AdminQuestionStatus = 'available' | 'closed' | 'deleted'; @@ -269,6 +271,11 @@ export type UserFilterBy = | 'suspended' | 'deleted'; +export type InstalledPluginsFilterBy = + | 'all' + | 'active' + | 'inactive' + | 'outdated'; /** * @description interface for Flags */ @@ -304,7 +311,6 @@ export interface HelmetUpdate extends Omit { export interface AdminSettingsInterface { language: string; time_zone?: string; - default_avatar?: string; } export interface AdminSettingsSmtp { @@ -319,6 +325,16 @@ export interface AdminSettingsSmtp { test_email_recipient?: string; } +export interface AdminSettingsUsers { + allow_update_avatar: boolean; + allow_update_bio: boolean; + allow_update_display_name: boolean; + allow_update_location: boolean; + allow_update_username: boolean; + allow_update_website: boolean; + default_avatar: string; +} + export interface SiteSettings { branding: AdminSettingBranding; general: AdminSettingsGeneral; @@ -327,6 +343,7 @@ export interface SiteSettings { custom_css_html: AdminSettingsCustom; theme: AdminSettingsTheme; site_seo: AdminSettingsSeo; + site_users: AdminSettingsUsers; version: string; revision: string; } @@ -377,11 +394,14 @@ export interface AdminSettingsCustom { custom_head: string; custom_header: string; custom_footer: string; + custom_sidebar: string; } export interface AdminSettingsLogin { allow_new_registrations: boolean; login_required: boolean; + allow_email_registrations: boolean; + allow_email_domains: string[]; } /** @@ -531,3 +551,25 @@ export interface QuestionOperationReq { id: string; operation: 'pin' | 'unpin' | 'hide' | 'show'; } + +export interface OauthBindEmailReq { + binding_key: string; + email: string; + must: boolean; +} + +export interface OauthConnectorItem { + icon: string; + name: string; + link: string; +} + +export interface UserOauthConnectorItem extends OauthConnectorItem { + binding: boolean; + external_id: string; +} + +export interface QuestionOperationReq { + id: string; + operation: 'pin' | 'unpin' | 'hide' | 'show'; +} diff --git a/ui/src/common/pattern.ts b/ui/src/common/pattern.ts index 72086a68..f79f7cc3 100644 --- a/ui/src/common/pattern.ts +++ b/ui/src/common/pattern.ts @@ -1,9 +1,9 @@ -import emojiRegex from 'emoji-regex'; - const pattern = { - emoji: emojiRegex(), email: /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+\.)+[a-zA-Z\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]{2,}))$/, + uaWeChat: /micromessenger/i, + uaWeCom: /wxwork/i, + uaDingTalk: /dingtalk/i, }; export default pattern; diff --git a/ui/src/components/AccordionNav/index.tsx b/ui/src/components/AccordionNav/index.tsx index 5efa87c8..4e64611f 100644 --- a/ui/src/components/AccordionNav/index.tsx +++ b/ui/src/components/AccordionNav/index.tsx @@ -18,12 +18,12 @@ function MenuNode({ }) { const { t } = useTranslation('translation', { keyPrefix: 'nav_menus' }); const isLeaf = !menu.children.length; - const href = isLeaf ? `${path}${menu.name}` : '#'; + const href = isLeaf ? `${path}${menu.path}` : '#'; return ( - + { callback(evt, menu, href, isLeaf); @@ -31,9 +31,11 @@ function MenuNode({ href={href} className={classNames( 'text-nowrap d-flex flex-nowrap align-items-center w-100', - { expanding, 'link-dark': activeKey !== menu.name }, + { expanding, 'link-dark': activeKey !== menu.path }, )}> - {t(menu.name)} + + {menu.displayName ? menu.displayName : t(menu.name)} + {menu.badgeContent ? ( {menu.badgeContent} ) : null} @@ -42,7 +44,7 @@ function MenuNode({ )} {menu.children.length ? ( - + <> {menu.children.map((leaf) => { return ( @@ -51,7 +53,7 @@ function MenuNode({ callback={callback} activeKey={activeKey} path={path} - key={leaf.name} + key={leaf.path} /> ); })} @@ -71,17 +73,24 @@ const AccordionNav: FC = ({ menus = [], path = '/' }) => { const pathMatch = useMatch(`${path}*`); // auto set menu fields menus.forEach((m) => { + if (!m.path) { + m.path = m.name; + } if (!Array.isArray(m.children)) { m.children = []; } m.children.forEach((sm) => { + if (!sm.path) { + sm.path = sm.name; + } if (!Array.isArray(sm.children)) { sm.children = []; } }); }); + const splat = pathMatch && pathMatch.params['*']; - let activeKey = menus[0].name; + let activeKey = menus[0].path; if (splat) { activeKey = splat; } @@ -90,10 +99,10 @@ const AccordionNav: FC = ({ menus = [], path = '/' }) => { menus.forEach((li) => { if (li.children.length) { const matchedChild = li.children.find((el) => { - return el.name === activeKey; + return el.path === activeKey; }); if (matchedChild) { - openKey = li.name; + openKey = li.path; } } }); @@ -109,12 +118,12 @@ const AccordionNav: FC = ({ menus = [], path = '/' }) => { navigate(href); } } else { - setOpenKey(openKey === menu.name ? '' : menu.name); + setOpenKey(openKey === menu.path ? '' : menu.path); } }; useEffect(() => { setOpenKey(getOpenKey()); - }, [activeKey]); + }, [activeKey, menus]); return (