fix(config): serve public settings and enforce login CAP

Public config now comes from admin as a flat visibility=1 map instead of
a cross-plugin query that compared an integer column to "visible". Login
and register resolve CaptchaService per request so CAP is not skipped
when user applies before cap.
This commit is contained in:
ryan
2026-09-02 17:07:07 +08:00
parent 4f50f6a8f9
commit 455e2f8be5
17 changed files with 289 additions and 94 deletions
+35 -13
View File
@@ -9,6 +9,7 @@ import (
"Wavelet/core/contracts"
"Wavelet/core/extpoints"
"Wavelet/pkg/ginutil"
"context"
"embed"
"reflect"
@@ -113,19 +114,12 @@ func (p *Plugin) Apply(ctx *core.Context) error {
}
core.Provide[contracts.UserService](ctx, p.userSvc)
passThrough := gin.HandlerFunc(func(c *gin.Context) { c.Next() })
loginCap, registerCap, emailCap := passThrough, passThrough, passThrough
if capSvc, err := core.Inject[contracts.CaptchaService](ctx); err == nil && capSvc != nil {
if mw, ok := capSvc.VerifyMiddleware("login").(gin.HandlerFunc); ok {
loginCap = mw
}
if mw, ok := capSvc.VerifyMiddleware("register").(gin.HandlerFunc); ok {
registerCap = mw
}
if mw, ok := capSvc.VerifyMiddleware("send_email_code").(gin.HandlerFunc); ok {
emailCap = mw
}
}
// CAP middleware is resolved per request. user Apply runs before cap in
// the default plugin list; snapshotting CaptchaService here would leave
// login/register as a permanent pass-through.
loginCap := captchaGuard(ctx, "login")
registerCap := captchaGuard(ctx, "register")
emailCap := captchaGuard(ctx, "send_email_code")
// 3. Register HTTP Routes
userGroup := ctx.Router().Group("/api/v1/user")
@@ -182,3 +176,31 @@ func (p *Plugin) Apply(ctx *core.Context) error {
return nil
}
func captchaGuard(appCtx *core.Context, scope string) gin.HandlerFunc {
return func(c *gin.Context) {
svc := resolveCaptchaService(c.Request.Context(), appCtx)
if svc == nil {
c.Next()
return
}
mw, ok := svc.VerifyMiddleware(scope).(gin.HandlerFunc)
if !ok || mw == nil {
c.Next()
return
}
mw(c)
}
}
func resolveCaptchaService(reqCtx context.Context, appCtx *core.Context) contracts.CaptchaService {
if s, err := core.InjectFrom[contracts.CaptchaService](reqCtx); err == nil && s != nil {
return s
}
if appCtx != nil {
if s, err := core.Inject[contracts.CaptchaService](appCtx); err == nil && s != nil {
return s
}
}
return nil
}
@@ -5,6 +5,8 @@ package user_test
import (
"context"
"net/http"
"net/http/httptest"
"reflect"
"testing"
@@ -72,3 +74,77 @@ func TestApplyWithCaptchaServiceWrapsLogin(t *testing.T) {
}
t.Fatal("missing POST /api/v1/user/login")
}
type denyCaptchaService struct{}
func (denyCaptchaService) VerifyMiddleware(string) any {
return gin.HandlerFunc(func(c *gin.Context) {
c.AbortWithStatus(http.StatusUnauthorized)
})
}
func (denyCaptchaService) ChallengeHandler() any { return gin.HandlerFunc(func(c *gin.Context) {}) }
func (denyCaptchaService) RedeemHandler() any { return gin.HandlerFunc(func(c *gin.Context) {}) }
func TestLoginCaptchaGuardResolvesServiceAfterApply(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx := core.NewContext(context.Background())
if err := user.New().Apply(ctx); err != nil {
t.Fatal(err)
}
core.Provide[contracts.CaptchaService](ctx, denyCaptchaService{})
handler := loginCaptchaGuard(t, ctx)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/user/login", nil)
handler(c)
if !c.IsAborted() {
t.Fatal("login captcha guard did not abort after late CaptchaService provide")
}
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestLoginCaptchaGuardPassesWithoutCaptchaService(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx := core.NewContext(context.Background())
if err := user.New().Apply(ctx); err != nil {
t.Fatal(err)
}
handler := loginCaptchaGuard(t, ctx)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/user/login", nil)
handler(c)
if c.IsAborted() {
t.Fatal("login captcha guard aborted without CaptchaService")
}
}
func loginCaptchaGuard(t *testing.T, ctx *core.Context) gin.HandlerFunc {
t.Helper()
for _, rd := range ctx.Router().Routes() {
if rd.Method != "POST" || rd.Path != "/api/v1/user/login" {
continue
}
if len(rd.Handlers) == 0 {
t.Fatal("POST /api/v1/user/login has no handlers")
}
switch h := rd.Handlers[0].(type) {
case gin.HandlerFunc:
return h
case func(*gin.Context):
return h
default:
t.Fatalf("unexpected handler type %T", rd.Handlers[0])
}
}
t.Fatal("missing POST /api/v1/user/login")
return nil
}