Merge branch 'beta/1.1.0' into beta.2/1.1.0

# Conflicts:
#	internal/migrations/migrations.go
#	internal/schema/user_schema.go
#	ui/src/common/interface.ts
This commit is contained in:
LinkinStars 2023-05-09 10:15:58 +08:00
commit dacc27e373
63 changed files with 4021 additions and 217 deletions

8
.github/Dockerfile vendored
View File

@ -25,9 +25,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

1
.gitignore vendored
View File

@ -26,5 +26,6 @@ tmp
vendor/
/answer-data/
/answer
/new_answer
dist/

View File

@ -13,7 +13,8 @@ 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
@ -27,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

View File

@ -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

View File

@ -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()
}

View File

@ -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 {

75
cmd/main.go Normal file
View File

@ -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)),
)
}

View File

@ -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"

View File

@ -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)
@ -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,9 @@ 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)
pluginAPIRouter := router.NewPluginAPIRouter(connectorController)
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() {

View File

@ -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": [
@ -2117,6 +2296,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": [
@ -5844,6 +6188,142 @@ 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": {
"input_type": {
"type": "string"
},
"label": {
"type": "string"
},
"placeholder": {
"type": "string"
},
"rows": {
"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 +6600,55 @@ 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.GetRankPersonalWithPageResp": {
"type": "object",
"properties": {
@ -6556,6 +7085,10 @@ const docTemplate = `{
"description": "follow count",
"type": "integer"
},
"have_password": {
"description": "user have password",
"type": "boolean"
},
"id": {
"description": "user id",
"type": "string"
@ -6656,6 +7189,9 @@ const docTemplate = `{
"description": "follow count",
"type": "integer"
},
"have_password": {
"type": "boolean"
},
"id": {
"description": "user id",
"type": "string"
@ -7757,6 +8293,37 @@ 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.UpdateSMTPConfigReq": {
"type": "object",
"properties": {
@ -8021,14 +8588,19 @@ const docTemplate = `{
},
"schema.UserModifyPassWordRequest": {
"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
}
}
},

View File

@ -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": [
@ -2105,6 +2284,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": [
@ -5832,6 +6176,142 @@
}
}
},
"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": {
"input_type": {
"type": "string"
},
"label": {
"type": "string"
},
"placeholder": {
"type": "string"
},
"rows": {
"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 +6588,55 @@
}
}
},
"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.GetRankPersonalWithPageResp": {
"type": "object",
"properties": {
@ -6544,6 +7073,10 @@
"description": "follow count",
"type": "integer"
},
"have_password": {
"description": "user have password",
"type": "boolean"
},
"id": {
"description": "user id",
"type": "string"
@ -6644,6 +7177,9 @@
"description": "follow count",
"type": "integer"
},
"have_password": {
"type": "boolean"
},
"id": {
"description": "user id",
"type": "string"
@ -7745,6 +8281,37 @@
}
}
},
"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.UpdateSMTPConfigReq": {
"type": "object",
"properties": {
@ -8009,14 +8576,19 @@
},
"schema.UserModifyPassWordRequest": {
"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
}
}
},

View File

@ -305,6 +305,98 @@ 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:
input_type:
type: string
label:
type: string
placeholder:
type: string
rows:
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 +599,38 @@ 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.GetRankPersonalWithPageResp:
properties:
answer_id:
@ -820,6 +944,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 +1021,8 @@ definitions:
follow_count:
description: follow count
type: integer
have_password:
type: boolean
id:
description: user id
type: string
@ -1668,6 +1797,27 @@ definitions:
required:
- display_name
type: object
schema.UpdatePluginConfigReq:
properties:
config_fields:
additionalProperties: {}
type: object
plugin_slug_name:
maxLength: 100
type: string
required:
- 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.UpdateSMTPConfigReq:
properties:
encryption:
@ -1859,11 +2009,15 @@ definitions:
schema.UserModifyPassWordRequest:
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 +2258,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:
@ -3263,6 +3523,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:

4
go.mod
View File

@ -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

8
go.sum
View File

@ -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=

View File

@ -155,6 +155,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.

View File

@ -1054,7 +1054,13 @@ ui:
themes: 主题
css-html: CSS/HTML
login: 登录
plugins: 插件
installed_plugins: 插件列表
website_welcome: 欢迎来到 {{site_name}}
plugins:
oauth:
connect: 连接到 {{ auth_name }}
remove: 解绑 {{ auth_name }}
admin:
admin_header:
title: 后台管理
@ -1367,6 +1373,23 @@ ui:
title: 非公开的
label: 需要登录
text: 只有登录用户才能访问这个社区。
installed_plugins:
title: 插件列表
filter:
all: 全部
active: 启用
inactive: 未启用
outdated: 已过期
plugins:
label: 插件
text: 选择一个插件
name: 插件名称
version: 插件版本
status: 状态
action: 操作
deactivate: 停用
activate: 启用
settings: 设置
form:
optional: (选填)
empty: 不能为空

View File

@ -0,0 +1,5 @@
package constant
const (
PluginStatus = "plugin.status"
)

View File

@ -0,0 +1,8 @@
package constant
import "time"
const (
ConnectorUserExternalInfoCacheKey = "answer:connector:"
ConnectorUserExternalInfoCacheTime = 10 * time.Minute
)

View File

@ -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()

View File

@ -14,38 +14,38 @@ const (
)
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"
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"
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"
@ -70,4 +70,5 @@ const (
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"
)

View File

@ -20,6 +20,7 @@ func NewHTTPServer(debug bool,
authUserMiddleware *middleware.AuthUserMiddleware,
avatarMiddleware *middleware.AvatarMiddleware,
templateRouter *router.TemplateRouter,
pluginAPIRouter *router.PluginAPIRouter,
) *gin.Engine {
if debug {
@ -62,5 +63,9 @@ func NewHTTPServer(debug bool,
answerRouter.RegisterAnswerAdminAPIRouter(adminauthV1)
templateRouter.RegisterTemplateRouter(rootGroup)
// plugin routes
pluginAPIRouter.RegisterUnAuthConnectorRouter(mustUnAuthV1)
pluginAPIRouter.RegisterAuthConnectorRouter(authV1)
return r
}

View File

@ -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)

358
internal/cli/build.go Normal file
View File

@ -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)
}

View File

@ -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/oauth?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)
}

View File

@ -24,4 +24,5 @@ var ProviderSetController = wire.NewSet(
NewUploadController,
NewActivityController,
NewTemplateController,
NewConnectorController,
)

View File

@ -9,4 +9,5 @@ var ProviderSetController = wire.NewSet(
NewThemeController,
NewSiteInfoController,
NewRoleController,
NewPluginController,
)

View File

@ -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)
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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{

View File

@ -58,6 +58,7 @@ 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),
}

34
internal/migrations/v7.go Normal file
View File

@ -0,0 +1,34 @@
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 {
if _, err = x.Update(c, &entity.Config{ID: c.ID, Key: c.Key}); 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 x.Sync(new(entity.PluginConfig), new(entity.UserExternalLogin))
}

View File

@ -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
}

View File

@ -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,
)

View File

@ -10,6 +10,7 @@ import (
"github.com/answerdev/answer/internal/service/config"
usercommon "github.com/answerdev/answer/internal/service/user_common"
"github.com/segmentfault/pacman/errors"
"xorm.io/xorm"
)
// userRepo user repository
@ -28,10 +29,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
}

View File

@ -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, externalID string) (
userInfo *entity.UserExternalLogin, exist bool, err error) {
userInfo = &entity.UserExternalLogin{}
exist, err = ur.data.DB.Where("external_id = ?", externalID).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
}

View File

@ -31,6 +31,7 @@ type AnswerAPIRouter struct {
uploadController *controller.UploadController
activityController *controller.ActivityController
roleController *controller_admin.RoleController
pluginController *controller_admin.PluginController
}
func NewAnswerAPIRouter(
@ -58,6 +59,7 @@ func NewAnswerAPIRouter(
uploadController *controller.UploadController,
activityController *controller.ActivityController,
roleController *controller_admin.RoleController,
pluginController *controller_admin.PluginController,
) *AnswerAPIRouter {
return &AnswerAPIRouter{
langController: langController,
@ -84,6 +86,7 @@ func NewAnswerAPIRouter(
uploadController: uploadController,
activityController: activityController,
roleController: roleController,
pluginController: pluginController,
}
}
@ -282,4 +285,10 @@ func (a *AnswerAPIRouter) RegisterAnswerAdminAPIRouter(r *gin.RouterGroup) {
// 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)
}

View File

@ -0,0 +1,32 @@
package router
import (
"github.com/answerdev/answer/internal/controller"
"github.com/gin-gonic/gin"
)
type PluginAPIRouter struct {
connectorController *controller.ConnectorController
}
func NewPluginAPIRouter(
connectorController *controller.ConnectorController,
) *PluginAPIRouter {
return &PluginAPIRouter{
connectorController: connectorController,
}
}
func (pr *PluginAPIRouter) RegisterUnAuthConnectorRouter(r *gin.RouterGroup) {
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)
}
func (pr *PluginAPIRouter) RegisterAuthConnectorRouter(r *gin.RouterGroup) {
connectorController := pr.connectorController
r.GET("/connector/user/info", connectorController.ConnectorsUserInfo)
r.DELETE("/connector/user/unbinding", connectorController.ExternalLoginUnbinding)
}

View File

@ -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,
)

View File

@ -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"`
}

View File

@ -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 {

View File

@ -0,0 +1,100 @@
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),
},
}
configField.UIOptions.Placeholder = field.UIOptions.Placeholder.Translate(ctx)
configField.UIOptions.Label = field.UIOptions.Label.Translate(ctx)
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"`
}
type ConfigFieldOption struct {
Label string `json:"label"`
Value string `json:"value"`
}
type UpdatePluginConfigReq struct {
PluginSlugName string `validate:"required,gt=1,lte=100" json:"plugin_slug_name"`
ConfigFields map[string]any `json:"config_fields"`
}

View File

@ -0,0 +1,58 @@
package schema
// UserExternalLoginResp user external login resp
type UserExternalLoginResp struct {
BindingKey string `json:"binding_key"`
AccessToken string `json:"access_token"`
}
// 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
}
// ExternalLoginUnbindingReq external login unbinding user
type ExternalLoginUnbindingReq struct {
ExternalID string `validate:"required,gt=0,lte=128" json:"external_id"`
UserID string `json:"-"`
}

View File

@ -112,6 +112,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 {
@ -128,9 +134,9 @@ 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 ""
@ -265,8 +271,8 @@ func (u *UserRegisterReq) Check() (errFields []*validator.FormErrorField, err er
}
type UserModifyPasswordReq struct {
OldPass string `json:"old_pass"`
Pass string `json:"pass"`
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:"-"`
}

View File

@ -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) {

View File

@ -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))
}

View File

@ -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,6 @@ var ProviderSetService = wire.NewSet(
role.NewRoleService,
role.NewUserRoleRelService,
role.NewRolePowerRelService,
user_external_login.NewUserExternalLoginService,
plugin_common.NewPluginCommonService,
)

View File

@ -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 {

View File

@ -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 {
@ -34,12 +36,20 @@ type UserRepo interface {
// UserCommon user service
type UserCommon struct {
userRepo UserRepo
userRepo UserRepo
userRoleService *role.UserRoleRelService
authService *auth.AuthService
}
func NewUserCommon(userRepo UserRepo) *UserCommon {
func NewUserCommon(
userRepo UserRepo,
userRoleService *role.UserRoleRelService,
authService *auth.AuthService,
) *UserCommon {
return &UserCommon{
userRepo: userRepo,
userRepo: userRepo,
userRoleService: userRoleService,
authService: authService,
}
}
@ -135,9 +145,33 @@ 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) (
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,
}
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
}

View File

@ -0,0 +1,313 @@
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/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, 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.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 {
newMailStatus, err := us.activeUser(ctx, oldUserInfo, externalUserInfo)
if err != nil {
log.Error(err)
}
accessToken, _, err := us.userCommonService.CacheLoginUserInfo(
ctx, oldUserInfo.ID, newMailStatus, oldUserInfo.Status)
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)
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()
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.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)
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)
}

View File

@ -20,6 +20,7 @@ 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/google/uuid"
"github.com/segmentfault/pacman/errors"
@ -31,15 +32,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 +53,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
@ -440,43 +445,38 @@ 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
}

View File

@ -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)
}

23
plugin/base.go Normal file
View File

@ -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)
)

23
plugin/cache.go Normal file
View File

@ -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)
)

74
plugin/config.go Normal file
View File

@ -0,0 +1,74 @@
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"
)
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"`
}
type ConfigFieldOption struct {
Label Translator `json:"label"`
Value string `json:"value"`
}
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)
)

49
plugin/connector.go Normal file
View File

@ -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)
)

12
plugin/filter.go Normal file
View File

@ -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)
)

12
plugin/parser.go Normal file
View File

@ -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)
)

148
plugin/plugin.go Normal file
View File

@ -0,0 +1,148 @@
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))
}
}
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)
}

50
plugin/storage.go Normal file
View File

@ -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)
)

31
script/build_plugin.sh Executable file
View File

@ -0,0 +1,31 @@
#!/bin/bash
plugin_file=./script/plugin_list
if [ ! -f "$plugin_file" ]; then
echo "plugin_list is not exist"
exit 0
fi
num=0
for line in `cat $plugin_file`
do
account=$line
accounts[$num]=$account
((num++))
done
if [ $num -eq 0 ]; then
echo "plugin_list is null"
exit 0
fi
cmd="./answer build "
for repo in ${accounts[@]}
do
echo ${repo}
cmd=$cmd" --with "${repo}
done
$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

1
script/plugin_list Normal file
View File

@ -0,0 +1 @@
github.com/answerdev/plugins/connector/basic@latest

View File

@ -535,6 +535,11 @@ export interface User {
avatar: string;
}
export interface QuestionOperationReq {
id: string;
operation: 'pin' | 'unpin' | 'hide' | 'show';
}
export interface OauthBindEmailReq {
binding_key: string;
email: string;