This commit is contained in:
ryan
2026-06-18 16:08:48 +08:00
parent 3366edb3a1
commit 772962c2e9
156 changed files with 23460 additions and 183 deletions
@@ -0,0 +1,13 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package option
const (
errInvalidParams = "无效的参数"
errOptionInitFailed = "系统选项初始化失败"
errGeoIPProvider = "归属方式仅支持 disabled、mmdb、ip-api、geojs、ipinfo"
errGeoIPIPEmpty = "IP 不能为空"
errGeoIPIPInvalid = "IP 格式无效"
errGeoIPLookupDisabled = "GeoIP 查询已禁用"
)
@@ -0,0 +1,244 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package option
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip"
"github.com/Rain-kl/Wavelet/internal/buildinfo"
"github.com/Rain-kl/Wavelet/internal/model"
)
var (
initOnce sync.Once
initErr error
)
// EnsureInitialized loads OptionMap from defaults and database once per process.
func EnsureInitialized(ctx context.Context) error {
initOnce.Do(func() {
initErr = model.InitOptionMap(ctx)
})
return initErr
}
// ResetInitializationForTest clears lazy-init state for unit tests.
func ResetInitializationForTest() {
initOnce = sync.Once{}
initErr = nil
model.ResetOptionMapForTest()
}
type publicAuthSourceView struct {
ID uint64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
AuthorizeURL string `json:"authorize_url"`
IconURL string `json:"icon_url"`
}
type statusView struct {
Version string `json:"version"`
StartTime int64 `json:"start_time"`
EmailVerification bool `json:"email_verification"`
GitHubOAuth bool `json:"github_oauth"`
GitHubClientID string `json:"github_client_id"`
SystemName string `json:"system_name"`
HomePageLink string `json:"home_page_link"`
FooterHTML string `json:"footer_html"`
WeChatQRCode string `json:"wechat_qrcode"`
WeChatLogin bool `json:"wechat_login"`
ServerAddress string `json:"server_address"`
PasswordRegisterEnabled bool `json:"password_register_enabled"`
CapLoginEnabled bool `json:"cap_login_enabled"`
AuthSources []publicAuthSourceView `json:"auth_sources"`
}
type geoIPLookupRequest struct {
Provider string `json:"provider"`
IP string `json:"ip"`
}
type geoIPLookupView struct {
Provider string `json:"provider"`
IP string `json:"ip"`
ISOCode string `json:"iso_code"`
Name string `json:"name"`
Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"`
}
type databaseCleanupInput struct {
Target string `json:"target"`
RetentionDays *int `json:"retention_days"`
}
type databaseCleanupResult struct {
Target string `json:"target"`
TargetLabel string `json:"target_label"`
DeletedCount int64 `json:"deleted_count"`
DeleteAll bool `json:"delete_all"`
RetentionDays *int `json:"retention_days,omitempty"`
}
type optionBatchPayload struct {
Options []model.OpenFlareOption `json:"options"`
}
func listOptions(ctx context.Context) ([]model.OpenFlareOption, error) {
if err := EnsureInitialized(ctx); err != nil {
return nil, err
}
model.OptionMapRWMutex.RLock()
defer model.OptionMapRWMutex.RUnlock()
options := make([]model.OpenFlareOption, 0, len(model.OptionMap))
for key, value := range model.OptionMap {
if isSecretOptionKey(key) {
continue
}
options = append(options, model.OpenFlareOption{
Key: key,
Value: value,
})
}
return options, nil
}
func updateOption(ctx context.Context, option model.OpenFlareOption) error {
if err := EnsureInitialized(ctx); err != nil {
return err
}
return updateOptions(ctx, []model.OpenFlareOption{option})
}
func updateOptionsBatch(ctx context.Context, payload optionBatchPayload) error {
if err := EnsureInitialized(ctx); err != nil {
return err
}
if len(payload.Options) == 0 {
return errors.New(errInvalidParams)
}
return updateOptions(ctx, payload.Options)
}
func updateOptions(ctx context.Context, options []model.OpenFlareOption) error {
if err := validateOptions(options); err != nil {
return err
}
return model.UpdateOpenFlareOptions(ctx, options)
}
func getNotice(ctx context.Context) (string, error) {
if err := EnsureInitialized(ctx); err != nil {
return "", err
}
return model.OptionValue("Notice"), nil
}
func getAbout(ctx context.Context) (string, error) {
if err := EnsureInitialized(ctx); err != nil {
return "", err
}
return model.OptionValue("About"), nil
}
func getStatus(ctx context.Context, baseAPIPath string) (*statusView, error) {
if err := EnsureInitialized(ctx); err != nil {
return nil, err
}
authSources, err := publicAuthSources(ctx, baseAPIPath)
if err != nil {
authSources = []publicAuthSourceView{}
}
return &statusView{
Version: buildinfo.Version,
StartTime: model.StartTime,
EmailVerification: model.EmailVerificationEnabled,
GitHubOAuth: model.GitHubOAuthEnabled,
GitHubClientID: model.GitHubClientId,
SystemName: model.SystemName,
HomePageLink: model.HomePageLink,
FooterHTML: model.Footer,
WeChatQRCode: model.WeChatAccountQRCodeImageURL,
WeChatLogin: model.WeChatAuthEnabled,
ServerAddress: model.ServerAddress,
PasswordRegisterEnabled: model.PasswordRegisterEnabled,
CapLoginEnabled: model.CapLoginEnabled,
AuthSources: authSources,
}, nil
}
func publicAuthSources(ctx context.Context, baseAPIPath string) ([]publicAuthSourceView, error) {
sources, err := model.GetActiveAuthSources(ctx)
if err != nil {
return nil, err
}
result := make([]publicAuthSourceView, 0, len(sources))
base := strings.TrimRight(baseAPIPath, "/")
for _, source := range sources {
result = append(result, publicAuthSourceView{
ID: source.ID,
Name: source.Name,
Type: source.Type,
DisplayName: source.DisplayName,
AuthorizeURL: fmt.Sprintf("%s/oauth/%s/authorize", base, source.Name),
IconURL: source.IconURL,
})
}
return result, nil
}
func lookupGeoIP(_ context.Context, provider, rawIP string) (*geoIPLookupView, error) {
view, err := geoip.Lookup(provider, rawIP)
if err != nil {
return nil, err
}
return &geoIPLookupView{
Provider: view.Provider,
IP: view.IP,
ISOCode: view.ISOCode,
Name: view.Name,
Latitude: view.Latitude,
Longitude: view.Longitude,
}, nil
}
func cleanupDatabaseObservability(_ context.Context, input databaseCleanupInput) (*databaseCleanupResult, error) {
target := strings.TrimSpace(input.Target)
if target == "" {
return nil, errors.New(errInvalidParams)
}
if input.RetentionDays != nil && *input.RetentionDays <= 0 {
return nil, errors.New("retention_days 必须为大于 0 的整数")
}
return &databaseCleanupResult{
Target: target,
TargetLabel: target,
DeletedCount: 0,
DeleteAll: input.RetentionDays == nil,
RetentionDays: input.RetentionDays,
}, nil
}
func syncUptimeKuma(_ context.Context) error {
// Stub: full Uptime Kuma sync is implemented in T-MISC.
return nil
}
func isSecretOptionKey(key string) bool {
return strings.Contains(key, "Token") ||
strings.Contains(key, "Secret") ||
strings.Contains(key, "Password")
}
@@ -0,0 +1,119 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package option
import (
"context"
"testing"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupOptionTestDB(t *testing.T) func() {
t.Helper()
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(&model.OpenFlareOption{}))
db.SetDB(sqliteDB)
ResetInitializationForTest()
return func() {
db.SetDB(nil)
ResetInitializationForTest()
}
}
func TestListOptionsFiltersSecretKeys(t *testing.T) {
cleanup := setupOptionTestDB(t)
defer cleanup()
ctx := context.Background()
require.NoError(t, model.UpdateOpenFlareOptions(ctx, []model.OpenFlareOption{
{Key: "SystemName", Value: "TestFlare"},
{Key: "SMTPToken", Value: "secret-token"},
{Key: "GitHubClientSecret", Value: "secret-id"},
}))
options, err := listOptions(ctx)
require.NoError(t, err)
keys := make(map[string]string, len(options))
for _, option := range options {
keys[option.Key] = option.Value
}
assert.Equal(t, "TestFlare", keys["SystemName"])
assert.NotContains(t, keys, "SMTPToken")
assert.NotContains(t, keys, "GitHubClientSecret")
}
func TestUpdateOptionHotReloadsOptionMap(t *testing.T) {
cleanup := setupOptionTestDB(t)
defer cleanup()
ctx := context.Background()
err := updateOption(ctx, model.OpenFlareOption{
Key: "SystemName",
Value: "HotReloaded",
})
require.NoError(t, err)
assert.Equal(t, "HotReloaded", model.OptionValue("SystemName"))
assert.Equal(t, "HotReloaded", model.SystemName)
}
func TestGetNoticeAndAbout(t *testing.T) {
cleanup := setupOptionTestDB(t)
defer cleanup()
ctx := context.Background()
require.NoError(t, updateOption(ctx, model.OpenFlareOption{Key: "Notice", Value: "hello"}))
require.NoError(t, updateOption(ctx, model.OpenFlareOption{Key: "About", Value: "about-us"}))
notice, err := getNotice(ctx)
require.NoError(t, err)
assert.Equal(t, "hello", notice)
about, err := getAbout(ctx)
require.NoError(t, err)
assert.Equal(t, "about-us", about)
}
func TestLookupGeoIPDisabledProvider(t *testing.T) {
cleanup := setupOptionTestDB(t)
defer cleanup()
ctx := context.Background()
view, err := lookupGeoIP(ctx, "disabled", "8.8.8.8")
require.NoError(t, err)
assert.Equal(t, "disabled", view.Provider)
assert.Equal(t, "8.8.8.8", view.IP)
}
func TestCleanupDatabaseObservabilityStub(t *testing.T) {
cleanup := setupOptionTestDB(t)
defer cleanup()
ctx := context.Background()
retention := 7
result, err := cleanupDatabaseObservability(ctx, databaseCleanupInput{
Target: "node_access_logs",
RetentionDays: &retention,
})
require.NoError(t, err)
assert.Equal(t, "node_access_logs", result.Target)
assert.Equal(t, int64(0), result.DeletedCount)
assert.False(t, result.DeleteAll)
require.NotNil(t, result.RetentionDays)
assert.Equal(t, 7, *result.RetentionDays)
}
@@ -0,0 +1,139 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package option
import (
"encoding/json"
"errors"
"io"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/gin-gonic/gin"
)
// RegisterRoutes mounts legacy OpenFlare option and public status routes.
func RegisterRoutes(apiGroup *gin.RouterGroup) {
apiGroup.GET("/status", getStatusHandler)
apiGroup.GET("/notice", getNoticeHandler)
apiGroup.GET("/about", getAboutHandler)
optionRoute := apiGroup.Group("/option")
optionRoute.Use(compat.BridgeOpenFlareToken(), compat.RootAuth())
{
optionRoute.GET("/", listOptionsHandler)
optionRoute.POST("/update", updateOptionHandler)
optionRoute.POST("/update-batch", updateOptionsBatchHandler)
optionRoute.POST("/geoip/lookup", lookupGeoIPHandler)
optionRoute.POST("/database/cleanup", cleanupDatabaseHandler)
}
uptimeKumaRoute := apiGroup.Group("/uptimekuma")
uptimeKumaRoute.Use(compat.BridgeOpenFlareToken(), compat.RootAuth())
{
uptimeKumaRoute.POST("/sync", syncUptimeKumaHandler)
}
}
func getStatusHandler(c *gin.Context) {
view, err := getStatus(c.Request.Context(), "/api")
if err != nil {
compat.Fail(c, errOptionInitFailed)
return
}
compat.OK(c, view)
}
func getNoticeHandler(c *gin.Context) {
notice, err := getNotice(c.Request.Context())
if err != nil {
compat.Fail(c, errOptionInitFailed)
return
}
compat.OK(c, notice)
}
func getAboutHandler(c *gin.Context) {
about, err := getAbout(c.Request.Context())
if err != nil {
compat.Fail(c, errOptionInitFailed)
return
}
compat.OK(c, about)
}
func listOptionsHandler(c *gin.Context) {
options, err := listOptions(c.Request.Context())
if err != nil {
compat.Fail(c, errOptionInitFailed)
return
}
compat.OK(c, options)
}
func updateOptionHandler(c *gin.Context) {
var option model.OpenFlareOption
if !compat.BindJSON(c, &option) {
return
}
if err := updateOption(c.Request.Context(), option); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func updateOptionsBatchHandler(c *gin.Context) {
var payload optionBatchPayload
if !compat.BindJSON(c, &payload) {
return
}
if err := updateOptionsBatch(c.Request.Context(), payload); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func lookupGeoIPHandler(c *gin.Context) {
var request geoIPLookupRequest
if !compat.BindJSON(c, &request) {
return
}
view, err := lookupGeoIP(c.Request.Context(), request.Provider, request.IP)
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, view)
}
func cleanupDatabaseHandler(c *gin.Context) {
var input databaseCleanupInput
if err := bindOptionalJSON(c.Request.Body, &input); err != nil {
compat.Fail(c, errInvalidParams)
return
}
result, err := cleanupDatabaseObservability(c.Request.Context(), input)
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, result)
}
func syncUptimeKumaHandler(c *gin.Context) {
if err := syncUptimeKuma(c.Request.Context()); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "同步成功")
}
func bindOptionalJSON(body io.Reader, target any) error {
if err := json.NewDecoder(body).Decode(target); err != nil && !errors.Is(err, io.EOF) {
return err
}
return nil
}
@@ -0,0 +1,310 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package option
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip"
"github.com/Rain-kl/Wavelet/internal/model"
)
const rateLimitKeyExpirationSeconds = 1200 // 20 minutes
var (
openRestySizePattern = regexp.MustCompile(`^\d+[kKmMgG]?$`)
openRestyProxyBuffersPattern = regexp.MustCompile(`^\d+\s+\d+[kKmMgG]?$`)
openRestyCacheLevelsPattern = regexp.MustCompile(`^\d{1,2}(?::\d{1,2}){0,2}$`)
openRestyDurationTokenPattern = regexp.MustCompile(`^\d+[smhdwSMHDW]$`)
)
func buildOptionValidationState(options []model.OpenFlareOption) map[string]string {
model.OptionMapRWMutex.RLock()
state := make(map[string]string, len(model.OptionMap)+len(options))
for key, value := range model.OptionMap {
state[key] = value
}
model.OptionMapRWMutex.RUnlock()
for _, option := range options {
state[option.Key] = option.Value
}
return state
}
func validateOptionWithState(option model.OpenFlareOption, state map[string]string) error {
switch option.Key {
case "GitHubOAuthEnabled":
if option.Value == "true" && strings.TrimSpace(state["GitHubClientId"]) == "" {
return fmt.Errorf("无法启用 GitHub OAuth,请先填入 GitHub Client ID 以及 GitHub Client Secret!")
}
case "WeChatAuthEnabled":
if option.Value == "true" && strings.TrimSpace(state["WeChatServerAddress"]) == "" {
return fmt.Errorf("无法启用微信登录,请先填入微信登录相关配置信息!")
}
}
if err := validateRateLimitOption(option.Key, option.Value); err != nil {
return err
}
if err := validateOpenRestyOption(option.Key, option.Value); err != nil {
return err
}
if err := validateGeoIPOption(option.Key, option.Value); err != nil {
return err
}
if err := validateDatabaseCleanupOption(option.Key, option.Value); err != nil {
return err
}
if err := validateAgentOption(option.Key, option.Value); err != nil {
return err
}
return validateUptimeKumaOption(option.Key, option.Value, state)
}
func validateRateLimitOption(key, value string) error {
switch key {
case "GlobalApiRateLimitNum", "GlobalWebRateLimitNum", "CriticalRateLimitNum":
intValue, err := strconv.Atoi(value)
if err != nil || intValue <= 0 {
return fmt.Errorf("%s 必须为大于 0 的整数", key)
}
case "GlobalApiRateLimitDuration", "GlobalWebRateLimitDuration", "CriticalRateLimitDuration":
intValue, err := strconv.Atoi(value)
if err != nil || intValue <= 0 {
return fmt.Errorf("%s 必须为大于 0 的整数秒", key)
}
if intValue > rateLimitKeyExpirationSeconds {
return fmt.Errorf("%s 不能大于 %d 秒", key, rateLimitKeyExpirationSeconds)
}
}
return nil
}
func validatePositiveIntegerOption(key, value string) error {
intValue, err := strconv.Atoi(value)
if err != nil || intValue <= 0 {
return fmt.Errorf("%s 必须为大于 0 的整数", key)
}
return nil
}
func validateBooleanOption(key, value string) error {
switch value {
case "true", "false":
return nil
default:
return fmt.Errorf("%s 必须为 true 或 false", key)
}
}
func validateGeoIPOption(key, value string) error {
if key != "GeoIPProvider" {
return nil
}
if geoip.IsValidProvider(value) {
return nil
}
return fmt.Errorf("%s 仅支持 disabled、mmdb、ip-api、geojs、ipinfo", key)
}
func validateDatabaseCleanupOption(key, value string) error {
switch key {
case "DatabaseAutoCleanupEnabled":
return validateBooleanOption(key, value)
case "DatabaseAutoCleanupRetentionDays":
intValue, err := strconv.Atoi(value)
if err != nil || intValue < 1 {
return fmt.Errorf("%s 必须为大于等于 1 的整数天", key)
}
}
return nil
}
func validateAgentOption(key, value string) error {
if key == "AgentWebsocketUpgradeEnabled" {
return validateBooleanOption(key, strings.TrimSpace(value))
}
return nil
}
func validateUptimeKumaOption(key, value string, state map[string]string) error {
trimmed := strings.TrimSpace(value)
switch key {
case "UptimeKumaEnabled":
if err := validateBooleanOption(key, trimmed); err != nil {
return err
}
if trimmed == "true" {
url := strings.TrimSpace(state["UptimeKumaUrl"])
username := strings.TrimSpace(state["UptimeKumaUsername"])
password := strings.TrimSpace(state["UptimeKumaPassword"])
if url == "" {
return fmt.Errorf("启用 Uptime Kuma 时地址不能为空")
}
if username == "" {
return fmt.Errorf("启用 Uptime Kuma 时用户名不能为空")
}
if password == "" && model.UptimeKumaPassword == "" {
return fmt.Errorf("启用 Uptime Kuma 时密码不能为空")
}
}
case "UptimeKumaUsername":
if trimmed == "" && state["UptimeKumaEnabled"] == "true" {
return fmt.Errorf("启用 Uptime Kuma 时用户名不能为空")
}
case "UptimeKumaUrl":
if trimmed != "" && !strings.HasPrefix(trimmed, "http://") && !strings.HasPrefix(trimmed, "https://") {
return fmt.Errorf("Uptime Kuma 地址必须以 http:// 或 https:// 开头")
}
case "UptimeKumaMonitorScope":
if trimmed != "all" && trimmed != "selected" {
return fmt.Errorf("监控范围必须为全部站点 (all) 或选择站点 (selected)")
}
case "UptimeKumaSyncInterval", "UptimeKumaInterval", "UptimeKumaRetryInterval", "UptimeKumaTimeout":
return validatePositiveIntegerOption(key, trimmed)
case "UptimeKumaRetry":
intValue, err := strconv.Atoi(trimmed)
if err != nil || intValue < 0 {
return fmt.Errorf("%s 必须为大于等于 0 的整数", key)
}
}
return nil
}
func validateOpenRestyOption(key, value string) error {
trimmed := strings.TrimSpace(value)
switch key {
case "OpenRestyDefaultServerReturnStatus":
if err := validatePositiveIntegerOption(key, trimmed); err != nil {
return err
}
statusCode, _ := strconv.Atoi(trimmed)
if statusCode < 100 || statusCode > 999 {
return fmt.Errorf("%s 必须在 100 到 999 之间", key)
}
case "OpenRestyWorkerProcesses":
if trimmed == "auto" {
return nil
}
return validatePositiveIntegerOption(key, trimmed)
case "OpenRestyWorkerConnections",
"OpenRestyWorkerRlimitNofile",
"OpenRestyKeepaliveTimeout",
"OpenRestyKeepaliveRequests",
"OpenRestyClientHeaderTimeout",
"OpenRestyClientBodyTimeout",
"OpenRestySendTimeout",
"OpenRestyProxyConnectTimeout",
"OpenRestyProxySendTimeout",
"OpenRestyProxyReadTimeout",
"OpenRestyGzipMinLength":
return validatePositiveIntegerOption(key, trimmed)
case "OpenRestyGzipCompLevel":
if err := validatePositiveIntegerOption(key, trimmed); err != nil {
return err
}
level, _ := strconv.Atoi(trimmed)
if level > 9 {
return fmt.Errorf("%s 不能大于 9", key)
}
case "OpenRestyEventsUse":
if trimmed == "" {
return nil
}
switch trimmed {
case "epoll", "kqueue", "poll", "select", "rtsig", "/dev/poll", "eventport":
return nil
default:
return fmt.Errorf("%s 仅支持 epoll、kqueue、poll、select、rtsig、/dev/poll、eventport 或留空", key)
}
case "OpenRestyResolvers":
if trimmed == "" {
return nil
}
if !regexp.MustCompile(`^[a-zA-Z0-9.:\-\s]+$`).MatchString(trimmed) {
return fmt.Errorf("%s 包含非法字符,请填入有效的 IP 地址或域名,以空格分隔", key)
}
case "OpenRestyEventsMultiAcceptEnabled",
"OpenRestyWebsocketEnabled",
"OpenRestyHTTP3Enabled",
"OpenRestyProxyRequestBufferingEnabled",
"OpenRestyProxyBufferingEnabled",
"OpenRestyGzipEnabled",
"OpenRestyCacheEnabled",
"OpenRestyCacheLockEnabled":
return validateBooleanOption(key, trimmed)
case "OpenRestyProxyBuffers", "OpenRestyLargeClientHeaderBuffers":
if openRestyProxyBuffersPattern.MatchString(trimmed) {
return nil
}
return fmt.Errorf("%s 格式必须类似 \"16 16k\"", key)
case "OpenRestyProxyBufferSize", "OpenRestyProxyBusyBuffersSize", "OpenRestyCacheMaxSize", "OpenRestyClientMaxBodySize":
if openRestySizePattern.MatchString(trimmed) {
return nil
}
return fmt.Errorf("%s 格式必须为整数或带 k/m/g 单位的大小值", key)
case "OpenRestyCachePath":
if strings.ContainsAny(trimmed, "\r\n\t") {
return fmt.Errorf("%s 不能包含换行或制表符", key)
}
case "OpenRestyCacheLevels":
if openRestyCacheLevelsPattern.MatchString(trimmed) {
return nil
}
return fmt.Errorf("%s 格式必须类似 \"1:2\" 或 \"1:2:2\"", key)
case "OpenRestyCacheInactive", "OpenRestyCacheLockTimeout":
if openRestyDurationTokenPattern.MatchString(trimmed) {
return nil
}
return fmt.Errorf("%s 格式必须为带单位的时长,例如 30m 或 5s", key)
case "OpenRestyCacheKeyTemplate":
if trimmed == "" {
return fmt.Errorf("%s 不能为空", key)
}
if strings.ContainsAny(trimmed, "\r\n") {
return fmt.Errorf("%s 不能包含换行", key)
}
case "OpenRestyCacheUseStale":
if trimmed == "" {
return fmt.Errorf("%s 不能为空", key)
}
allowedTokens := map[string]struct{}{
"error": {}, "timeout": {}, "invalid_header": {}, "updating": {},
"http_500": {}, "http_502": {}, "http_503": {}, "http_504": {},
"http_403": {}, "http_404": {}, "http_429": {}, "off": {},
}
for _, token := range strings.Fields(trimmed) {
if _, ok := allowedTokens[token]; !ok {
return fmt.Errorf("%s 包含不支持的值 %q", key, token)
}
}
case "OpenRestyMainConfigTemplate":
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s 不能为空", key)
}
}
return nil
}
func validateOptions(options []model.OpenFlareOption) error {
if len(options) == 0 {
return fmt.Errorf(errInvalidParams)
}
state := buildOptionValidationState(options)
for _, option := range options {
if strings.TrimSpace(option.Key) == "" {
return fmt.Errorf(errInvalidParams)
}
if err := validateOptionWithState(option, state); err != nil {
return err
}
}
return nil
}