diff --git a/internal/captcha/pages/geetest.html b/internal/captcha/pages/geetest.html index 3592e4f0a..a8fb4575b 100644 --- a/internal/captcha/pages/geetest.html +++ b/internal/captcha/pages/geetest.html @@ -29,21 +29,20 @@ gt.appendTo("#captcha").onSuccess(function (e) { var result = gt.getValidate(); - var form = new FormData() - form.append("value", JSON.stringify(result)) console.log("[极验验证结果] ", result) - fetch("./check", { + fetch("./verify", { method: 'POST', - body: form, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ value: JSON.stringify(result) }), }) - .then(function (resp) { - return resp.json() - }) - .then(function (json) { - if (json.success) { - console.log("验证成功:" + json) - } else { - alert("验证失败:" + json.msg || '') + .then(function (res) { + if (!res.ok) { + res.json().then(json => { + alert("验证失败:" + res.status + " " + json.msg || '') + }) } }) .catch(function (err) { diff --git a/internal/captcha/pages/hcaptcha.html b/internal/captcha/pages/hcaptcha.html index cf3d6c38a..dd652c70c 100644 --- a/internal/captcha/pages/hcaptcha.html +++ b/internal/captcha/pages/hcaptcha.html @@ -20,20 +20,19 @@ hcaptcha.render("container", { sitekey: "{{.site_key}}", callback: function(token) { - var form = new FormData() - form.append("value", token) - fetch("./check", { + fetch("./verify", { method: 'POST', - body: form, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ value: token }), }) - .then(function (resp) { - return resp.json() - }) - .then(function (json) { - if (json.success) { - console.log("验证成功:" + json) - } else { - alert("验证失败:" + json.msg || '') + .then(function (res) { + if (!res.ok) { + res.json().then(json => { + alert("验证失败:" + res.status + " " + json.msg || '') + }) } }) .catch(function (err) { diff --git a/internal/captcha/pages/recaptcha.html b/internal/captcha/pages/recaptcha.html index d539967fe..bb5715b15 100644 --- a/internal/captcha/pages/recaptcha.html +++ b/internal/captcha/pages/recaptcha.html @@ -18,20 +18,19 @@ grecaptcha.render("container", { sitekey: "{{.site_key}}", callback: function(token) { - var form = new FormData() - form.append("value", token) - fetch("./check", { + fetch("./verify", { method: 'POST', - body: form, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ value: token }), }) - .then(function (resp) { - return resp.json() - }) - .then(function (json) { - if (json.success) { - console.log("验证成功:" + json) - } else { - alert("验证失败:" + json.msg || '') + .then(function (res) { + if (!res.ok) { + res.json().then(json => { + alert("验证失败:" + res.status + " " + json.msg || '') + }) } }) .catch(function (err) { diff --git a/internal/captcha/pages/turnstile.html b/internal/captcha/pages/turnstile.html index 229b25d77..a91883175 100644 --- a/internal/captcha/pages/turnstile.html +++ b/internal/captcha/pages/turnstile.html @@ -18,20 +18,19 @@ const turnstileOptions = { sitekey: '{{.site_key}}', callback: function(token) { - var form = new FormData() - form.append("value", token) - fetch("./check", { + fetch("./verify", { method: 'POST', - body: form, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ value: token }), }) - .then(function (resp) { - return resp.json() - }) - .then(function (json) { - if (json.success) { - console.log("验证成功:" + json) - } else { - alert("验证失败:" + json.msg || '') + .then(function (res) { + if (!res.ok) { + res.json().then(json => { + alert("验证失败:" + res.status + " " + json.msg || '') + }) } }) .catch(function (err) { diff --git a/server/common/captcha.go b/server/common/captcha.go index b47efe31f..e2ca2124a 100644 --- a/server/common/captcha.go +++ b/server/common/captcha.go @@ -5,13 +5,15 @@ import ( "github.com/ArtalkJS/Artalk/internal/captcha" "github.com/ArtalkJS/Artalk/internal/core" + "github.com/ArtalkJS/Artalk/internal/i18n" + "github.com/ArtalkJS/Artalk/server/middleware/limiter" "github.com/gofiber/fiber/v2" ) -func GetLimiter[T any](c *fiber.Ctx) (lmt *T, error error) { - l := c.Locals("limiter").(*T) - if l == nil { - return nil, RespError(c, 500, "limiter is not initialize") +func GetLimiter(c *fiber.Ctx) (lmt *limiter.Limiter, err error) { + l, ok := c.Locals("limiter").(*limiter.Limiter) + if l == nil || !ok { + return nil, RespError(c, 500, "limiter is not initialize, but middleware is used") } return l, nil } @@ -25,3 +27,55 @@ func NewCaptchaChecker(app *core.App, c *fiber.Ctx) captcha.Checker { }, }) } + +func LimiterGuard(app *core.App, handler fiber.Handler) fiber.Handler { + return func(c *fiber.Ctx) error { + limiter, err := GetLimiter(c) + if err != nil { + return err + } + + // 关闭验证码功能,直接 Skip + if !app.Conf().Captcha.Enabled { + return handler(c) + } + + // 管理员直接忽略 + if CheckIsAdminReq(app, c) { + return handler(c) + } + + // 检测是否需要验证码 + ip := c.IP() + if limiter.IsPass(ip) { + // 无需验证码 + err := handler(c) + + if c.Method() != fiber.MethodOptions { // 忽略 Options 请求 + limiter.Log(ip) // 记录操作 + } + + return err + } else { + // create new captcha checker instance + cap := NewCaptchaChecker(app, c) + + // response need captcha check + respData := Map{ + "need_captcha": true, + } + + switch cap.Type() { + case captcha.Image: + // 图片验证码 + img, _ := cap.Get() + respData["img_data"] = string(img) + case captcha.IFrame: + // iFrame 验证模式 + respData["iframe"] = true + } + + return RespError(c, 403, i18n.T("Captcha required"), respData) + } + } +} diff --git a/server/handler/captcha_status.go b/server/handler/captcha_status.go index 7686993cb..9bc414a0b 100644 --- a/server/handler/captcha_status.go +++ b/server/handler/captcha_status.go @@ -2,7 +2,6 @@ package handler import ( "github.com/ArtalkJS/Artalk/internal/core" - "github.com/ArtalkJS/Artalk/internal/limiter" "github.com/ArtalkJS/Artalk/server/common" "github.com/gofiber/fiber/v2" ) @@ -20,8 +19,8 @@ type ResponseCaptchaStatus struct { // @Router /captcha/status [get] func CaptchaStatus(app *core.App, router fiber.Router) { router.Get("/captcha/status", func(c *fiber.Ctx) error { - limiter, err := common.GetLimiter[limiter.Limiter](c) - if limiter == nil { + limiter, err := common.GetLimiter(c) + if err != nil { return err } diff --git a/server/handler/captcha_verify.go b/server/handler/captcha_verify.go index 80d6a4b66..b34bdf791 100644 --- a/server/handler/captcha_verify.go +++ b/server/handler/captcha_verify.go @@ -4,7 +4,6 @@ import ( "github.com/ArtalkJS/Artalk/internal/captcha" "github.com/ArtalkJS/Artalk/internal/core" "github.com/ArtalkJS/Artalk/internal/i18n" - "github.com/ArtalkJS/Artalk/internal/limiter" "github.com/ArtalkJS/Artalk/internal/log" "github.com/ArtalkJS/Artalk/server/common" "github.com/gofiber/fiber/v2" @@ -31,8 +30,8 @@ func CaptchaVerify(app *core.App, router fiber.Router) { return resp } - limiter, err := common.GetLimiter[limiter.Limiter](c) - if limiter == nil { + limiter, err := common.GetLimiter(c) + if err != nil { return err } diff --git a/server/handler/comment_create.go b/server/handler/comment_create.go index 016555529..98c4ed88e 100644 --- a/server/handler/comment_create.go +++ b/server/handler/comment_create.go @@ -43,7 +43,7 @@ type ResponseCommentCreate struct { // @Produce json // @Router /comments [post] func CommentCreate(app *core.App, router fiber.Router) { - router.Post("/comments", func(c *fiber.Ctx) error { + router.Post("/comments", common.LimiterGuard(app, func(c *fiber.Ctx) error { var p ParamsCommentCreate if isOK, resp := common.ParamsDecode(c, &p); !isOK { return resp @@ -192,7 +192,7 @@ func CommentCreate(app *core.App, router fiber.Router) { return common.RespData(c, ResponseCommentCreate{ CookedComment: cookedComment, }) - }) + })) } func isAllowComment(app *core.App, c *fiber.Ctx, name string, email string, page entity.Page) (bool, error) { diff --git a/server/handler/upload.go b/server/handler/upload.go index e8e4a5560..b606c9b19 100644 --- a/server/handler/upload.go +++ b/server/handler/upload.go @@ -43,7 +43,7 @@ type ResponseUpload struct { // @Failure 500 {object} Map{msg=string} // @Router /upload [post] func Upload(app *core.App, router fiber.Router) { - router.Post("/upload", func(c *fiber.Ctx) error { + router.Post("/upload", common.LimiterGuard(app, func(c *fiber.Ctx) error { // 功能开关 (管理员始终开启) if !app.Conf().ImgUpload.Enabled && !common.CheckIsAdminReq(app, c) { return common.RespError(c, 403, i18n.T("Image upload forbidden"), common.Map{ @@ -196,7 +196,7 @@ func Upload(app *core.App, router fiber.Router) { FileName: filename, PublicURL: imgURL, }) - }) + })) } // 调用 upgit 上传图片获得 URL diff --git a/server/handler/user_login.go b/server/handler/user_login.go index 3ea5d9689..8f3cde4fc 100644 --- a/server/handler/user_login.go +++ b/server/handler/user_login.go @@ -38,7 +38,7 @@ type ResponseUserLogin struct { // @Failure 500 {object} Map{msg=string} // @Router /user/access_token [post] func UserLogin(app *core.App, router fiber.Router) { - router.Post("/user/access_token", func(c *fiber.Ctx) error { + router.Post("/user/access_token", common.LimiterGuard(app, func(c *fiber.Ctx) error { var p ParamsUserLogin if isOK, resp := common.ParamsDecode(c, &p); !isOK { return resp @@ -112,5 +112,5 @@ func UserLogin(app *core.App, router fiber.Router) { Token: jwtToken, User: app.Dao().CookUser(&user), }) - }) + })) } diff --git a/server/handler/vote.go b/server/handler/vote.go index f49d05c12..27f7ad1d3 100644 --- a/server/handler/vote.go +++ b/server/handler/vote.go @@ -35,7 +35,7 @@ type ResponseVote struct { // @Failure 500 {object} Map{msg=string} // @Router /votes/{type}/{target_id} [post] func Vote(app *core.App, router fiber.Router) { - router.Post("/votes/:type/:target_id", func(c *fiber.Ctx) error { + router.Post("/votes/:type/:target_id", common.LimiterGuard(app, func(c *fiber.Ctx) error { rawType := c.Params("type") targetID, _ := c.ParamsInt("target_id") @@ -135,5 +135,5 @@ func Vote(app *core.App, router fiber.Router) { Up: up, Down: down, }) - }) + })) } diff --git a/server/middleware/admin.go b/server/middleware/admin.go deleted file mode 100644 index b66ea4285..000000000 --- a/server/middleware/admin.go +++ /dev/null @@ -1,18 +0,0 @@ -package middleware - -import ( - "github.com/ArtalkJS/Artalk/internal/core" - "github.com/ArtalkJS/Artalk/internal/i18n" - "github.com/ArtalkJS/Artalk/server/common" - "github.com/gofiber/fiber/v2" -) - -func AdminOnlyMiddleware(app *core.App) fiber.Handler { - return func(c *fiber.Ctx) error { - if !common.CheckIsAdminReq(app, c) { - return common.RespError(c, 403, i18n.T("Admin access required"), common.Map{"need_login": true}) - } - - return c.Next() - } -} diff --git a/server/middleware/limiter/limiter.go b/server/middleware/limiter/limiter.go index cbc71d461..987f3aee6 100644 --- a/server/middleware/limiter/limiter.go +++ b/server/middleware/limiter/limiter.go @@ -1,22 +1,18 @@ package limiter import ( - "path" - - "github.com/ArtalkJS/Artalk/internal/captcha" "github.com/ArtalkJS/Artalk/internal/core" - "github.com/ArtalkJS/Artalk/internal/i18n" "github.com/ArtalkJS/Artalk/internal/limiter" - "github.com/ArtalkJS/Artalk/server/common" "github.com/gofiber/fiber/v2" ) type ActionLimitConf struct { - ProtectPaths [][]string } const LimiterLocalKey = "limiter" +type Limiter = limiter.Limiter + // 操作限制 中间件 func ActionLimitMiddleware(app *core.App, conf ActionLimitConf) fiber.Handler { limiter := limiter.NewLimiter(&limiter.LimiterConf{ @@ -29,63 +25,6 @@ func ActionLimitMiddleware(app *core.App, conf ActionLimitConf) fiber.Handler { // 任何页面都保存 limiter 实例 c.Locals(LimiterLocalKey, limiter) - // 关闭验证码功能,直接 Skip - if !app.Conf().Captcha.Enabled { - return c.Next() - } - - // 路径跳过 - if !isProtectPath(c.Method(), c.Path(), conf.ProtectPaths) { - return c.Next() - } - - // 管理员直接忽略 - if common.CheckIsAdminReq(app, c) { - return c.Next() - } - - // 检测是否需要验证码 - ip := c.IP() - if limiter.IsPass(ip) { - // 无需验证码 - err := c.Next() - - if c.Method() != fiber.MethodOptions { // 忽略 Options 请求 - limiter.Log(ip) // 记录操作 - } - - return err - } else { - // create new captcha checker instance - cap := common.NewCaptchaChecker(app, c) - - // response need captcha check - respData := common.Map{ - "need_captcha": true, - } - - switch cap.Type() { - case captcha.Image: - // 图片验证码 - img, _ := cap.Get() - respData["img_data"] = string(img) - case captcha.IFrame: - // iFrame 验证模式 - respData["iframe"] = true - // 前端新版不会再用到 img_data,向旧版响应 ArtalkFrontend out-of-date 图片 - respData["img_data"] = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 40'%3E%3Cdefs%3E%3Cstyle%3E.a%7Bfill:%23328ce6%3B%7D.b%7Bfont-size:12px%3Bfill:%23fff%3Bfont-family:sans-serif%3B%7D%3C/style%3E%3C/defs%3E%3Crect class='a' width='160' height='40'/%3E%3Ctext class='b' transform='translate(18.37 16.67)'%3EArtalk Frontend%3Ctspan x='0' y='14.4'%3EOut-Of-Date.%3C/tspan%3E%3C/text%3E%3C/svg%3E" - } - - return common.RespError(c, 403, i18n.T("Captcha required"), respData) - } - } -} - -func isProtectPath(targetMethod string, targetPath string, protectPaths [][]string) bool { - for _, p := range protectPaths { - if targetMethod == p[0] && path.Clean(targetPath) == path.Clean(p[1]) { - return true - } + return c.Next() } - return false } diff --git a/server/server.go b/server/server.go index 8a0e6865d..08f501e30 100644 --- a/server/server.go +++ b/server/server.go @@ -153,15 +153,7 @@ func cors(app *core.App, f fiber.Router) { } func actionLimit(app *core.App, f fiber.Router) { - f.Use(limiter.ActionLimitMiddleware(app, limiter.ActionLimitConf{ - // 保护的路径 - ProtectPaths: [][]string{ - {fiber.MethodPost, "/api/v2/comments"}, - {fiber.MethodPost, "/api/v2/access_token"}, - {fiber.MethodPost, "/api/v2/votes"}, - {fiber.MethodPost, "/api/v2/upload"}, - }, - })) + f.Use(limiter.ActionLimitMiddleware(app, limiter.ActionLimitConf{})) } func static(f fiber.Router) { diff --git a/ui/artalk/src/api/fetch.ts b/ui/artalk/src/api/fetch.ts index 44f7153ca..23b42aef4 100644 --- a/ui/artalk/src/api/fetch.ts +++ b/ui/artalk/src/api/fetch.ts @@ -27,7 +27,7 @@ export const Fetch = async (opts: ApiOptions, input: string | URL | Request, ini if (json.need_captcha) { // 请求需要验证码 opts.onNeedCheckCaptcha && await opts.onNeedCheckCaptcha({ - data: { imgData: json.data.img_data, iframe: json.data.iframe } + data: { imgData: json.img_data, iframe: json.iframe } }) return Fetch(opts, input, init) // retry } diff --git a/ui/artalk/src/api/options.ts b/ui/artalk/src/api/options.ts index 69a5419f6..cee57334b 100644 --- a/ui/artalk/src/api/options.ts +++ b/ui/artalk/src/api/options.ts @@ -16,8 +16,8 @@ export interface ApiOptions { onNeedCheckCaptcha?: (payload: { data: { - imgData: string - iframe: string + imgData?: string + iframe?: string } }) => Promise diff --git a/ui/artalk/src/layer/sidebar-layer.ts b/ui/artalk/src/layer/sidebar-layer.ts index 5d86ad5bc..0b6d3f04c 100644 --- a/ui/artalk/src/layer/sidebar-layer.ts +++ b/ui/artalk/src/layer/sidebar-layer.ts @@ -136,7 +136,6 @@ export default class SidebarLayer extends Component { if (view) query.view = view if (this.conf.darkMode) query.darkMode = '1' - if (typeof this.conf.locale === 'string') query.locale = this.conf.locale const urlParams = new URLSearchParams(query); this.iframeLoad($iframe, `${baseURL}?${urlParams.toString()}`) diff --git a/ui/artalk/src/lib/component.ts b/ui/artalk/src/lib/component.ts index fe1cc1cac..167ed2631 100644 --- a/ui/artalk/src/lib/component.ts +++ b/ui/artalk/src/lib/component.ts @@ -1,12 +1,12 @@ -import type { ArtalkConfig, ContextApi } from '@/types' +import type { ContextApi } from '@/types' export default abstract class Component { public $el!: HTMLElement - public readonly conf: ArtalkConfig + public get conf() { + return this.ctx.conf + } public constructor( public ctx: ContextApi - ) { - this.conf = ctx.conf - } + ) {} } diff --git a/ui/artalk/src/lib/user.ts b/ui/artalk/src/lib/user.ts index fb4e0c1fd..bb173b2aa 100644 --- a/ui/artalk/src/lib/user.ts +++ b/ui/artalk/src/lib/user.ts @@ -12,8 +12,10 @@ class User { constructor( private opts: UserOpts ) { - // 从 localStorage 导入 + // Import from localStorage const localUser = JSON.parse(window.localStorage.getItem(LOCAL_USER_KEY) || '{}') + + // Initialize this.data = { nick: localUser.nick || '', email: localUser.email || '', @@ -27,7 +29,7 @@ class User { return this.data } - /** 保存用户到 localStorage 中 */ + /** Update user data and save to localStorage */ update(obj: Partial = {}) { Object.entries(obj).forEach(([key, value]) => { this.data[key] = value @@ -49,7 +51,7 @@ class User { }) } - /** 是否已填写基本用户信息 */ + /** Check if user has filled basic data */ checkHasBasicUserInfo() { return !!this.data.nick && !!this.data.email } diff --git a/ui/artalk/src/plugins/list/fetch.ts b/ui/artalk/src/plugins/list/fetch.ts index 41503c29e..11c5d5a58 100644 --- a/ui/artalk/src/plugins/list/fetch.ts +++ b/ui/artalk/src/plugins/list/fetch.ts @@ -35,6 +35,7 @@ export const Fetch: ArtalkPlugin = (ctx) => { ctx.getApi().comments .getComments({ ...reqParams, + ...ctx.getApi().getUserFields() }) .then(({ data }) => { // Must before all other function call and event trigger, diff --git a/ui/artalk/src/service.ts b/ui/artalk/src/service.ts index bd19ab7bc..1bc6a23c2 100644 --- a/ui/artalk/src/service.ts +++ b/ui/artalk/src/service.ts @@ -46,7 +46,9 @@ const services = { getCtx: () => ctx, getApi: () => ctx.getApi(), onReload: () => ctx.reload(), - getCaptchaIframeURL: () => `${ctx.conf.server}/api/v2/captcha?t=${+new Date()}` + + // make sure suffix with a slash, because it will be used as a base url when call `fetch` + getCaptchaIframeURL: () => `${ctx.conf.server}/api/v2/captcha/?t=${+new Date()}` }) return checkerLauncher },