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
+5 -3
View File
@@ -5,8 +5,10 @@ package contracts
import "context"
// PublicConfigProvider supplies the payload for GET /api/v1/config/public
// when a downstream plugin replaces Wavelet's default {configs, app} JSON.
// PublicConfigProvider supplies GET /api/v1/config/public.
// The owner of w_system_configs (admin) must provide this. The payload is a
// flat key/value map of visibility=1 rows; the frontend reads keys such as
// cap_login_enabled directly off data.
type PublicConfigProvider interface {
PublicConfig(ctx context.Context) (any, error)
PublicConfig(ctx context.Context) (map[string]string, error)
}
+1 -1
View File
@@ -4538,7 +4538,7 @@ const docTemplate = `{
},
"/api/v1/config/public": {
"get": {
"description": "返回系统配置表中 visibility 为 1 的配置键值集合",
"description": "返回系统配置表中 visibility 为 1 的扁平键值集合(如 cap_login_enabled)",
"consumes": [
"application/json"
],
+1 -1
View File
@@ -4531,7 +4531,7 @@
},
"/api/v1/config/public": {
"get": {
"description": "返回系统配置表中 visibility 为 1 的配置键值集合",
"description": "返回系统配置表中 visibility 为 1 的扁平键值集合(如 cap_login_enabled)",
"consumes": [
"application/json"
],
+1 -1
View File
@@ -4115,7 +4115,7 @@ paths:
get:
consumes:
- application/json
description: 返回系统配置表中 visibility 为 1 的配置键值集合
description: 返回系统配置表中 visibility 为 1 的扁平键值集合(如 cap_login_enabled)
produces:
- application/json
responses:
@@ -16,7 +16,7 @@ import (
// GetPublicConfig 获取公共配置
// @Summary 获取公共配置
// @Description 返回系统配置表中 visibility 为 1 的配置键值集合
// @Description 返回系统配置表中 visibility 为 1 的扁平键值集合(如 cap_login_enabled)
// @Tags config
// @Accept json
// @Produce json
+1
View File
@@ -92,6 +92,7 @@ func (p *Plugin) Apply(ctx *core.Context) error {
core.Bind[contracts.StorageService](ctx, service.SetStorageService)
core.Bind[contracts.RiskControlService](ctx, service.SetRiskControlService)
service.SetEventEmitter(ctx.Events().Emit)
core.Provide[contracts.PublicConfigProvider](ctx, service.PublicConfigAdapter{})
ctx.OnDispose(func() error {
service.ResetServices()
@@ -5,6 +5,7 @@ package admin_test
import (
"Wavelet/core"
"Wavelet/core/contracts"
"Wavelet/plugins/domain/admin"
"context"
"testing"
@@ -40,6 +41,10 @@ func TestAdminPluginUnit(t *testing.T) {
setting, ok := ctx.Settings().Get("admin.system_cleanup_cron")
require.True(t, ok)
assert.Equal(t, "0 4 * * *", setting.Default)
provider, err := core.Inject[contracts.PublicConfigProvider](ctx)
require.NoError(t, err)
require.NotNil(t, provider)
}
func TestAdminMigrationsIncludeTaskExecutionsAndSchedules(t *testing.T) {
@@ -18,6 +18,14 @@ import (
const maskedConfigValue = "******"
// PublicConfigAdapter exposes visibility=1 system configs as PublicConfigProvider.
type PublicConfigAdapter struct{}
// PublicConfig returns the unauthenticated public config map.
func (PublicConfigAdapter) PublicConfig(ctx context.Context) (map[string]string, error) {
return PublicSystemConfigs(ctx)
}
// PublicSystemConfigs returns the key/value map exposed to unauthenticated clients.
func PublicSystemConfigs(ctx context.Context) (map[string]string, error) {
configs, err := repository.ListVisibleSystemConfigs(ctx)
@@ -69,6 +69,51 @@ func setupSystemConfigTest(t *testing.T) (*gorm.DB, func()) {
return sqliteDB, cleanup
}
func TestPublicSystemConfigsExposesVisibleKeys(t *testing.T) {
dbConn, cleanup := setupSystemConfigTest(t)
defer cleanup()
repository.ResetSystemConfigRAMCacheForTest()
ctx := context.Background()
hidden := model.SystemConfig{
Key: "secret_key",
Value: "nope",
Type: "system",
Visibility: model.ConfigVisibilityHidden,
}
visible := model.SystemConfig{
Key: model.ConfigKeyCapLoginEnabled,
Value: "true",
Type: "system",
Visibility: model.ConfigVisibilityVisible,
}
if err := dbConn.Create(&hidden).Error; err != nil {
t.Fatalf("Create(hidden) error = %v", err)
}
if err := dbConn.Create(&visible).Error; err != nil {
t.Fatalf("Create(visible) error = %v", err)
}
got, err := service.PublicSystemConfigs(ctx)
if err != nil {
t.Fatalf("PublicSystemConfigs() error = %v", err)
}
if got[model.ConfigKeyCapLoginEnabled] != "true" {
t.Fatalf("PublicSystemConfigs()[%s] = %q, want %q", model.ConfigKeyCapLoginEnabled, got[model.ConfigKeyCapLoginEnabled], "true")
}
if _, ok := got["secret_key"]; ok {
t.Fatalf("PublicSystemConfigs() leaked hidden key secret_key")
}
viaProvider, err := service.PublicConfigAdapter{}.PublicConfig(ctx)
if err != nil {
t.Fatalf("PublicConfigAdapter.PublicConfig() error = %v", err)
}
if viaProvider[model.ConfigKeyCapLoginEnabled] != "true" {
t.Fatalf("PublicConfigAdapter.PublicConfig()[%s] = %q, want %q", model.ConfigKeyCapLoginEnabled, viaProvider[model.ConfigKeyCapLoginEnabled], "true")
}
}
func TestListSystemConfigsByKeys_EmptyKeys(t *testing.T) {
result, err := repository.ListSystemConfigsByKeys(context.Background(), nil)
if err != nil {
+1
View File
@@ -119,6 +119,7 @@ func (p *Plugin) Apply(ctx *core.Context) error {
"/api/v1/user/login",
"/api/v1/user/register",
"/api/v1/user/send-email-code",
"/api/v1/config/public",
"/api/v1/cap/challenge",
"/api/v1/cap/redeem",
"/api/healthz",
+3 -5
View File
@@ -24,12 +24,10 @@ func setDBService(s contracts.DBService) {
dbSvc = s
}
// getDB resolves a GORM handle, preferring the *core.Context when supplied by callers.
// getDB resolves a GORM handle from the request/app context, then the Bind fallback.
func getDB(ctx context.Context) *gorm.DB {
if c, ok := ctx.(*core.Context); ok && c != nil {
if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil {
return s.DB(ctx)
}
if s, err := core.InjectFrom[contracts.DBService](ctx); err == nil && s != nil {
return s.DB(ctx)
}
dbMu.RLock()
s := dbSvc
+63
View File
@@ -11,16 +11,21 @@ import (
"Wavelet/plugins/domain/auth"
"Wavelet/plugins/domain/message_gateway"
"Wavelet/plugins/domain/risk_control"
"Wavelet/plugins/domain/system"
"Wavelet/plugins/domain/user"
"Wavelet/plugins/infra/cache"
"Wavelet/plugins/infra/logger"
"Wavelet/plugins/infra/storage"
"context"
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
@@ -388,6 +393,64 @@ func TestAdminPlugin(t *testing.T) {
schema, ok := ctx.Settings().Get("admin.system_cleanup_cron")
require.True(t, ok)
assert.Equal(t, "0 4 * * *", schema.Default)
provider, err := core.Inject[contracts.PublicConfigProvider](ctx)
require.NoError(t, err)
require.NotNil(t, provider)
}
func TestPublicConfigExposesVisibleAdminRows(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx := core.NewContext(context.Background())
ctx.Config().SetSource(core.NewMapSource(nil))
require.NoError(t, ctx.Config().Resolve())
testDB := setupTestDB(t)
require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx))
require.NoError(t, cache.New().Apply(ctx))
require.NoError(t, logger.New().Apply(ctx))
require.NoError(t, testDB.Create(&admin.SystemConfig{
Key: "cap_login_enabled",
Value: "true",
Type: "system",
Visibility: 1,
}).Error)
require.NoError(t, admin.New().Apply(ctx))
require.NoError(t, system.New().Apply(ctx))
var handler gin.HandlerFunc
for _, rd := range ctx.Router().Routes() {
if rd.Method != "GET" || rd.Path != "/api/v1/config/public" {
continue
}
require.NotEmpty(t, rd.Handlers)
switch h := rd.Handlers[0].(type) {
case gin.HandlerFunc:
handler = h
case func(*gin.Context):
handler = h
default:
t.Fatalf("unexpected handler type %T", rd.Handlers[0])
}
break
}
require.NotNil(t, handler)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/config/public", nil)
c.Request = c.Request.WithContext(core.WithAppContext(c.Request.Context(), ctx))
handler(c)
var body struct {
Data map[string]string `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "body = %s", w.Body.String())
assert.Equal(t, "true", body.Data["cap_login_enabled"])
_, wrapped := body.Data["configs"]
assert.False(t, wrapped, "payload must be a flat map, got %v", body.Data)
}
func TestAllDomainPluginsCombined(t *testing.T) {
+26 -27
View File
@@ -9,8 +9,8 @@ import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"Wavelet/pkg/response"
"context"
"net/http"
"reflect"
"github.com/gin-gonic/gin"
)
@@ -28,13 +28,6 @@ func (p *Plugin) Name() string {
return "system"
}
// Inject declares required dependencies for the system domain plugin.
func (p *Plugin) Inject() []reflect.Type {
return []reflect.Type{
reflect.TypeFor[contracts.DBService](),
}
}
// Manifest returns the plugin metadata.
func (p *Plugin) Manifest() core.Manifest {
return core.Manifest{
@@ -47,37 +40,31 @@ func (p *Plugin) Manifest() core.Manifest {
// Apply registers system routes.
func (p *Plugin) Apply(ctx *core.Context) error {
appName := ctx.Config().String("app.app_name", "Wavelet")
// 1. Health check
ctx.Router().GET("/api/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
ctx.Router().RegisterWhitelist("/api/healthz")
// 2. Public config
// 2. Public config — owned data comes from PublicConfigProvider (admin).
ctx.Router().GET("/api/v1/config/public", func(c *gin.Context) {
if p, err := core.Inject[contracts.PublicConfigProvider](ctx); err == nil && p != nil {
data, err := p.PublicConfig(c.Request.Context())
if err != nil {
logger.ErrorF(c.Request.Context(), "[System] public config provider failed: %v", err)
response.AbortInternal(c, "public config unavailable")
return
}
c.JSON(http.StatusOK, response.OK(data))
provider := resolvePublicConfigProvider(c.Request.Context(), ctx)
if provider == nil {
c.JSON(http.StatusOK, response.OK(map[string]string{}))
return
}
configs, err := listPublicSystemConfigs(c.Request.Context(), ctx)
data, err := provider.PublicConfig(c.Request.Context())
if err != nil {
logger.ErrorF(c.Request.Context(), "[System] query public system configs failed: %v", err)
logger.ErrorF(c.Request.Context(), "[System] public config provider failed: %v", err)
response.AbortInternal(c, "public config unavailable")
return
}
c.JSON(http.StatusOK, response.OK(gin.H{
"configs": configs,
"app": gin.H{
"name": appName,
},
}))
if data == nil {
data = map[string]string{}
}
c.JSON(http.StatusOK, response.OK(data))
})
ctx.Router().RegisterWhitelist("/api/v1/config/public")
// 3. Custom injection
ctx.Router().GET("/custom", func(c *gin.Context) {
@@ -86,3 +73,15 @@ func (p *Plugin) Apply(ctx *core.Context) error {
return nil
}
func resolvePublicConfigProvider(reqCtx context.Context, appCtx *core.Context) contracts.PublicConfigProvider {
if p, err := core.InjectFrom[contracts.PublicConfigProvider](reqCtx); err == nil && p != nil {
return p
}
if appCtx != nil {
if p, err := core.Inject[contracts.PublicConfigProvider](appCtx); err == nil && p != nil {
return p
}
}
return nil
}
@@ -18,13 +18,17 @@ import (
"github.com/gin-gonic/gin"
)
type stubPublic struct{ payload any }
type stubPublic struct{ payload map[string]string }
func (s stubPublic) PublicConfig(context.Context) (any, error) { return s.payload, nil }
func (s stubPublic) PublicConfig(context.Context) (map[string]string, error) {
return s.payload, nil
}
type errPublic struct{ err error }
func (s errPublic) PublicConfig(context.Context) (any, error) { return nil, s.err }
func (s errPublic) PublicConfig(context.Context) (map[string]string, error) {
return nil, s.err
}
func TestPublicConfigUsesProviderWhenPresent(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -54,16 +58,22 @@ func TestPublicConfigDefaultWithoutProvider(t *testing.T) {
if err := New().Apply(ctx); err != nil {
t.Fatal(err)
}
if !ctx.Router().IsWhitelisted("/api/v1/config/public") {
t.Fatal("GET /api/v1/config/public not whitelisted")
}
raw := invokePublicConfig(t, publicConfigHandler(t, ctx))
var data map[string]any
if err := json.Unmarshal(raw, &data); err != nil {
t.Fatal(err)
}
if _, ok := data["configs"]; !ok {
t.Fatalf("data = %s, want key configs", raw)
if len(data) != 0 {
t.Fatalf("data = %s, want empty flat map", raw)
}
if _, ok := data["app"]; !ok {
t.Fatalf("data = %s, want key app", raw)
if _, ok := data["configs"]; ok {
t.Fatalf("data = %s, default payload must not wrap configs", raw)
}
if _, ok := data["app"]; ok {
t.Fatalf("data = %s, default payload must not wrap app", raw)
}
}
@@ -1,35 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package system
import (
"Wavelet/core"
"Wavelet/core/contracts"
"context"
)
// publicSystemConfig 前端公共配置接口的只读投影。
type publicSystemConfig struct {
Key string `json:"key"`
Value string `json:"value"`
}
// listPublicSystemConfigs 读取对前端可见的系统配置项。
//
// 注意:w_system_configs 的所有者插件是 admin,contracts 目前尚未暴露读取契约,
// 因此此处仍只能直连只读查询;待 admin 提供 SettingsService 契约后应改为调用契约。
func listPublicSystemConfigs(ctx context.Context, appCtx *core.Context) ([]publicSystemConfig, error) {
dbSvc, err := core.Inject[contracts.DBService](appCtx)
if err != nil {
return nil, err
}
if dbSvc == nil {
return nil, nil
}
var configs []publicSystemConfig
err = dbSvc.DB(ctx).Table("w_system_configs").
Where("visibility = ?", "visible").
Find(&configs).Error
return configs, err
}
+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
}