feat(i18n): default to English, localize runtime text and add Russian (#52)

This commit is contained in:
ryan
2026-11-07 21:35:55 +08:00
359 changed files with 15795 additions and 4333 deletions
+5 -5
View File
@@ -209,7 +209,7 @@ func getPostgresOverview(gormDB *gorm.DB) DBOverviewResponse {
func GetDBOverview(c *gin.Context) {
gormDB := db.DB(c.Request.Context())
if gormDB == nil {
response.AbortInternal(c, "数据库未初始化")
response.AbortInternal(c, "Database is not initialized")
return
}
@@ -238,7 +238,7 @@ func GetDBOverview(c *gin.Context) {
func ListDBTables(c *gin.Context) {
gormDB := db.DB(c.Request.Context())
if gormDB == nil {
response.AbortInternal(c, "数据库未初始化")
response.AbortInternal(c, "Database is not initialized")
return
}
@@ -269,7 +269,7 @@ func GetDBTableData(c *gin.Context) {
gormDB := db.DB(c.Request.Context())
if gormDB == nil {
response.AbortInternal(c, "数据库未初始化")
response.AbortInternal(c, "Database is not initialized")
return
}
@@ -442,13 +442,13 @@ func ExecuteSQL(c *gin.Context) {
gormDB := db.DB(c.Request.Context())
if gormDB == nil {
response.AbortInternal(c, "数据库未初始化")
response.AbortInternal(c, "Database is not initialized")
return
}
trimmedSQL := strings.TrimSpace(req.SQL)
if trimmedSQL == "" {
response.AbortBadRequest(c, "SQL 语句不能为空")
response.AbortBadRequest(c, "SQL statement must not be empty")
return
}
+5 -5
View File
@@ -7,9 +7,9 @@ package admin
// 管理后台错误消息常量
const (
AdminRequired = "未经授权访问"
TokenAdminRequired = "该访问令牌没有管理员权限,无法访问管理端点" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
InvalidAuthSourceID = "认证源 ID 无效"
InvalidCursorParam = "无效的 cursor 参数"
InvalidTaskExecutionID = "无效的任务执行记录 ID"
AdminRequired = "Unauthorized access"
TokenAdminRequired = "This access token has no administrator privileges and cannot access admin endpoints" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
InvalidAuthSourceID = "invalid authentication source ID"
InvalidCursorParam = "Invalid cursor parameter"
InvalidTaskExecutionID = "Invalid task execution ID"
)
+8 -8
View File
@@ -165,7 +165,7 @@ func buildAccessLogFilter(ctx context.Context, c *gin.Context) (analyticsmodel.A
if username != "" {
userIDs, err := repository.ListUserIDsByUsernameContains(ctx, username)
if err != nil {
return filter, fmt.Errorf("查询用户信息失败: %w", err)
return filter, fmt.Errorf("failed to query user information: %w", err)
}
filter.UserIDs = userIDs
}
@@ -246,8 +246,8 @@ func GetAccessLogs(c *gin.Context) {
ctx := c.Request.Context()
store, err := logstore.Active(ctx)
if err != nil {
logger.ErrorF(ctx, "获取日志存储实例失败: %v", err)
response.AbortWithError(c, http.StatusBadRequest, "日志存储未启用,无法检索访问日志")
logger.ErrorF(ctx, "Failed to get log storage instance: %v", err)
response.AbortWithError(c, http.StatusBadRequest, "Log storage is disabled; access logs cannot be retrieved")
return
}
@@ -348,8 +348,8 @@ func GetLogsAnalytics(c *gin.Context) {
ctx := c.Request.Context()
store, err := logstore.Active(ctx)
if err != nil {
logger.ErrorF(ctx, "获取日志存储实例失败: %v", err)
response.AbortWithError(c, http.StatusBadRequest, "日志存储未启用,无法获取分析数据")
logger.ErrorF(ctx, "Failed to get log storage instance: %v", err)
response.AbortWithError(c, http.StatusBadRequest, "Log storage is disabled; analytics are unavailable")
return
}
@@ -357,7 +357,7 @@ func GetLogsAnalytics(c *gin.Context) {
trendPoints, err := store.UserAccessLogs.GetDailyTrend(ctx, analyticsDays)
if err != nil {
response.AbortWithError(c, http.StatusInternalServerError, "查询访问趋势失败: "+err.Error())
response.AbortWithError(c, http.StatusInternalServerError, "Failed to query access trends: "+err.Error())
return
}
trendList := make([]trendItem, len(trendPoints))
@@ -370,7 +370,7 @@ func GetLogsAnalytics(c *gin.Context) {
browserPoints, err := store.UserAccessLogs.GetBrowserDistribution(ctx, startTime)
if err != nil {
response.AbortWithError(c, http.StatusInternalServerError, "查询浏览器分布失败: "+err.Error())
response.AbortWithError(c, http.StatusInternalServerError, "Failed to query browser distribution: "+err.Error())
return
}
browserList := make([]browserItem, len(browserPoints))
@@ -383,7 +383,7 @@ func GetLogsAnalytics(c *gin.Context) {
topUserPoints, err := store.UserAccessLogs.GetTopActiveUsers(ctx, startTime, topActiveLimit)
if err != nil {
response.AbortWithError(c, http.StatusInternalServerError, "查询活跃用户失败: "+err.Error())
response.AbortWithError(c, http.StatusInternalServerError, "Failed to query active users: "+err.Error())
return
}
+5 -5
View File
@@ -221,9 +221,9 @@ func TestChannel(c *gin.Context) {
default:
config = pkgpush.Config{Channel: channelCustom, URL: url}
customPushReq := CustomPushRequest{
Title: "通道测试通知",
Content: "这是一条来自系统的消息通道连通性测试消息。",
Description: "系统通道测试",
Title: "Channel test notification",
Content: "This is a system notification channel connectivity test.",
Description: "System channel test",
URL: "https://example.com",
To: req.Target,
}
@@ -235,8 +235,8 @@ func TestChannel(c *gin.Context) {
Config: config,
Target: req.Target,
Body: NotificationMessage{
Title: "通道测试通知",
Content: "这是一条来自系统的消息通道连通性测试消息。",
Title: "Channel test notification",
Content: "This is a system notification channel connectivity test.",
Level: defaultLevelInfo,
},
Template: renderedJSON,
+30 -30
View File
@@ -81,24 +81,24 @@ func init() {
// Register custom webhook channel
RegisterChannelDefinition(Definition{
Type: channelCustom,
Name: "自定义消息通道",
Description: "使用自定义 HTTP POST 请求向外部 Webhook 发送数据。",
Name: "Custom notification channel",
Description: "Send data to an external webhook using a custom HTTP POST request.",
Fields: []Field{
{
Key: KeyURL,
Label: "请求地址",
Label: "Request URL",
Type: TypeText,
Required: true,
Placeholder: "在此填写完整的请求地址,必须使用 HTTPS 协议",
Description: "接口请求的完整 HTTPS URL,例如 https://api.example.com/webhook",
Placeholder: "Enter the complete request URL using HTTPS",
Description: "Complete HTTPS endpoint URL, for example https://api.example.com/webhook",
},
{
Key: KeyOther,
Label: "请求体 (JSON)",
Label: "Request body (JSON)",
Type: TypeTextarea,
Required: true,
Placeholder: "在此输入请求体,支持模板变量,必须为合法的 JSON 格式",
Description: "可使用的变量:$title, $description, $content, $url, $to。例如 {\"text\": \"$content\"}",
Placeholder: "Enter a valid JSON request body; template variables are supported",
Description: `Available variables: $title, $description, $content, $url, $to. Example: {"text": "$content"}`,
},
},
})
@@ -106,33 +106,33 @@ func init() {
// Register Lark robot channel
RegisterChannelDefinition(Definition{
Type: channelLark,
Name: "飞书群机器人",
Description: "配置飞书群自定义机器人的 Webhook 接口投递。",
Name: "Feishu group bot",
Description: "Deliver notifications through a Feishu group bot webhook.",
Fields: []Field{
{
Key: KeyURL,
Label: "Webhook 地址",
Label: "Webhook URL",
Type: TypeText,
Required: true,
Placeholder: "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN",
Description: "从飞书群机器人设置中复制 of Webhook URL",
Description: "Copy the webhook URL from the Feishu group bot settings",
// Note: using 'of' was in feishu.go, let's keep original wording or fix it
},
{
Key: KeyToken,
Label: "签名校验密钥 (Secret) (可选)",
Label: "Signing secret (optional)",
Type: TypeText,
Required: false,
Placeholder: "可选,若机器人启用了安全设置中的签名校验,请在此输入",
Description: "飞书群机器人安全设置中的签名校验 Key",
Placeholder: "Enter the secret if signature verification is enabled for the bot",
Description: "Signature verification key from the Feishu group bot security settings",
},
{
Key: KeyOther,
Label: "自定义卡片 JSON 模版 (可选)",
Label: "Custom card JSON template (optional)",
Type: TypeTextarea,
Required: false,
Placeholder: "可选,留空则默认使用系统内置的精美互动卡片",
Description: "若填写,必须是合法的飞书卡片 JSON 格式",
Placeholder: "Leave empty to use the built-in interactive card",
Description: "If specified, this must be valid Feishu card JSON",
},
},
})
@@ -140,32 +140,32 @@ func init() {
// Register Telegram channel
RegisterChannelDefinition(Definition{
Type: channelTelegram,
Name: "Telegram 机器人",
Description: "配置 Telegram 机器人推送消息。",
Name: "Telegram bot",
Description: "Configure Telegram bot notifications.",
Fields: []Field{
{
Key: KeyURL,
Label: "API 基础地址 (可选)",
Label: "API base URL (optional)",
Type: TypeText,
Required: false,
Placeholder: "https://api.telegram.org",
Description: "接口请求的 HTTPS 基础地址,留空默认为 https://api.telegram.org",
Description: "HTTPS API base URL; defaults to https://api.telegram.org",
},
{
Key: KeyToken,
Label: "机器人 Token (Bot Token)",
Label: "Bot token",
Type: TypePassword,
Required: true,
Placeholder: "在此输入 Telegram 机器人的 Bot Token",
Description: "通过 BotFather 申请到的机器人 Access Token",
Placeholder: "Enter the Telegram bot token",
Description: "Bot access token issued by BotFather",
},
{
Key: KeyOther,
Label: "默认会话 ID (Chat ID) (可选)",
Label: "Default chat ID (optional)",
Type: TypeText,
Required: false,
Placeholder: "例如 -100123456789 或 @channel_name",
Description: "默认的消息接收 Chat ID。如果通知事件中未配置 targets,将推送到此 ID",
Placeholder: "For example -100123456789 or @channel_name",
Description: "Default recipient chat ID, used when the notification event has no targets",
},
},
})
@@ -173,8 +173,8 @@ func init() {
// Register Email channel
RegisterChannelDefinition(Definition{
Type: channelEmail,
Name: "邮件推送通道",
Description: "邮件推送通道直接使用系统全局 SMTP 设置进行发送,无需在此填写服务器配置。",
Name: "Email notification channel",
Description: "Email notifications use the global SMTP settings; no server configuration is required here.",
Fields: []Field{},
})
}
@@ -15,13 +15,13 @@ import (
// AdminLogin is the metadata definition for the admin login event.
var AdminLogin = push.EventMetadata{
Key: "admin_login",
Name: "管理员登录",
Name: "Administrator login",
DefaultTemplate: push.NotificationMessage{
Title: "管理员登录提醒",
Content: "管理员 {{user.username}} 于 {{time}} 从 IP {{ip}} 登录系统。",
Title: "Administrator login notification",
Content: "Administrator {{user.username}} signed in at {{time}} from IP {{ip}}.",
Level: "INFO",
},
Description: "当管理员成功登录系统时触发此通知",
Description: "Triggered when an administrator signs in successfully",
}
func handleAdminLogin(ctx context.Context, event listener.AdminLoggedIn) {
@@ -138,7 +138,7 @@ func TestAdminLoginPushIntegration(t *testing.T) {
assert.Equal(t, AdminLogin.Key, payload.EventKey)
assert.Equal(t, "ops_team", payload.Target)
assert.Equal(t, "管理员登录提醒", payload.Body.Title)
assert.Equal(t, "Administrator login notification", payload.Body.Title)
assert.Contains(t, payload.Body.Content, "super_admin")
assert.Contains(t, payload.Body.Content, "203.0.113.42")
})
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package push
import (
"github.com/Rain-kl/Wavelet/internal/infra/task"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/pkg/i18n"
)
func localizeEventMetadata(locale string, meta EventMetadata) EventMetadata {
meta.Name = i18n.Translate(locale, meta.Name)
meta.Description = i18n.Translate(locale, meta.Description)
meta.DefaultTemplate.Title = i18n.Translate(locale, meta.DefaultTemplate.Title)
meta.DefaultTemplate.Content = i18n.Translate(locale, meta.DefaultTemplate.Content)
return meta
}
func localizePushEvent(locale string, event model.PushEvent) model.PushEvent {
if meta, found := findBuiltInEvent(event.EventKey); found {
if event.Name == meta.Name || i18n.Translate("en", event.Name) == meta.Name {
event.Name = i18n.Translate(locale, meta.Name)
}
} else if meta := task.GetTaskMetaByAsynqTask(event.TaskType); meta != nil {
prefix := "Task completed: "
english := prefix + meta.Name
chinese := i18n.Translate("zh-CN", prefix) + i18n.Translate("zh-CN", meta.Name)
if event.Name == english || event.Name == chinese {
event.Name = i18n.Translate(locale, prefix) + i18n.Translate(locale, meta.Name)
}
}
// Templates and targets are administrator-owned data, even when they happen
// to match a built-in message. Never rewrite them during presentation.
return event
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package push
import (
"testing"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/stretchr/testify/assert"
)
func TestLocalizeEventMetadataPreservesTemplatesAndRegistry(t *testing.T) {
source := EventMetadata{Name: "Administrator login", DefaultTemplate: NotificationMessage{Title: "Administrator login notification"}}
localized := localizeEventMetadata("zh-CN", source)
assert.Equal(t, "管理员登录", localized.Name)
assert.Equal(t, "管理员登录提醒", localized.DefaultTemplate.Title)
assert.Equal(t, "Administrator login", source.Name)
assert.Equal(t, "Administrator login notification", source.DefaultTemplate.Title)
event := model.PushEvent{Name: "自定义事件", Template: `{"title":"管理员登录提醒"}`, Targets: []string{"系统"}}
assert.Equal(t, event, localizePushEvent("en", event))
}
+9 -5
View File
@@ -24,6 +24,10 @@ type smtpConfig struct {
Password string
}
// This persisted recipient alias predates localization. Display translations
// must never expand the set of reserved routing identifiers.
const legacySystemTarget = "\u7cfb\u7edf"
func loadSMTPConfig(ctx context.Context) smtpConfig {
host, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPHost)
port, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPPort)
@@ -278,7 +282,7 @@ func recordPushHistory(ctx context.Context, req SendPayload, status, errMsg stri
content := req.Body.Content
level := req.Body.Level
if title == "" {
title = "系统通知"
title = "System notification"
}
if level == "" {
level = defaultLevelInfo
@@ -331,7 +335,7 @@ func resolveTargetUser(ctx context.Context, resolved string, _ string) (model.Us
}
func resolveSystemTarget(ctx context.Context, resolved string, channel string) (string, bool) {
if resolved != "系统" && resolved != "system" && resolved != "0" {
if resolved != legacySystemTarget && resolved != "system" && resolved != "0" {
return "", false
}
adminUser, err := repository.GetFirstAdminUser(ctx)
@@ -383,10 +387,10 @@ func getEventInfo(req CreateEventRequest) (string, string, []byte, error) {
return "", "", nil, errors.New("unsupported task type")
}
eventKey := "task_completed:" + req.TaskType
eventName := "任务完成: " + meta.Name
eventName := "Task completed: " + meta.Name
defaultTemplate := NotificationMessage{
Title: "任务完成: " + meta.Name,
Content: "异步任务 {{task_name}} (ID: {{task_id}}) 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。",
Title: "Task completed: " + meta.Name,
Content: "Asynchronous task {{task_name}} (ID: {{task_id}}) completed. Status: {{task_status}}; duration: {{task_duration}} ms.",
Level: defaultLevelInfo,
}
defaultTemplateBytes, err := json.Marshal(defaultTemplate)
+19 -1
View File
@@ -312,7 +312,7 @@ func TestPushHandler(t *testing.T) {
t.Run("execute task successfully", func(t *testing.T) {
res, execErr := handler.Execute(context.Background(), payloadBytes)
require.NoError(t, execErr)
assert.Contains(t, res.Message, "推送成功")
assert.Contains(t, res.Message, "Notification sent")
// Verify mock pusher received flattened variables
mPusher.mu.Lock()
@@ -553,6 +553,24 @@ func TestResolveTarget(t *testing.T) {
ctx := context.Background()
for index, username := range []string{"System", "Система"} {
t.Run("translated system label is an ordinary username/"+username, func(t *testing.T) {
user := model.User{
ID: uint64(20000 + index), Username: username,
Email: "ordinary_" + strconv.Itoa(index) + "@example.invalid",
}
if err := dbConn.Create(&user).Error; err != nil {
t.Fatalf("create user %q: %v", username, err)
}
for _, target := range []string{username, "user.username"} {
body := map[string]any{"user.username": username}
if got := resolveTarget(ctx, target, body, channelEmail); got != user.Email {
t.Errorf("resolveTarget(%q, username=%q, email) = %q, want %q", target, username, got, user.Email)
}
}
})
}
t.Run("dynamic user.id resolved and converted for email channel", func(t *testing.T) {
res := resolveTarget(ctx, "user.id", flatBody, "email")
assert.Equal(t, "target@test.com", res)
+12 -4
View File
@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"github.com/Rain-kl/Wavelet/internal/model"
@@ -54,6 +55,9 @@ func ListEvents(c *gin.Context) {
response.AbortInternal(c, err.Error())
return
}
for index := range events {
events[index] = localizePushEvent(response.Locale(c), events[index])
}
c.JSON(http.StatusOK, response.OK(events))
}
@@ -85,7 +89,11 @@ func findBuiltInEvent(key string) (EventMetadata, bool) {
// @Success 200 {object} response.Any{data=[]EventMetadata} "内置通知事件列表"
// @Router /api/v1/admin/push/events/builtin [get]
func ListBuiltInEvents(c *gin.Context) {
c.JSON(http.StatusOK, response.OK(BuiltInEvents))
metas := slices.Clone(BuiltInEvents)
for index := range metas {
metas[index] = localizeEventMetadata(response.Locale(c), metas[index])
}
c.JSON(http.StatusOK, response.OK(metas))
}
// CreateEvent 创建通知事件
@@ -110,7 +118,7 @@ func CreateEvent(c *gin.Context) {
response.AbortBadRequest(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(event))
c.JSON(http.StatusOK, response.OK(localizePushEvent(response.Locale(c), event)))
}
// DeleteEvent 删除通知事件配置
@@ -280,8 +288,8 @@ func TestPush(c *gin.Context) {
applySMTPFallbackToPushConfig(c.Request.Context(), &req.Config)
testBody := map[string]any{
keyTitle: "测试通道推送",
keyContent: "当您收到这条消息,说明当前渠道连通性测试通过。",
keyTitle: "Notification channel test",
keyContent: "Receiving this message confirms that the notification channel connectivity test succeeded.",
keyLevel: defaultLevelInfo,
}
if _, err := pusher.Send(c.Request.Context(), req.Config, req.Target, testBody, "", nil); err != nil {
+1 -1
View File
@@ -67,7 +67,7 @@ func handleTaskCompleted(ctx context.Context, execution *model.TaskExecution, re
meta := EventMetadata{
Key: event.EventKey,
Name: event.Name,
Description: "异步任务执行完毕触发的自动通知",
Description: "Automatic notification triggered when an asynchronous task finishes",
}
DefaultTrigger.Trigger(ctx, meta, body)
}
+13 -13
View File
@@ -25,8 +25,8 @@ const (
var SendNotificationMeta = task.TaskMeta{
Type: TaskTypeSendNotification,
AsynqTask: SendNotificationTask,
Name: "推送通知",
Description: "异步执行系统通知的多渠道派发与推送",
Name: "Send notification",
Description: "Dispatch system notifications asynchronously across multiple channels",
SupportsTime: false,
MaxRetry: task.DefaultMaxRetry,
Queue: task.QueueDefault,
@@ -34,14 +34,14 @@ var SendNotificationMeta = task.TaskMeta{
Params: []task.TaskParam{
{
Name: "event_key",
Label: "事件标识",
Label: "Event key",
Type: "string",
Required: true,
Placeholder: "admin_login",
},
{
Name: "target",
Label: "目标接收者",
Label: "Recipient",
Type: "string",
Required: false,
},
@@ -75,16 +75,16 @@ func (h *PushHandler) ValidatePayload(payload []byte) ([]byte, error) {
func (h *PushHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
var req SendPayload
if err := json.Unmarshal(payload, &req); err != nil {
task.AppendLog(ctx, "解析推送参数失败: %v", err)
task.AppendLog(ctx, "Failed to parse notification parameters: %v", err)
return nil, fmt.Errorf("parse payload failed: %w", err)
}
task.AppendLog(ctx, "开始推送通知: 事件 = %s, 渠道 = %s, 接收目标 = %s", req.EventKey, req.Config.Channel, req.Target)
task.AppendLog(ctx, "Starting notification: event = %s, channel = %s, recipient = %s", req.EventKey, req.Config.Channel, req.Target)
pusher, err := push.GetPusher(req.Config.Channel)
if err != nil {
errWrap := fmt.Errorf("get pusher failed: %w", err)
task.AppendLog(ctx, "推送失败: %v", errWrap)
task.AppendLog(ctx, "Notification failed: %v", errWrap)
if task.IsFinalAttempt(ctx) {
h.recordHistory(ctx, req, "failed", errWrap.Error())
}
@@ -99,9 +99,9 @@ func (h *PushHandler) Execute(ctx context.Context, payload []byte) (*task.TaskRe
content := req.Body.Content
if err != nil {
task.AppendLog(ctx, "消息推送失败 (标题: %s): %v", title, err)
task.AppendLog(ctx, "Failed to send notification (title: %s): %v", title, err)
if upstreamResp != "" {
task.AppendLog(ctx, "上游返回: %s", upstreamResp)
task.AppendLog(ctx, "Upstream response: %s", upstreamResp)
}
if task.IsFinalAttempt(ctx) {
h.recordHistory(ctx, req, "failed", err.Error())
@@ -109,19 +109,19 @@ func (h *PushHandler) Execute(ctx context.Context, payload []byte) (*task.TaskRe
return nil, fmt.Errorf("pusher.Send failed: %w", err)
}
task.AppendLog(ctx, "消息推送成功 (标题: %s, 内容摘要: %s)", title, content)
task.AppendLog(ctx, "Notification sent (title: %s, content summary: %s)", title, content)
if upstreamResp != "" {
task.AppendLog(ctx, "上游返回: %s", upstreamResp)
task.AppendLog(ctx, "Upstream response: %s", upstreamResp)
}
h.recordHistory(ctx, req, "success", "")
return &task.TaskResult{
Message: fmt.Sprintf("推送成功: [%s] -> %s", req.Config.Channel, req.Target),
Message: fmt.Sprintf("Notification sent: [%s] -> %s", req.Config.Channel, req.Target),
}, nil
}
func (h *PushHandler) recordHistory(ctx context.Context, req SendPayload, status string, errMsg string) {
if dbErr := recordPushHistory(ctx, req, status, errMsg); dbErr != nil {
task.AppendLog(ctx, "写入推送历史审计记录失败: %v", dbErr)
task.AppendLog(ctx, "Failed to write notification audit history: %v", dbErr)
}
}
+6 -6
View File
@@ -55,16 +55,16 @@ func GetLogDatabaseStatus(c *gin.Context) {
ctx := c.Request.Context()
store, err := logstore.Active(ctx)
if err != nil {
logger.ErrorF(ctx, "获取日志存储实例失败: %v", err)
response.AbortInternal(c, "日志存储初始化失败")
logger.ErrorF(ctx, "Failed to get log storage instance: %v", err)
response.AbortInternal(c, "Failed to initialize log storage")
return
}
// 分支判定复用同一 store 实例的 ActiveDatabase,避免与 Active 解析之间出现 TOCTOU。
activeDB, err := store.Status.ActiveDatabase(ctx)
if err != nil {
logger.ErrorF(ctx, "获取日志库状态失败: %v", err)
response.AbortInternal(c, "获取日志库状态失败")
logger.ErrorF(ctx, "Failed to get log database status: %v", err)
response.AbortInternal(c, "Failed to get log database status")
return
}
migration := "idle"
@@ -86,7 +86,7 @@ func GetLogDatabaseStatus(c *gin.Context) {
if activeDB == logDBNameClickHouse {
stats, err := store.Status.ClickHouseOperationalStats(ctx)
if err != nil {
logger.ErrorF(ctx, "获取 ClickHouse 运行指标失败: %v", err)
logger.ErrorF(ctx, "Failed to get ClickHouse runtime metrics: %v", err)
} else {
stats.BatchWriters = collectBatchWriterStats()
out.ClickHouse = stats
@@ -101,7 +101,7 @@ func retentionOr(ctx context.Context, key string) int {
v, err := repository.GetIntByKey(ctx, key)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
logger.ErrorF(ctx, "读取日志保留天数配置失败 key=%s: %v", key, err)
logger.ErrorF(ctx, "Failed to read log retention configuration key=%s: %v", key, err)
}
return defaultLogRetentionDays
}
@@ -0,0 +1,26 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package status
import (
"testing"
"time"
)
func TestFormatDuration(t *testing.T) {
for _, tc := range []struct {
duration time.Duration
want string
}{
{duration: 0, want: "0 seconds"},
{duration: time.Second, want: "1 second"},
{duration: 61 * time.Second, want: "1 minute 1 second"},
{duration: 26 * time.Hour, want: "1 day 2 hours"},
{duration: 48*time.Hour + 2*time.Minute + 5*time.Second, want: "2 days 2 minutes 5 seconds"},
} {
if got := formatDuration(tc.duration); got != tc.want {
t.Errorf("formatDuration(%v) = %q, want %q", tc.duration, got, tc.want)
}
}
}
+21 -20
View File
@@ -16,6 +16,7 @@ import (
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/infra/config"
@@ -118,20 +119,20 @@ func formatDuration(d time.Duration) string {
minutes := int(d.Minutes()) % minutesInHour
seconds := int(d.Seconds()) % secondsInMinute
var res string
if days > 0 {
res += fmt.Sprintf("%d天", days)
var parts []string
for _, part := range []struct {
value int
unit string
}{{days, "day"}, {hours, "hour"}, {minutes, "minute"}, {seconds, "second"}} {
if part.value > 0 || (part.unit == "second" && len(parts) == 0) {
unit := part.unit
if part.value != 1 {
unit += "s"
}
parts = append(parts, fmt.Sprintf("%d %s", part.value, unit))
}
}
if hours > 0 {
res += fmt.Sprintf("%d小时", hours)
}
if minutes > 0 {
res += fmt.Sprintf("%d分钟", minutes)
}
if seconds > 0 || res == "" {
res += fmt.Sprintf("%d秒钟", seconds)
}
return res
return strings.Join(parts, " ")
}
// GetSystemStatus 获取系统状态信息
@@ -156,9 +157,9 @@ func GetSystemStatus(c *gin.Context) {
case m.LastGC > 0 && m.LastGC <= math.MaxInt64:
lastGCTime = formatDuration(time.Since(time.Unix(0, int64(m.LastGC))))
case m.LastGC > 0:
lastGCTime = "未知"
lastGCTime = "Unknown"
default:
lastGCTime = "无"
lastGCTime = "None"
}
var lastPause string
@@ -292,7 +293,7 @@ func exportSQLite(c *gin.Context) {
dir, err := os.MkdirTemp("", "openflare-db-export-*")
if err != nil {
logger.ErrorF(c.Request.Context(), "[db-export] create temporary directory failed: %v", err)
response.AbortInternal(c, "无法创建数据库导出文件")
response.AbortInternal(c, "Unable to create database export file")
return
}
defer func() {
@@ -303,13 +304,13 @@ func exportSQLite(c *gin.Context) {
path := filepath.Join(dir, "openflare.db")
if err := repository.ExportSQLiteSnapshot(c.Request.Context(), path); err != nil {
logger.ErrorF(c.Request.Context(), "[db-export] create SQLite snapshot failed: %v", err)
response.AbortInternal(c, "无法导出数据库")
response.AbortInternal(c, "Unable to export database")
return
}
f, err := os.Open(path) //nolint:gosec // path is generated in a private temporary directory, not from user input
if err != nil {
response.AbortInternal(c, "无法打开数据库文件: "+err.Error())
response.AbortInternal(c, "Unable to open database file: "+err.Error())
return
}
defer func() {
@@ -320,7 +321,7 @@ func exportSQLite(c *gin.Context) {
fi, err := f.Stat()
if err != nil {
response.AbortInternal(c, "无法读取数据库文件信息: "+err.Error())
response.AbortInternal(c, "Unable to read database file information: "+err.Error())
return
}
@@ -338,7 +339,7 @@ func exportPostgres(c *gin.Context) {
// 检查 pg_dump 是否可用
pgDumpPath, err := exec.LookPath("pg_dump")
if err != nil {
response.AbortInternal(c, "pg_dump 不可用,请确保服务器已安装 PostgreSQL 客户端工具")
response.AbortInternal(c, "pg_dump is unavailable; install the PostgreSQL client tools on the server")
return
}
+5 -5
View File
@@ -7,9 +7,9 @@ package system_config
// 系统配置错误消息常量
const (
SystemConfigNotFound = "系统配置不存在"
ConfigKeyRequired = "配置键不能为空"
ConfigValueRequired = "配置值不能为空"
ConfigKeyExists = "配置键已存在"
StorageDriverSwitchRequiresMigration = "存在存量文件,请通过存储迁移任务切换存储引擎"
SystemConfigNotFound = "System configuration was not found"
ConfigKeyRequired = "Configuration key must not be empty"
ConfigValueRequired = "Configuration value must not be empty"
ConfigKeyExists = "configuration key already exists"
StorageDriverSwitchRequiresMigration = "existing files were found; use the storage migration task to switch storage backends"
)
+4 -4
View File
@@ -43,7 +43,7 @@ func createSystemConfig(ctx context.Context, req CreateSystemConfigRequest) erro
invalidateSystemConfigCaches(ctx, req.Key)
if err := repository.InvalidateVisibleSystemConfigsCache(ctx); err != nil {
logger.WarnF(ctx, "清理公共配置列表缓存失败: %v", err)
logger.WarnF(ctx, "Failed to clear public configuration list cache: %v", err)
}
return nil
}
@@ -71,7 +71,7 @@ func updateSystemConfig(ctx context.Context, key string, req UpdateSystemConfigR
validatedVal, err := validateAndMergeStorageConfig(ctx, req.Value, config.Value)
if err != nil {
return err
return &storageConfigValidationError{err}
}
req.Value = validatedVal
}
@@ -123,9 +123,9 @@ func resolveStorageMigrationTasksOnDirectDriverUpdate(
if err := repository.MarkFailedTaskExecutionsSucceededTx(
tx,
"storage:migrate",
"存储配置直接更新,故障迁移任务自动标记为已解决",
"Storage configuration updated directly; failed migration task marked as resolved",
time.Now(),
); err != nil {
logger.ErrorF(ctx, "自动更新迁移任务状态失败: %v", err)
logger.ErrorF(ctx, "Failed to automatically update migration task status: %v", err)
}
}
+17 -20
View File
@@ -10,7 +10,6 @@ import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -28,7 +27,7 @@ import (
const maskedConfigValue = "******"
// protectedConfigKeyMessage 命中受保护 key 时返回给管理员的业务错误文案。
const protectedConfigKeyMessage = "该配置项由系统任务管理,禁止手动修改"
const protectedConfigKeyMessage = "this configuration is managed by system tasks and cannot be changed manually"
// protectedConfigKeys 仅允许内部(迁移任务/bootstrap)写入的 key。
var protectedConfigKeys = map[string]bool{
@@ -198,7 +197,7 @@ func UpdateSystemConfig(c *gin.Context) {
func invalidateSystemConfigCaches(ctx context.Context, key string) {
if err := repository.InvalidateSystemConfigCache(ctx, key); err != nil {
logger.WarnF(ctx, "清理系统配置缓存失败: %v", err)
logger.WarnF(ctx, "Failed to clear system configuration cache: %v", err)
}
if cap.IsRuntimeConfigKey(key) {
cap.InvalidateRuntimeSettings()
@@ -220,7 +219,7 @@ func invalidateCachesAfterConfigUpdate(ctx context.Context, key string) {
}
if err := repository.InvalidateVisibleSystemConfigsCache(ctx); err != nil {
logger.WarnF(ctx, "清理公共配置列表缓存失败: %v", err)
logger.WarnF(ctx, "Failed to clear public configuration list cache: %v", err)
}
}
@@ -289,15 +288,13 @@ func TestSMTP(c *gin.Context) {
c.JSON(http.StatusOK, response.OK(resp))
}
type storageConfigValidationError struct{ error }
func (e *storageConfigValidationError) Unwrap() error { return e.error }
func isStorageConfigValidationError(err error) bool {
msg := err.Error()
return msg == StorageDriverSwitchRequiresMigration ||
strings.HasPrefix(msg, "解析") ||
strings.HasPrefix(msg, "验证") ||
strings.HasPrefix(msg, "初始化测试") ||
strings.HasPrefix(msg, "存储连通性") ||
strings.HasPrefix(msg, "序列化") ||
strings.HasPrefix(msg, "检查存量文件")
var validationErr *storageConfigValidationError
return errors.As(err, &validationErr)
}
func maskSensitiveConfig(key, value string) string {
@@ -324,12 +321,12 @@ func maskSensitiveConfig(key, value string) string {
func validateAndMergeStorageConfig(ctx context.Context, value string, currentConfig string) (string, error) {
var currentCfg objectstore.Config
if err := json.Unmarshal([]byte(currentConfig), &currentCfg); err != nil {
return "", fmt.Errorf("解析当前存储配置失败: %w", err)
return "", fmt.Errorf("failed to parse current storage configuration: %w", err)
}
var newCfg objectstore.Config
if err := json.Unmarshal([]byte(value), &newCfg); err != nil {
return "", fmt.Errorf("解析目标存储配置失败: %w", err)
return "", fmt.Errorf("failed to parse target storage configuration: %w", err)
}
// 合并被掩码屏蔽的敏感信息,获取完整的真实配置
@@ -341,7 +338,7 @@ func validateAndMergeStorageConfig(ctx context.Context, value string, currentCon
// 序列化为最终保存的真实明文配置,防止保存屏蔽的 ****** 字符
unmaskedVal, err := json.Marshal(targetCfg)
if err != nil {
return "", fmt.Errorf("序列化存储配置失败: %w", err)
return "", fmt.Errorf("failed to serialize storage configuration: %w", err)
}
return string(unmaskedVal), nil
@@ -351,13 +348,13 @@ func validateMergedStorageConfig(ctx context.Context, currentCfg, newCfg, target
if newCfg.Driver != "" && newCfg.Driver != currentCfg.Driver {
uploadCount, err := repository.CountActiveUploads(ctx)
if err != nil {
return fmt.Errorf("检查存量文件失败: %w", err)
return fmt.Errorf("failed to check existing files: %w", err)
}
if uploadCount > 0 {
return errors.New(StorageDriverSwitchRequiresMigration)
}
if err := validateDriverConfig(targetCfg, newCfg.Driver); err != nil {
return fmt.Errorf("验证目标存储配置参数失败: %w", err)
return fmt.Errorf("failed to validate target storage parameters: %w", err)
}
pendingCfg := targetCfg
pendingCfg.Driver = newCfg.Driver
@@ -365,7 +362,7 @@ func validateMergedStorageConfig(ctx context.Context, currentCfg, newCfg, target
}
if err := objectstore.ValidateConfig(targetCfg); err != nil {
return fmt.Errorf("验证存储配置参数失败: %w", err)
return fmt.Errorf("failed to validate storage parameters: %w", err)
}
return testStorageBackend(ctx, targetCfg, targetCfg.Driver)
}
@@ -378,10 +375,10 @@ func validateDriverConfig(cfg objectstore.Config, driver objectstore.Driver) err
func testStorageBackend(ctx context.Context, cfg objectstore.Config, driver objectstore.Driver) error {
testBackend, err := objectstore.NewBackend(ctx, cfg, driver)
if err != nil {
return fmt.Errorf("初始化测试存储实例失败: %w", err)
return fmt.Errorf("failed to initialize test storage instance: %w", err)
}
if err := testBackend.Test(ctx); err != nil {
return fmt.Errorf("存储连通性测试失败: %w", err)
return fmt.Errorf("storage connectivity test failed: %w", err)
}
return nil
}
+13 -13
View File
@@ -7,17 +7,17 @@ package task
// 任务管理相关错误消息
const (
InvalidTaskType = "无效的任务类型"
InvalidTimeRange = "无效的时间范围"
TaskDispatchFailed = "任务下发失败"
UserIDRequired = "用户ID必填"
TaskNotFound = "任务执行记录不存在"
TaskNotRetryable = "该任务不支持重试"
TaskNotFailed = "只有失败的任务才能重试"
TaskMaxRetryExceeded = "已达到最大重试次数"
TaskRetryFailed = "任务重试失败"
InvalidCronExpression = "无效的 Cron 表达式"
ScheduleNotFound = "定时任务不存在"
ScheduleSaveFailed = "保存定时任务失败"
ScheduleDeleteFailed = "删除定时任务失败"
InvalidTaskType = "Invalid task type"
InvalidTimeRange = "Invalid time range"
TaskDispatchFailed = "Failed to dispatch task"
UserIDRequired = "User ID is required"
TaskNotFound = "Task execution was not found"
TaskNotRetryable = "this task cannot be retried"
TaskNotFailed = "Only failed tasks can be retried"
TaskMaxRetryExceeded = "Maximum retry count reached"
TaskRetryFailed = "Failed to retry task"
InvalidCronExpression = "Invalid Cron expression"
ScheduleNotFound = "Schedule was not found"
ScheduleSaveFailed = "Failed to save schedule"
ScheduleDeleteFailed = "Failed to delete schedule"
)
+53
View File
@@ -0,0 +1,53 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package task
import (
"slices"
"github.com/Rain-kl/Wavelet/internal/infra/task"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/pkg/i18n"
)
// Localize copies, never the shared registry or persistent task records.
func localizeTaskMeta(locale string, meta task.TaskMeta) task.TaskMeta {
meta.Name = i18n.Translate(locale, meta.Name)
meta.Description = i18n.Translate(locale, meta.Description)
meta.Params = slices.Clone(meta.Params)
for index := range meta.Params {
param := &meta.Params[index]
param.Label = i18n.Translate(locale, param.Label)
param.Placeholder = i18n.Translate(locale, param.Placeholder)
param.Description = i18n.Translate(locale, param.Description)
}
return meta
}
func localizeTaskExecution(locale string, execution model.TaskExecution) model.TaskExecution {
execution.TaskName = i18n.Translate(locale, execution.TaskName)
execution.Log = i18n.TranslateLog(locale, execution.Log)
execution.ErrorMessage = i18n.Translate(locale, execution.ErrorMessage)
execution.Result = i18n.Translate(locale, execution.Result)
return execution
}
func localizeTaskExecutions(locale string, executions []model.TaskExecution) []model.TaskExecution {
localized := slices.Clone(executions)
for index := range localized {
localized[index] = localizeTaskExecution(locale, localized[index])
}
return localized
}
func localizeScheduleName(locale, name string, meta *task.TaskMeta) string {
// Only the built-in default name is system text. Custom schedule names are data.
if meta != nil && meta.Type == "system_cleanup" && i18n.Translate("en", name) == "Scheduled system cleanup" {
return i18n.Translate(locale, "Scheduled system cleanup")
}
if meta != nil && i18n.Translate("en", name) == meta.Name {
return i18n.Translate(locale, meta.Name)
}
return name
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package task
import (
"testing"
"github.com/Rain-kl/Wavelet/internal/infra/task"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/stretchr/testify/assert"
)
func TestLocalizeTaskMetaDoesNotMutateSharedParams(t *testing.T) {
source := task.TaskMeta{
Name: "Switch log database", AsynqTask: "openflare:log_db_switch",
Params: []task.TaskParam{{Name: "target", Label: "Target log database", Placeholder: "postgres|sqlite|clickhouse"}},
}
localized := localizeTaskMeta("zh-CN", source)
assert.Equal(t, "切换日志数据库", localized.Name)
assert.Equal(t, "目标日志库", localized.Params[0].Label)
assert.Equal(t, "openflare:log_db_switch", localized.AsynqTask)
assert.Equal(t, "target", localized.Params[0].Name)
assert.Equal(t, "Target log database", source.Params[0].Label)
}
func TestLocalizeTaskExecutionsPreservesSourceAndPayload(t *testing.T) {
source := []model.TaskExecution{{
TaskName: "OpenFlare SSL 自动续期", Log: "[12:34:56] 开始扫描待续期证书\n",
Payload: `{"name":"开始扫描待续期证书"}`, TriggeredBy: "用户甲",
}}
localized := localizeTaskExecutions("en", source)
assert.Equal(t, "OpenFlare automatic SSL renewal", localized[0].TaskName)
assert.Equal(t, "[12:34:56] Scanning certificates due for renewal\n", localized[0].Log)
assert.Equal(t, source[0].Payload, localized[0].Payload)
assert.Equal(t, source[0].TriggeredBy, localized[0].TriggeredBy)
assert.Equal(t, "OpenFlare SSL 自动续期", source[0].TaskName)
}
func TestLocalizeScheduleNamePreservesCustomNames(t *testing.T) {
meta := &task.TaskMeta{Type: "system_cleanup", Name: "System cleanup"}
assert.Equal(t, "Scheduled system cleanup", localizeScheduleName("en", "系统定期垃圾清理", meta))
assert.Equal(t, "系统定期垃圾清理", localizeScheduleName("zh-CN", "Scheduled system cleanup", meta))
assert.Equal(t, "自定义每周清理", localizeScheduleName("en", "自定义每周清理", meta))
}
+18 -10
View File
@@ -5,6 +5,7 @@
package task
import (
"errors"
"fmt"
"net/http"
"strconv"
@@ -35,7 +36,11 @@ import (
// @Failure 403 {object} response.Any "无管理员权限"
// @Router /api/v1/admin/tasks/types [get]
func ListTaskTypes(c *gin.Context) {
c.JSON(http.StatusOK, response.OK(task.GetDispatchableTasks()))
metas := task.GetDispatchableTasks()
for index := range metas {
metas[index] = localizeTaskMeta(response.Locale(c), metas[index])
}
c.JSON(http.StatusOK, response.OK(metas))
}
// DispatchTaskRequest 下发任务请求
@@ -130,7 +135,7 @@ func ListTaskExecutions(c *gin.Context) {
}
c.JSON(http.StatusOK, response.OK(gin.H{
"items": executions,
"items": localizeTaskExecutions(response.Locale(c), executions),
"total": total,
"page": req.Page,
"page_size": req.PageSize,
@@ -163,7 +168,8 @@ func GetTaskExecution(c *gin.Context) {
return
}
c.JSON(http.StatusOK, response.OK(execution))
localized := localizeTaskExecution(response.Locale(c), *execution)
c.JSON(http.StatusOK, response.OK(localized))
}
// RetryTask 重试失败的任务
@@ -190,10 +196,11 @@ func RetryTask(c *gin.Context) {
newTaskID, err := task.RetryTask(c.Request.Context(), id)
if err != nil {
errMsg := err.Error()
var retryErr *task.RetryError
switch {
case strings.Contains(errMsg, "不存在"):
case errors.As(err, &retryErr) && retryErr.NotFound:
response.AbortNotFound(c, errMsg)
case strings.Contains(errMsg, "只有失败的任务") || strings.Contains(errMsg, "不支持重试") || strings.Contains(errMsg, "已达到最大重试"):
case retryErr != nil:
response.AbortBadRequest(c, errMsg)
default:
response.AbortInternal(c, fmt.Sprintf("%s: %v", TaskRetryFailed, err))
@@ -226,6 +233,7 @@ func ListSchedules(c *gin.Context) {
if meta != nil && meta.InternalOnly {
continue
}
schedule.Name = localizeScheduleName(response.Locale(c), schedule.Name, meta)
visible = append(visible, schedule)
}
c.JSON(http.StatusOK, response.OK(visible))
@@ -300,7 +308,7 @@ func CreateSchedule(c *gin.Context) {
// 触发调度服务重载
if err := scheduler.ReloadScheduler(); err != nil {
logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err)
logger.ErrorF(c.Request.Context(), "[TaskAdmin] Failed to reload scheduler: %v", err)
}
c.JSON(http.StatusOK, response.OK(schedule))
@@ -334,7 +342,7 @@ type UpdateScheduleRequest struct {
func UpdateSchedule(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.AbortBadRequest(c, "无效的定时任务ID")
response.AbortBadRequest(c, "Invalid schedule ID")
return
}
@@ -392,7 +400,7 @@ func UpdateSchedule(c *gin.Context) {
// 触发调度服务重载
if err := scheduler.ReloadScheduler(); err != nil {
logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err)
logger.ErrorF(c.Request.Context(), "[TaskAdmin] Failed to reload scheduler: %v", err)
}
c.JSON(http.StatusOK, response.OK(schedule))
@@ -423,7 +431,7 @@ func getAdminTaskMeta(taskType string) *task.TaskMeta {
func DeleteSchedule(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.AbortBadRequest(c, "无效的定时任务ID")
response.AbortBadRequest(c, "Invalid schedule ID")
return
}
schedule, err := repository.GetScheduleByID(c.Request.Context(), id)
@@ -443,7 +451,7 @@ func DeleteSchedule(c *gin.Context) {
// 触发调度服务重载
if err := scheduler.ReloadScheduler(); err != nil {
logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err)
logger.ErrorF(c.Request.Context(), "[TaskAdmin] Failed to reload scheduler: %v", err)
}
c.JSON(http.StatusOK, response.OKNil())
+2 -2
View File
@@ -410,7 +410,7 @@ func TestDispatchTask(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp response.Any
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Contains(t, resp.ErrorMsg, "无效的 JSON 格式")
assert.Contains(t, resp.ErrorMsg, "invalid JSON format")
})
t.Run("dispatch send_email task failure with missing fields", func(t *testing.T) {
@@ -427,7 +427,7 @@ func TestDispatchTask(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp response.Any
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Contains(t, resp.ErrorMsg, "不能为空")
assert.Contains(t, resp.ErrorMsg, "must not be empty")
})
t.Run("dispatch invalid task type failure", func(t *testing.T) {
+7 -7
View File
@@ -6,11 +6,11 @@ package template
// 模板管理相关错误消息
const (
TemplateNotFound = "模板不存在"
TemplateKeyRequired = "模板标识符不能为空"
TemplateNameRequired = "模板名称不能为空"
TemplateContentRequired = "模板内容不能为空"
TemplateKeyExists = "模板标识符已存在"
SystemTemplateCannotDelete = "系统预置模板不可删除"
SystemTemplateCannotModifyKey = "系统预置模板不可修改标识符"
TemplateNotFound = "Template was not found"
TemplateKeyRequired = "Template identifier must not be empty"
TemplateNameRequired = "Template name must not be empty"
TemplateContentRequired = "Template content must not be empty"
TemplateKeyExists = "template identifier already exists"
SystemTemplateCannotDelete = "built-in templates cannot be deleted"
SystemTemplateCannotModifyKey = "Built-in template identifiers cannot be changed"
)
+9 -9
View File
@@ -5,13 +5,13 @@
package updater
const (
errInvalidRepository = "上游仓库地址无效"
errReleaseRequestFailed = "获取上游版本失败"
errReleaseResponseInvalid = "上游版本响应无效"
errNoCompatibleRelease = "未找到兼容的 Release"
errNoCompatibleAsset = "未找到当前系统对应的 Release 资产"
errDevelopmentBuild = "开发版本无法执行自动升级"
errAlreadyUpToDate = "当前已是最新版本"
errUpgradeAlreadyRunning = "已有升级任务正在执行"
errAutomaticUpgradeBlocked = "当前平台暂不支持自动替换二进制"
errInvalidRepository = "invalid upstream repository URL"
errReleaseRequestFailed = "Failed to fetch upstream version"
errReleaseResponseInvalid = "Invalid upstream version response"
errNoCompatibleRelease = "no compatible release was found"
errNoCompatibleAsset = "no release asset was found for the current platform"
errDevelopmentBuild = "development builds do not support automatic updates"
errAlreadyUpToDate = "already running the latest version"
errUpgradeAlreadyRunning = "an update is already in progress"
errAutomaticUpgradeBlocked = "automatic binary replacement is not supported on this platform"
)
+16 -16
View File
@@ -265,42 +265,42 @@ func (m *manager) status(ctx context.Context) (Status, releaseAsset, error) {
func downloadArchive(ctx context.Context, client releaseClient, asset releaseAsset, destination string) error {
if asset.Size <= 0 || asset.Size > maxArchiveSize {
return fmt.Errorf("release 资产大小无效: %d", asset.Size)
return fmt.Errorf("invalid release asset size: %d", asset.Size)
}
logger.InfoF(ctx, "[Updater] Downloading release asset: %s", asset.Name)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.BrowserDownloadURL, nil)
if err != nil {
return fmt.Errorf("创建升级下载请求失败: %w", err)
return fmt.Errorf("failed to create update download request: %w", err)
}
req.Header.Set("User-Agent", "OpenFlare-Updater")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("下载升级资产失败: %w", err)
return fmt.Errorf("failed to download update asset: %w", err)
}
defer func() {
// The downloaded body has already been validated by size before use.
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("下载升级资产失败: HTTP %d", resp.StatusCode)
return fmt.Errorf("failed to download update asset: HTTP %d", resp.StatusCode)
}
file, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, archiveFileMode) //nolint:gosec // destination is created inside the verified executable directory.
if err != nil {
return fmt.Errorf("创建升级归档失败: %w", err)
return fmt.Errorf("failed to create update archive: %w", err)
}
written, err := io.Copy(file, io.LimitReader(resp.Body, maxArchiveSize+1))
if err != nil {
_ = file.Close()
return fmt.Errorf("写入升级归档失败: %w", err)
return fmt.Errorf("failed to write update archive: %w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("关闭升级归档失败: %w", err)
return fmt.Errorf("failed to close update archive: %w", err)
}
if written > maxArchiveSize || written != asset.Size {
return fmt.Errorf("升级归档大小不匹配: got %d, want %d", written, asset.Size)
return fmt.Errorf("update archive size mismatch: got %d, want %d", written, asset.Size)
}
logger.InfoF(ctx, "[Updater] Successfully downloaded release asset to %s", destination)
return nil
@@ -309,12 +309,12 @@ func downloadArchive(ctx context.Context, client releaseClient, asset releaseAss
func safeArchivePath(destination, name string) (string, error) {
cleanName := filepath.Clean(name)
if filepath.IsAbs(cleanName) || cleanName == "." || strings.HasPrefix(cleanName, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("归档包含非法路径: %s", name)
return "", fmt.Errorf("archive contains an invalid path: %s", name)
}
target := filepath.Join(destination, cleanName)
relative, err := filepath.Rel(destination, target)
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("归档路径越界: %s", name)
return "", fmt.Errorf("archive path escapes the destination: %s", name)
}
return target, nil
}
@@ -510,7 +510,7 @@ func extractTarGz(ctx context.Context, archivePath, destination, targetName stri
return "", closeErr
}
if written > maxArchiveSize {
return "", errors.New("解压后的程序文件超过大小限制")
return "", errors.New("extracted executable exceeds the size limit")
}
logger.InfoF(ctx, "[Updater] Successfully extracted binary to %s", target)
return target, nil
@@ -563,7 +563,7 @@ func extractZip(ctx context.Context, archivePath, destination, targetName string
return "", outputCloseErr
}
if written > maxArchiveSize {
return "", errors.New("解压后的程序文件超过大小限制")
return "", errors.New("extracted executable exceeds the size limit")
}
logger.InfoF(ctx, "[Updater] Successfully extracted binary to %s", target)
return target, nil
@@ -597,16 +597,16 @@ func (m *manager) prepareUpgrade(ctx context.Context) (string, string, error) {
executable, err := os.Executable()
if err != nil {
return "", "", fmt.Errorf("定位当前程序失败: %w", err)
return "", "", fmt.Errorf("failed to locate current executable: %w", err)
}
executable, err = filepath.EvalSymlinks(executable)
if err != nil {
return "", "", fmt.Errorf("解析当前程序路径失败: %w", err)
return "", "", fmt.Errorf("failed to resolve current executable path: %w", err)
}
tempDir, err := os.MkdirTemp(filepath.Dir(executable), ".wavelet-update-*")
if err != nil {
return "", "", fmt.Errorf("创建升级目录失败: %w", err)
return "", "", fmt.Errorf("failed to create update directory: %w", err)
}
archivePath := filepath.Join(tempDir, asset.Name)
@@ -628,7 +628,7 @@ func (m *manager) prepareUpgrade(ctx context.Context) (string, string, error) {
if err != nil {
// Cleanup is best effort because the extraction error is the actionable failure.
_ = os.RemoveAll(tempDir)
return "", "", fmt.Errorf("解压升级资产失败: %w", err)
return "", "", fmt.Errorf("failed to extract update asset: %w", err)
}
logger.InfoF(ctx, "[Updater] Staged binary successfully prepared: %s", stagedBinary)
m.upgrading = true
+4 -4
View File
@@ -23,22 +23,22 @@ func replaceAndRestart(executable, stagedBinary string) error {
backup := executable + ".old"
if err := os.Remove(backup); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("删除旧备份失败: %w", err)
return fmt.Errorf("failed to remove previous backup: %w", err)
}
if err := os.Rename(executable, backup); err != nil {
return fmt.Errorf("备份当前程序失败: %w", err)
return fmt.Errorf("failed to back up current executable: %w", err)
}
if err := os.Rename(stagedBinary, executable); err != nil {
_ = os.Rename(backup, executable)
return fmt.Errorf("替换当前程序失败: %w", err)
return fmt.Errorf("failed to replace current executable: %w", err)
}
if err := os.Chmod(executable, installedBinaryMode); err != nil { //nolint:gosec // the installed application binary must be executable.
_ = os.Remove(executable)
_ = os.Rename(backup, executable)
return fmt.Errorf("设置程序执行权限失败: %w", err)
return fmt.Errorf("failed to set executable permissions: %w", err)
}
stagingDir := filepath.Dir(stagedBinary)
+14 -14
View File
@@ -6,18 +6,18 @@
package user
const (
userNotFound = "用户不存在"
cannotDisable = "不能禁用管理员用户"
cannotDelete = "不能删除管理员用户"
cannotDeleteSelf = "不能删除当前登录用户"
updateUserFailed = "更新用户状态失败"
deleteUserFailed = "删除用户失败"
usernameExists = "用户名已存在"
usernameRequired = "用户名不能为空"
passwordTooShort = "密码长度不能少于 8 位" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
createUserFailed = "创建用户失败"
emailRequired = "邮箱不能为空"
emailExists = "邮箱已被注册"
cannotRevokeSelfAdmin = "不能撤销当前登录用户的管理员权限"
updateUserInfoFailed = "更新用户信息失败"
userNotFound = "user was not found"
cannotDisable = "administrator accounts cannot be disabled"
cannotDelete = "administrator accounts cannot be deleted"
cannotDeleteSelf = "the currently signed-in user cannot be deleted"
updateUserFailed = "Failed to update user status"
deleteUserFailed = "Failed to delete user"
usernameExists = "username already exists"
usernameRequired = "username must not be empty"
passwordTooShort = "password must contain at least 8 characters" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
createUserFailed = "Failed to create user"
emailRequired = "email address must not be empty"
emailExists = "email address is already registered"
cannotRevokeSelfAdmin = "administrator privileges cannot be revoked from the currently signed-in user"
updateUserInfoFailed = "Failed to update user information"
)
+2 -2
View File
@@ -103,7 +103,7 @@ func abortUserLogicError(c *gin.Context, err error, notFoundMsg string, forbidde
return true
}
logger.ErrorF(c.Request.Context(), "Admin user error: %v", err)
response.AbortInternal(c, "内部服务器错误")
response.AbortInternal(c, "Internal server error")
return true
}
@@ -130,7 +130,7 @@ func ListUsers(c *gin.Context) {
total, modelUsers, err := listUsers(c.Request.Context(), req)
if err != nil {
logger.ErrorF(c.Request.Context(), "List admin users failed: %v", err)
response.AbortInternal(c, "获取用户列表失败")
response.AbortInternal(c, "Failed to get user list")
return
}
+1 -1
View File
@@ -479,7 +479,7 @@ func (r *Runner) tryRegister(ctx context.Context, nodeID *string) (resultErr err
}
}()
if strings.TrimSpace(r.Config.DiscoveryToken) == "" {
return errors.New("agent token is empty and no discovery token is configured")
return errors.New("agent_token is empty and discovery_token is not configured")
}
registrationKey, err := r.StateStore.EnsureRegistrationKey()
if err != nil {
+9 -9
View File
@@ -353,25 +353,25 @@ func joinManagedPath(base string, relative string) string {
func validate(cfg *Config) error {
if cfg.ServerURL == "" {
return errors.New("server_url 不能为空")
return errors.New("server_url must not be empty")
}
if strings.TrimSpace(cfg.AccessToken) == "" && strings.TrimSpace(cfg.DiscoveryToken) == "" {
return errors.New("agent_token 和 discovery_token 不能同时为空")
return errors.New("agent_token and discovery_token must not both be empty")
}
if cfg.NodeName == "" {
return errors.New("node_name 不能为空")
return errors.New("node_name must not be empty")
}
if cfg.NodeIP == "" {
return errors.New("node_ip 不能为空")
return errors.New("node_ip must not be empty")
}
if cfg.OpenrestyObservabilityPort <= 0 || cfg.OpenrestyObservabilityPort > 65535 {
return errors.New("openresty_observability_port 必须在 1-65535 之间")
return errors.New("openresty_observability_port must be between 1 and 65535")
}
if cfg.ObservabilityReplayMinutes <= 0 {
return errors.New("observability_replay_minutes 必须大于 0")
return errors.New("observability_replay_minutes must be greater than 0")
}
if cfg.MMDBUpdateInterval <= 0 {
return errors.New("mmdb_update_interval 必须大于 0")
return errors.New("mmdb_update_interval must be greater than 0")
}
return nil
}
@@ -398,10 +398,10 @@ func (cfg *Config) toConfigFile() configFile {
// Save persists the current configuration back to its original file path.
func (cfg *Config) Save() error {
if cfg == nil {
return errors.New("config 不能为空")
return errors.New("config must not be empty")
}
if cfg.configPath == "" {
return errors.New("config path 未初始化")
return errors.New("configuration path is not initialized")
}
data, err := json.MarshalIndent(cfg.toConfigFile(), "", " ") //nolint:gosec // agent token must be persisted in local config file
if err != nil {
+15 -15
View File
@@ -325,7 +325,7 @@ func (m *Manager) ensureOpenRestyWorkerReadAccess() error {
func (m *Manager) activateConfig(ctx context.Context) error {
if m.Executor == nil {
return errors.New("executor 未配置")
return errors.New("Executor is not configured")
}
if err := m.Executor.Test(ctx); err != nil {
return err
@@ -407,7 +407,7 @@ func (m *Manager) EnsureRuntime(ctx context.Context, recreate bool) error {
m.runtimeMu.Lock()
defer m.runtimeMu.Unlock()
if m.Executor == nil {
return errors.New("executor 未配置")
return errors.New("Executor is not configured")
}
slog.Info("openresty ensure runtime requested", "recreate", recreate)
return m.Executor.EnsureRuntime(ctx, recreate)
@@ -422,7 +422,7 @@ func (m *Manager) EnsureSafeFallbackRuntime(ctx context.Context, reason string)
func (m *Manager) ensureSafeFallbackRuntime(ctx context.Context, reason string) error {
if m.Executor == nil {
return errors.New("executor 未配置")
return errors.New("Executor is not configured")
}
trimmedReason := strings.TrimSpace(reason)
if trimmedReason == "" {
@@ -444,7 +444,7 @@ func (m *Manager) CheckHealth(ctx context.Context) error {
m.runtimeMu.Lock()
defer m.runtimeMu.Unlock()
if m.Executor == nil {
return errors.New("executor 未配置")
return errors.New("Executor is not configured")
}
if m.MainConfigPath != "" {
if _, err := os.Stat(m.MainConfigPath); os.IsNotExist(err) {
@@ -462,7 +462,7 @@ func (m *Manager) Restart(ctx context.Context) error {
m.runtimeMu.Lock()
defer m.runtimeMu.Unlock()
if m.Executor == nil {
return errors.New("executor 未配置")
return errors.New("Executor is not configured")
}
slog.Info("openresty restart requested")
return m.Executor.Restart(ctx)
@@ -473,10 +473,10 @@ func (m *Manager) CurrentChecksum() (string, error) {
m.runtimeMu.Lock()
defer m.runtimeMu.Unlock()
if m.RouteConfigPath == "" {
return "", errors.New("route config path 不能为空")
return "", errors.New("route configuration path must not be empty")
}
if m.MainConfigPath == "" {
return "", errors.New("main config path 不能为空")
return "", errors.New("main configuration path must not be empty")
}
mainData, err := os.ReadFile(m.MainConfigPath)
if err != nil {
@@ -828,10 +828,10 @@ type managedFile struct {
func (m *Manager) backup() (*backupState, error) {
if m.MainConfigPath == "" {
return nil, errors.New("main config path 不能为空")
return nil, errors.New("main configuration path must not be empty")
}
if m.RouteConfigPath == "" {
return nil, errors.New("route config path 不能为空")
return nil, errors.New("route configuration path must not be empty")
}
if err := os.MkdirAll(filepath.Dir(m.MainConfigPath), nginxDirPerm); err != nil {
return nil, err
@@ -1143,10 +1143,10 @@ func (m *Manager) restoreRuntimeConfig(file *protocol.SupportFile, name string)
func (m *Manager) writeSafeDefaultFallbackFiles() error {
if strings.TrimSpace(m.MainConfigPath) == "" {
return errors.New("main config path 不能为空")
return errors.New("main configuration path must not be empty")
}
if strings.TrimSpace(m.RouteConfigPath) == "" {
return errors.New("route config path 不能为空")
return errors.New("route configuration path must not be empty")
}
if err := os.MkdirAll(filepath.Dir(m.MainConfigPath), nginxDirPerm); err != nil {
return err
@@ -1203,7 +1203,7 @@ func removeLegacyPowConfig(path string) error {
func (m *Manager) certFileTargetPath(relativePath string) (string, error) {
if strings.TrimSpace(m.CertDir) == "" {
return "", errors.New("cert dir 不能为空")
return "", errors.New("certificate directory must not be empty")
}
candidate := strings.TrimSpace(relativePath)
if strings.Contains(candidate, `\`) {
@@ -1211,7 +1211,7 @@ func (m *Manager) certFileTargetPath(relativePath string) (string, error) {
}
normalizedPath := filepath.Clean(filepath.FromSlash(candidate))
if normalizedPath == "." || normalizedPath == "" {
return "", errors.New("cert file path 不能为空")
return "", errors.New("certificate file path must not be empty")
}
if filepath.IsAbs(normalizedPath) || filepath.VolumeName(normalizedPath) != "" {
return "", fmt.Errorf("cert file path %q must be relative", relativePath)
@@ -1240,7 +1240,7 @@ func certFileMode(relativePath string) fs.FileMode {
func luaFileTargetPath(baseDir string, relativePath string) (string, error) {
if strings.TrimSpace(baseDir) == "" {
return "", errors.New("lua dir 不能为空")
return "", errors.New("lua directory must not be empty")
}
candidate := strings.TrimSpace(relativePath)
if strings.Contains(candidate, `\`) {
@@ -1248,7 +1248,7 @@ func luaFileTargetPath(baseDir string, relativePath string) (string, error) {
}
normalizedPath := filepath.Clean(filepath.FromSlash(candidate))
if normalizedPath == "." || normalizedPath == "" {
return "", errors.New("lua file path 不能为空")
return "", errors.New("lua file path must not be empty")
}
if filepath.IsAbs(normalizedPath) || filepath.VolumeName(normalizedPath) != "" {
return "", fmt.Errorf("lua file path %q must be relative", relativePath)
+18 -16
View File
@@ -14,6 +14,12 @@ import (
//go:embed pow_static
var powStaticFS embed.FS
//go:embed pow_static/locales/challenge.en.json
var powChallengeEnglish string
//go:embed pow_static/locales/challenge.zh-CN.json
var powChallengeChinese string
const openRestyPowRuntimeLua = `local _M = {}
local source = debug.getinfo(1, "S").source or ""
@@ -290,7 +296,7 @@ end
return require("pow.runtime").check()
`
const openRestyPowChallengeLua = `local cjson = require "cjson.safe"
var openRestyPowChallengeLua = `local cjson = require "cjson.safe"
local pow_challenges = ngx.shared.openflare_pow_challenges
local pow_config_dict = ngx.shared.openflare_pow_config
@@ -353,21 +359,17 @@ if string.find(accept_lang, "zh") then
lang = "zh-CN"
end
local t_title = "Making sure you're not a bot!"
local t_status = "Loading..."
local t_protected = "This site is protected by a Proof-of-Work challenge. Your browser will solve a small puzzle before the upstream response is shown."
local t_why = "Why am I seeing this?"
local t_why_desc = "OpenFlare is asking your browser to complete a lightweight computation to distinguish normal browser traffic from automated abuse. This should finish automatically."
local t_noscript = "JavaScript is required to pass this verification. Please enable JavaScript and reload."
if lang == "zh-CN" then
t_title = "正在确认你是不是机器人!"
t_status = "加载中..."
t_protected = "本网站受工作量证明(Proof-of-Work)挑战保护。在显示源站响应之前,您的浏览器将解决一个微型谜题。"
t_why = "为什么我会看到这个?"
t_why_desc = "OpenFlare 正在要求您的浏览器完成一项轻量级计算,以区分正常的浏览器流量和自动化的恶意请求。这应该会自动完成。"
t_noscript = "很遗憾,您必须启用 JavaScript 才能通过这项验证。请开启 JavaScript 并刷新页面。"
end
local translations = {
en = cjson.decode([==[` + powChallengeEnglish + `]==]),
["zh-CN"] = cjson.decode([==[` + powChallengeChinese + `]==])
}
local text = translations[lang] or translations.en
local t_title = text.title
local t_status = text.status
local t_protected = text.protected
local t_why = text.why
local t_why_desc = text.why_description
local t_noscript = text.noscript
ngx.header.content_type = "text/html; charset=utf-8"
ngx.say([[<!DOCTYPE html>
@@ -0,0 +1,8 @@
{
"title": "Making sure you're not a bot!",
"status": "Loading...",
"protected": "This site is protected by a Proof-of-Work challenge. Your browser will solve a small puzzle before the upstream response is shown.",
"why": "Why am I seeing this?",
"why_description": "OpenFlare is asking your browser to complete a lightweight computation to distinguish normal browser traffic from automated abuse. This should finish automatically.",
"noscript": "JavaScript is required to pass this verification. Please enable JavaScript and reload."
}
@@ -0,0 +1,8 @@
{
"title": "正在确认你是不是机器人!",
"status": "加载中...",
"protected": "本网站受工作量证明(Proof-of-Work)挑战保护。在显示源站响应之前,您的浏览器将解决一个微型谜题。",
"why": "为什么我会看到这个?",
"why_description": "OpenFlare 正在要求您的浏览器完成一项轻量级计算,以区分正常的浏览器流量和自动化的恶意请求。这应该会自动完成。",
"noscript": "很遗憾,您必须启用 JavaScript 才能通过这项验证。请开启 JavaScript 并刷新页面。"
}
+2 -2
View File
@@ -5,6 +5,6 @@
package cap
const (
errCapTokenMissing = "验证码验证失败,缺少验证码凭证" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errCapTokenInvalidOrExpired = "验证码校验失败或已过期,请重试" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errCapTokenMissing = "CAPTCHA verification failed: missing verification credential" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errCapTokenInvalidOrExpired = "CAPTCHA verification failed or expired; please try again" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
)
+1 -1
View File
@@ -70,7 +70,7 @@ func Challenge(c *gin.Context) {
func Redeem(c *gin.Context) {
var req redeemRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, "无效的参数")
response.AbortBadRequest(c, "invalid parameters")
return
}
+4 -4
View File
@@ -115,10 +115,10 @@ func applyDefaults(cfg *Config, baseDir string) {
func validate(cfg *Config) error {
if cfg.ServerURL == "" {
return errors.New("server_url 不能为空")
return errors.New("server_url must not be empty")
}
if strings.TrimSpace(cfg.TunnelToken) == "" {
return errors.New("tunnel_token 不能为空")
return errors.New("tunnel_token must not be empty")
}
return nil
}
@@ -134,10 +134,10 @@ func (cfg *Config) InitialAuthToken() string {
// Save writes the current configuration back to the loaded config path.
func (cfg *Config) Save() error {
if cfg == nil {
return errors.New("config 不能为空")
return errors.New("config must not be empty")
}
if cfg.configPath == "" {
return errors.New("config path 未初始化")
return errors.New("configuration path is not initialized")
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
+13 -13
View File
@@ -6,18 +6,18 @@ package oauth
// OAuth 认证相关错误消息
const (
errInvalidState = "非法登录请求"
errIDTokenVerifyFailed = "ID Token 验证失败" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errInvalidState = "Invalid login request"
errIDTokenVerifyFailed = "ID token verification failed" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errIDTokenVerifyFailedFormat = "%s: %w"
errNonceMismatch = "nonce 不匹配,可能存在重放攻击"
errNoActiveAuthSource = "未配置可用认证源"
errServerAddressMissing = "服务器地址 (server_address) 未配置或配置为空,请在后台系统设置中配置后再试"
errAuthSourceRequired = "认证源不能为空"
errDiscoveryURLRequired = "OIDC 认证源必须配置 Discovery URL"
errUsernameGenerateFailed = "无法生成可用用户名"
errUsernameFromSourceFailed = "无法从认证源获取用户名"
errAuthSourceDisabled = "认证源未启用"
errInvalidExternalAccountBindingID = "绑定记录 ID 无效"
ErrTokenAuthNotAllowed = "该端点不允许使用访问令牌进行身份验证" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errOAuthStateRateLimited = "登录请求过于频繁,请稍后再试"
errNonceMismatch = "nonce mismatch; possible replay attack"
errNoActiveAuthSource = "no available authentication source is configured"
errServerAddressMissing = "server address (server_address) is not configured; configure it in system settings and try again"
errAuthSourceRequired = "authentication source must not be empty"
errDiscoveryURLRequired = "OIDC authentication sources require a Discovery URL"
errUsernameGenerateFailed = "unable to generate an available username"
errUsernameFromSourceFailed = "unable to get username from authentication source"
errAuthSourceDisabled = "Authentication source is disabled"
errInvalidExternalAccountBindingID = "Invalid account binding ID"
ErrTokenAuthNotAllowed = "This endpoint does not allow access-token authentication" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errOAuthStateRateLimited = "too many login requests; please try again later"
)
+12 -12
View File
@@ -4,19 +4,19 @@
package agent
const (
errMissingAgentToken = "缺少 Agent Token" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errInvalidAgentToken = "无权进行此操作,Agent Token 无效" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errInvalidDiscoveryToken = "无权进行此操作,注册 Token 无效" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errMissingAgentToken = "missing agent token" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errInvalidAgentToken = "Operation not authorized: invalid agent token" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errInvalidDiscoveryToken = "Operation not authorized: invalid registration token" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errNodeMissingFromContext = "Node object missing from context"
errNoActiveConfig = "当前没有激活版本"
errNodeNotFound = "节点不存在"
errNodeIDRequired = "node_id 不能为空"
errVersionRequired = "version 不能为空"
errInvalidApplyResult = "result 仅支持 success、warning 或 failed"
errIPRequired = "ip 不能为空"
errIPInvalid = "ip 格式无效"
errAgentVersionRequired = "version 不能为空"
errNodeIDConflict = "节点标识生成冲突,请重试"
errNoActiveConfig = "no active configuration version"
errNodeNotFound = "node was not found"
errNodeIDRequired = "node_id must not be empty"
errVersionRequired = "version must not be empty"
errInvalidApplyResult = "result must be success, warning or failed"
errIPRequired = "ip must not be empty"
errIPInvalid = "invalid IP address format"
errAgentVersionRequired = "version must not be empty"
errNodeIDConflict = "node identifier conflict; please try again"
errRegistrationKeyInvalid = "invalid discovery registration key"
errRegistrationCredentialChanged = "discovery enrollment credential has changed; use the assigned agent token"
)
+2 -2
View File
@@ -270,12 +270,12 @@ func DownloadPagesProjectLatestPackageHandler(c *gin.Context) {
func pagesUintParam(c *gin.Context, name string) (uint, bool) {
raw := c.Param(name)
if raw == "" {
response.AbortBadRequest(c, "无效的 ID")
response.AbortBadRequest(c, "Invalid ID")
return 0, false
}
id64, err := strconv.ParseUint(raw, 10, 64)
if err != nil || id64 == 0 {
response.AbortBadRequest(c, "无效的 ID")
response.AbortBadRequest(c, "Invalid ID")
return 0, false
}
return uint(id64), true
@@ -124,7 +124,7 @@ func buildAgentWAFIPGroups(ctx context.Context, ids []uint) ([]WAFIPGroup, error
func buildAgentWAFIPGroup(group *model.OpenFlareWAFIPGroup) (WAFIPGroup, error) {
if group == nil {
return WAFIPGroup{}, errors.New("IP 组不存在")
return WAFIPGroup{}, errors.New("IP group was not found")
}
ips, err := decodeWAFIPGroupStringList(group.IPList)
if err != nil {
@@ -192,7 +192,7 @@ func activeConfigWAFIPGroupIDs(ctx context.Context) ([]uint, error) {
}
ids, err := runtimeIPMatchGroupIDs(node.Config)
if err != nil {
return nil, fmt.Errorf("活动配置 WAF 规则 %d 节点 %s 的 IP 匹配配置无效: %w", group.ID, nodeID, err)
return nil, fmt.Errorf("invalid IP match configuration in active WAF rule %d, node %s: %w", group.ID, nodeID, err)
}
for _, id := range ids {
if id > 0 {
@@ -150,8 +150,8 @@ func TestChangedWAFIPGroupsForAgentRejectsMalformedIPMatchConfig(t *testing.T) {
}).Error)
_, err := ChangedWAFIPGroupsForAgent(ctx, nil, nil)
require.ErrorContains(t, err, "规则 7 节点 match")
require.ErrorContains(t, err, "IP 匹配配置无效")
require.ErrorContains(t, err, "rule 7, node match")
require.ErrorContains(t, err, "invalid IP match configuration")
}
func TestChangedWAFIPGroupsForAgentRejectsOversizedSnapshotBeforeChecksumDelta(t *testing.T) {
@@ -172,8 +172,8 @@ func TestChangedWAFIPGroupsForAgentRejectsOversizedSnapshotBeforeChecksumDelta(t
_, err = ChangedWAFIPGroupsForAgent(ctx, []uint{ipGroup.ID}, map[string]string{
strconv.FormatUint(uint64(ipGroup.ID), 10): agentGroup.Checksum,
})
require.ErrorContains(t, err, "WAF IP 组快照大小")
require.ErrorContains(t, err, "超过上限")
require.ErrorContains(t, err, "WAF IP group snapshot size")
require.ErrorContains(t, err, "exceeds the limit")
}
func TestChangedWAFIPGroupsForAgentReturnsChecksumDelta(t *testing.T) {
+2 -2
View File
@@ -11,8 +11,8 @@ import (
"github.com/gin-gonic/gin"
)
const errInvalidParams = "参数错误"
const errInvalidID = "无效的 ID"
const errInvalidParams = "invalid parameters"
const errInvalidID = "Invalid ID"
// BindJSON binds JSON body; returns false after aborting with 400.
func BindJSON(c *gin.Context, dst any) bool {
+1 -1
View File
@@ -6,5 +6,5 @@
package apply_log
const (
errRetentionDaysOutOfRange = "retention_days 必须在 1 到 3650 之间"
errRetentionDaysOutOfRange = "retention_days must be between 1 and 3650"
)
+22 -22
View File
@@ -49,8 +49,8 @@ var (
var SSLRenewMeta = task.TaskMeta{
Type: TaskTypeSSLRenew,
AsynqTask: SSLRenewTask,
Name: "OpenFlare SSL 自动续期",
Description: "扫描即将到期的 ACME 证书并触发自动续期",
Name: "OpenFlare automatic SSL renewal",
Description: "Scan ACME certificates nearing expiration and renew them automatically",
SupportsTime: false,
MaxRetry: task.DefaultMaxRetry,
Queue: task.QueueDefault,
@@ -61,8 +61,8 @@ var SSLRenewMeta = task.TaskMeta{
var WAFIPGroupSyncMeta = task.TaskMeta{
Type: TaskTypeWAFIPGroupSync,
AsynqTask: WAFIPGroupSyncTask,
Name: "OpenFlare WAF IP 组同步",
Description: "同步到期的自动规则与订阅类型 WAF IP 组",
Name: "OpenFlare WAF IP group synchronization",
Description: "Synchronize automatic and subscription WAF IP groups that are due for an update",
SupportsTime: false,
MaxRetry: task.DefaultMaxRetry,
Queue: task.QueueDefault,
@@ -73,8 +73,8 @@ var WAFIPGroupSyncMeta = task.TaskMeta{
var UptimeKumaSyncMeta = task.TaskMeta{
Type: TaskTypeUptimeKumaSync,
AsynqTask: UptimeKumaSyncTask,
Name: "OpenFlare Uptime Kuma 同步",
Description: "将启用的代理规则同步到 Uptime Kuma 监控",
Name: "OpenFlare Uptime Kuma synchronization",
Description: "Synchronize enabled proxy routes with Uptime Kuma monitors",
SupportsTime: false,
MaxRetry: task.DefaultMaxRetry,
Queue: task.QueueDefault,
@@ -85,15 +85,15 @@ var UptimeKumaSyncMeta = task.TaskMeta{
var LogDBSwitchMeta = task.TaskMeta{
Type: TaskTypeLogDBSwitch,
AsynqTask: LogDBSwitchTask,
Name: "切换日志数据库",
Description: "复制迁移日志数据并在成功后切换日志主库(期间禁止日志写入)",
Name: "Switch log database",
Description: "Copy log data and switch the log database on success; log writes are paused during migration",
SupportsTime: false,
MaxRetry: task.DefaultMaxRetry,
Queue: task.QueueDefault,
Retryable: true,
Params: []task.TaskParam{
{Name: "target", Label: "目标日志库", Type: "string", Required: true,
Placeholder: "postgres|sqlite|clickhouse", Description: "迁移目标:postgres(主库为 PG 时)、sqlite(主库为 SQLite 时)或 clickhouse"},
{Name: "target", Label: "Target log database", Type: "string", Required: true,
Placeholder: "postgres|sqlite|clickhouse", Description: "Migration target: postgres for a PostgreSQL primary, sqlite for a SQLite primary, or clickhouse"},
},
}
@@ -102,12 +102,12 @@ type SSLRenewHandler struct{}
// Execute runs SSL certificate renewal for all due certificates.
func (h *SSLRenewHandler) Execute(ctx context.Context, _ []byte) (*task.TaskResult, error) {
task.AppendLog(ctx, "开始扫描待续期证书")
task.AppendLog(ctx, "Scanning certificates due for renewal")
if err := tasks.RunSSLRenewJob(ctx); err != nil {
task.AppendLog(ctx, "SSL 自动续期失败: %v", err)
task.AppendLog(ctx, "Automatic SSL renewal failed: %v", err)
return nil, err
}
msg := "SSL 自动续期任务完成"
msg := "Automatic SSL renewal task completed"
task.AppendLog(ctx, "%s", msg)
return &task.TaskResult{Message: msg}, nil
}
@@ -117,12 +117,12 @@ type WAFIPGroupSyncHandler struct{}
// Execute syncs all due automatic/subscription WAF IP groups.
func (h *WAFIPGroupSyncHandler) Execute(ctx context.Context, _ []byte) (*task.TaskResult, error) {
task.AppendLog(ctx, "开始同步到期的 WAF IP 组")
task.AppendLog(ctx, "Synchronizing WAF IP groups due for an update")
if err := waf.SyncDueWAFIPGroups(ctx); err != nil {
task.AppendLog(ctx, "WAF IP 组同步失败: %v", err)
task.AppendLog(ctx, "WAF IP group synchronization failed: %v", err)
return nil, err
}
msg := "WAF IP 组同步完成"
msg := "WAF IP group synchronization completed"
task.AppendLog(ctx, "%s", msg)
return &task.TaskResult{Message: msg}, nil
}
@@ -135,7 +135,7 @@ func (h *UptimeKumaSyncHandler) Execute(ctx context.Context, _ []byte) (*task.Ta
// 从 SystemConfig 读取 UptimeKuma 配置
enabled, _ := repository.GetBoolByKey(ctx, model.ConfigKeyUptimeKumaEnabled)
if !enabled {
msg := "Uptime Kuma 集成未启用,跳过执行"
msg := "Uptime Kuma integration is disabled; skipping execution"
task.AppendLog(ctx, "%s", msg)
return &task.TaskResult{Message: msg}, nil
}
@@ -145,26 +145,26 @@ func (h *UptimeKumaSyncHandler) Execute(ctx context.Context, _ []byte) (*task.Ta
interval = 5
}
if time.Since(lastUptimeKumaSyncTime) < time.Duration(interval)*time.Minute {
msg := fmt.Sprintf("距上次同步不足 %d 分钟,跳过执行", interval)
msg := fmt.Sprintf("Less than %d minutes since the last synchronization; skipping execution", interval)
task.AppendLog(ctx, "%s", msg)
return &task.TaskResult{Message: msg}, nil
}
if !uptimeKumaSyncMutex.TryLock() {
msg := "Uptime Kuma 同步任务正在执行,跳过本次调度"
msg := "Uptime Kuma synchronization is already running; skipping this schedule"
task.AppendLog(ctx, "%s", msg)
return &task.TaskResult{Message: msg}, nil
}
defer uptimeKumaSyncMutex.Unlock()
task.AppendLog(ctx, "开始同步代理规则到 Uptime Kuma")
task.AppendLog(ctx, "Synchronizing proxy routes with Uptime Kuma")
if err := uptimekuma.SyncToUptimeKuma(ctx); err != nil {
task.AppendLog(ctx, "Uptime Kuma 同步失败: %v", err)
task.AppendLog(ctx, "Uptime Kuma synchronization failed: %v", err)
return nil, err
}
lastUptimeKumaSyncTime = time.Now()
msg := "Uptime Kuma 同步完成"
msg := "Uptime Kuma synchronization completed"
task.AppendLog(ctx, "%s", msg)
return &task.TaskResult{Message: msg}, nil
}
+1 -1
View File
@@ -31,5 +31,5 @@ func TestUptimeKumaSyncHandlerSkipsWhenDisabled(t *testing.T) {
result, err := (&UptimeKumaSyncHandler{}).Execute(ctx, nil)
require.NoError(t, err)
require.NotNil(t, result)
assert.Contains(t, result.Message, "未启用")
assert.Contains(t, result.Message, "disabled")
}
+5 -5
View File
@@ -118,7 +118,7 @@ func (client *HTTPClient) VerifyToken(ctx context.Context) error {
return err
}
if result.Status != "active" {
return errors.New("cloudflare API Token 未激活")
return errors.New("cloudflare API token is inactive")
}
return nil
}
@@ -131,7 +131,7 @@ func (client *HTTPClient) FindZone(ctx context.Context, name string) (*Zone, err
return nil, err
}
if len(zones) != 1 {
return nil, fmt.Errorf("cloudflare 中未找到唯一 Zone %s", name)
return nil, fmt.Errorf("no unique Cloudflare zone found for %s", name)
}
return &zones[0], nil
}
@@ -220,7 +220,7 @@ func (client *HTTPClient) do(ctx context.Context, method, path string, query url
}
return decodeAPIResponse(statusCode, responseBody, result)
}
return errors.New("cloudflare API 请求超过重试次数")
return errors.New("cloudflare API retry limit exceeded")
}
func encodeRequestBody(body any) ([]byte, error) {
@@ -251,7 +251,7 @@ func (client *HTTPClient) send(ctx context.Context, method, requestURL string, b
request.Header.Set("Content-Type", "application/json")
response, err := client.httpClient.Do(request)
if err != nil {
return 0, "", nil, fmt.Errorf("cloudflare API 请求失败: %w", err)
return 0, "", nil, fmt.Errorf("cloudflare API request failed: %w", err)
}
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, maxResponseBodyBytes))
closeErr := response.Body.Close()
@@ -281,7 +281,7 @@ func decodeAPIResponse(statusCode int, responseBody []byte, result any) error {
return fmt.Errorf("decode Cloudflare response: %w", err)
}
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices || !envelope.Success {
message := "cloudflare API 请求失败"
message := "Cloudflare API request failed"
if len(envelope.Errors) > 0 && strings.TrimSpace(envelope.Errors[0].Message) != "" {
message = envelope.Errors[0].Message
}
+21 -21
View File
@@ -4,25 +4,25 @@
package cloudflare
const (
errConnectionNotConfigured = "尚未配置 Cloudflare 连接"
errConnectionSourceInvalid = "无效的 Cloudflare 连接来源"
errStandaloneInputRequired = "请填写 Cloudflare API Token"
errStandaloneInputInvalid = "配置的 Cloudflare API Token 无效"
errDNSAccountInvalid = "请选择有效的 Cloudflare DNS 账号"
errGroupNameRequired = "分组名称不能为空"
errGroupNodeSame = "主节点和备用节点不能相同"
errGroupTargetModeInvalid = "请选择有效的指向方式"
errRecordTypeInvalid = "请选择 CNAME、A 或 AAAA 记录类型"
errRecordContentInvalid = "DNS 记录值无效,请检查域名或 IP 地址"
errNodeInvalid = "请选择有效的边缘节点"
errNodeIPv4Required = "生效节点必须配置合法 IPv4"
errGroupDisabled = "指向分组已停用"
errMemberExists = "该域名已加入其他指向分组"
errMultipleDNSRecords = "检测到 Cloudflare 中存在多条同名 DNS 记录,请先手动清理"
errSyncFailed = "Cloudflare DNS 同步失败"
errDeleteRemoteFailed = "删除 Cloudflare DNS 记录失败"
errTaskDispatchFailed = "无法投递 Cloudflare 同步任务"
errTargetGroupSame = "目标分组不能为当前分组"
errTargetGroupInvalid = "目标分组不存在"
errNoMembersSelected = "未选择任何成员"
errConnectionNotConfigured = "no Cloudflare connection is configured"
errConnectionSourceInvalid = "invalid Cloudflare connection source"
errStandaloneInputRequired = "enter a Cloudflare API token"
errStandaloneInputInvalid = "configured Cloudflare API token is invalid"
errDNSAccountInvalid = "select a valid Cloudflare DNS account"
errGroupNameRequired = "group name must not be empty"
errGroupNodeSame = "primary and standby nodes must be different"
errGroupTargetModeInvalid = "select a valid target mode"
errRecordTypeInvalid = "select a CNAME, A or AAAA record type"
errRecordContentInvalid = "invalid DNS record value; check the domain name or IP address"
errNodeInvalid = "select a valid edge node"
errNodeIPv4Required = "the active node requires a valid IPv4 address"
errGroupDisabled = "target group is disabled"
errMemberExists = "this domain already belongs to another target group"
errMultipleDNSRecords = "multiple Cloudflare DNS records have the same name; remove the duplicates manually first"
errSyncFailed = "Cloudflare DNS synchronization failed"
errDeleteRemoteFailed = "failed to delete Cloudflare DNS record"
errTaskDispatchFailed = "unable to enqueue Cloudflare synchronization task"
errTargetGroupSame = "target group must differ from the current group"
errTargetGroupInvalid = "target group was not found"
errNoMembersSelected = "no members selected"
)
@@ -19,7 +19,7 @@ func abortLogic(c *gin.Context, err error) bool {
}
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
response.AbortNotFound(c, "Cloudflare 资源不存在")
response.AbortNotFound(c, "Cloudflare resource was not found")
case err.Error() == errMemberExists:
response.AbortConflict(c, err.Error())
case err.Error() == errTaskDispatchFailed:
+50 -50
View File
@@ -42,8 +42,8 @@ const (
var FailoverCheckMeta = task.TaskMeta{
Type: TaskTypeFailoverCheck,
AsynqTask: FailoverCheckTask,
Name: "Cloudflare 节点故障回退检查",
Description: "根据节点在线和应用状态切换分组主备节点",
Name: "Cloudflare node failover check",
Description: "Switch group primary and standby nodes based on node availability and configuration status",
SupportsTime: false,
MaxRetry: 2,
Queue: task.QueueDefault,
@@ -55,8 +55,8 @@ var FailoverCheckMeta = task.TaskMeta{
var SyncMemberMeta = task.TaskMeta{
Type: TaskTypeSyncMember,
AsynqTask: SyncMemberTask,
Name: "Cloudflare 域名同步",
Description: "同步单个域名的 Cloudflare DNS 记录",
Name: "Cloudflare domain synchronization",
Description: "Synchronize Cloudflare DNS records for a single domain",
SupportsTime: false,
MaxRetry: 3,
Queue: task.QueueDefault,
@@ -64,11 +64,11 @@ var SyncMemberMeta = task.TaskMeta{
Params: []task.TaskParam{
{
Name: "member_id",
Label: "成员 ID",
Label: "Member ID",
Type: "number",
Required: true,
Placeholder: "请输入 Cloudflare 指向成员 ID",
Description: "of_cf_pointing_members 表中的成员主键 ID",
Placeholder: "Enter the Cloudflare target member ID",
Description: "Member primary key in the of_cf_pointing_members table",
},
},
}
@@ -77,8 +77,8 @@ var SyncMemberMeta = task.TaskMeta{
var SyncGroupMeta = task.TaskMeta{
Type: TaskTypeSyncGroup,
AsynqTask: SyncGroupTask,
Name: "Cloudflare 分组同步",
Description: "同步指向分组内全部域名",
Name: "Cloudflare group synchronization",
Description: "Synchronize all domains in a target group",
SupportsTime: false,
MaxRetry: 2,
Queue: task.QueueDefault,
@@ -86,11 +86,11 @@ var SyncGroupMeta = task.TaskMeta{
Params: []task.TaskParam{
{
Name: "group_id",
Label: "分组 ID",
Label: "Group ID",
Type: "number",
Required: true,
Placeholder: "请输入 Cloudflare 指向分组 ID",
Description: "of_cf_pointing_groups 表中的分组主键 ID",
Placeholder: "Enter the Cloudflare target group ID",
Description: "Group primary key in the of_cf_pointing_groups table",
},
},
}
@@ -99,8 +99,8 @@ var SyncGroupMeta = task.TaskMeta{
var SyncByNodeMeta = task.TaskMeta{
Type: TaskTypeSyncByNode,
AsynqTask: SyncByNodeTask,
Name: "Cloudflare 节点同步",
Description: "同步当前指向指定节点的全部域名",
Name: "Cloudflare node synchronization",
Description: "Synchronize all domains currently targeting the specified node",
SupportsTime: false,
MaxRetry: 2,
Queue: task.QueueDefault,
@@ -161,14 +161,14 @@ type SyncMemberTaskHandler struct{}
// ValidatePayload validates a one-member task payload.
func (handler *SyncMemberTaskHandler) ValidatePayload(payload []byte) ([]byte, error) {
if len(payload) == 0 {
return nil, errors.New("任务参数不能为空")
return nil, errors.New("task parameters must not be empty")
}
var input SyncMemberPayload
if err := decodePayload(payload, &input); err != nil {
return nil, fmt.Errorf("无效的 Cloudflare 成员同步参数: %w", err)
return nil, fmt.Errorf("invalid Cloudflare member synchronization parameters: %w", err)
}
if input.MemberID == 0 {
return nil, errors.New("成员 ID 不能为空或零")
return nil, errors.New("member ID must be nonzero")
}
return json.Marshal(input)
}
@@ -184,14 +184,14 @@ func (handler *SyncMemberTaskHandler) Execute(ctx context.Context, payload []byt
state, loadErr := repository.GetCFPointingMemberContext(ctx, input.MemberID)
if loadErr != nil {
task.AppendLog(ctx, "加载成员上下文失败: member_id=%d error=%v", input.MemberID, loadErr)
task.AppendLog(ctx, "Failed to load member context: member_id=%d error=%v", input.MemberID, loadErr)
} else {
target := fmt.Sprintf("%s(%s)", state.Node.Name, strings.TrimSpace(state.Node.IP))
if state.Group.TargetMode == model.CFPointingTargetModeCustom {
target = fmt.Sprintf("%s %s", state.Group.RecordType, state.Group.RecordContent)
}
task.AppendLog(ctx,
"开始域名同步: domain=%s zone=%s group=%s(#%d) target=%s proxied=%v member_id=%d",
"Starting domain synchronization: domain=%s zone=%s group=%s(#%d) target=%s proxied=%v member_id=%d",
state.Domain.Domain,
state.Zone.Domain,
state.Group.Name,
@@ -204,28 +204,28 @@ func (handler *SyncMemberTaskHandler) Execute(ctx context.Context, payload []byt
if err = ReconcileMember(ctx, input.MemberID); err != nil {
if state != nil {
task.AppendLog(ctx, "域名同步失败: domain=%s member_id=%d error=%v",
task.AppendLog(ctx, "Domain synchronization failed: domain=%s member_id=%d error=%v",
state.Domain.Domain, input.MemberID, err)
} else {
task.AppendLog(ctx, "域名同步失败: member_id=%d error=%v", input.MemberID, err)
task.AppendLog(ctx, "Domain synchronization failed: member_id=%d error=%v", input.MemberID, err)
}
return nil, fmt.Errorf("%s: %w", errSyncFailed, err)
}
message := "Cloudflare 域名同步成功"
message := "Cloudflare domain synchronization succeeded"
if state != nil {
target := strings.TrimSpace(state.Node.IP)
if state.Group.TargetMode == model.CFPointingTargetModeCustom {
target = fmt.Sprintf("%s %s", state.Group.RecordType, state.Group.RecordContent)
}
message = fmt.Sprintf("Cloudflare 域名同步成功: %s → %s (proxied=%v)",
message = fmt.Sprintf("Cloudflare domain synchronization succeeded: %s -> %s (proxied=%v)",
state.Domain.Domain, target, state.Member.Proxied)
task.AppendLog(ctx,
"域名同步成功: domain=%s desired_target=%s proxied=%v group=%s target_mode=%s",
"Domain synchronization succeeded: domain=%s desired_target=%s proxied=%v group=%s target_mode=%s",
state.Domain.Domain, target, state.Member.Proxied, state.Group.Name, state.Group.TargetMode,
)
} else {
task.AppendLog(ctx, "域名同步成功: member_id=%d", input.MemberID)
task.AppendLog(ctx, "Domain synchronization succeeded: member_id=%d", input.MemberID)
}
return &task.TaskResult{Message: message}, nil
}
@@ -236,14 +236,14 @@ type SyncGroupTaskHandler struct{}
// ValidatePayload validates a group task payload.
func (handler *SyncGroupTaskHandler) ValidatePayload(payload []byte) ([]byte, error) {
if len(payload) == 0 {
return nil, errors.New("任务参数不能为空")
return nil, errors.New("task parameters must not be empty")
}
var input SyncGroupPayload
if err := decodePayload(payload, &input); err != nil {
return nil, fmt.Errorf("无效的 Cloudflare 分组同步参数: %w", err)
return nil, fmt.Errorf("invalid Cloudflare group synchronization parameters: %w", err)
}
if input.GroupID == 0 {
return nil, errors.New("分组 ID 不能为空或零")
return nil, errors.New("group ID must be nonzero")
}
return json.Marshal(input)
}
@@ -262,18 +262,18 @@ func (handler *SyncGroupTaskHandler) Execute(ctx context.Context, payload []byte
scopeName := fmt.Sprintf("#%d", input.GroupID)
activeNode := ""
if group, groupErr := repository.GetCFPointingGroup(ctx, input.GroupID); groupErr != nil {
task.AppendLog(ctx, "加载分组失败: group_id=%d error=%v", input.GroupID, groupErr)
task.AppendLog(ctx, "Failed to load group: group_id=%d error=%v", input.GroupID, groupErr)
} else {
scopeName = group.Name
activeNode = groupSyncTarget(ctx, group)
task.AppendLog(ctx,
"准备分组同步: group=%s id=%d enabled=%v target=%s default_proxied=%v",
"Preparing group synchronization: group=%s id=%d enabled=%v target=%s default_proxied=%v",
group.Name, group.ID, group.Enabled, activeNode, group.DefaultProxied,
)
}
members, err := repository.ListCFPointingMembersByGroupID(ctx, input.GroupID)
return executeBatchSync(ctx, members, err, "分组", scopeName, input.GroupID, activeNode)
return executeBatchSync(ctx, members, err, "group", scopeName, input.GroupID, activeNode)
}
func groupSyncTarget(ctx context.Context, group *model.CFPointingGroup) string {
@@ -282,7 +282,7 @@ func groupSyncTarget(ctx context.Context, group *model.CFPointingGroup) string {
}
node, err := repository.GetOpenFlareNodeByID(ctx, group.ActiveNodeID)
if err != nil {
task.AppendLog(ctx, "加载生效节点失败: group=%s active_node_id=%d error=%v",
task.AppendLog(ctx, "Failed to load active node: group=%s active_node_id=%d error=%v",
group.Name, group.ActiveNodeID, err)
return ""
}
@@ -296,7 +296,7 @@ type SyncByNodeTaskHandler struct{}
func (handler *SyncByNodeTaskHandler) ValidatePayload(payload []byte) ([]byte, error) {
var input SyncByNodePayload
if err := decodePayload(payload, &input); err != nil || input.NodeID == 0 {
return nil, errors.New("无效的 Cloudflare 节点同步参数")
return nil, errors.New("invalid Cloudflare node synchronization parameters")
}
return json.Marshal(input)
}
@@ -315,16 +315,16 @@ func (handler *SyncByNodeTaskHandler) Execute(ctx context.Context, payload []byt
scopeName := fmt.Sprintf("#%d", input.NodeID)
activeNode := ""
if node, nodeErr := repository.GetOpenFlareNodeByID(ctx, input.NodeID); nodeErr != nil {
task.AppendLog(ctx, "加载节点失败: node_id=%d error=%v", input.NodeID, nodeErr)
task.AppendLog(ctx, "Failed to load node: node_id=%d error=%v", input.NodeID, nodeErr)
} else {
scopeName = node.Name
activeNode = fmt.Sprintf("%s(%s)", node.Name, strings.TrimSpace(node.IP))
task.AppendLog(ctx, "准备节点同步: node=%s id=%d ip=%s",
task.AppendLog(ctx, "Preparing node synchronization: node=%s id=%d ip=%s",
node.Name, node.ID, strings.TrimSpace(node.IP))
}
members, err := repository.ListCFPointingMembersByActiveNodeID(ctx, input.NodeID)
return executeBatchSync(ctx, members, err, "节点", scopeName, input.NodeID, activeNode)
return executeBatchSync(ctx, members, err, "node", scopeName, input.NodeID, activeNode)
}
func executeBatchSync(
@@ -336,15 +336,15 @@ func executeBatchSync(
activeNode string,
) (*task.TaskResult, error) {
if listErr != nil {
task.AppendLog(ctx, "列出%s成员失败: name=%s id=%d error=%v",
task.AppendLog(ctx, "Failed to list %s members: name=%s id=%d error=%v",
scope, scopeName, scopeID, listErr)
return nil, listErr
}
task.AppendLog(ctx, "开始%s同步: name=%s id=%d target=%s 域名数=%d",
task.AppendLog(ctx, "Starting %s synchronization: name=%s id=%d target=%s domains=%d",
scope, scopeName, scopeID, activeNode, len(members))
if len(members) == 0 {
message := fmt.Sprintf("Cloudflare %s同步完成: %s 无域名成员", scope, scopeName)
message := fmt.Sprintf("Cloudflare %s synchronization completed: %s has no domain members", scope, scopeName)
task.AppendLog(ctx, "%s", message)
return &task.TaskResult{Message: message}, nil
}
@@ -355,28 +355,28 @@ func executeBatchSync(
if domain, domainErr := repository.GetZoneDomainByID(ctx, member.ZoneDomainID); domainErr == nil {
domainName = domain.Domain
} else if errors.Is(domainErr, gorm.ErrRecordNotFound) {
task.AppendLog(ctx, "[%d/%d] 域名记录已不存在,清理孤立成员: member_id=%d zone_domain_id=%d",
task.AppendLog(ctx, "[%d/%d] Domain record no longer exists; removing orphaned member: member_id=%d zone_domain_id=%d",
index+1, len(members), member.ID, member.ZoneDomainID)
if delErr := repository.DeleteCFPointingMember(ctx, &member); delErr != nil {
task.AppendLog(ctx, "[%d/%d] 清理孤立成员失败: member_id=%d error=%v",
task.AppendLog(ctx, "[%d/%d] Failed to remove orphaned member: member_id=%d error=%v",
index+1, len(members), member.ID, delErr)
}
continue
}
task.AppendLog(ctx, "[%d/%d] 同步域名 %s (member_id=%d proxied=%v)",
task.AppendLog(ctx, "[%d/%d] Synchronizing domain %s (member_id=%d proxied=%v)",
index+1, len(members), domainName, member.ID, member.Proxied)
if err := ReconcileMember(ctx, member.ID); err != nil {
task.AppendLog(ctx, "[%d/%d] 失败: domain=%s member_id=%d error=%v",
task.AppendLog(ctx, "[%d/%d] Failed: domain=%s member_id=%d error=%v",
index+1, len(members), domainName, member.ID, err)
return nil, err
}
syncedCount++
task.AppendLog(ctx, "[%d/%d] 成功: domain=%s", index+1, len(members), domainName)
task.AppendLog(ctx, "[%d/%d] Succeeded: domain=%s", index+1, len(members), domainName)
}
message := fmt.Sprintf("Cloudflare %s同步完成: %s 共 %d 个域名", scope, scopeName, syncedCount)
message := fmt.Sprintf("Cloudflare %s synchronization completed: %s, %d domains", scope, scopeName, syncedCount)
if activeNode != "" {
message = fmt.Sprintf("Cloudflare %s同步完成: %s → %s,共 %d 个域名",
message = fmt.Sprintf("Cloudflare %s synchronization completed: %s -> %s, %d domains",
scope, scopeName, activeNode, syncedCount)
}
task.AppendLog(ctx, "%s", message)
@@ -403,7 +403,7 @@ type FailoverCheckTaskHandler struct{}
func (handler *FailoverCheckTaskHandler) ValidatePayload(payload []byte) ([]byte, error) {
var input struct{}
if err := decodePayload(payload, &input); err != nil {
return nil, fmt.Errorf("无效的 Cloudflare 故障回退检查参数: %w", err)
return nil, fmt.Errorf("invalid Cloudflare failover check parameters: %w", err)
}
return json.Marshal(input)
}
@@ -413,13 +413,13 @@ func (handler *FailoverCheckTaskHandler) Execute(ctx context.Context, payload []
if _, err := handler.ValidatePayload(payload); err != nil {
return nil, task.PermanentError(err.Error())
}
task.AppendLog(ctx, "开始检查 Cloudflare 节点分组主备状态")
task.AppendLog(ctx, "Checking Cloudflare group primary and standby node status")
changed, err := CheckNodeFailover(ctx)
if err != nil {
task.AppendLog(ctx, "Cloudflare 节点故障回退检查失败: error=%v", err)
task.AppendLog(ctx, "Cloudflare node failover check failed: error=%v", err)
return nil, err
}
message := fmt.Sprintf("Cloudflare 节点故障回退检查完成,切换分组 %d 个", changed)
message := fmt.Sprintf("Cloudflare node failover check completed; switched %d groups", changed)
task.AppendLog(ctx, "%s", message)
return &task.TaskResult{Message: message}, nil
}
@@ -5,9 +5,9 @@
package config_version
const (
errNoActiveVersion = "当前没有激活版本"
errNoEnabledRoutes = "没有可发布的启用规则"
errNoChangesToPublish = "当前规则没有变更,不能重复发布"
errVersionConflict = "版本号生成冲突,请重试"
errInvalidSnapshotFormat = "历史版本快照格式不合法"
errNoActiveVersion = "no active configuration version"
errNoEnabledRoutes = "no enabled rules are available to publish"
errNoChangesToPublish = "the current rules have not changed and cannot be published again"
errVersionConflict = "version identifier conflict; please try again"
errInvalidSnapshotFormat = "invalid historical version snapshot format"
)
@@ -24,7 +24,7 @@ import (
)
const (
cleanupSuccessMessage = "清理成功"
cleanupSuccessMessage = "Cleanup completed"
minConfigVersionKeepCount = 3
)
@@ -26,45 +26,45 @@ func buildPagesRouteSnapshot(
route *model.ProxyRoute,
) (originURL string, upstreams []string, pagesProjectID *uint, deployment *openrestyrender.PagesDeployment, err error) {
if route == nil {
return "", nil, nil, nil, errors.New("pages 路由配置无效")
return "", nil, nil, nil, errors.New("invalid Pages route configuration")
}
if !repository.HasPagesProjectsTable(ctx) {
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 模块不可用", route.SiteName)
return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages module is unavailable", route.SiteName)
}
if route.PagesProjectID == nil || *route.PagesProjectID == 0 {
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: 未绑定 Pages 项目", route.SiteName)
return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: no Pages project is bound", route.SiteName)
}
project, err := repository.GetPagesProjectByID(ctx, *route.PagesProjectID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目不存在", route.SiteName)
return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages project was not found", route.SiteName)
}
return "", nil, nil, nil, err
}
if !project.Enabled {
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目未启用", route.SiteName)
return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages project is disabled", route.SiteName)
}
if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID == 0 {
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目没有激活部署", route.SiteName)
return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages project has no active deployment", route.SiteName)
}
activeDeployment, err := repository.GetPagesDeploymentByID(ctx, *project.ActiveDeploymentID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不存在", route.SiteName)
return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: active Pages deployment was not found", route.SiteName)
}
return "", nil, nil, nil, err
}
if activeDeployment.ProjectID != project.ID {
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不匹配", route.SiteName)
return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: active Pages deployment does not match", route.SiteName)
}
if strings.TrimSpace(activeDeployment.Checksum) == "" {
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 部署校验和缺失", route.SiteName)
return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages deployment checksum is missing", route.SiteName)
}
pagesProjectID = route.PagesProjectID
deployment, err = buildSnapshotPagesDeployment(project, activeDeployment)
if err != nil {
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: %w", route.SiteName, err)
return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: %w", route.SiteName, err)
}
originURL = fmt.Sprintf("openflare-pages://project/%d", project.ID)
return originURL, []string{originURL}, pagesProjectID, deployment, nil
@@ -75,11 +75,11 @@ func buildSnapshotPagesDeployment(
activeDeployment *model.PagesDeployment,
) (*openrestyrender.PagesDeployment, error) {
if project == nil || activeDeployment == nil {
return nil, errors.New("pages 项目或部署为空")
return nil, errors.New("pages project or deployment is empty")
}
rootDir, err := pagesarchive.NormalizeLogicalPath(strings.TrimSpace(project.RootDir), true)
if err != nil {
return nil, fmt.Errorf("pages 根目录不合法: %w", err)
return nil, fmt.Errorf("invalid Pages root directory: %w", err)
}
entryFile := strings.TrimSpace(project.EntryFile)
if entryFile == "" {
@@ -87,7 +87,7 @@ func buildSnapshotPagesDeployment(
}
entryFile, err = pagesarchive.NormalizeLogicalPath(entryFile, false)
if err != nil {
return nil, fmt.Errorf("pages 入口文件不合法: %w", err)
return nil, fmt.Errorf("invalid Pages entry file: %w", err)
}
fallbackPath := strings.TrimSpace(project.SPAFallbackPath)
if fallbackPath == "" {
@@ -15,13 +15,13 @@ func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
return apiutil.AbortNotFoundIfMissing(c, err, "记录不存在")
return apiutil.AbortNotFoundIfMissing(c, err, "Record was not found")
}
func versionParam(c *gin.Context) (string, bool) {
version := c.Param("id")
if version == "" {
response.AbortBadRequest(c, "无效的版本号")
response.AbortBadRequest(c, "Invalid version identifier")
return "", false
}
return version, true
@@ -252,13 +252,13 @@ func buildSnapshotRoutes(ctx context.Context, routes []*model.ProxyRoute) ([]sna
}
customHeaders, err := decodeStoredCustomHeaders(route.CustomHeaders)
if err != nil {
return nil, fmt.Errorf("路由 %s 自定义请求头无效", route.SiteName)
return nil, fmt.Errorf("invalid custom request headers for route %s", route.SiteName)
}
upstreamType := normalizeUpstreamType(route.UpstreamType)
originURL := route.OriginURL
upstreams, err := decodeStoredUpstreams(route.Upstreams, route.OriginURL)
if err != nil {
return nil, fmt.Errorf("路由 %s 上游配置无效", route.SiteName)
return nil, fmt.Errorf("invalid upstream configuration for route %s", route.SiteName)
}
var tunnelNodeID *uint
var tunnelTargetAddr string
@@ -280,7 +280,7 @@ func buildSnapshotRoutes(ctx context.Context, routes []*model.ProxyRoute) ([]sna
}
cacheRules, err := decodeStoredCacheRules(route.CacheRules)
if err != nil {
return nil, fmt.Errorf("路由 %s 缓存规则无效", route.SiteName)
return nil, fmt.Errorf("invalid cache rules for route %s", route.SiteName)
}
items = append(items, snapshotRoute{
ID: route.ID,
@@ -333,14 +333,14 @@ func buildSnapshotWAFDocument(ctx context.Context, routes []*model.ProxyRoute) (
}
var editorGraph waf.RuleGraph
if err = json.Unmarshal([]byte(group.Graph), &editorGraph); err != nil {
return snapshotWAFDocument{}, fmt.Errorf("WAF 规则 %s 的图数据无效: %w", group.Name, err)
return snapshotWAFDocument{}, fmt.Errorf("invalid graph data for WAF rule %s: %w", group.Name, err)
}
if err = waf.ValidateRuleGraph(ctx, editorGraph, snapshotWAFIPGroupExists); err != nil {
return snapshotWAFDocument{}, fmt.Errorf("WAF 规则 %s 的图无效: %w", group.Name, err)
return snapshotWAFDocument{}, fmt.Errorf("invalid graph for WAF rule %s: %w", group.Name, err)
}
runtimeGraph, compileErr := waf.CompileRuleGraph(editorGraph)
if compileErr != nil {
return snapshotWAFDocument{}, fmt.Errorf("WAF 规则 %s 编译失败: %w", group.Name, compileErr)
return snapshotWAFDocument{}, fmt.Errorf("failed to compile WAF rule %s: %w", group.Name, compileErr)
}
ruleGroups = append(ruleGroups, snapshotWAFRuleGroup{
ID: group.ID, Name: group.Name, Enabled: group.Enabled, IsGlobal: group.IsGlobal, Graph: runtimeGraph,
@@ -445,7 +445,7 @@ func buildSnapshotWAFIPGroups(ctx context.Context, idSet map[uint]struct{}) ([]s
for _, id := range ids {
group := groupByID[id]
if group == nil {
return nil, fmt.Errorf("IP 组 %d 不存在", id)
return nil, fmt.Errorf("IP group %d was not found", id)
}
ipList, decodeErr := decodeIPList(group.IPList)
if decodeErr != nil {
@@ -43,8 +43,8 @@ func TestBuildSnapshotRejectsOversizedAggregateWAFIPGroups(t *testing.T) {
createSnapshotRule(t, ctx, "oversized-aggregate", snapshotIPMatchGraphForGroups(groupIDs))
_, err = buildSnapshotWAFDocument(ctx, nil)
require.ErrorContains(t, err, "WAF IP 组快照大小")
require.ErrorContains(t, err, "超过上限")
require.ErrorContains(t, err, "WAF IP group snapshot size")
require.ErrorContains(t, err, "exceeds the limit")
}
func TestWAFGraphSnapshotPreservesOrderAndGraphReferences(t *testing.T) {
+2 -2
View File
@@ -5,6 +5,6 @@
package flared
const (
errTunnelTokenInvalid = "无权进行此操作,Tunnel Token 无效" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errTunnelNodeTypeMismatch = "此节点不是 TunnelClient 类型"
errTunnelTokenInvalid = "Operation not authorized: invalid tunnel token" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errTunnelNodeTypeMismatch = "This node is not a TunnelClient"
)
+3 -3
View File
@@ -169,13 +169,13 @@ func ReportApplyLog(ctx context.Context, payload ApplyLogPayload) (*model.OpenFl
now := time.Now().UTC()
payload = normalizeApplyLogPayload(payload)
if payload.NodeID == "" {
return nil, errors.New("node_id 不能为空")
return nil, errors.New("node_id must not be empty")
}
if payload.Version == "" {
return nil, errors.New("version 不能为空")
return nil, errors.New("version must not be empty")
}
if payload.Result != applyResultOK && payload.Result != applyResultWarn && payload.Result != applyResultFail {
return nil, errors.New("result 仅支持 success、warning 或 failed")
return nil, errors.New("result must be success, warning or failed")
}
latest, err := repository.GetLatestOpenFlareApplyLogByNodeID(ctx, payload.NodeID)
+4 -4
View File
@@ -23,10 +23,10 @@ type LookupView struct {
}
const (
errProviderInvalid = "归属方式仅支持 disabled、mmdb、ip-api、geojs、ipinfo"
errIPEmpty = "IP 不能为空"
errIPInvalid = "IP 格式无效"
errLookupEmpty = "未获取到 IP 归属结果"
errProviderInvalid = "geolocation provider must be disabled, mmdb, ip-api, geojs or ipinfo"
errIPEmpty = "IP address must not be empty"
errIPInvalid = "invalid IP address format"
errLookupEmpty = "no IP geolocation result was returned"
)
// IsValidProvider reports whether provider is a supported GeoIP backend.
+14 -14
View File
@@ -7,18 +7,18 @@ package node
const (
errInvalidNodeListQuery = "invalid node list parameters"
errListNodesFailed = "Failed to load nodes"
errNodeNameRequired = "节点名不能为空"
errNodeIPTooLong = "节点 IP 不能超过 64 个字符"
errNodeIPInvalid = "节点 IP 格式无效"
errNodeIPManualRequired = "锁定节点 IP 时必须填写节点 IP"
errNodeGeoNameTooLong = "节点位置名不能超过 128 个字符"
errNodeGeoCoordinateMismatch = "地图坐标必须同时填写纬度和经度"
errNodeGeoLatitudeInvalid = "纬度必须在 -90 到 90 之间"
errNodeGeoLongitudeInvalid = "经度必须在 -180 到 180 之间"
errNodeIDConflict = "节点标识生成冲突,请重试"
errNodeNotFound = "节点不存在"
errNodeForceSyncFailed = "节点不在线或通过 WebSocket 发送同步指令失败"
errNoActiveConfigVersion = "当前没有激活版本"
errAgentPreviewTagInvalid = "指定版本不是 preview 发布"
errAgentStableTagInvalid = "正式版更新不能选择 preview 发布"
errNodeNameRequired = "node name must not be empty"
errNodeIPTooLong = "Node IP must not exceed 64 characters"
errNodeIPInvalid = "Invalid node IP address format"
errNodeIPManualRequired = "A node IP is required when IP locking is enabled"
errNodeGeoNameTooLong = "Node location name must not exceed 128 characters"
errNodeGeoCoordinateMismatch = "Both latitude and longitude are required"
errNodeGeoLatitudeInvalid = "Latitude must be between -90 and 90"
errNodeGeoLongitudeInvalid = "Longitude must be between -180 and 180"
errNodeIDConflict = "node identifier conflict; please try again"
errNodeNotFound = "node was not found"
errNodeForceSyncFailed = "node is offline or the synchronization command could not be sent over WebSocket"
errNoActiveConfigVersion = "no active configuration version"
errAgentPreviewTagInvalid = "the selected version is not a preview release"
errAgentStableTagInvalid = "a preview release cannot be selected for a stable update"
)
+14 -14
View File
@@ -332,15 +332,15 @@ func fetchLatestStableGitHubRelease(ctx context.Context, repo string) (*githubRe
url := fmt.Sprintf(githubReleasesAPIBase+"/latest", strings.TrimSpace(repo))
req, err := newGitHubReleaseRequest(ctx, url)
if err != nil {
return nil, errors.New("创建更新请求失败")
return nil, errors.New("failed to create update request")
}
resp, err := releaseHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("获取最新版本失败: %w", err)
return nil, fmt.Errorf("failed to get latest version: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status)
return nil, fmt.Errorf("GitHub returned an unexpected status: %s", resp.Status)
}
return decodeGitHubRelease(resp.Body)
}
@@ -349,19 +349,19 @@ func fetchLatestPreviewGitHubRelease(ctx context.Context, repo string) (*githubR
url := fmt.Sprintf(githubReleasesAPIBase+"?per_page=20", strings.TrimSpace(repo))
req, err := newGitHubReleaseRequest(ctx, url)
if err != nil {
return nil, errors.New("创建更新请求失败")
return nil, errors.New("failed to create update request")
}
resp, err := releaseHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("获取 preview 版本失败: %w", err)
return nil, fmt.Errorf("failed to get preview version: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status)
return nil, fmt.Errorf("GitHub returned an unexpected status: %s", resp.Status)
}
var releases []githubReleaseResponse
if err = json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, errors.New("解析 preview 版本信息失败")
return nil, errors.New("failed to parse preview version information")
}
for _, release := range releases {
if release.Draft || !release.Prerelease {
@@ -370,29 +370,29 @@ func fetchLatestPreviewGitHubRelease(ctx context.Context, repo string) (*githubR
releaseCopy := release
return &releaseCopy, nil
}
return nil, errors.New("当前没有可用的 preview 发布")
return nil, errors.New("no preview release is currently available")
}
func fetchGitHubReleaseByTag(ctx context.Context, repo string, tag string) (*githubReleaseResponse, error) {
tag = strings.TrimSpace(tag)
if tag == "" {
return nil, errors.New("缺少发布版本号")
return nil, errors.New("missing release version identifier")
}
url := fmt.Sprintf(githubReleasesAPIBase+"/tags/%s", strings.TrimSpace(repo), tag)
req, err := newGitHubReleaseRequest(ctx, url)
if err != nil {
return nil, errors.New("创建更新请求失败")
return nil, errors.New("failed to create update request")
}
resp, err := releaseHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("获取指定版本失败: %w", err)
return nil, fmt.Errorf("failed to get specified version: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("未找到指定版本: %s", tag)
return nil, fmt.Errorf("specified version was not found: %s", tag)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status)
return nil, fmt.Errorf("GitHub returned an unexpected status: %s", resp.Status)
}
return decodeGitHubRelease(resp.Body)
}
@@ -410,7 +410,7 @@ func newGitHubReleaseRequest(ctx context.Context, url string) (*http.Request, er
func decodeGitHubRelease(reader io.Reader) (*githubReleaseResponse, error) {
var release githubReleaseResponse
if err := json.NewDecoder(reader).Decode(&release); err != nil {
return nil, errors.New("解析版本信息失败")
return nil, errors.New("failed to parse version information")
}
return &release, nil
}
+4 -4
View File
@@ -363,9 +363,9 @@ func RequestForceSync(ctx context.Context, id uint) (*View, error) {
activeConfig, err := repository.GetActiveConfigVersion(ctx)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("无法获取当前激活的配置版本:%s", errNoActiveConfigVersion)
return nil, fmt.Errorf("unable to get the currently active configuration version: %s", errNoActiveConfigVersion)
}
return nil, fmt.Errorf("无法获取当前激活的配置版本:%w", err)
return nil, fmt.Errorf("unable to get the currently active configuration version: %w", err)
}
if !ofws.SendForceSyncConfig(node.NodeID, forceSyncConfigPayload{
Version: activeConfig.Version,
@@ -418,14 +418,14 @@ type forceSyncConfigPayload struct {
func ValidateDiscoveryToken(ctx context.Context, token string) error {
token = strings.TrimSpace(token)
if token == "" {
return errors.New("缺少 Discovery Token")
return errors.New("missing discovery token")
}
discoveryToken, err := ensureGlobalDiscoveryToken(ctx)
if err != nil {
return err
}
if !tokenEqual(token, discoveryToken) {
return errors.New("discovery Token 无效") // error 消息首字母小写
return errors.New("invalid discovery token") // error 消息首字母小写
}
return nil
}
+2 -2
View File
@@ -203,7 +203,7 @@ func RequestAgentUpdateHandler(c *gin.Context) {
var request AgentUpdateInput
if c.Request.ContentLength > 0 {
if err := bindOptionalJSON(c.Request.Body, &request); err != nil {
response.AbortBadRequest(c, "参数错误")
response.AbortBadRequest(c, "invalid parameters")
return
}
}
@@ -283,7 +283,7 @@ func GetObservabilityHandler(c *gin.Context) {
}
var query ObservabilityQuery
if err := c.ShouldBindQuery(&query); err != nil {
response.AbortBadRequest(c, "参数错误")
response.AbortBadRequest(c, "invalid parameters")
return
}
view, err := GetObservability(c.Request.Context(), id, query)
@@ -860,7 +860,7 @@ func limitDistributionItems(items []DistributionItem, limit int) []DistributionI
func normalizeAccessLogIPAnalysisQuery(input AccessLogIPAnalysisQuery) (AccessLogIPAnalysisQuery, error) {
remoteAddr := strings.TrimSpace(input.RemoteAddr)
if remoteAddr == "" {
return AccessLogIPAnalysisQuery{}, errors.New("remote_addr 不能为空")
return AccessLogIPAnalysisQuery{}, errors.New("remote_addr must not be empty")
}
hours := input.Hours
if hours <= 0 {
@@ -880,7 +880,7 @@ func normalizeAccessLogIPAnalysisQuery(input AccessLogIPAnalysisQuery) (AccessLo
// CleanupAccessLogs removes access logs older than retention days.
func CleanupAccessLogs(ctx context.Context, input AccessLogCleanupInput) (*AccessLogCleanupResult, error) {
if input.RetentionDays <= 0 || input.RetentionDays > nodeAccessLogRetentionDays {
return nil, errors.New("retention_days 必须在 1 到 90 之间")
return nil, errors.New("retention_days must be between 1 and 90")
}
cutoff := time.Now().UTC().Add(-time.Duration(input.RetentionDays) * 24 * time.Hour)
deleted, err := repository.DeleteOpenFlareAccessLogsBefore(ctx, cutoff)
@@ -923,18 +923,18 @@ func buildModelAccessLogQuery(input AccessLogQuery) (model.OpenFlareAccessLogQue
// 且 until 需晚于 since。
func resolveAccessLogWindow(sinceRaw, untilRaw string) (time.Time, time.Time, error) {
if sinceRaw == "" || untilRaw == "" {
return time.Time{}, time.Time{}, errors.New("since 与 until 需同时提供")
return time.Time{}, time.Time{}, errors.New("since and until must be provided together")
}
parsedSince, err := time.Parse(time.RFC3339, sinceRaw)
if err != nil {
return time.Time{}, time.Time{}, errors.New("since 时间格式无效,需为 RFC3339")
return time.Time{}, time.Time{}, errors.New("invalid since timestamp; RFC3339 is required")
}
parsedUntil, err := time.Parse(time.RFC3339, untilRaw)
if err != nil {
return time.Time{}, time.Time{}, errors.New("until 时间格式无效,需为 RFC3339")
return time.Time{}, time.Time{}, errors.New("invalid until timestamp; RFC3339 is required")
}
if !parsedUntil.After(parsedSince) {
return time.Time{}, time.Time{}, errors.New("until 必须晚于 since")
return time.Time{}, time.Time{}, errors.New("until must be later than since")
}
return parsedSince, parsedUntil, nil
}
@@ -1021,23 +1021,23 @@ func resolveAccessLogIPSummaryWindow(sinceRaw, untilRaw string, hours int) (time
return until.Add(-time.Duration(hours) * time.Hour), until, hours, nil
}
if sinceRaw == "" || untilRaw == "" {
return time.Time{}, time.Time{}, 0, errors.New("since 与 until 需同时提供")
return time.Time{}, time.Time{}, 0, errors.New("since and until must be provided together")
}
parsedSince, err := time.Parse(time.RFC3339, sinceRaw)
if err != nil {
return time.Time{}, time.Time{}, 0, errors.New("since 必须为 RFC3339 时间")
return time.Time{}, time.Time{}, 0, errors.New("since must be an RFC3339 timestamp")
}
parsedUntil, err := time.Parse(time.RFC3339, untilRaw)
if err != nil {
return time.Time{}, time.Time{}, 0, errors.New("until 必须为 RFC3339 时间")
return time.Time{}, time.Time{}, 0, errors.New("until must be an RFC3339 timestamp")
}
since := parsedSince.UTC()
until := parsedUntil.UTC()
if !until.After(since) {
return time.Time{}, time.Time{}, 0, errors.New("until 必须晚于 since")
return time.Time{}, time.Time{}, 0, errors.New("until must be later than since")
}
if until.Sub(since) > time.Duration(maxAccessLogOverviewHours)*time.Hour {
return time.Time{}, time.Time{}, 0, errors.New("时间范围不能超过 30 天")
return time.Time{}, time.Time{}, 0, errors.New("time range must not exceed 30 days")
}
hours = int(until.Sub(since).Hours())
if hours <= 0 {
@@ -1053,7 +1053,7 @@ func normalizeFoldedAccessLogIPQuery(input FoldedAccessLogIPQuery) (FoldedAccess
}
bucketStartedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(input.BucketStartedAt))
if err != nil {
return FoldedAccessLogIPQuery{}, time.Time{}, errors.New("bucket_started_at 必须为 RFC3339 时间")
return FoldedAccessLogIPQuery{}, time.Time{}, errors.New("bucket_started_at must be an RFC3339 timestamp")
}
normalizedSortBy := strings.TrimSpace(input.SortBy)
switch normalizedSortBy {
@@ -1078,7 +1078,7 @@ func normalizeFoldedAccessLogIPQuery(input FoldedAccessLogIPQuery) (FoldedAccess
func normalizeAccessLogIPTrendQuery(input AccessLogIPTrendQuery) (AccessLogIPTrendQuery, error) {
remoteAddr := strings.TrimSpace(input.RemoteAddr)
if remoteAddr == "" {
return AccessLogIPTrendQuery{}, errors.New("remote_addr 不能为空")
return AccessLogIPTrendQuery{}, errors.New("remote_addr must not be empty")
}
hours := input.Hours
if hours <= 0 {
@@ -1094,7 +1094,7 @@ func normalizeAccessLogIPTrendQuery(input AccessLogIPTrendQuery) (AccessLogIPTre
switch bucketMinutes {
case 5, 10, 15, 30, 60:
default:
return AccessLogIPTrendQuery{}, errors.New("bucket_minutes 仅支持 5、10、15、30、60")
return AccessLogIPTrendQuery{}, errors.New("bucket_minutes must be 5, 10, 15, 30 or 60")
}
return AccessLogIPTrendQuery{
NodeID: strings.TrimSpace(input.NodeID),
@@ -1166,7 +1166,7 @@ func normalizeFoldMinutes(value int) (int, error) {
case 3, 5:
return value, nil
default:
return 0, errors.New("fold_minutes 仅支持 3 或 5")
return 0, errors.New("fold_minutes must be 3 or 5")
}
}
@@ -5,5 +5,5 @@
package observability
const (
errInvalidStatusCode = "status_code 必须为 100-599 之间的整数"
errInvalidStatusCode = "status_code must be an integer between 100 and 599"
)
+6 -6
View File
@@ -5,10 +5,10 @@
package option
const (
errInvalidParams = "无效的参数"
errOptionInitFailed = "系统选项初始化失败"
errGeoIPProvider = "归属方式仅支持 disabled、mmdb、ip-api、geojs、ipinfo"
errGeoIPIPEmpty = "IP 不能为空"
errGeoIPIPInvalid = "IP 格式无效"
errGeoIPLookupDisabled = "GeoIP 查询已禁用"
errInvalidParams = "invalid parameters"
errOptionInitFailed = "Failed to initialize system options"
errGeoIPProvider = "geolocation provider must be disabled, mmdb, ip-api, geojs or ipinfo"
errGeoIPIPEmpty = "IP address must not be empty"
errGeoIPIPInvalid = "invalid IP address format"
errGeoIPLookupDisabled = "GeoIP lookup is disabled"
)
@@ -93,7 +93,7 @@ func validateOpenRestyDefaultServerReturnStatus(key, trimmed string) error {
}
statusCode, _ := strconv.Atoi(trimmed)
if statusCode < 100 || statusCode > 999 {
return fmt.Errorf("%s 必须在 100 到 999 之间", key)
return fmt.Errorf("%s must be between 100 and 999", key)
}
return nil
}
@@ -111,7 +111,7 @@ func validateOpenRestyGzipCompLevel(key, trimmed string) error {
}
level, _ := strconv.Atoi(trimmed)
if level > maxOpenRestyGzipCompLevel {
return fmt.Errorf("%s 不能大于 %d", key, maxOpenRestyGzipCompLevel)
return fmt.Errorf("%s must not exceed %d", key, maxOpenRestyGzipCompLevel)
}
return nil
}
@@ -124,7 +124,7 @@ func validateOpenRestyEventsUse(key, trimmed string) error {
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)
return fmt.Errorf("%s must be epoll, kqueue, poll, select, rtsig, /dev/poll, eventport or empty", key)
}
}
@@ -133,7 +133,7 @@ func validateOpenRestyResolvers(key, trimmed string) error {
return nil
}
if !regexp.MustCompile(`^[a-zA-Z0-9.:\-\s]+$`).MatchString(trimmed) {
return fmt.Errorf("%s 包含非法字符,请填入有效的 IP 地址或域名,以空格分隔", key)
return fmt.Errorf("%s contains invalid characters; enter valid IP addresses or domain names separated by spaces", key)
}
return nil
}
@@ -142,19 +142,19 @@ func validateOpenRestyProxyBuffers(key, trimmed string) error {
if openRestyProxyBuffersPattern.MatchString(trimmed) {
return nil
}
return fmt.Errorf("%s 格式必须类似 \"16 16k\"", key)
return fmt.Errorf("%s must use a format such as \\\"16 16k\\\"", key)
}
func validateOpenRestySizeValue(key, trimmed string) error {
if openRestySizePattern.MatchString(trimmed) {
return nil
}
return fmt.Errorf("%s 格式必须为整数或带 k/m/g 单位的大小值", key)
return fmt.Errorf("%s must be an integer or a size with a k/m/g suffix", key)
}
func validateOpenRestyCachePath(key, trimmed string) error {
if strings.ContainsAny(trimmed, "\r\n\t") {
return fmt.Errorf("%s 不能包含换行或制表符", key)
return fmt.Errorf("%s must not contain newlines or tabs", key)
}
return nil
}
@@ -163,29 +163,29 @@ func validateOpenRestyCacheLevels(key, trimmed string) error {
if openRestyCacheLevelsPattern.MatchString(trimmed) {
return nil
}
return fmt.Errorf("%s 格式必须类似 \"1:2\" 或 \"1:2:2\"", key)
return fmt.Errorf("%s must use a format such as \\\"1:2\\\" or \\\"1:2:2\\\"", key)
}
func validateOpenRestyDurationToken(key, trimmed string) error {
if openRestyDurationTokenPattern.MatchString(trimmed) {
return nil
}
return fmt.Errorf("%s 格式必须为带单位的时长,例如 30m 或 5s", key)
return fmt.Errorf("%s must be a duration with a unit, such as 30m or 5s", key)
}
func validateOpenRestyCacheKeyTemplate(key, trimmed string) error {
if trimmed == "" {
return fmt.Errorf("%s 不能为空", key)
return fmt.Errorf("%s must not be empty", key)
}
if strings.ContainsAny(trimmed, "\r\n") {
return fmt.Errorf("%s 不能包含换行", key)
return fmt.Errorf("%s must not contain newlines", key)
}
return nil
}
func validateOpenRestyCacheUseStale(key, trimmed string) error {
if trimmed == "" {
return fmt.Errorf("%s 不能为空", key)
return fmt.Errorf("%s must not be empty", key)
}
allowedTokens := map[string]struct{}{
"error": {}, "timeout": {}, "invalid_header": {}, "updating": {},
@@ -194,7 +194,7 @@ func validateOpenRestyCacheUseStale(key, trimmed string) error {
}
for token := range strings.FieldsSeq(trimmed) {
if _, ok := allowedTokens[token]; !ok {
return fmt.Errorf("%s 包含不支持的值 %q", key, token)
return fmt.Errorf("%s contains unsupported value %q", key, token)
}
}
return nil
@@ -202,7 +202,7 @@ func validateOpenRestyCacheUseStale(key, trimmed string) error {
func validateOpenRestyMainConfigTemplate(key, value string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s 不能为空", key)
return fmt.Errorf("%s must not be empty", key)
}
return nil
}
@@ -210,15 +210,15 @@ func validateOpenRestyMainConfigTemplate(key, value string) error {
func validateOpenRestyTrustedProxyCIDRs(key, value string) error {
var cidrs []string
if err := json.Unmarshal([]byte(value), &cidrs); err != nil || cidrs == nil {
return fmt.Errorf("%s 必须为 JSON 字符串数组", key)
return fmt.Errorf("%s must be a JSON array of strings", key)
}
if len(cidrs) > maxTrustedProxyCIDRs {
return fmt.Errorf("%s 最多允许 %d 个网段", key, maxTrustedProxyCIDRs)
return fmt.Errorf("%s must contain at most %d network ranges", key, maxTrustedProxyCIDRs)
}
for _, cidr := range cidrs {
prefix, err := netip.ParsePrefix(strings.TrimSpace(cidr))
if err != nil || prefix != prefix.Masked() {
return fmt.Errorf("%s 包含无效网段 %q", key, cidr)
return fmt.Errorf("%s contains invalid network range %q", key, cidr)
}
}
return nil
@@ -229,7 +229,7 @@ func validateOpenRestyDefaultLimitRate(key, trimmed string) error {
return nil
}
if !openRestyDefaultLimitRatePattern.MatchString(strings.ToLower(trimmed)) {
return fmt.Errorf("%s 格式不合法,请使用 512k、1m 或纯数字,空表示关闭", key)
return fmt.Errorf("invalid %s format; use 512k, 1m or a number, or leave empty to disable", key)
}
return nil
}
@@ -241,35 +241,35 @@ func validateOpenRestyDefaultLimitReqPerIP(key, trimmed string) error {
return nil
}
if !openRestyDefaultLimitReqPerIPPattern.MatchString(strings.ToLower(trimmed)) {
return fmt.Errorf("%s 格式不合法,请输入类似 10r/s、100r/m,或留空关闭", key)
return fmt.Errorf("invalid %s format; use a rate such as 10r/s or 100r/m, or leave empty to disable", key)
}
return nil
}
func validateOriginErrorPageStatusCodes(key, trimmed string) error {
if trimmed == "" {
return fmt.Errorf("%s 不能为空", key)
return fmt.Errorf("%s must not be empty", key)
}
var tags []string
if err := json.Unmarshal([]byte(trimmed), &tags); err != nil {
return fmt.Errorf("%s 必须为 JSON 字符串数组", key)
return fmt.Errorf("%s must be a JSON array of strings", key)
}
if len(tags) == 0 {
return fmt.Errorf("%s 至少包含一个状态码标签", key)
return fmt.Errorf("%s must contain at least one status code tag", key)
}
codes, err := openrestyrender.ExpandStatusCodeTags(tags)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}
if len(codes) == 0 {
return fmt.Errorf("%s 展开后不能为空", key)
return fmt.Errorf("%s must not be empty after expansion", key)
}
return nil
}
func validateOriginErrorPageHTML(key, value string) error {
if len(value) > maxOriginErrorPageHTMLBytes {
return fmt.Errorf("%s 长度不能超过 %d 字节(256 KiB)", key, maxOriginErrorPageHTMLBytes)
return fmt.Errorf("%s must not exceed %d bytes (256 KiB)", key, maxOriginErrorPageHTMLBytes)
}
return nil
}
@@ -281,22 +281,22 @@ func validateSWOfflineHTML(key, value string) error {
func validateSWOfflineDomains(key, value string) error {
var domains []string
if err := json.Unmarshal([]byte(value), &domains); err != nil || domains == nil {
return fmt.Errorf("%s 必须为 JSON 字符串数组", key)
return fmt.Errorf("%s must be a JSON array of strings", key)
}
if len(domains) > maxSWOfflineDomains {
return fmt.Errorf("%s 最多支持 %d 个域名", key, maxSWOfflineDomains)
return fmt.Errorf("%s supports at most %d domain names", key, maxSWOfflineDomains)
}
seen := make(map[string]struct{}, len(domains))
for _, raw := range domains {
domain := strings.ToLower(strings.TrimSpace(raw))
if domain == "" {
return fmt.Errorf("%s 包含空域名", key)
return fmt.Errorf("%s contains an empty domain name", key)
}
if raw != domain {
return fmt.Errorf("%s 域名必须为小写且不含首尾空格:%s", key, raw)
return fmt.Errorf("%s domain name must be lowercase without surrounding whitespace: %s", key, raw)
}
if _, ok := seen[domain]; ok {
return fmt.Errorf("%s 包含重复域名 %s", key, domain)
return fmt.Errorf("%s contains duplicate domain name %s", key, domain)
}
seen[domain] = struct{}{}
}
@@ -32,27 +32,27 @@ func TestValidateOriginErrorPageStatusCodes(t *testing.T) {
{
name: "非法标签",
value: `["abc"]`,
wantErr: "无效状态码",
wantErr: "invalid status code",
},
{
name: "非 JSON 数组",
value: `500-599`,
wantErr: "必须为 JSON 字符串数组",
wantErr: "must be a JSON array of strings",
},
{
name: "空数组",
value: `[]`,
wantErr: "至少包含一个状态码标签",
wantErr: "at least one status code tag",
},
{
name: "越界状态码",
value: `["399"]`,
wantErr: "状态码须在",
wantErr: "status code must be between",
},
{
name: "空字符串",
value: "",
wantErr: "不能为空",
wantErr: "must not be empty",
},
}
@@ -87,7 +87,7 @@ func TestValidateOriginErrorPageHTML(t *testing.T) {
oversized := strings.Repeat("a", maxOriginErrorPageHTMLBytes+1)
err := validateOpenRestyOption(model.ConfigKeyOriginErrorPageHTML, oversized)
require.Error(t, err)
assert.Contains(t, err.Error(), "长度不能超过")
assert.Contains(t, err.Error(), "must not exceed")
// 恰好上限应通过
atLimit := strings.Repeat("b", maxOriginErrorPageHTMLBytes)
@@ -101,7 +101,7 @@ func TestValidateOriginErrorPageEnabled(t *testing.T) {
require.NoError(t, validateOpenRestyOption(model.ConfigKeyOriginErrorPageEnabled, "false"))
err := validateOpenRestyOption(model.ConfigKeyOriginErrorPageEnabled, "yes")
require.Error(t, err)
assert.Contains(t, err.Error(), "true 或 false")
assert.Contains(t, err.Error(), "true or false")
}
func TestValidateSWOfflineDomains(t *testing.T) {
+1 -1
View File
@@ -139,5 +139,5 @@ func SyncUptimeKumaHandler(c *gin.Context) {
if apiutil.AbortBadRequestOnError(c, syncUptimeKuma(c.Request.Context())) {
return
}
c.JSON(http.StatusOK, response.OK("同步成功"))
c.JSON(http.StatusOK, response.OK("Synchronization succeeded"))
}
+15 -15
View File
@@ -28,7 +28,7 @@ var (
const optionValueTrue = "true"
// protectedConfigKeyMessage 命中受保护 key 时返回给管理员的业务错误文案。
const protectedConfigKeyMessage = "该配置项由系统任务管理,禁止手动修改"
const protectedConfigKeyMessage = "this configuration is managed by system tasks and cannot be changed manually"
// protectedConfigKeys 仅允许内部(迁移任务/bootstrap)写入的 key。
var protectedConfigKeys = map[string]bool{
@@ -79,7 +79,7 @@ func validateOptionWithState(ctx context.Context, option model.OpenFlareOption,
func validatePositiveIntegerOption(key, value string) error {
intValue, err := strconv.Atoi(value)
if err != nil || intValue <= 0 {
return fmt.Errorf("%s 必须为大于 0 的整数", key)
return fmt.Errorf("%s must be an integer greater than 0", key)
}
return nil
}
@@ -87,7 +87,7 @@ func validatePositiveIntegerOption(key, value string) error {
func validateNonNegativeIntegerOption(key, value string) error {
intValue, err := strconv.Atoi(value)
if err != nil || intValue < 0 {
return fmt.Errorf("%s 必须为大于等于 0 的整数", key)
return fmt.Errorf("%s must be a nonnegative integer", key)
}
return nil
}
@@ -97,7 +97,7 @@ func validateBooleanOption(key, value string) error {
case optionValueTrue, "false":
return nil
default:
return fmt.Errorf("%s 必须为 true 或 false", key)
return fmt.Errorf("%s must be true or false", key)
}
}
@@ -108,7 +108,7 @@ func validateGeoIPOption(key, value string) error {
if geoip.IsValidProvider(value) {
return nil
}
return fmt.Errorf("%s 仅支持 disabled、mmdb、ip-api、geojs、ipinfo", key)
return fmt.Errorf("%s must be disabled, mmdb, ip-api, geojs or ipinfo", key)
}
func validateLogRetentionOption(key, value string) error {
@@ -116,7 +116,7 @@ func validateLogRetentionOption(key, value string) error {
case model.ConfigKeyLogRetentionDaysPostgres, model.ConfigKeyLogRetentionDaysSQLite, model.ConfigKeyLogRetentionDaysClickHouse:
intValue, err := strconv.Atoi(value)
if err != nil || intValue < 1 {
return fmt.Errorf("%s 必须为大于等于 1 的整数天", key)
return fmt.Errorf("%s must be a whole number of days greater than or equal to 1", key)
}
}
return nil
@@ -135,12 +135,12 @@ func validatePagesOption(key, value string) error {
case model.ConfigKeyPagesMaxPackageSizeMB:
intValue, err := strconv.Atoi(trimmed)
if err != nil || intValue < 1 || intValue > 2048 {
return fmt.Errorf("%s 必须为 1~2048 的整数(MiB)", key)
return fmt.Errorf("%s must be an integer between 1 and 2048 (MiB)", key)
}
case model.ConfigKeyPagesMaxHistoryCount:
intValue, err := strconv.Atoi(trimmed)
if err != nil || intValue < 0 {
return fmt.Errorf("%s 必须为大于等于 0 的整数(0 表示不限制)", key)
return fmt.Errorf("%s must be a nonnegative integer (0 means unlimited)", key)
}
}
return nil
@@ -176,16 +176,16 @@ func validateUptimeKumaEnabled(ctx context.Context, key, trimmed string, state m
username := strings.TrimSpace(state[model.ConfigKeyUptimeKumaUsername])
password := strings.TrimSpace(state[model.ConfigKeyUptimeKumaPassword])
if url == "" {
return errors.New("启用 Uptime Kuma 时地址不能为空")
return errors.New("uptime Kuma address is required when the integration is enabled")
}
if username == "" {
return errors.New("启用 Uptime Kuma 时用户名不能为空")
return errors.New("uptime Kuma username is required when the integration is enabled")
}
// 如果待验证的密码为空,且当前配置中也没有密码,则报错
if password == "" {
existingPwd, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeyUptimeKumaPassword)
if strings.TrimSpace(existingPwd.Value) == "" {
return errors.New("启用 Uptime Kuma 时密码不能为空")
return errors.New("uptime Kuma password is required when the integration is enabled")
}
}
return nil
@@ -193,21 +193,21 @@ func validateUptimeKumaEnabled(ctx context.Context, key, trimmed string, state m
func validateUptimeKumaUsername(trimmed string, state map[string]string) error {
if trimmed == "" && state[model.ConfigKeyUptimeKumaEnabled] == optionValueTrue {
return errors.New("启用 Uptime Kuma 时用户名不能为空")
return errors.New("uptime Kuma username is required when the integration is enabled")
}
return nil
}
func validateUptimeKumaURL(trimmed string) error {
if trimmed != "" && !strings.HasPrefix(trimmed, "http://") && !strings.HasPrefix(trimmed, "https://") {
return errors.New("uptime Kuma 地址必须以 http:// 或 https:// 开头")
return errors.New("uptime Kuma address must start with http:// or https://")
}
return nil
}
func validateUptimeKumaMonitorScope(trimmed string) error {
if trimmed != "all" && trimmed != "selected" {
return errors.New("监控范围必须为全部站点 (all) 或选择站点 (selected)")
return errors.New("monitor scope must be all or selected sites")
}
return nil
}
@@ -215,7 +215,7 @@ func validateUptimeKumaMonitorScope(trimmed string) error {
func validateUptimeKumaRetry(key, trimmed string) error {
intValue, err := strconv.Atoi(trimmed)
if err != nil || intValue < 0 {
return fmt.Errorf("%s 必须为大于等于 0 的整数", key)
return fmt.Errorf("%s must be a nonnegative integer", key)
}
return nil
}
+6 -6
View File
@@ -5,10 +5,10 @@
package origin
const (
errOriginAddressRequired = "源站地址不能为空"
errOriginAddressInvalid = "源站地址格式不合法"
errOriginAddressExists = "源站地址已存在"
errOriginDeleteReferenced = "该源站仍被规则引用,无法删除"
errOriginMissingPort = "源站地址缺少端口"
errOriginNotFound = "源站不存在"
errOriginAddressRequired = "origin address must not be empty"
errOriginAddressInvalid = "invalid origin address format"
errOriginAddressExists = "origin address already exists"
errOriginDeleteReferenced = "this origin is still referenced by rules and cannot be deleted"
errOriginMissingPort = "origin address is missing a port"
errOriginNotFound = "Origin was not found"
)
@@ -19,7 +19,7 @@ import (
func TestParseAndValidatePagesDownloadURL(t *testing.T) {
_, err := parseAndValidatePagesDownloadURL("")
require.Error(t, err)
assert.Contains(t, err.Error(), "填写")
assert.Contains(t, err.Error(), "enter")
_, err = parseAndValidatePagesDownloadURL("ftp://example.com/a.zip")
require.Error(t, err)
+58 -58
View File
@@ -5,62 +5,62 @@
package pages
const (
errPagesProjectNotFound = "pages 项目不存在"
errPagesSlugExists = "pages 项目标识已存在"
errPagesNameRequired = "pages 项目名称不能为空"
errPagesSlugInvalid = "pages 项目标识只能包含小写字母、数字和连字符"
errPagesDeleteReferenced = "pages 项目已被规则引用,不能删除"
errPagesDeploymentNotFound = "pages 部署不存在"
errPagesDeploymentMismatch = "pages 部署不属于该项目"
errPagesDeleteActiveDeploy = "不能删除当前激活的 Pages 部署"
errPagesPackageMissing = "缺少 Pages 部署包"
errPagesPackageURLRequired = "请填写部署包下载链接"
errPagesPackageURLInvalid = "部署包下载链接无效,仅支持 http/https"
errPagesPackageURLDownloadFailed = "从链接下载部署包失败"
errPagesPackageURLTooLarge = "链接指向的部署包超过大小限制"
errPagesPackageNotZip = "pages 部署包必须是 .zip 文件" // legacy alias kept for tests
errPagesPackageUnsupported = "pages 部署包仅支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式"
errPagesPackageInvalidZip = "pages 部署包不是有效 zip 文件" // legacy alias
errPagesPackageInvalid = "pages 部署包不是有效的压缩文件"
errPagesPackageEmpty = "pages 部署包不能为空"
errPagesPackageExtractedTooLarge = "pages 部署包展开后体积超过限制"
errPagesPackageFileTooLarge = "pages 部署包内文件过大"
errPagesAPIProxyPathRequired = "启用 API 反代时,匹配路径不能为空"
errPagesAPIProxyPathPrefix = "API 反代匹配路径必须以 '/' 开头"
errPagesAPIProxyPassRequired = "启用 API 反代时,后端服务地址不能为空" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errPagesAPIProxyPassInvalid = "API 反代后端服务地址必须是有效的 HTTP/HTTPS URL" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errPagesPackagePathEmpty = "pages 部署包路径为空"
errPagesPackageUploadMissing = "pages 部署包上传记录不存在"
errPagesPackageNotInActiveConfig = "pages 部署尚未进入激活配置"
errPagesDeploymentHashMissing = "pages 部署包哈希缺失"
errPagesInvalidSnapshotFormat = "配置快照格式无效"
errPagesActorMissing = "无法识别当前用户"
errPagesEntryFileMissing = "当前激活部署中不存在指定入口文件"
errPagesSourceNotFound = "pages 部署源不存在"
errPagesSourceTypeRequired = "请选择 pages 部署源类型"
errPagesSourceTypeUnsupported = "pages 部署源类型不受支持"
errPagesSourceRemoteFields = "远程地址来源不能包含 GitHub 或自动更新配置"
errPagesSourceRemoteURLRequired = "请提供远程部署包地址"
errPagesSourceRemoteURLInvalid = "远程部署包地址无效,仅支持不含用户信息和片段的 http/https 地址"
errPagesSourceGitHubFields = "GitHub Release 来源不能包含远程地址配置"
errPagesSourceRepositoryInvalid = "GitHub 仓库地址无效,仅支持 https://github.com/{owner}/{repo}"
errPagesSourceSelectorInvalid = "GitHub Release 选择方式无效"
errPagesSourceAssetNameInvalid = "GitHub Release 资源名称必须是安全的文件名"
errPagesSourceCheckInterval = "GitHub latest 检查间隔必须在 5 到 1440 分钟之间"
errPagesSourceAutoNotAvailable = "自动更新将在后续阶段开放,当前必须保持关闭"
errPagesSourceReleaseNotFound = "未找到符合配置的 GitHub Release 资源"
errPagesSourceDigestInvalid = "GitHub Release 资源摘要格式无效"
errPagesSourceDigestMismatch = "GitHub Release 资源摘要校验失败"
errPagesSourceConfirmationNeeded = "检测到同一 Release 的资源已被替换,请刷新并确认当前版本"
errPagesSourceConfirmationStale = "确认的版本已变化,请刷新后重新确认"
errPagesSourceInitialCheckWarning = "部署源已保存,但首次检查任务入队失败,请稍后手动检查"
errPagesSourceCheckUnsupported = "远程地址来源不支持检查更新,请使用立即同步"
errPagesSourceActionBusy = "pages 部署源任务正在执行"
errPagesSourceActionInvalid = "pages 部署源任务参数无效"
errPagesSourceActionStale = "pages 部署源配置已变化,本次任务已跳过"
errPagesSourceLeaseLost = "pages 部署源任务执行权已失效"
errPagesSourceLeaseExpired = "上次 pages 部署源任务租约已过期"
errPagesSourceSyncFailed = "pages 部署源同步失败"
errPagesSourceTaskDispatchFailed = "pages 部署源任务入队失败"
errPagesSourceInternal = "pages 部署源操作失败,请稍后重试"
errPagesProjectNotFound = "pages project was not found"
errPagesSlugExists = "pages project slug already exists"
errPagesNameRequired = "pages project name must not be empty"
errPagesSlugInvalid = "pages project slug may only contain lowercase letters, digits and hyphens"
errPagesDeleteReferenced = "pages project is referenced by rules and cannot be deleted"
errPagesDeploymentNotFound = "pages deployment was not found"
errPagesDeploymentMismatch = "pages deployment does not belong to this project"
errPagesDeleteActiveDeploy = "the active Pages deployment cannot be deleted"
errPagesPackageMissing = "missing Pages deployment archive"
errPagesPackageURLRequired = "enter the deployment archive download URL"
errPagesPackageURLInvalid = "invalid deployment archive URL; only HTTP and HTTPS are supported"
errPagesPackageURLDownloadFailed = "Failed to download deployment archive from URL"
errPagesPackageURLTooLarge = "Deployment archive at the URL exceeds the size limit"
errPagesPackageNotZip = "Pages deployment archive must be a .zip file" // legacy alias kept for tests
errPagesPackageUnsupported = "pages archives support zip, tar.gz, tar.xz, tar.bz2, tar and 7z formats only"
errPagesPackageInvalidZip = "Pages deployment archive is not a valid ZIP file" // legacy alias
errPagesPackageInvalid = "pages deployment archive is not a valid compressed archive"
errPagesPackageEmpty = "pages deployment archive must not be empty"
errPagesPackageExtractedTooLarge = "extracted Pages deployment exceeds the size limit"
errPagesPackageFileTooLarge = "a file in the Pages deployment archive exceeds the size limit"
errPagesAPIProxyPathRequired = "match path is required when the API reverse proxy is enabled"
errPagesAPIProxyPathPrefix = "API reverse proxy match path must start with '/'"
errPagesAPIProxyPassRequired = "backend URL is required when the API reverse proxy is enabled" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errPagesAPIProxyPassInvalid = "API reverse proxy backend must be a valid HTTP/HTTPS URL" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errPagesPackagePathEmpty = "Pages deployment archive path is empty"
errPagesPackageUploadMissing = "pages deployment archive upload record was not found"
errPagesPackageNotInActiveConfig = "pages deployment is not included in the active configuration"
errPagesDeploymentHashMissing = "pages deployment archive hash is missing"
errPagesInvalidSnapshotFormat = "invalid configuration snapshot format"
errPagesActorMissing = "Unable to identify the current user"
errPagesEntryFileMissing = "The specified entry file does not exist in the active deployment"
errPagesSourceNotFound = "pages deployment source was not found"
errPagesSourceTypeRequired = "select a Pages deployment source type"
errPagesSourceTypeUnsupported = "unsupported Pages deployment source type"
errPagesSourceRemoteFields = "remote URL sources must not include GitHub or automatic update configuration"
errPagesSourceRemoteURLRequired = "provide the remote deployment archive URL"
errPagesSourceRemoteURLInvalid = "invalid remote archive URL; use HTTP/HTTPS without user information or fragments"
errPagesSourceGitHubFields = "GitHub Release sources must not include remote URL configuration"
errPagesSourceRepositoryInvalid = "invalid GitHub repository URL; use https://github.com/{owner}/{repo}"
errPagesSourceSelectorInvalid = "invalid GitHub Release selection mode"
errPagesSourceAssetNameInvalid = "GitHub Release asset name must be a safe filename"
errPagesSourceCheckInterval = "GitHub latest check interval must be between 5 and 1440 minutes"
errPagesSourceAutoNotAvailable = "Automatic updates will be available in a later stage and must remain disabled for now"
errPagesSourceReleaseNotFound = "no GitHub Release asset matches the configuration"
errPagesSourceDigestInvalid = "invalid GitHub Release asset digest format"
errPagesSourceDigestMismatch = "GitHub Release asset digest verification failed"
errPagesSourceConfirmationNeeded = "an asset in the same release was replaced; refresh and confirm the current version"
errPagesSourceConfirmationStale = "the confirmed version has changed; refresh and confirm again"
errPagesSourceInitialCheckWarning = "Deployment source saved, but the initial check could not be enqueued; run a manual check later"
errPagesSourceCheckUnsupported = "remote URL sources do not support update checks; use Sync now"
errPagesSourceActionBusy = "a Pages deployment source task is already running"
errPagesSourceActionInvalid = "invalid Pages deployment source task parameters"
errPagesSourceActionStale = "Pages deployment source configuration changed; task skipped"
errPagesSourceLeaseLost = "pages deployment source task ownership is no longer valid"
errPagesSourceLeaseExpired = "Previous Pages deployment source task lease expired"
errPagesSourceSyncFailed = "pages deployment source synchronization failed"
errPagesSourceTaskDispatchFailed = "failed to enqueue Pages deployment source task"
errPagesSourceInternal = "Pages deployment source operation failed; please try again later"
)
@@ -80,7 +80,7 @@ func checkGitHubSource(
if snapshot == nil || snapshot.SourceType != PagesSourceTypeGitHubRelease {
return nil, errors.New(errPagesSourceTypeUnsupported)
}
task.AppendLog(ctx, "[check] 正在检查 GitHub Release:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
task.AppendLog(ctx, "[check] Checking GitHub Release: repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
client := newGitHubReleaseClient()
result, err := client.Resolve(ctx, githubrelease.ResolveRequest{
Repository: snapshot.GitHubRepository,
@@ -111,7 +111,7 @@ func checkGitHubSource(
}
detail, _ := json.Marshal(map[string]string{"revision": revision, pagesDeploymentColumnStatus: status})
return &githubCheckTaskResult{
Message: "GitHub Release 检查完成,内容未变化",
Message: "GitHub Release check completed; content is unchanged",
Detail: string(detail),
Revision: revision,
Status: status,
@@ -140,12 +140,12 @@ func checkGitHubSource(
return nil, err
}
detail, _ := json.Marshal(map[string]string{"revision": target.Revision, pagesDeploymentColumnStatus: status})
message := "GitHub Release 检查完成"
message := "GitHub Release check completed"
switch status {
case pagesSourceStatusUpdateAvailable:
message = "发现新的 GitHub Release 部署包"
message = "A new GitHub Release deployment archive is available"
case pagesSourceStatusAttention:
message = "检测到同一 Release 的资源被替换,需要确认"
message = "An asset in the same release was replaced; confirmation is required"
}
return &githubCheckTaskResult{
Message: message, Detail: string(detail), Revision: target.Revision,
@@ -454,7 +454,7 @@ func activatePreparedGitHubSource(
triggerType string,
prepared *preparedGitHubSource,
) (*sourceSyncOutcome, error) {
task.AppendLog(ctx, "[activate] 正在原子切换 GitHub Release 部署")
task.AppendLog(ctx, "[activate] Atomically switching GitHub Release deployment")
deployment, reused, referenced, err := commitSourceDeploymentWithTrigger(
ctx, snapshot, prepared.target.Revision, prepared.download.SHA256,
prepared.target.Detail, prepared.target.DetailJSON, actor, triggerType, prepared.manifest,
@@ -482,7 +482,7 @@ func resolveAndGuardGitHubSync(
targetRevision string,
confirmedRevision string,
) (*githubSourceTarget, *sourceSyncOutcome, error) {
task.AppendLog(ctx, "[resolve] 正在解析 GitHub Release:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
task.AppendLog(ctx, "[resolve] Resolving GitHub Release: repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
resolved, err := client.Resolve(ctx, githubrelease.ResolveRequest{
Repository: snapshot.GitHubRepository,
Selector: githubrelease.Selector(snapshot.ReleaseSelector),
@@ -557,7 +557,7 @@ func prepareGitHubSyncPackage(
target *githubSourceTarget,
) (*preparedGitHubSource, error) {
limits := resolvePagesLimits(ctx)
task.AppendLog(ctx, "[download] 正在下载 GitHub Release asset:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
task.AppendLog(ctx, "[download] Downloading GitHub Release asset: repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
download, err := client.Download(ctx, githubrelease.DownloadRequest{
Repository: snapshot.GitHubRepository,
Asset: target.Asset,
@@ -606,7 +606,7 @@ func inspectAndIngestGitHubPackage(
if err != nil {
return nil, err
}
task.AppendLog(ctx, "[verify] 正在校验 GitHub Release 归档与入口")
task.AppendLog(ctx, "[verify] Verifying GitHub Release archive and entry file")
manifest, err := inspectPagesPackage(download.Path, format, rootDir, entryFile, limits)
if err != nil {
return nil, err
@@ -633,7 +633,7 @@ func resolveGitHubSourceIngest(
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
task.AppendLog(ctx, "[ingest] 正在保存 GitHub Release 部署包")
task.AppendLog(ctx, "[ingest] Saving GitHub Release deployment archive")
result, err := ingestPagesDeploymentPackageWithSource(
ctx, download.Path, download.SHA256, snapshot.ProjectID, snapshot.SourceID, target.Asset.Name, format,
)
+15 -15
View File
@@ -116,11 +116,11 @@ func normalizePagesSlug(raw string) string {
func validateAndNormalizePagesRootDir(raw string) (string, error) {
value := strings.TrimSpace(raw)
if len(value) > pagesMaxPathLength {
return "", errors.New("pages 根目录长度不能超过 512")
return "", errors.New("pages root directory must not exceed 512 characters")
}
normalized, err := pagesarchive.NormalizeLogicalPath(value, true)
if err != nil {
return "", fmt.Errorf("pages 根目录不合法: %w", err)
return "", fmt.Errorf("invalid Pages root directory: %w", err)
}
return normalized, nil
}
@@ -131,33 +131,33 @@ func normalizePagesFallbackPath(raw string) (string, error) {
value = defaultPagesFallbackPath
}
if len(value) > pagesMaxPathLength {
return "", errors.New("spa fallback 回退路径长度不能超过 512")
return "", errors.New("SPA fallback path must not exceed 512 characters")
}
if !strings.HasPrefix(value, "/") {
return "", errors.New("spa fallback 回退路径必须以 / 开头")
return "", errors.New("SPA fallback path must start with /")
}
if value == "/" || strings.HasSuffix(value, "/") {
return "", errors.New("spa fallback 回退路径必须指向具体文件")
return "", errors.New("SPA fallback path must point to a specific file")
}
if strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") {
return "", errors.New("spa fallback 回退路径包含不支持的字符")
return "", errors.New("SPA fallback path contains unsupported characters")
}
for _, r := range value {
if r <= 0x20 || r == 0x7f {
return "", errors.New("spa fallback 回退路径不能包含空白或控制字符")
return "", errors.New("SPA fallback path must not contain whitespace or control characters")
}
}
for segment := range strings.SplitSeq(value, "/") {
if segment == "." || segment == ".." {
return "", errors.New("spa fallback 回退路径不能包含 . 或 .. 路径段")
return "", errors.New("SPA fallback path must not contain . or .. segments")
}
}
cleaned := path.Clean(value)
if cleaned == "." || !strings.HasPrefix(cleaned, "/") {
return "", errors.New("spa fallback 回退路径不合法")
return "", errors.New("invalid SPA fallback path")
}
if cleaned == "/" || strings.HasSuffix(cleaned, "/") {
return "", errors.New("spa fallback 回退路径必须指向具体文件")
return "", errors.New("SPA fallback path must point to a specific file")
}
return cleaned, nil
}
@@ -176,11 +176,11 @@ func validateAndNormalizePagesEntryFile(raw string) (string, error) {
value = defaultPagesEntryFile
}
if len(value) > pagesMaxPathLength {
return "", errors.New("pages 入口文件长度不能超过 512")
return "", errors.New("pages entry file must not exceed 512 characters")
}
normalized, err := pagesarchive.NormalizeLogicalPath(value, false)
if err != nil {
return "", fmt.Errorf("pages 入口文件不合法: %w", err)
return "", fmt.Errorf("invalid Pages entry file: %w", err)
}
return normalized, nil
}
@@ -209,7 +209,7 @@ func persistPagesUploadTemp(fileHeader *multipart.FileHeader, maxPackageBytes in
}
if written > maxPackageBytes {
_ = os.Remove(temp.Name())
return "", "", 0, "", fmt.Errorf("pages 部署包不能超过 %d MiB", maxPackageBytes/bytesPerMiB)
return "", "", 0, "", fmt.Errorf("pages deployment archive must not exceed %d MiB", maxPackageBytes/bytesPerMiB)
}
return temp.Name(), hex.EncodeToString(hash.Sum(nil)), written, format, nil
}
@@ -337,7 +337,7 @@ func removePagesUploadIfUnreferenced(ctx context.Context, projectID uint, upload
return err
}
if uploadRecord.Type != upload.ReservedPagesDeploymentType {
return fmt.Errorf("pages 部署包上传类型不匹配: %s", uploadRecord.Type)
return fmt.Errorf("pages deployment archive upload type mismatch: %s", uploadRecord.Type)
}
var references int64
@@ -406,7 +406,7 @@ func mapPagesArchiveError(err error) error {
case strings.Contains(message, "missing entry file"):
return err
case strings.Contains(message, "file count exceeds"):
return fmt.Errorf("pages 部署文件数不能超过 %d", pagesMaxDeploymentFiles)
return fmt.Errorf("pages deployment must not contain more than %d files", pagesMaxDeploymentFiles)
case strings.Contains(message, "extracted size exceeds"):
return errors.New(errPagesPackageExtractedTooLarge)
case strings.Contains(message, "file too large"), strings.Contains(message, "size out of bounds"):
+3 -3
View File
@@ -958,7 +958,7 @@ func deploymentPackageHash(ctx context.Context, deployment *model.PagesDeploymen
}
uploadRecord, err := upload.GetActiveUpload(ctx, deployment.UploadID)
if err != nil {
return "", fmt.Errorf("pages 部署包不存在: %w", err)
return "", fmt.Errorf("pages deployment archive was not found: %w", err)
}
hash := strings.TrimSpace(uploadRecord.Hash)
if hash == "" {
@@ -973,7 +973,7 @@ func deploymentPackageHash(ctx context.Context, deployment *model.PagesDeploymen
func openDeploymentPackageFromUpload(ctx context.Context, uploadID uint64, deploymentID uint) (DeploymentPackage, error) {
opened, err := upload.OpenStoredUpload(ctx, uploadID)
if err != nil {
return DeploymentPackage{}, fmt.Errorf("pages 部署包不存在: %w", err)
return DeploymentPackage{}, fmt.Errorf("pages deployment archive was not found: %w", err)
}
contentType := opened.ContentType
if contentType == "" {
@@ -1028,7 +1028,7 @@ func hydrateLegacyDeploymentUpload(
RelativePaths: pagesLegacyRelativeCandidates(project, deployment),
})
if err != nil {
return nil, fmt.Errorf("pages 部署包不存在: %w", err)
return nil, fmt.Errorf("pages deployment archive was not found: %w", err)
}
ingestResult, err := ingestPagesDeploymentPackage(
+2 -2
View File
@@ -163,7 +163,7 @@ func TestCreateProjectRejectsUnsafeFallbackPath(t *testing.T) {
SPAFallbackPath: "/index.html; proxy_pass http://evil",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "回退路径")
assert.Contains(t, err.Error(), "fallback path")
}
func TestCreateProjectRejectsUnsafeContentPaths(t *testing.T) {
@@ -412,7 +412,7 @@ func TestOpenDeploymentPackageRequiresActiveConfigSnapshot(t *testing.T) {
_, err = OpenDeploymentPackage(ctx, deployment.ID)
require.Error(t, err)
assert.Contains(t, err.Error(), "激活配置")
assert.Contains(t, err.Error(), "active configuration")
require.NoError(t, db.DB(ctx).Create(&model.ConfigVersion{
Version: "v2026-001",
@@ -45,7 +45,7 @@ func GetProjectLatestPackageMetadata(ctx context.Context, projectID uint) (*Proj
uploadRecord, err := upload.GetActiveUpload(ctx, deployment.UploadID)
if err != nil {
return nil, fmt.Errorf("pages 部署包不存在: %w", err)
return nil, fmt.Errorf("pages deployment archive was not found: %w", err)
}
hash := strings.TrimSpace(uploadRecord.Hash)
if hash == "" {
+8 -8
View File
@@ -100,7 +100,7 @@ func rebindPagesRouteMaps(ctx context.Context, routes []map[string]json.RawMessa
if siteName == "" {
siteName = "pages"
}
return false, fmt.Errorf("路由 %s %w", siteName, err)
return false, fmt.Errorf("route %s %w", siteName, err)
}
project, activeDeployment, err := loadActivePagesProject(ctx, projectID, siteName)
if err != nil {
@@ -152,7 +152,7 @@ func resolveProjectIDFromRouteMap(route map[string]json.RawMessage) (uint, error
return deployment.ProjectID, nil
}
}
return 0, errors.New("pages 配置无效: 缺少 pages_project_id")
return 0, errors.New("invalid Pages configuration: missing pages_project_id")
}
func loadActivePagesProject(ctx context.Context, projectID uint, siteName string) (*model.PagesProject, *model.PagesDeployment, error) {
@@ -162,28 +162,28 @@ func loadActivePagesProject(ctx context.Context, projectID uint, siteName string
project, err := repository.GetPagesProjectByID(ctx, projectID)
if err != nil {
if errorsIsNotFound(err) {
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目不存在", siteName)
return nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages project was not found", siteName)
}
return nil, nil, err
}
if !project.Enabled {
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目未启用", siteName)
return nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages project is disabled", siteName)
}
if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID == 0 {
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目没有激活部署", siteName)
return nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages project has no active deployment", siteName)
}
activeDeployment, err := repository.GetPagesDeploymentByID(ctx, *project.ActiveDeploymentID)
if err != nil {
if errorsIsNotFound(err) {
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不存在", siteName)
return nil, nil, fmt.Errorf("invalid Pages configuration for route %s: active Pages deployment was not found", siteName)
}
return nil, nil, err
}
if activeDeployment.ProjectID != project.ID {
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不匹配", siteName)
return nil, nil, fmt.Errorf("invalid Pages configuration for route %s: active Pages deployment does not match", siteName)
}
if strings.TrimSpace(activeDeployment.Checksum) == "" {
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 部署校验和缺失", siteName)
return nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages deployment checksum is missing", siteName)
}
return project, activeDeployment, nil
}
+2 -2
View File
@@ -86,12 +86,12 @@ func decodeStrictJSON(c *gin.Context, target any, allowEmpty bool) bool {
func deploymentIDParam(c *gin.Context) (uint, bool) {
raw := c.Param("deployment_id")
if raw == "" {
response.AbortBadRequest(c, "无效的 ID")
response.AbortBadRequest(c, "Invalid ID")
return 0, false
}
id64, err := strconv.ParseUint(raw, 10, 64)
if err != nil || id64 == 0 {
response.AbortBadRequest(c, "无效的 ID")
response.AbortBadRequest(c, "Invalid ID")
return 0, false
}
return uint(id64), true
@@ -46,13 +46,13 @@ func (providerError remoteProviderError) Error() string {
}
const (
errRemoteProviderInvalidLimit remoteProviderError = "远程来源部署包大小限制无效"
errRemoteProviderRedirectLimit remoteProviderError = "远程来源重定向次数超过限制"
errRemoteProviderInvalidLimit remoteProviderError = "Invalid remote source archive size limit"
errRemoteProviderRedirectLimit remoteProviderError = "Remote source redirect limit exceeded"
errRemoteProviderDownloadFailed remoteProviderError = errPagesPackageURLDownloadFailed
errRemoteProviderTooLarge remoteProviderError = errPagesPackageURLTooLarge
errRemoteProviderEmpty remoteProviderError = errPagesPackageEmpty
errRemoteProviderUnsupported remoteProviderError = errPagesPackageUnsupported
errRemoteProviderCleanupFailed remoteProviderError = "清理远程来源临时文件失败"
errRemoteProviderCleanupFailed remoteProviderError = "Failed to clean up remote source temporary file"
)
// RemoteSourceRequest describes one immutable Remote URL package fetch.
+10 -10
View File
@@ -30,8 +30,8 @@ const (
var PagesSourceScanMeta = task.TaskMeta{
Type: TaskTypePagesSourceScan,
AsynqTask: PagesSourceScanTask,
Name: "OpenFlare Pages 部署源扫描",
Description: "补偿孤儿部署包、恢复过期执行权并串行检查到期的 GitHub latest 部署源",
Name: "OpenFlare Pages deployment source scan",
Description: "Clean up orphaned archives, recover expired source leases, and check GitHub sources tracking the latest release for updates",
SupportsTime: false,
MaxRetry: 0,
Queue: task.QueueDefault,
@@ -125,7 +125,7 @@ func (handler *SourceScanHandler) Execute(ctx context.Context, payload []byte) (
summary.OrphanCleanup = orphanSummary
task.AppendLog(
ctx,
"[cleanup] orphan 候选=%d,已补偿=%d,仍被引用=%d,lease busy=%d,非法 marker=%d,跳过=%d,失败=%d",
"[cleanup] orphan candidates=%d, compensated=%d, referenced=%d, lease busy=%d, invalid markers=%d, skipped=%d, failed=%d",
orphanSummary.Candidates,
orphanSummary.Reconciled,
orphanSummary.Referenced,
@@ -143,7 +143,7 @@ func (handler *SourceScanHandler) Execute(ctx context.Context, payload []byte) (
return nil, err
}
message := fmt.Sprintf(
"Pages 部署源扫描完成:恢复 %d 个租约,补偿 %d 个孤儿记录,检查 %d 个来源,投递 %d 个自动更新,积压 %d 个",
"Pages source scan completed: recovered %d leases, compensated %d orphan records, checked %d sources, dispatched %d automatic updates, backlog %d",
summary.RecoveredLeases,
summary.OrphanCleanup.Reconciled,
summary.CheckedSources,
@@ -195,7 +195,7 @@ func recoverExpiredPagesSourceLeases(
}
if recovered {
summary.RecoveredLeases++
task.AppendLog(ctx, "[recover] 已恢复过期来源租约:source_id=%d", candidate.SourceID)
task.AppendLog(ctx, "[recover] Recovered expired source lease: source_id=%d", candidate.SourceID)
}
}
return nil
@@ -223,7 +223,7 @@ func scanDueGitHubSources(
summary.SelectedSources = len(candidates)
task.AppendLog(
ctx,
"[scan] 到期来源=%d,本批=%d",
"[scan] Due sources=%d, batch=%d",
summary.DueSources,
summary.SelectedSources,
)
@@ -238,7 +238,7 @@ func scanDueGitHubSources(
return err
}
summary.Backlog = int(remainingDue)
task.AppendLog(ctx, "[scan] 本批处理后仍到期来源=%d", summary.Backlog)
task.AppendLog(ctx, "[scan] Sources still due after this batch=%d", summary.Backlog)
return nil
}
@@ -261,7 +261,7 @@ func scanOneDueGitHubSource(
switch outcome {
case sourceLeaseBusy:
summary.BusySources++
task.AppendLog(ctx, "[check] 来源正在执行其它任务,跳过:source_id=%d", candidate.SourceID)
task.AppendLog(ctx, "[check] Source is running another task; skipping: source_id=%d", candidate.SourceID)
return
case sourceLeaseStale:
summary.StaleSources++
@@ -333,7 +333,7 @@ func recordPagesSourceProviderBackoff(
})
task.AppendLog(
ctx,
"[check] GitHub provider 退避:source_id=%d status=%d retry_at=%s",
"[check] GitHub provider backoff: source_id=%d status=%d retry_at=%s",
sourceID,
domainError.statusCode,
retryAtText,
@@ -369,7 +369,7 @@ func handleCheckedGitHubSource(
if receipt != nil {
task.AppendLog(
ctx,
"[dispatch] 已投递自动更新:source_id=%d execution_id=%s revision=%s",
"[dispatch] Automatic update dispatched: source_id=%d execution_id=%s revision=%s",
snapshot.SourceID,
receipt.ExecutionID,
checkResult.Revision,
+5 -5
View File
@@ -224,7 +224,7 @@ func syncRemoteSourceWithTrigger(
return &sourceSyncOutcome{Stale: true}, nil
}
task.AppendLog(ctx, "[activate] 正在原子切换生产部署")
task.AppendLog(ctx, "[activate] Atomically switching production deployment")
deployment, reused, referenced, err := commitSourceDeploymentWithTrigger(
ctx,
snapshot,
@@ -266,7 +266,7 @@ func prepareRemoteSource(
snapshot *sourceExecutionSnapshot,
limits pagesLimits,
) (*preparedRemoteSource, error) {
task.AppendLog(ctx, "[download] 正在获取远程部署包")
task.AppendLog(ctx, "[download] Fetching remote deployment archive")
candidate, err := FetchRemoteSource(ctx, RemoteSourceRequest{
URL: snapshot.RemoteURL,
AllowInsecure: snapshot.AllowInsecure,
@@ -288,7 +288,7 @@ func prepareRemoteSource(
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
return nil, err
}
task.AppendLog(ctx, "[verify] 正在校验归档结构与入口文件")
task.AppendLog(ctx, "[verify] Verifying archive structure and entry file")
manifest, err := inspectPagesPackage(candidate.TempPath, candidate.Format, rootDir, entryFile, limits)
if err != nil {
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
@@ -335,7 +335,7 @@ func resolveSourceIngest(
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
task.AppendLog(ctx, "[ingest] 正在保存受管部署包")
task.AppendLog(ctx, "[ingest] Saving managed deployment archive")
result, err := ingestPagesDeploymentPackageWithSource(
ctx,
prepared.Candidate.TempPath,
@@ -361,7 +361,7 @@ func compensateSourceIngest(
}
cleanupCtx, cancel := sourceCleanupContext(ctx)
defer cancel()
task.AppendLog(cleanupCtx, "[cleanup] 正在补偿未引用的部署包记录")
task.AppendLog(cleanupCtx, "[cleanup] Compensating unreferenced deployment archive records")
if err := removePagesUploadIfUnreferenced(cleanupCtx, snapshot.ProjectID, state.Result.Upload.ID); err != nil {
logger.ErrorF(cleanupCtx,
"[PagesSource] compensate upload failed: project_id=%d source_id=%d upload_id=%d error=%v",
+10 -10
View File
@@ -36,8 +36,8 @@ var errUnexpectedJSONTrailingValue = errors.New("unexpected trailing JSON value"
var PagesSourceActionMeta = task.TaskMeta{
Type: TaskTypePagesSourceAction,
AsynqTask: PagesSourceActionTask,
Name: "OpenFlare Pages 部署源操作",
Description: "检查或同步 Pages 项目部署源",
Name: "OpenFlare Pages deployment source operation",
Description: "Check or synchronize a Pages project deployment source",
SupportsTime: false,
MaxRetry: 2,
Queue: task.QueueDefault,
@@ -138,14 +138,14 @@ func (h *SourceActionHandler) Execute(ctx context.Context, payload []byte) (*tas
source, err := repository.GetPagesProjectSourceByID(ctx, input.SourceID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
task.AppendLog(ctx, "[resolve] 来源已不存在,本次任务跳过")
task.AppendLog(ctx, "[resolve] Source no longer exists; skipping task")
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
}
logger.ErrorF(ctx, "[PagesSource] load source failed: source_id=%d error=%v", input.SourceID, err)
return nil, errors.New(errPagesSourceSyncFailed)
}
if source.ConfigVersion != input.ConfigVersion {
task.AppendLog(ctx, "[resolve] 来源配置已变化,本次任务跳过")
task.AppendLog(ctx, "[resolve] Source configuration changed; skipping task")
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
}
if input.Action == sourceActionCheck && source.SourceType == PagesSourceTypeRemoteURL {
@@ -159,7 +159,7 @@ func (h *SourceActionHandler) Execute(ctx context.Context, payload []byte) (*tas
return nil, task.PermanentError(errPagesSourceActionInvalid)
}
task.AppendLog(ctx, "[resolve] 正在获取来源执行权")
task.AppendLog(ctx, "[resolve] Acquiring source task ownership")
snapshot, outcome, err := acquireSourceLease(ctx, input.SourceID, input.ConfigVersion, input.Action)
if err != nil {
logger.ErrorF(ctx, "[PagesSource] acquire lease failed: source_id=%d error=%v", input.SourceID, err)
@@ -167,10 +167,10 @@ func (h *SourceActionHandler) Execute(ctx context.Context, payload []byte) (*tas
}
switch outcome {
case sourceLeaseBusy:
task.AppendLog(ctx, "[resolve] 已有来源任务正在执行,本次任务跳过")
task.AppendLog(ctx, "[resolve] Another source task is running; skipping task")
return &task.TaskResult{Message: errPagesSourceActionBusy}, nil
case sourceLeaseStale:
task.AppendLog(ctx, "[resolve] 来源配置或执行权已变化,本次任务跳过")
task.AppendLog(ctx, "[resolve] Source configuration or ownership changed; skipping task")
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
case sourceLeaseAcquired:
// 已获取执行权,继续执行。
@@ -220,12 +220,12 @@ func executeSourceSyncAction(
return nil, errors.New(errPagesSourceSyncFailed)
}
if result == nil || result.Stale {
task.AppendLog(ctx, "[activate] 来源配置或执行权已变化,本次任务未切换部署")
task.AppendLog(ctx, "[activate] Source configuration or ownership changed; deployment was not switched")
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
}
message := "Pages 部署源同步并发布成功"
message := "Pages deployment source synchronized and published successfully"
if result.Reused {
message = "Pages 部署源内容未变化,已重新激活现有部署"
message = "Pages source content is unchanged; existing deployment reactivated"
}
return &task.TaskResult{Message: message, Detail: sourceSyncResultDetail(result)}, nil
}
+8 -8
View File
@@ -43,12 +43,12 @@ const (
errProxyRouteTunnelAddrReq = "tunnel_target_addr is required for tunnel upstream"
errProxyRouteTunnelProtocol = "tunnel_target_protocol must be http or https"
errProxyRoutePagesProjectReq = "pages_project_id is required for Pages upstream"
errProxyRoutePagesNotFound = "pages 项目不存在"
errProxyRoutePagesDisabled = "pages 项目未启用"
errProxyRoutePagesNoDeploy = "pages 项目没有激活部署"
errProxyRouteOriginSchemeOnly = "源站协议仅支持 http 或 https"
errProxyRouteOriginPort = "端口格式不合法"
errProxyRouteOriginPortEmpty = "端口不能为空"
errProxyRouteOriginURI = "源站路径需以 / 或 ? 开头"
errProxyRouteOriginURIProto = "源站路径不能包含协议"
errProxyRoutePagesNotFound = "pages project was not found"
errProxyRoutePagesDisabled = "pages project is disabled"
errProxyRoutePagesNoDeploy = "pages project has no active deployment"
errProxyRouteOriginSchemeOnly = "origin protocol must be http or https"
errProxyRouteOriginPort = "invalid port format"
errProxyRouteOriginPortEmpty = "port must not be empty"
errProxyRouteOriginURI = "origin path must start with / or ?"
errProxyRouteOriginURIProto = "origin path must not contain a protocol"
)
@@ -361,7 +361,7 @@ func normalizeProxyRouteLimitReqPerIP(raw string) (string, error) {
return "-1", nil
}
if !proxyRouteLimitReqPattern.MatchString(normalized) {
return "", errors.New("请求频率格式不合法,请使用类似 10r/s、100r/m,或 -1 禁用")
return "", errors.New("invalid request rate; use a rate such as 10r/s or 100r/m, or -1 to disable")
}
return normalized, nil
}
+2 -2
View File
@@ -6,6 +6,6 @@ package relay
const (
//nolint:gosec // error message text, not a credential
errAgentTokenInvalid = "无权进行此操作,Agent Token 无效"
errRelayNodeTypeMismatch = "此节点不是 TunnelRelay 类型"
errAgentTokenInvalid = "Operation not authorized: invalid agent token"
errRelayNodeTypeMismatch = "This node is not a TunnelRelay"
)
+29 -29
View File
@@ -40,11 +40,11 @@ type LogDBSwitchHandler struct{}
func (h *LogDBSwitchHandler) ValidatePayload(payload []byte) ([]byte, error) {
var p logDBSwitchPayload
if err := json.Unmarshal(payload, &p); err != nil {
return nil, fmt.Errorf("参数解析失败: %w", err)
return nil, fmt.Errorf("failed to parse parameters: %w", err)
}
p.Target = normalizeTarget(p.Target)
if !validTarget(p.Target) {
return nil, fmt.Errorf("目标日志库不合法: %s", p.Target)
return nil, fmt.Errorf("invalid target log database: %s", p.Target)
}
out, err := json.Marshal(p)
if err != nil {
@@ -73,7 +73,7 @@ func validTarget(v string) bool {
func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
var p logDBSwitchPayload
if err := json.Unmarshal(payload, &p); err != nil {
return nil, fmt.Errorf("参数解析失败: %w", err)
return nil, fmt.Errorf("failed to parse parameters: %w", err)
}
p.Target = normalizeTarget(p.Target)
if err := validateSwitch(ctx, p.Target); err != nil {
@@ -82,10 +82,10 @@ func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*task
source, err := currentLogDatabase(ctx)
if err != nil {
task.AppendLog(ctx, "读取日志主库失败: %v", err)
task.AppendLog(ctx, "Failed to read log database: %v", err)
return nil, err
}
task.AppendLog(ctx, "开始切换日志数据库:%s -> %s", source, p.Target)
task.AppendLog(ctx, "Starting log database migration: %s -> %s", source, p.Target)
// 设置迁移冻结标记(置位后由 ensureWritable 拒绝新写入)。
if err := setMigrationFlag(ctx, "migrating"); err != nil {
@@ -94,14 +94,14 @@ func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*task
// 失败也清除(SaveOrUpdateSystemConfig 会失效 RAM 缓存并广播),保持源库可写。
defer func() {
if err := setMigrationFlag(ctx, ""); err != nil {
logger.ErrorF(ctx, "清除日志迁移冻结标记失败: %v", err)
logger.ErrorF(ctx, "Failed to clear log migration freeze marker: %v", err)
}
}()
// 冻结标记置位后再排空在途批次(chwriter + 用户访问日志 writer),
// 保证排空完成后不再有新批次进入源库。
if err := drainLogWriters(ctx); err != nil {
return nil, fmt.Errorf("排空日志写入队列失败: %w", err)
return nil, fmt.Errorf("failed to drain log write queue: %w", err)
}
src, err := logstore.Active(ctx)
@@ -138,8 +138,8 @@ func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*task
if err := flipLogDatabase(ctx, p.Target); err != nil {
return nil, err
}
task.AppendLog(ctx, "日志数据库已切换为 %s,写入恢复", p.Target)
return &task.TaskResult{Message: fmt.Sprintf("日志数据库已从 %s 切换为 %s", source, p.Target)}, nil
task.AppendLog(ctx, "Log database switched to %s; writes resumed", p.Target)
return &task.TaskResult{Message: fmt.Sprintf("Log database switched from %s to %s", source, p.Target)}, nil
}
func validateSwitch(ctx context.Context, target string) error {
@@ -148,20 +148,20 @@ func validateSwitch(ctx context.Context, target string) error {
return err
}
if source == target {
return errors.New("目标日志库与当前日志库相同,无需迁移")
return errors.New("target log database is already active; no migration is required")
}
switch target {
case "clickhouse":
if !config.Config.ClickHouse.Enabled {
return errors.New("ClickHouse 未启用,无法迁移到 ClickHouse")
return errors.New("ClickHouse is disabled; migration to ClickHouse is unavailable")
}
case "postgres":
if !config.Config.Database.Enabled {
return errors.New("PostgreSQL 未启用(当前主库为 SQLite),无法迁移到 PostgreSQL")
return errors.New("PostgreSQL is disabled and SQLite is the primary database; migration to PostgreSQL is unavailable")
}
case "sqlite":
if config.Config.Database.Enabled {
return errors.New("当前主库为 PostgreSQL,日志库不能设置为 SQLite")
return errors.New("PostgreSQL is the primary database; SQLite cannot be selected for log storage")
}
}
return nil
@@ -170,10 +170,10 @@ func validateSwitch(ctx context.Context, target string) error {
func currentLogDatabase(ctx context.Context) (string, error) {
cfg, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyLogDatabase)
if err != nil {
return "", fmt.Errorf("读取日志主库失败: %w", err)
return "", fmt.Errorf("failed to read log database: %w", err)
}
if cfg.Value == "" {
return "", errors.New("日志主库配置为空")
return "", errors.New("log database configuration is empty")
}
return cfg.Value, nil
}
@@ -205,10 +205,10 @@ func clearTargetLogTables(ctx context.Context, dst *logstore.Store) error {
// 依次清空 6 张表:AccessLogs.DeleteAll、UserAccessLogs.DeleteAll、Observability.DeleteAll*
// (SQLite/PG 用 DeleteAll;CH 用 TRUNCATE 语义)。
if _, err := dst.AccessLogs.DeleteAll(ctx); err != nil {
return fmt.Errorf("清空目标访问日志失败: %w", err)
return fmt.Errorf("failed to clear target access logs: %w", err)
}
if _, err := dst.UserAccessLogs.DeleteAll(ctx); err != nil {
return fmt.Errorf("清空目标用户访问日志失败: %w", err)
return fmt.Errorf("failed to clear target user access logs: %w", err)
}
for _, fn := range []func(context.Context) (int64, error){
dst.Observability.DeleteAllMetricSnapshots,
@@ -234,13 +234,13 @@ func ensureTargetPartitions(ctx context.Context, src, dst *logstore.Store, targe
return err
}
if from.IsZero() || to.IsZero() {
task.AppendLog(ctx, "源库无日志数据,跳过分区预建")
task.AppendLog(ctx, "Source database contains no logs; skipping partition precreation")
return nil
}
if err := dst.AccessLogs.EnsurePartitions(ctx, from, to.AddDate(0, 1, 0)); err != nil {
return fmt.Errorf("预建目标 PG 分区失败: %w", err)
return fmt.Errorf("failed to precreate target PostgreSQL partitions: %w", err)
}
task.AppendLog(ctx, "已为目标 PG 预建分区 %s ~ %s", from.Format("2006-01"), to.Format("2006-01"))
task.AppendLog(ctx, "Precreated target PostgreSQL partitions from %s to %s", from.Format("2006-01"), to.Format("2006-01"))
return nil
}
@@ -249,11 +249,11 @@ func ensureTargetPartitions(ctx context.Context, src, dst *logstore.Store, targe
func migrationRange(ctx context.Context, src *logstore.Store) (time.Time, time.Time, error) {
fromAccess, toAccess, err := src.AccessLogs.MigrationRange(ctx)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("读取源访问日志时间范围失败: %w", err)
return time.Time{}, time.Time{}, fmt.Errorf("failed to read source access log time range: %w", err)
}
fromUser, toUser, err := src.UserAccessLogs.MigrationRange(ctx)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("读取源用户访问日志时间范围失败: %w", err)
return time.Time{}, time.Time{}, fmt.Errorf("failed to read source user access log time range: %w", err)
}
return minTime(fromAccess, fromUser), maxTime(toAccess, toUser), nil
}
@@ -297,9 +297,9 @@ func copyAccessLogs(ctx context.Context, src, dst *logstore.Store) error {
break
}
if err := dst.AccessLogs.BatchInsertNodeAccessLogs(ctx, rows); err != nil {
return fmt.Errorf("写入目标访问日志失败(批 %d): %w", lastID, err)
return fmt.Errorf("failed to write target access logs (batch %d): %w", lastID, err)
}
task.AppendLog(ctx, "已复制访问日志 %d 条(截至 id=%d)", len(rows), rows[len(rows)-1].ID)
task.AppendLog(ctx, "Copied %d access logs (through id=%d)", len(rows), rows[len(rows)-1].ID)
lastID = rows[len(rows)-1].ID
if len(rows) < copyBatchSize {
break
@@ -324,10 +324,10 @@ func copyUserAccessLogs(ctx context.Context, src, dst *logstore.Store) error {
return nil
}
if err := dst.UserAccessLogs.BatchInsert(ctx, rows); err != nil {
return fmt.Errorf("写入目标用户访问日志失败(批 %d): %w", lastID, err)
return fmt.Errorf("failed to write target user access logs (batch %d): %w", lastID, err)
}
lastID = rows[len(rows)-1].ID
task.AppendLog(ctx, "已复制用户访问日志 %d 条(截至 id=%d)", len(rows), lastID)
task.AppendLog(ctx, "Copied %d user access logs (through id=%d)", len(rows), lastID)
if len(rows) < copyBatchSize {
return nil
}
@@ -374,16 +374,16 @@ func copyObsTable[T any](ctx context.Context, name string,
for {
rows, err := list(ctx, lastID, copyBatchSize)
if err != nil {
return fmt.Errorf("复制 %s 失败: %w", name, err)
return fmt.Errorf("failed to copy %s: %w", name, err)
}
if len(rows) == 0 {
return nil
}
if err := insert(ctx, rows); err != nil {
return fmt.Errorf("复制 %s 失败: %w", name, err)
return fmt.Errorf("failed to copy %s: %w", name, err)
}
lastID = idOf(rows)
task.AppendLog(ctx, "已复制 %s %d 条(截至 id=%d)", name, len(rows), lastID)
task.AppendLog(ctx, "Copied %s: %d records (through id=%d)", name, len(rows), lastID)
if len(rows) < copyBatchSize {
return nil
}
@@ -231,21 +231,21 @@ func TestValidateSwitch(t *testing.T) {
config.Config.Database.Enabled, config.Config.ClickHouse.Enabled = false, false
err := validateSwitch(ctx, "sqlite")
require.Error(t, err)
assert.Contains(t, err.Error(), "相同")
assert.Contains(t, err.Error(), "already active")
})
t.Run("clickhouse disabled rejected", func(t *testing.T) {
setLogDB("sqlite")
config.Config.Database.Enabled, config.Config.ClickHouse.Enabled = false, false
err := validateSwitch(ctx, "clickhouse")
require.Error(t, err)
assert.Contains(t, err.Error(), "ClickHouse 未启用")
assert.Contains(t, err.Error(), "ClickHouse is disabled")
})
t.Run("postgres requires main db enabled", func(t *testing.T) {
setLogDB("sqlite")
config.Config.Database.Enabled, config.Config.ClickHouse.Enabled = false, false
err := validateSwitch(ctx, "postgres")
require.Error(t, err)
assert.Contains(t, err.Error(), "PostgreSQL 未启用")
assert.Contains(t, err.Error(), "PostgreSQL is disabled")
})
t.Run("sqlite rejected when main db is postgres", func(t *testing.T) {
setLogDB("postgres")
+2 -2
View File
@@ -15,7 +15,7 @@ const (
errCertificateAlreadyApplying = "certificate is already applying"
errCertificateOnlyACMERenew = "only acme certificates can be renewed"
errCertificateFilesRequired = "certificate file and key file cannot be empty"
errCertificatePEMInvalid = "证书 PEM 内容不合法"
errCertificatePEMInvalid = "invalid certificate PEM content"
errDNSAccountInUse = "该 DNS 账号已被证书使用,无法删除"
errDNSAccountInUse = "this DNS account is used by certificates and cannot be deleted"
)
+2 -2
View File
@@ -329,7 +329,7 @@ func CreateDNSAccount(ctx context.Context, input DNSAccountInput) (*model.DNSAcc
Authorization: authorization,
}
if account.Name == "" || account.Type == "" || authorization == "" {
return nil, errors.New("DNS 账号参数不完整")
return nil, errors.New("DNS account parameters are incomplete")
}
if err := repository.CreateDNSAccountRecord(ctx, account); err != nil {
return nil, err
@@ -351,7 +351,7 @@ func UpdateDNSAccount(ctx context.Context, id uint, input DNSAccountInput) (*mod
account.Type = strings.TrimSpace(input.Type)
account.Authorization = authorization
if account.Name == "" || account.Type == "" || authorization == "" {
return nil, errors.New("DNS 账号参数不完整")
return nil, errors.New("DNS account parameters are incomplete")
}
if err := repository.SaveDNSAccount(ctx, account); err != nil {
return nil, err
+9 -9
View File
@@ -35,25 +35,25 @@ func SetObtainCertificateFuncForTest(fn func(context.Context, *model.TLSCertific
}
func obtainCertificate(ctx context.Context, cert *model.TLSCertificate) error {
task.AppendLog(ctx, "【续签任务】开始续签,设置申请状态为 applying...")
task.AppendLog(ctx, "[Renewal] Starting renewal; setting application status to applying...")
cert.ApplyStatus = tlsApplyStatusApplying
if err := repository.SaveTLSCertificate(ctx, cert); err != nil {
return err
}
task.AppendLog(ctx, "【续签任务】正在解析 ACME 账户...")
task.AppendLog(ctx, "[Renewal] Resolving ACME account...")
acmeAccount, err := resolveAcmeAccount(ctx, cert)
if err != nil {
return updateCertError(ctx, cert, fmt.Sprintf("Failed to get ACME account: %v", err))
}
task.AppendLog(ctx, "【续签任务】正在解析 DNS 账户信息 (ID=%d)...", cert.DNSAccountID)
task.AppendLog(ctx, "[Renewal] Resolving DNS account information (ID=%d)...", cert.DNSAccountID)
dnsAccount, err := repository.GetDNSAccountByID(ctx, cert.DNSAccountID)
if err != nil {
return updateCertError(ctx, cert, fmt.Sprintf("Failed to get DNS account: %v", err))
}
task.AppendLog(ctx, "【续签任务】正在解密 DNS 账号凭据及 ACME 账户私钥...")
task.AppendLog(ctx, "[Renewal] Decrypting DNS account credentials and ACME account private key...")
dnsAuth, err := openSensitive(dnsAccount.Authorization)
if err != nil {
return updateCertError(ctx, cert, fmt.Sprintf("Failed to decrypt DNS credentials: %v", err))
@@ -65,9 +65,9 @@ func obtainCertificate(ctx context.Context, cert *model.TLSCertificate) error {
}
domains := splitAcmeDomains(cert.PrimaryDomain, cert.OtherDomains)
task.AppendLog(ctx, "【续签任务】待申请的域名列表: %v", domains)
task.AppendLog(ctx, "[Renewal] Requested domains: %v", domains)
task.AppendLog(ctx, "【续签任务】正在调用 ACME 客户端(通过 DNS-01 挑战)发起 SSL 证书签发请求,请稍候...")
task.AppendLog(ctx, "[Renewal] Requesting SSL certificate issuance through the ACME DNS-01 challenge; please wait...")
newAccountURL, newPrivateKeyPEM, result, err := acme.ObtainSSL(
acmeAccount.Email,
acmePrivateKey,
@@ -82,7 +82,7 @@ func obtainCertificate(ctx context.Context, cert *model.TLSCertificate) error {
domains,
)
task.AppendLog(ctx, "【续签任务】正在保存 ACME 账户可能的变更...")
task.AppendLog(ctx, "[Renewal] Saving ACME account changes...")
if err := persistAcmeAccountUpdates(ctx, cert, acmeAccount, newAccountURL, newPrivateKeyPEM, acmePrivateKey); err != nil {
return updateCertError(ctx, cert, err.Error())
}
@@ -91,11 +91,11 @@ func obtainCertificate(ctx context.Context, cert *model.TLSCertificate) error {
return updateCertError(ctx, cert, err.Error())
}
task.AppendLog(ctx, "【续签任务】证书签发成功,正在将证书内容与私钥安全写入数据库...")
task.AppendLog(ctx, "[Renewal] Certificate issued; securely storing certificate and private key in the database...")
if err := saveObtainedCertificate(ctx, cert, result); err != nil {
return updateCertError(ctx, cert, err.Error())
}
task.AppendLog(ctx, "【续签任务】证书数据存储完成!")
task.AppendLog(ctx, "[Renewal] Certificate data saved")
return nil
}
+3 -3
View File
@@ -15,7 +15,7 @@ func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
return apiutil.AbortNotFoundIfMissing(c, err, "记录不存在")
return apiutil.AbortNotFoundIfMissing(c, err, "Record was not found")
}
// GetCertificates 列出 TLS 证书。
@@ -170,12 +170,12 @@ func ImportCertificateFile(c *gin.Context) {
remark := c.PostForm("remark")
certFile, err := c.FormFile("cert_file")
if err != nil {
response.AbortBadRequest(c, "缺少证书文件")
response.AbortBadRequest(c, "Missing certificate file")
return
}
keyFile, err := c.FormFile("key_file")
if err != nil {
response.AbortBadRequest(c, "缺少私钥文件")
response.AbortBadRequest(c, "Missing private key file")
return
}
certificate, err := CreateCertificateFromFiles(c.Request.Context(), name, certFile, keyFile, remark)
+17 -17
View File
@@ -25,8 +25,8 @@ const (
var SSLSingleRenewMeta = task.TaskMeta{
Type: TaskTypeSSLSingleRenew,
AsynqTask: SSLSingleRenewTask,
Name: "OpenFlare 单证书 SSL 续期",
Description: "对单个指定的 ACME 证书执行续期",
Name: "OpenFlare single-certificate SSL renewal",
Description: "Renew a single specified ACME certificate",
SupportsTime: false,
MaxRetry: task.DefaultMaxRetry,
Queue: task.QueueDefault,
@@ -34,11 +34,11 @@ var SSLSingleRenewMeta = task.TaskMeta{
Params: []task.TaskParam{
{
Name: "id",
Label: "证书 ID",
Label: "Certificate ID",
Type: "number",
Required: true,
Placeholder: "请输入证书 ID",
Description: "待续期的 TLS 证书 ID",
Placeholder: "Enter the certificate ID",
Description: "ID of the TLS certificate to renew",
},
},
}
@@ -54,16 +54,16 @@ type SSLSingleRenewHandler struct{}
// ValidatePayload validates and normalizes the task payload.
func (h *SSLSingleRenewHandler) ValidatePayload(payload []byte) ([]byte, error) {
if len(payload) == 0 {
return nil, errors.New("任务参数不能为空")
return nil, errors.New("task parameters must not be empty")
}
var req SSLSingleRenewPayload
if err := json.Unmarshal(payload, &req); err != nil {
return nil, fmt.Errorf("无效的 JSON 格式: %w", err)
return nil, fmt.Errorf("invalid JSON format: %w", err)
}
if req.ID == 0 {
return nil, errors.New("证书 ID 不能为空或零")
return nil, errors.New("certificate ID must be nonzero")
}
return json.Marshal(req)
@@ -73,29 +73,29 @@ func (h *SSLSingleRenewHandler) ValidatePayload(payload []byte) ([]byte, error)
func (h *SSLSingleRenewHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
var req SSLSingleRenewPayload
if err := json.Unmarshal(payload, &req); err != nil {
return nil, fmt.Errorf("解析任务参数: %w", err)
return nil, fmt.Errorf("parse task parameters: %w", err)
}
task.AppendLog(ctx, "开始续期证书,ID: %d", req.ID)
task.AppendLog(ctx, "Starting certificate renewal, ID: %d", req.ID)
cert, err := repository.GetTLSCertificateByID(ctx, req.ID)
if err != nil {
task.AppendLog(ctx, "获取证书记录失败 ID=%d: %v", req.ID, err)
return nil, fmt.Errorf("获取证书记录失败: %w", err)
task.AppendLog(ctx, "Failed to get certificate record ID=%d: %v", req.ID, err)
return nil, fmt.Errorf("failed to get certificate record: %w", err)
}
if cert.Provider != tlsProviderACME {
task.AppendLog(ctx, "证书 %s (ID=%d) 不是 ACME 托管证书,无法自动续期 (Provider: %s)", cert.PrimaryDomain, req.ID, cert.Provider)
return nil, fmt.Errorf("证书 %s 不是 ACME 托管证书,无法自动续期", cert.PrimaryDomain)
task.AppendLog(ctx, "Certificate %s (ID=%d) is not ACME-managed and cannot be renewed automatically (provider: %s)", cert.PrimaryDomain, req.ID, cert.Provider)
return nil, fmt.Errorf("certificate %s is not ACME-managed and cannot be renewed automatically", cert.PrimaryDomain)
}
task.AppendLog(ctx, "准备为域名 [%s] 申请/续期证书", cert.PrimaryDomain)
task.AppendLog(ctx, "Preparing to issue or renew a certificate for domains [%s]", cert.PrimaryDomain)
if err := obtainTLSCertificate(ctx, cert); err != nil {
task.AppendLog(ctx, "申请证书失败: %v", err)
task.AppendLog(ctx, "Certificate issuance failed: %v", err)
return nil, err
}
msg := fmt.Sprintf("证书 %s 续签成功", cert.PrimaryDomain)
msg := fmt.Sprintf("Certificate %s renewed successfully", cert.PrimaryDomain)
task.AppendLog(ctx, "%s", msg)
return &task.TaskResult{Message: msg}, nil
}
+6 -6
View File
@@ -27,19 +27,19 @@ func TestSSLSingleRenewHandler_ValidatePayload(t *testing.T) {
name: "empty payload",
payload: nil,
wantErr: true,
errMsg: "任务参数不能为空",
errMsg: "task parameters must not be empty",
},
{
name: "invalid JSON",
payload: []byte(`{`),
wantErr: true,
errMsg: "无效的 JSON 格式",
errMsg: "invalid JSON format",
},
{
name: "zero ID",
payload: []byte(`{"id":0}`),
wantErr: true,
errMsg: "证书 ID 不能为空或零",
errMsg: "certificate ID must be nonzero",
},
{
name: "valid ID",
@@ -84,7 +84,7 @@ func TestSSLSingleRenewHandler_Execute(t *testing.T) {
require.NoError(t, err)
_, err = handler.Execute(ctx, payload)
require.Error(t, err)
assert.Contains(t, err.Error(), "获取证书记录失败")
assert.Contains(t, err.Error(), "failed to get certificate record")
})
t.Run("certificate provider is not ACME", func(t *testing.T) {
@@ -101,7 +101,7 @@ func TestSSLSingleRenewHandler_Execute(t *testing.T) {
_, err = handler.Execute(ctx, payload)
require.Error(t, err)
assert.Contains(t, err.Error(), "不是 ACME 托管证书")
assert.Contains(t, err.Error(), "is not ACME-managed")
})
t.Run("successful renewal", func(t *testing.T) {
@@ -125,7 +125,7 @@ func TestSSLSingleRenewHandler_Execute(t *testing.T) {
res, err := handler.Execute(ctx, payload)
require.NoError(t, err)
assert.Contains(t, res.Message, "续签成功")
assert.Contains(t, res.Message, "renewed successfully")
updated, err := repository.GetTLSCertificateByID(ctx, cert.ID)
require.NoError(t, err)
+7 -7
View File
@@ -32,32 +32,32 @@ func CompileRuleGraph(graph RuleGraph) (RuntimeRuleGraph, error) {
for _, node := range graph.Nodes {
config, err := compileRuleNodeConfig(node)
if err != nil {
return RuntimeRuleGraph{}, fmt.Errorf("节点 %s 的配置无法编译: %w", node.ID, err)
return RuntimeRuleGraph{}, fmt.Errorf("unable to compile configuration for node %s: %w", node.ID, err)
}
if node.Type == RuleNodeStart {
if runtime.Entry != "" {
return RuntimeRuleGraph{}, errors.New("规则图包含多个开始节点")
return RuntimeRuleGraph{}, errors.New("rule graph contains multiple start nodes")
}
runtime.Entry = node.ID
}
runtime.Nodes[node.ID] = RuntimeRuleNode{Type: node.Type, Config: config}
}
if runtime.Entry == "" {
return RuntimeRuleGraph{}, errors.New("规则图缺少开始节点")
return RuntimeRuleGraph{}, errors.New("rule graph is missing a start node")
}
for _, edge := range graph.Edges {
node, ok := runtime.Nodes[edge.Source]
if !ok {
return RuntimeRuleGraph{}, fmt.Errorf("边 %s 的源节点 %s 不存在", edge.ID, edge.Source)
return RuntimeRuleGraph{}, fmt.Errorf("edge %s references missing source node %s", edge.ID, edge.Source)
}
if _, ok := runtime.Nodes[edge.Target]; !ok {
return RuntimeRuleGraph{}, fmt.Errorf("边 %s 的目标节点 %s 不存在", edge.ID, edge.Target)
return RuntimeRuleGraph{}, fmt.Errorf("edge %s references missing target node %s", edge.ID, edge.Target)
}
if node.Next == nil {
node.Next = make(map[string]string)
}
if _, exists := node.Next[edge.SourceHandle]; exists {
return RuntimeRuleGraph{}, fmt.Errorf("节点 %s 的 %s 出口连接了多个目标", edge.Source, edge.SourceHandle)
return RuntimeRuleGraph{}, fmt.Errorf("node %s output %s is connected to multiple targets", edge.Source, edge.SourceHandle)
}
node.Next[edge.SourceHandle] = edge.Target
runtime.Nodes[edge.Source] = node
@@ -109,7 +109,7 @@ func compileRuleNodeConfig(node RuleNode) (any, error) {
var config BlockNodeConfig
return config, decodeStrictConfig(node.Config, &config)
default:
return nil, fmt.Errorf("未知节点类型 %s", node.Type)
return nil, fmt.Errorf("unknown node type %s", node.Type)
}
}
+48 -48
View File
@@ -44,7 +44,7 @@ func ValidateRuleGraph(ctx context.Context, graph RuleGraph, ipGroupExists func(
return err
}
if hasRuleGraphCycle(nodes, outgoing, incoming) {
return errors.New("规则图不能包含循环")
return errors.New("rule graph must not contain cycles")
}
if err := validateRequiredHandles(graph.Nodes, handleTargets); err != nil {
return err
@@ -57,18 +57,18 @@ func ValidateRuleGraph(ctx context.Context, graph RuleGraph, ipGroupExists func(
func validateRuleGraphLimits(graph RuleGraph) error {
if graph.SchemaVersion != RuleGraphSchemaVersion {
return fmt.Errorf("规则图 schema_version 必须为 %d", RuleGraphSchemaVersion)
return fmt.Errorf("rule graph schema_version must be %d", RuleGraphSchemaVersion)
}
if len(graph.Nodes) > maxRuleGraphNodes {
return fmt.Errorf("规则图节点数不能超过 %d", maxRuleGraphNodes)
return fmt.Errorf("rule graph must not contain more than %d nodes", maxRuleGraphNodes)
}
if len(graph.Edges) > maxRuleGraphEdges {
return fmt.Errorf("规则图边数不能超过 %d", maxRuleGraphEdges)
return fmt.Errorf("rule graph must not contain more than %d edges", maxRuleGraphEdges)
}
if raw, err := json.Marshal(graph); err != nil {
return fmt.Errorf("规则图无法序列化: %w", err)
return fmt.Errorf("unable to serialize rule graph: %w", err)
} else if len(raw) > maxRuleGraphBytes {
return errors.New("规则图大小不能超过 256 KiB")
return errors.New("rule graph must not exceed 256 KiB")
}
return nil
}
@@ -78,10 +78,10 @@ func validateRuleGraphNodes(ctx context.Context, graphNodes []RuleNode, ipGroupE
startCount, allowCount, startID := 0, 0, ""
for _, node := range graphNodes {
if strings.TrimSpace(node.ID) == "" {
return nil, "", errors.New("节点 ID 不能为空")
return nil, "", errors.New("node ID must not be empty")
}
if _, exists := nodes[node.ID]; exists {
return nil, "", fmt.Errorf("节点 ID %s 重复", node.ID)
return nil, "", fmt.Errorf("duplicate node ID %s", node.ID)
}
nodes[node.ID] = node
switch node.Type {
@@ -92,17 +92,17 @@ func validateRuleGraphNodes(ctx context.Context, graphNodes []RuleNode, ipGroupE
allowCount++
case RuleNodeBlock, RuleNodeIPMatch, RuleNodeGeoMatch, RuleNodePoW, RuleNodeUACheck, RuleNodeSecurityCheck:
default:
return nil, "", fmt.Errorf("节点 %s 的类型 %s 未知", node.ID, node.Type)
return nil, "", fmt.Errorf("node %s has unknown type %s", node.ID, node.Type)
}
if err := validateRuleNodeConfig(ctx, node, ipGroupExists); err != nil {
return nil, "", err
}
}
if startCount != 1 {
return nil, "", errors.New("规则图必须恰好包含一个开始节点")
return nil, "", errors.New("rule graph must contain exactly one start node")
}
if allowCount != 1 {
return nil, "", errors.New("规则图必须恰好包含一个通过节点")
return nil, "", errors.New("rule graph must contain exactly one allow node")
}
return nodes, startID, nil
}
@@ -114,26 +114,26 @@ func validateRuleGraphEdges(nodes map[string]RuleNode, graphEdges []RuleEdge) (m
handleTargets := make(map[string]int)
for _, edge := range graphEdges {
if strings.TrimSpace(edge.ID) == "" {
return nil, nil, nil, errors.New("边 ID 不能为空")
return nil, nil, nil, errors.New("edge ID must not be empty")
}
if _, exists := edgeIDs[edge.ID]; exists {
return nil, nil, nil, fmt.Errorf("边 ID %s 重复", edge.ID)
return nil, nil, nil, fmt.Errorf("duplicate edge ID %s", edge.ID)
}
edgeIDs[edge.ID] = struct{}{}
source, ok := nodes[edge.Source]
if !ok {
return nil, nil, nil, fmt.Errorf("边 %s 的源节点 %s 不存在", edge.ID, edge.Source)
return nil, nil, nil, fmt.Errorf("edge %s references missing source node %s", edge.ID, edge.Source)
}
if _, ok := nodes[edge.Target]; !ok {
return nil, nil, nil, fmt.Errorf("边 %s 的目标节点 %s 不存在", edge.ID, edge.Target)
return nil, nil, nil, fmt.Errorf("edge %s references missing target node %s", edge.ID, edge.Target)
}
if !validSourceHandle(source.Type, edge.SourceHandle) {
return nil, nil, nil, fmt.Errorf("边 %s 的源端口 %s 不适用于节点 %s", edge.ID, edge.SourceHandle, edge.Source)
return nil, nil, nil, fmt.Errorf("edge %s source port %s is not valid for node %s", edge.ID, edge.SourceHandle, edge.Source)
}
key := edge.Source + "\x00" + edge.SourceHandle
handleTargets[key]++
if handleTargets[key] > 1 {
return nil, nil, nil, fmt.Errorf("节点 %s 的 %s 出口连接了多个目标", edge.Source, edge.SourceHandle)
return nil, nil, nil, fmt.Errorf("node %s output %s is connected to multiple targets", edge.Source, edge.SourceHandle)
}
outgoing[edge.Source] = append(outgoing[edge.Source], edge)
incoming[edge.Target]++
@@ -145,7 +145,7 @@ func validateRequiredHandles(nodes []RuleNode, handleTargets map[string]int) err
for _, node := range nodes {
for _, handle := range requiredHandles(node.Type) {
if handleTargets[node.ID+"\x00"+handle] == 0 {
return fmt.Errorf("节点 %s 的 %s 出口未连接", node.ID, handle)
return fmt.Errorf("node %s output %s is not connected", node.ID, handle)
}
}
}
@@ -156,18 +156,18 @@ func validateRuleGraphConnectivity(nodes []RuleNode, startID string, outgoing ma
reachable := walkRuleGraph(startID, outgoing)
for _, node := range nodes {
if !reachable[node.ID] {
return fmt.Errorf("节点 %s 无法从开始节点到达", node.ID)
return fmt.Errorf("node %s is not reachable from the start node", node.ID)
}
}
for _, node := range nodes {
if node.Type == RuleNodeStart && incoming[node.ID] != 0 {
return fmt.Errorf("开始节点 %s 不能有入边", node.ID)
return fmt.Errorf("start node %s must not have incoming edges", node.ID)
}
if node.Type != RuleNodeStart && incoming[node.ID] == 0 {
return fmt.Errorf("节点 %s 必须至少有一条入边", node.ID)
return fmt.Errorf("node %s must have at least one incoming edge", node.ID)
}
if (node.Type == RuleNodeAllow || node.Type == RuleNodeBlock) && len(outgoing[node.ID]) != 0 {
return fmt.Errorf("终止节点 %s 不能有出口", node.ID)
return fmt.Errorf("terminal node %s must not have outgoing edges", node.ID)
}
}
return nil
@@ -205,12 +205,12 @@ func validateIPMatchNodeConfig(ctx context.Context, node RuleNode, exists func(c
}
for _, raw := range cfg.IPs {
if _, err := netip.ParseAddr(raw); err != nil {
return fmt.Errorf("节点 %s 的 IP %s 无效", node.ID, raw)
return fmt.Errorf("node %s contains invalid IP %s", node.ID, raw)
}
}
for _, raw := range cfg.CIDRs {
if _, err := netip.ParsePrefix(raw); err != nil {
return fmt.Errorf("节点 %s 的 CIDR %s 无效", node.ID, raw)
return fmt.Errorf("node %s contains invalid CIDR %s", node.ID, raw)
}
}
for _, id := range cfg.IPGroupIDs {
@@ -223,17 +223,17 @@ func validateIPMatchNodeConfig(ctx context.Context, node RuleNode, exists func(c
func validateIPGroupReference(ctx context.Context, nodeID string, id uint, exists func(context.Context, uint) (bool, error)) error {
if id == 0 {
return fmt.Errorf("节点 %s 引用的 IP 组 ID 无效", nodeID)
return fmt.Errorf("node %s references an invalid IP group ID", nodeID)
}
if exists == nil {
return fmt.Errorf("节点 %s 无法校验 IP 组 %d", nodeID, id)
return fmt.Errorf("node %s cannot validate IP group %d", nodeID, id)
}
ok, err := exists(ctx, id)
if err != nil {
return fmt.Errorf("节点 %s 校验 IP 组 %d 失败: %w", nodeID, id, err)
return fmt.Errorf("node %s failed to validate IP group %d: %w", nodeID, id, err)
}
if !ok {
return fmt.Errorf("节点 %s 引用的 IP 组 %d 不存在", nodeID, id)
return fmt.Errorf("node %s references missing IP group %d", nodeID, id)
}
return nil
}
@@ -245,12 +245,12 @@ func validateGeoMatchNodeConfig(node RuleNode) error {
}
for _, code := range cfg.Countries {
if !countryCodePattern.MatchString(code) {
return fmt.Errorf("节点 %s 的国家代码 %s 无效", node.ID, code)
return fmt.Errorf("node %s contains invalid country code %s", node.ID, code)
}
}
for _, code := range cfg.Regions {
if !regionCodePattern.MatchString(code) {
return fmt.Errorf("节点 %s 的地区代码 %s 无效", node.ID, code)
return fmt.Errorf("node %s contains invalid region code %s", node.ID, code)
}
}
return nil
@@ -262,16 +262,16 @@ func validatePoWNodeConfig(node RuleNode) error {
return err
}
if cfg.Difficulty < 1 || cfg.Difficulty > 16 {
return fmt.Errorf("节点 %s 的 PoW 难度必须在 1-16 之间", node.ID)
return fmt.Errorf("PoW difficulty for node %s must be between 1 and 16", node.ID)
}
if cfg.Algorithm != powAlgorithmFast && cfg.Algorithm != powAlgorithmSlow {
return fmt.Errorf("节点 %s 的 PoW 算法必须为 fast 或 slow", node.ID)
return fmt.Errorf("PoW algorithm for node %s must be fast or slow", node.ID)
}
if cfg.SessionTTL < minPoWSessionTTLSeconds {
return fmt.Errorf("节点 %s 的 PoW 会话 TTL 不能小于 60 秒", node.ID)
return fmt.Errorf("PoW session TTL for node %s must be at least 60 seconds", node.ID)
}
if cfg.ChallengeTTL < minPoWChallengeTTLSeconds {
return fmt.Errorf("节点 %s 的 PoW 挑战 TTL 不能小于 30 秒", node.ID)
return fmt.Errorf("PoW challenge TTL for node %s must be at least 30 seconds", node.ID)
}
return nil
}
@@ -282,10 +282,10 @@ func validateBlockNodeConfig(node RuleNode) error {
return err
}
if cfg.StatusCode < 400 || cfg.StatusCode > 599 {
return fmt.Errorf("节点 %s 的阻止状态码必须在 400-599 之间", node.ID)
return fmt.Errorf("block status code for node %s must be between 400 and 599", node.ID)
}
if len([]byte(cfg.ResponseBody)) > maxWAFBlockBodyBytes {
return fmt.Errorf("节点 %s 的阻止响应体不能超过 %d 字节", node.ID, maxWAFBlockBodyBytes)
return fmt.Errorf("block response body for node %s must not exceed %d bytes", node.ID, maxWAFBlockBodyBytes)
}
return nil
}
@@ -300,34 +300,34 @@ func validateUACheckNodeConfig(node RuleNode) error {
mode = UACheckMatchModeOr
}
if mode != UACheckMatchModeAnd && mode != UACheckMatchModeOr {
return fmt.Errorf("节点 %s 的匹配模式必须为 and 或 or", node.ID)
return fmt.Errorf("match mode for node %s must be and or or", node.ID)
}
for _, label := range cfg.Browsers {
if !uaBrowserLabels[label] {
return fmt.Errorf("节点 %s 的浏览器标签 %s 无效", node.ID, label)
return fmt.Errorf("node %s contains invalid browser tag %s", node.ID, label)
}
}
for _, label := range cfg.OperatingSystems {
if !uaOSLabels[label] {
return fmt.Errorf("节点 %s 的操作系统标签 %s 无效", node.ID, label)
return fmt.Errorf("node %s contains invalid operating system tag %s", node.ID, label)
}
}
if len(cfg.CustomUAPatterns) > maxUACustomPatterns {
return fmt.Errorf("节点 %s 的自定义 UA 正则不能超过 %d 条", node.ID, maxUACustomPatterns)
return fmt.Errorf("node %s must not contain more than %d custom User-Agent patterns", node.ID, maxUACustomPatterns)
}
for _, pattern := range cfg.CustomUAPatterns {
if strings.TrimSpace(pattern) == "" {
return fmt.Errorf("节点 %s 的自定义 UA 正则不能为空", node.ID)
return fmt.Errorf("custom User-Agent pattern in node %s must not be empty", node.ID)
}
if len(pattern) > maxUACustomPatternBytes {
return fmt.Errorf("节点 %s 的自定义 UA 正则不能超过 %d 字节", node.ID, maxUACustomPatternBytes)
return fmt.Errorf("custom User-Agent pattern in node %s must not exceed %d bytes", node.ID, maxUACustomPatternBytes)
}
if _, err := regexp.Compile(pattern); err != nil {
return fmt.Errorf("节点 %s 的自定义 UA 正则无效: %s", node.ID, pattern)
return fmt.Errorf("invalid custom User-Agent pattern in node %s: %s", node.ID, pattern)
}
}
if cfg.BlockCustomUA && len(cfg.CustomUAPatterns) == 0 {
return fmt.Errorf("节点 %s 开启屏蔽自定义 UA 时至少需要一条正则", node.ID)
return fmt.Errorf("node %s requires at least one pattern when custom User-Agent blocking is enabled", node.ID)
}
return nil
}
@@ -350,7 +350,7 @@ var uaOSLabels = map[string]bool{
func decodeNodeConfig(node RuleNode, dst any) error {
if err := decodeStrictConfig(node.Config, dst); err != nil {
return fmt.Errorf("节点 %s 的配置无效: %w", node.ID, err)
return fmt.Errorf("invalid configuration for node %s: %w", node.ID, err)
}
return nil
}
@@ -358,7 +358,7 @@ func decodeNodeConfig(node RuleNode, dst any) error {
func decodeStrictConfig(raw json.RawMessage, dst any) error {
trimmed := bytes.TrimSpace(raw)
if bytes.Equal(trimmed, []byte("null")) {
return errors.New("配置不能为 null")
return errors.New("configuration must not be null")
}
if len(trimmed) == 0 {
raw = json.RawMessage(`{}`)
@@ -369,7 +369,7 @@ func decodeStrictConfig(raw json.RawMessage, dst any) error {
return err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return errors.New("配置包含额外 JSON 值")
return errors.New("configuration contains an additional JSON value")
}
return nil
}
@@ -451,7 +451,7 @@ func validateTerminalPaths(nodes []RuleNode, outgoing map[string][]RuleEdge) err
}
for _, node := range nodes {
if !terminating[node.ID] {
return fmt.Errorf("节点 %s 不存在通往终止节点的路径", node.ID)
return fmt.Errorf("node %s has no path to a terminal node", node.ID)
}
}
return nil

Some files were not shown because too many files have changed in this diff Show More