diff --git a/api/docs.go b/api/docs.go index 9334f0ab..f43862d9 100644 --- a/api/docs.go +++ b/api/docs.go @@ -491,6 +491,22 @@ const docTemplate = `{ "responses": {} } }, + "/configs/captcha": { + "get": { + "description": "Captcha 配置查询", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Config" + ], + "summary": "Captcha 配置查询", + "responses": {} + } + }, "/games/": { "get": { "security": [ @@ -2408,6 +2424,10 @@ const docTemplate = `{ "description": "The user's password. Crypt.", "type": "string" }, + "remote_ip": { + "description": "The user's remote ip.", + "type": "string" + }, "teams": { "description": "The user's teams.", "type": "array", diff --git a/api/swagger.json b/api/swagger.json index dd976859..fc0e3f7e 100644 --- a/api/swagger.json +++ b/api/swagger.json @@ -482,6 +482,22 @@ "responses": {} } }, + "/configs/captcha": { + "get": { + "description": "Captcha 配置查询", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Config" + ], + "summary": "Captcha 配置查询", + "responses": {} + } + }, "/games/": { "get": { "security": [ @@ -2399,6 +2415,10 @@ "description": "The user's password. Crypt.", "type": "string" }, + "remote_ip": { + "description": "The user's remote ip.", + "type": "string" + }, "teams": { "description": "The user's teams.", "type": "array", diff --git a/api/swagger.yaml b/api/swagger.yaml index a63c233c..42dedadc 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -381,6 +381,9 @@ definitions: password: description: The user's password. Crypt. type: string + remote_ip: + description: The user's remote ip. + type: string teams: description: The user's teams. items: @@ -1082,6 +1085,17 @@ paths: summary: 更新配置 tags: - Config + /configs/captcha: + get: + consumes: + - application/json + description: Captcha 配置查询 + produces: + - application/json + responses: {} + summary: Captcha 配置查询 + tags: + - Config /games/: get: consumes: diff --git a/internal/app/config/application.go b/internal/app/config/application.go index a11ada66..254a5930 100644 --- a/internal/app/config/application.go +++ b/internal/app/config/application.go @@ -54,7 +54,7 @@ type ApplicationCfg struct { SiteKey string `yaml:"site_key" json:"site_key" mapstructure:"site_key"` SecretKey string `yaml:"secret_key" json:"secret_key" mapstructure:"secret_key"` Threshold float64 `yaml:"threshold" json:"threshold" mapstructure:"threshold"` - } `yaml:"re_captcha" json:"re_captcha" mapstructure:"re_captcha"` + } `yaml:"recaptcha" json:"recaptcha" mapstructure:"recaptcha"` Turnstile struct { URL string `yaml:"url" json:"url" mapstructure:"url"` SiteKey string `yaml:"site_key" json:"site_key" mapstructure:"site_key"` diff --git a/internal/controller/config.go b/internal/controller/config.go index f172b132..b2cb93ae 100644 --- a/internal/controller/config.go +++ b/internal/controller/config.go @@ -1,6 +1,7 @@ package controller import ( + "github.com/elabosak233/cloudsdale/internal/app/config" "github.com/elabosak233/cloudsdale/internal/model/request" "github.com/elabosak233/cloudsdale/internal/service" "github.com/elabosak233/cloudsdale/internal/utils/validator" @@ -11,6 +12,7 @@ import ( type IConfigController interface { Find(ctx *gin.Context) Update(ctx *gin.Context) + FindCaptcha(ctx *gin.Context) } type ConfigController struct { @@ -33,7 +35,7 @@ func NewConfigController(appService *service.Service) IConfigController { func (c *ConfigController) Find(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{ "code": http.StatusOK, - "data": c.configService.FindAll(), + "data": *(config.PltCfg()), }) } @@ -68,3 +70,26 @@ func (c *ConfigController) Update(ctx *gin.Context) { }) } } + +// FindCaptcha +// @Summary Captcha 配置查询 +// @Description Captcha 配置查询 +// @Tags Config +// @Accept json +// @Produce json +// @Router /configs/captcha [get] +func (c *ConfigController) FindCaptcha(ctx *gin.Context) { + ctx.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "data": map[string]any{ + "enabled": config.AppCfg().Captcha.Enabled, + "provider": config.AppCfg().Captcha.Provider, + "turnstile": map[string]any{ + "site_key": config.AppCfg().Captcha.Turnstile.SiteKey, + }, + "recaptcha": map[string]any{ + "site_key": config.AppCfg().Captcha.ReCaptcha.SiteKey, + }, + }, + }) +} diff --git a/internal/controller/user.go b/internal/controller/user.go index 59ace780..75b7505c 100644 --- a/internal/controller/user.go +++ b/internal/controller/user.go @@ -70,6 +70,10 @@ func (c *UserController) Login(ctx *gin.Context) { zap.L().Warn(fmt.Sprintf("User %s login failed", user.Username), zap.Uint("user_id", user.ID)) return } + _ = c.userService.Update(request.UserUpdateRequest{ + ID: user.ID, + RemoteIP: ctx.RemoteIP(), + }) tokenString, err := c.userService.GetJwtTokenByID(user) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{ diff --git a/internal/extension/casbin/policy.go b/internal/extension/casbin/policy.go index 81a10c30..e2e78136 100644 --- a/internal/extension/casbin/policy.go +++ b/internal/extension/casbin/policy.go @@ -43,7 +43,7 @@ func initDefaultPolicy() { {"user", "/api/pods/{id}", "DELETE"}, {"guest", "/api/", "GET"}, - {"guest", "/api/configs/", "GET"}, + {"guest", "/api/configs/*", "GET"}, {"guest", "/api/categories/", "GET"}, {"guest", "/api/users/", "GET"}, {"guest", "/api/users/register", "POST"}, diff --git a/internal/middleware/frontend.go b/internal/middleware/frontend.go index 945b5d88..314372b0 100644 --- a/internal/middleware/frontend.go +++ b/internal/middleware/frontend.go @@ -1,6 +1,8 @@ package middleware import ( + "github.com/elabosak233/cloudsdale/internal/app/config" + "github.com/elabosak233/cloudsdale/internal/utils" "github.com/gin-gonic/gin" "net/http" "os" @@ -8,9 +10,26 @@ import ( "strings" ) +func index(ctx *gin.Context) { + filePath := filepath.Join(utils.FrontendPath, "index.html") + indexContent, err := os.ReadFile(filePath) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "msg": "Error reading index.html", + }) + ctx.Abort() + return + } + indexContentStr := string(indexContent) + indexContentStr = strings.ReplaceAll(indexContentStr, "{{ Cloudsdale.Title }}", config.PltCfg().Site.Title) + ctx.Header("Content-Type", "text/html; charset=utf-8") + ctx.String(http.StatusOK, indexContentStr) + ctx.Abort() +} + func Frontend(urlPrefix string) gin.HandlerFunc { - root := "./dist" - fileServer := http.FileServer(http.Dir(root)) + fileServer := http.FileServer(http.Dir(utils.FrontendPath)) if !strings.HasSuffix(urlPrefix, "/") { urlPrefix = urlPrefix + "/" } @@ -20,14 +39,17 @@ func Frontend(urlPrefix string) gin.HandlerFunc { ctx.Next() } else { ctx.Set("skip_logging", true) - filePath := filepath.Join(root, ctx.Request.URL.Path) + filePath := filepath.Join(utils.FrontendPath, ctx.Request.URL.Path) _, err := os.Stat(filePath) if err == nil { - http.StripPrefix(staticServerPrefix, fileServer).ServeHTTP(ctx.Writer, ctx.Request) - ctx.Abort() + if ctx.Request.URL.Path == "/" || ctx.Request.URL.Path == "/index.html" { + index(ctx) + } else { + http.StripPrefix(staticServerPrefix, fileServer).ServeHTTP(ctx.Writer, ctx.Request) + ctx.Abort() + } } else if os.IsNotExist(err) { - http.ServeFile(ctx.Writer, ctx.Request, filepath.Join(root, "index.html")) - ctx.Abort() + index(ctx) } else { ctx.Next() } diff --git a/internal/model/request/user_request.go b/internal/model/request/user_request.go index c6957bbe..2475210b 100644 --- a/internal/model/request/user_request.go +++ b/internal/model/request/user_request.go @@ -41,6 +41,7 @@ type UserUpdateRequest struct { Password string `binding:"omitempty,min=6" json:"password,omitempty"` Email string `binding:"omitempty,email" json:"email,omitempty"` Group string `json:"group"` + RemoteIP string `json:"-"` } type UserDeleteRequest struct { diff --git a/internal/model/user.go b/internal/model/user.go index 66307609..5bd18303 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -17,6 +17,7 @@ type User struct { Avatar *File `gorm:"-" json:"avatar"` // The user's avatar. Group string `gorm:"column:group;varchar(16);not null;" json:"group,omitempty"` // The user's group. Password string `gorm:"column:password;type:varchar(255);not null" json:"password,omitempty"` // The user's password. Crypt. + RemoteIP string `gorm:"column:remote_ip;type:varchar(32)" json:"remote_ip,omitempty"` // The user's remote ip. CreatedAt int64 `gorm:"autoUpdateTime:milli" json:"created_at,omitempty"` // The user's creation time. UpdatedAt int64 `gorm:"autoUpdateTime:milli" json:"updated_at,omitempty"` // The user's last update time. Teams []*Team `gorm:"many2many:user_teams;" json:"teams,omitempty"` // The user's teams. diff --git a/internal/router/config.go b/internal/router/config.go index 2636d806..d5451814 100644 --- a/internal/router/config.go +++ b/internal/router/config.go @@ -24,4 +24,5 @@ func NewConfigRouter(configRouter *gin.RouterGroup, configController controller. func (c *ConfigRouter) Register() { c.router.GET("/", c.controller.Find) c.router.PUT("/", c.controller.Update) + c.router.GET("/captcha", c.controller.FindCaptcha) } diff --git a/internal/service/config.go b/internal/service/config.go index 2d988db5..cda13fd3 100644 --- a/internal/service/config.go +++ b/internal/service/config.go @@ -7,7 +7,6 @@ import ( ) type IConfigService interface { - FindAll() (cfg config.PlatformCfg) Update(req request.ConfigUpdateRequest) (err error) } @@ -18,10 +17,6 @@ func NewConfigService(appRepository *repository.Repository) IConfigService { return &ConfigService{} } -func (c *ConfigService) FindAll() (cfg config.PlatformCfg) { - return *(config.PltCfg()) -} - func (c *ConfigService) Update(req request.ConfigUpdateRequest) (err error) { config.PltCfg().Site.Title = req.Site.Title config.PltCfg().Site.Description = req.Site.Description diff --git a/internal/utils/const.go b/internal/utils/const.go index dbc5e2d9..8da27a26 100644 --- a/internal/utils/const.go +++ b/internal/utils/const.go @@ -12,4 +12,5 @@ const ( MediaPath = "./media" FilesPath = "./files" CapturesPath = "./captures" + FrontendPath = "./dist" ) diff --git a/web/index.html b/web/index.html index d6e3e903..9e453ecd 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ - Cloudsdale + {{ Cloudsdale.Title }}
diff --git a/web/package.json b/web/package.json index 2b77243b..0beda063 100644 --- a/web/package.json +++ b/web/package.json @@ -36,7 +36,9 @@ "prismjs": "^1.29.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-google-recaptcha": "^3.1.0", "react-router-dom": "^6.23.1", + "react-turnstile": "^1.1.3", "sass": "^1.77.1", "vite-plugin-pages": "^0.32.1", "zod": "^3.23.8", @@ -48,6 +50,7 @@ "@types/prismjs": "^1.26.4", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", + "@types/react-google-recaptcha": "^2.1.9", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react": "^4.2.1", diff --git a/web/src/App.tsx b/web/src/App.tsx index 0899393a..e8c09dfd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -34,6 +34,14 @@ function App() { }); }, [configStore.refresh]); + // Get captcha config + useEffect(() => { + configApi.getCaptchaCfg().then((res) => { + const r = res.data; + configStore.setCaptchaCfg(r.data); + }); + }, []); + // Get exists categories useEffect(() => { categoryApi.getCategories().then((res) => { diff --git a/web/src/api/config.ts b/web/src/api/config.ts index 1d7ce172..4fb4649e 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -12,8 +12,13 @@ export function useConfigApi() { return auth.put("/configs/", request); }; + const getCaptchaCfg = () => { + return auth.get("/configs/captcha"); + }; + return { getPltCfg, updatePltCfg, + getCaptchaCfg, }; } diff --git a/web/src/api/user.ts b/web/src/api/user.ts index ba58aa26..03c29ee5 100644 --- a/web/src/api/user.ts +++ b/web/src/api/user.ts @@ -2,6 +2,8 @@ import { UserCreateRequest, UserDeleteRequest, UserFindRequest, + UserLoginRequest, + UserRegisterRequest, UserUpdateRequest, } from "@/types/user"; import { useApi, useAuth } from "@/utils/axios"; @@ -11,25 +13,12 @@ export function useUserApi() { const api = useApi(); const auth = useAuth(); - const login = (username: string, password: string) => { - return api.post("/users/login", { - username, - password, - }); + const login = (request: UserLoginRequest) => { + return api.post("/users/login", request); }; - const register = ( - username: string, - nickname: string, - email: string, - password: string - ) => { - return api.post("/users/register", { - username, - nickname, - email, - password, - }); + const register = (request: UserRegisterRequest) => { + return api.post("/users/register", request); }; const getUsers = (request: UserFindRequest) => { diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index 273541be..e54f6ad2 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -25,7 +25,7 @@ export default function Page() { const [loginLoading, setLoginLoading] = useState(false); const form = useForm({ - mode: "uncontrolled", + mode: "controlled", initialValues: { username: "", password: "", @@ -47,10 +47,13 @@ export default function Page() { }, }); - function login(username: string, password: string) { + function login() { setLoginLoading(true); userApi - .login(username, password) + .login({ + username: form.getValues().username, + password: form.getValues().password, + }) .then((res) => { const r = res.data; authStore.setPgsToken(r.token as string); @@ -100,11 +103,7 @@ export default function Page() { marginTop: "2rem", }} > -
- login(values.username, values.password) - )} - > + login())}> { + document.title = `注册 - ${configStore?.pltCfg?.site?.title}`; + }, []); + + const [registerLoading, setRegisterLoading] = useState(false); + + const form = useForm({ + mode: "controlled", + initialValues: { + username: "", + nickname: "", + password: "", + email: "", + token: "", + }, + + validate: { + username: (value) => { + if (value === "") { + return "用户名不能为空"; + } + return null; + }, + password: (value) => { + if (value === "") { + return "密码不能为空"; + } + return null; + }, + }, + }); + + function register() { + if (configStore?.captchaCfg?.enabled && !form.getValues().token) { + showErrNotification({ + title: "注册失败", + message: "请完成验证码验证", + }); + return; + } + setRegisterLoading(true); + userApi + .register({ + username: form.getValues().username, + nickname: form.getValues().nickname, + password: form.getValues().password, + email: form.getValues().email, + token: form.getValues().token, + }) + .then((res) => { + const r = res.data; + authStore.setPgsToken(r.token as string); + authStore.setUser(r.data as User); + showSuccessNotification({ + title: "注册成功", + message: "请登录", + }); + navigate("/login"); + }) + .catch((err) => { + switch (err.response?.status) { + case 400: + showErrNotification({ + title: "注册失败", + message: "用户名或邮箱已被注册", + }); + break; + } + }) + .finally(() => { + setRegisterLoading(false); + }); + } + + return ( + <> + + + register())}> + + + person} + key={form.key("username")} + {...form.getInputProps("username")} + /> + person} + key={form.key("nickname")} + {...form.getInputProps("nickname")} + /> + + email} + key={form.key("email")} + {...form.getInputProps("email")} + /> + lock} + key={form.key("password")} + {...form.getInputProps("password")} + /> + + {configStore?.captchaCfg?.enabled && ( + <> + {configStore?.captchaCfg?.provider === + "turnstile" && ( + { + form.setValues({ + ...form.values, + token: token, + }); + }} + /> + )} + {configStore?.captchaCfg?.provider === + "recaptcha" && ( + { + form.setValues({ + ...form.values, + token: String(token), + }); + }} + /> + )} + + )} + + + + + + 已有帐号? + navigate("/login")} + sx={{ + fontStyle: "italic", + ":hover": { + cursor: "pointer", + }, + }} + > + 登录 + + + + + + ); +} diff --git a/web/src/stores/config.ts b/web/src/stores/config.ts index ed8773f6..1eadda19 100644 --- a/web/src/stores/config.ts +++ b/web/src/stores/config.ts @@ -1,10 +1,12 @@ -import { Config } from "@/types/config"; +import { CaptchaConfig, Config } from "@/types/config"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; interface ConfigState { pltCfg: Config; setPltCfg: (pltCfg: Config) => void; + captchaCfg: CaptchaConfig; + setCaptchaCfg: (captchaCfg: CaptchaConfig) => void; refresh: number; setRefresh: (refresh: number) => void; } @@ -21,6 +23,17 @@ export const useConfigStore = create()( setPltCfg: (pltCfg) => set({ pltCfg }), refresh: 0, setRefresh: (refresh) => set({ refresh }), + captchaCfg: { + enabled: false, + provider: "turnstile", + turnstile: { + site_key: "", + }, + recaptcha: { + site_key: "", + }, + }, + setCaptchaCfg: (captchaCfg) => set({ captchaCfg }), }), { name: "config_storage", diff --git a/web/src/types/config.ts b/web/src/types/config.ts index feaa3407..e771261c 100644 --- a/web/src/types/config.ts +++ b/web/src/types/config.ts @@ -37,3 +37,14 @@ export interface ConfigUpdateRequest { }; }; } + +export interface CaptchaConfig { + enabled?: boolean; + provider?: string; + turnstile?: { + site_key?: string; + }; + recaptcha?: { + site_key?: string; + }; +} diff --git a/web/src/types/user.ts b/web/src/types/user.ts index a4c5567e..a4f3710b 100644 --- a/web/src/types/user.ts +++ b/web/src/types/user.ts @@ -51,3 +51,11 @@ export interface UserLoginRequest { username: string; password: string; } + +export interface UserRegisterRequest { + username: string; + nickname: string; + email: string; + password: string; + token?: string; +}