mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 23:26:38 +08:00
chore(docs): purge
This commit is contained in:
@@ -1,709 +0,0 @@
|
||||
# 边缘限流全局默认 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为边缘限流增加三项全局默认;站点 `0`/空继承默认、`-1` 显式关闭、`>0` 覆盖;在 `RenderRouteConfig` 唯一合并。
|
||||
|
||||
**Architecture:** 全局默认存 `system_configs`,进入 `openresty_config` 快照;站点字段语义变更后仍原样入库与快照;`pkg/render/openresty.RenderRouteConfig` 用 `doc.OpenRestyConfig` 与 route 字段合并后输出 location 指令。UI:安全性下新页「限流」+ 站点限流文案更新。
|
||||
|
||||
**Tech Stack:** Go、goose SQL、Option API、`pkg/render/openresty`、Next.js、OptionService
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-07-19-http-default-rate-limit-design.md](../specs/2026-07-19-http-default-rate-limit-design.md)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 合并**只**在 `RenderRouteConfig`;快照保留站点原始值(含 `0`/`-1`)
|
||||
- 不引入 `limit_req`;不在 `http {}` 写默认 `limit_conn`/`limit_rate`
|
||||
- 全局默认初始 `0`/空 → 存量行为不变
|
||||
- 完成后 `make code-check`;改前端后 `make prettier`;中文 changelog;不写英文文档
|
||||
- 所有 HTTP 路由仍只在 `internal/router/router.go` 委派(本功能复用 Option API,无需新业务路由)
|
||||
|
||||
## File map
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `internal/model/system_configs.go` | 三个 ConfigKey 常量 |
|
||||
| `internal/infra/persistence/migrator/goose/{postgres,sqlite}/202607190001_add_openresty_default_rate_limits.sql` | seed 默认值 |
|
||||
| `internal/apps/openflare/option/openresty_validators.go` + `validate.go` | 全局默认校验 |
|
||||
| `internal/apps/openflare/config_version/snapshot.go` | 快照字段 + 读取 |
|
||||
| `internal/apps/openflare/config_version/logics.go` | option diff keys |
|
||||
| `pkg/render/openresty/types.go` | `ConfigSnapshot` 三字段 |
|
||||
| `pkg/render/openresty/render.go` | `mergeRouteLimit*` + 调用点 |
|
||||
| `pkg/render/openresty/render_test.go` | 合并渲染单测 |
|
||||
| `internal/apps/openflare/proxy_route/helpers.go` | 站点 normalize 允许 -1 |
|
||||
| `frontend/lib/navigation/openflare-nav.ts` | 安全性子菜单 |
|
||||
| `frontend/app/(main)/rate-limits/page.tsx` | 全局限流设置页 |
|
||||
| `frontend/app/(main)/proxy-routes/.../limits-section.tsx` + helpers | 站点语义 UI |
|
||||
| `frontend/lib/utils/search-data.ts` | 搜索入口 |
|
||||
| `docs/reference/configuration.md` | 配置键说明 |
|
||||
| `docs/changelog/index.md` | Unreleased |
|
||||
| `docs/plan/index.md` | 进行中计划索引 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Render 合并(TDD 核心)
|
||||
|
||||
**Files:**
|
||||
- Modify: `pkg/render/openresty/types.go` (`ConfigSnapshot`)
|
||||
- Modify: `pkg/render/openresty/render.go`
|
||||
- Test: `pkg/render/openresty/render_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ConfigSnapshot` 字段 `DefaultLimitConnPerServer int`, `DefaultLimitConnPerIP int`, `DefaultLimitRate string`(json: `default_limit_conn_per_server` 等)
|
||||
- Produces: `mergeRouteLimitConfig(route Route, cfg ConfigSnapshot) routeLimitConfig`
|
||||
- Produces: `mergeLimitConn(route, def int) int`, `mergeLimitRate(route, def string) string`
|
||||
|
||||
- [ ] **Step 1: 写失败单测**
|
||||
|
||||
在 `render_test.go` 末尾追加:
|
||||
|
||||
```go
|
||||
func TestMergeRouteLimitConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
route Route
|
||||
cfg ConfigSnapshot
|
||||
want routeLimitConfig
|
||||
}{
|
||||
{
|
||||
name: "both zero off",
|
||||
route: Route{},
|
||||
cfg: ConfigSnapshot{},
|
||||
want: routeLimitConfig{},
|
||||
},
|
||||
{
|
||||
name: "inherit all defaults",
|
||||
route: Route{},
|
||||
cfg: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 100,
|
||||
DefaultLimitConnPerIP: 10,
|
||||
DefaultLimitRate: "512k",
|
||||
},
|
||||
want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 10, LimitRate: "512k"},
|
||||
},
|
||||
{
|
||||
name: "explicit off ignores default",
|
||||
route: Route{LimitConnPerServer: -1, LimitConnPerIP: -1, LimitRate: "-1"},
|
||||
cfg: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 100,
|
||||
DefaultLimitConnPerIP: 10,
|
||||
DefaultLimitRate: "512k",
|
||||
},
|
||||
want: routeLimitConfig{},
|
||||
},
|
||||
{
|
||||
name: "route overrides default",
|
||||
route: Route{LimitConnPerServer: 50, LimitConnPerIP: 5, LimitRate: "1m"},
|
||||
cfg: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 100,
|
||||
DefaultLimitConnPerIP: 10,
|
||||
DefaultLimitRate: "512k",
|
||||
},
|
||||
want: routeLimitConfig{LimitConnPerServer: 50, LimitConnPerIP: 5, LimitRate: "1m"},
|
||||
},
|
||||
{
|
||||
name: "partial inherit",
|
||||
route: Route{LimitConnPerServer: 0, LimitConnPerIP: -1, LimitRate: ""},
|
||||
cfg: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 100,
|
||||
DefaultLimitConnPerIP: 10,
|
||||
DefaultLimitRate: "256k",
|
||||
},
|
||||
want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 0, LimitRate: "256k"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := mergeRouteLimitConfig(tc.route, tc.cfg)
|
||||
if got != tc.want {
|
||||
t.Fatalf("mergeRouteLimitConfig() = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRouteConfigAppliesDefaultLimits(t *testing.T) {
|
||||
doc := Document{
|
||||
Routes: []Route{{
|
||||
SiteName: "example.com",
|
||||
Domains: []string{"example.com"},
|
||||
Enabled: true,
|
||||
OriginURL: "http://127.0.0.1:8080",
|
||||
Upstreams: []string{"http://127.0.0.1:8080"},
|
||||
}},
|
||||
OpenRestyConfig: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 120,
|
||||
DefaultLimitConnPerIP: 12,
|
||||
DefaultLimitRate: "512k",
|
||||
},
|
||||
}
|
||||
rendered, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRouteConfig() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"limit_conn openflare_conn_per_server 120;",
|
||||
"limit_conn openflare_conn_per_ip 12;",
|
||||
"limit_rate 512k;",
|
||||
} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("expected %q in route config, got:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRouteConfigExplicitOffSkipsDefaultLimits(t *testing.T) {
|
||||
doc := Document{
|
||||
Routes: []Route{{
|
||||
SiteName: "example.com",
|
||||
Domains: []string{"example.com"},
|
||||
Enabled: true,
|
||||
OriginURL: "http://127.0.0.1:8080",
|
||||
Upstreams: []string{"http://127.0.0.1:8080"},
|
||||
LimitConnPerServer: -1,
|
||||
LimitConnPerIP: -1,
|
||||
LimitRate: "-1",
|
||||
}},
|
||||
OpenRestyConfig: ConfigSnapshot{
|
||||
DefaultLimitConnPerServer: 120,
|
||||
DefaultLimitConnPerIP: 12,
|
||||
DefaultLimitRate: "512k",
|
||||
},
|
||||
}
|
||||
rendered, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRouteConfig() error = %v", err)
|
||||
}
|
||||
if strings.Contains(rendered, "limit_conn") || strings.Contains(rendered, "limit_rate") {
|
||||
t.Fatalf("expected no limit directives, got:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测确认失败**
|
||||
|
||||
```bash
|
||||
go test ./pkg/render/openresty/ -run 'TestMergeRouteLimitConfig|TestRenderRouteConfigAppliesDefaultLimits|TestRenderRouteConfigExplicitOffSkipsDefaultLimits' -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL(`mergeRouteLimitConfig` undefined 或行为不符)
|
||||
|
||||
- [ ] **Step 3: 实现 types + merge + 调用**
|
||||
|
||||
`ConfigSnapshot` 增加:
|
||||
|
||||
```go
|
||||
DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"`
|
||||
DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"`
|
||||
DefaultLimitRate string `json:"default_limit_rate,omitempty"`
|
||||
```
|
||||
|
||||
`render.go` 中 `RenderRouteConfig` 将:
|
||||
|
||||
```go
|
||||
limitConfig := routeLimitConfig{LimitConnPerServer: route.LimitConnPerServer, LimitConnPerIP: route.LimitConnPerIP, LimitRate: route.LimitRate}
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```go
|
||||
limitConfig := mergeRouteLimitConfig(route, doc.OpenRestyConfig)
|
||||
```
|
||||
|
||||
并新增:
|
||||
|
||||
```go
|
||||
func mergeRouteLimitConfig(route Route, cfg ConfigSnapshot) routeLimitConfig {
|
||||
return routeLimitConfig{
|
||||
LimitConnPerServer: mergeLimitConn(route.LimitConnPerServer, cfg.DefaultLimitConnPerServer),
|
||||
LimitConnPerIP: mergeLimitConn(route.LimitConnPerIP, cfg.DefaultLimitConnPerIP),
|
||||
LimitRate: mergeLimitRate(route.LimitRate, cfg.DefaultLimitRate),
|
||||
}
|
||||
}
|
||||
|
||||
func mergeLimitConn(route, def int) int {
|
||||
if route == -1 {
|
||||
return 0
|
||||
}
|
||||
if route > 0 {
|
||||
return route
|
||||
}
|
||||
if def > 0 {
|
||||
return def
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func mergeLimitRate(route, def string) string {
|
||||
r := strings.ToLower(strings.TrimSpace(route))
|
||||
if r == "-1" {
|
||||
return ""
|
||||
}
|
||||
if r != "" && r != "0" {
|
||||
return r
|
||||
}
|
||||
d := strings.ToLower(strings.TrimSpace(def))
|
||||
if d != "" && d != "0" {
|
||||
return d
|
||||
}
|
||||
return ""
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测通过**
|
||||
|
||||
```bash
|
||||
go test ./pkg/render/openresty/ -count=1
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add pkg/render/openresty/types.go pkg/render/openresty/render.go pkg/render/openresty/render_test.go
|
||||
git commit -m "feat(openresty): merge global default limits at route render"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 配置键、迁移、校验、快照
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/model/system_configs.go`
|
||||
- Create: `internal/infra/persistence/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql`
|
||||
- Create: `internal/infra/persistence/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql`
|
||||
- Modify: `internal/apps/openflare/option/validate.go`
|
||||
- Modify: `internal/apps/openflare/option/openresty_validators.go`
|
||||
- Modify: `internal/apps/openflare/config_version/snapshot.go`
|
||||
- Modify: `internal/apps/openflare/config_version/logics.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 的 `ConfigSnapshot` JSON 字段名
|
||||
- Produces: `ConfigKeyOpenRestyDefaultLimitConnPerServer` 等三常量;snapshot 填充;diff 可见
|
||||
|
||||
- [ ] **Step 1: 常量**
|
||||
|
||||
在 `system_configs.go` OpenResty 段末尾(`MainConfigTemplate` 前或后)加入:
|
||||
|
||||
```go
|
||||
ConfigKeyOpenRestyDefaultLimitConnPerServer = "openresty_default_limit_conn_per_server" // 默认站点并发连接
|
||||
ConfigKeyOpenRestyDefaultLimitConnPerIP = "openresty_default_limit_conn_per_ip" // 默认单 IP 并发连接
|
||||
ConfigKeyOpenRestyDefaultLimitRate = "openresty_default_limit_rate" // 默认单请求带宽
|
||||
```
|
||||
|
||||
- [ ] **Step 2: goose 迁移(PG + SQLite 同内容)**
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||
VALUES
|
||||
('openresty_default_limit_conn_per_server', '0', 'business', 0, '默认站点并发连接上限(0 关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('openresty_default_limit_conn_per_ip', '0', 'business', 0, '默认单 IP 并发连接上限(0 关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('openresty_default_limit_rate', '', 'business', 0, '默认单请求带宽限速(空关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM w_system_configs WHERE key IN (
|
||||
'openresty_default_limit_conn_per_server',
|
||||
'openresty_default_limit_conn_per_ip',
|
||||
'openresty_default_limit_rate'
|
||||
);
|
||||
```
|
||||
|
||||
SQLite:若项目其它 seed 不用 `ON CONFLICT`,对照 `202607170001_add_pages_system_configs.sql` 的 sqlite twin 写法保持一致(通常可同用 `ON CONFLICT (key) DO NOTHING`)。
|
||||
|
||||
- [ ] **Step 3: 校验器**
|
||||
|
||||
`validate.go` 增加:
|
||||
|
||||
```go
|
||||
func validateNonNegativeIntegerOption(key, value string) error {
|
||||
intValue, err := strconv.Atoi(value)
|
||||
if err != nil || intValue < 0 {
|
||||
return fmt.Errorf("%s 必须为大于等于 0 的整数", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
`openresty_validators.go` 注册:
|
||||
|
||||
```go
|
||||
model.ConfigKeyOpenRestyDefaultLimitConnPerServer: validateNonNegativeIntegerOption,
|
||||
model.ConfigKeyOpenRestyDefaultLimitConnPerIP: validateNonNegativeIntegerOption,
|
||||
model.ConfigKeyOpenRestyDefaultLimitRate: validateOpenRestyDefaultLimitRate,
|
||||
```
|
||||
|
||||
```go
|
||||
var openRestyDefaultLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`)
|
||||
|
||||
func validateOpenRestyDefaultLimitRate(key, trimmed string) error {
|
||||
if trimmed == "" || trimmed == "0" {
|
||||
return nil
|
||||
}
|
||||
if !openRestyDefaultLimitRatePattern.MatchString(strings.ToLower(trimmed)) {
|
||||
return fmt.Errorf("%s 格式不合法,请使用 512k、1m 或纯数字,空表示关闭", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 快照读取(注意 0 合法)**
|
||||
|
||||
`openRestyConfigSnapshot` 与 `buildOpenRestyConfigSnapshot` 增加三字段。
|
||||
|
||||
**禁止**对这三项使用现有 `getIntConfig`(其 `val <= 0` 会把合法 `0` 与错误混在一起;虽 default=0 时偶然正确,但语义不清)。改为:
|
||||
|
||||
```go
|
||||
getNonNegIntConfig := func(key string, defaultVal int) int {
|
||||
val, err := repository.GetIntByKey(ctx, key)
|
||||
if err != nil || val < 0 {
|
||||
return defaultVal
|
||||
}
|
||||
return val
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
DefaultLimitConnPerServer: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerServer, 0),
|
||||
DefaultLimitConnPerIP: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerIP, 0),
|
||||
DefaultLimitRate: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitRate, ""))),
|
||||
```
|
||||
|
||||
若 `DefaultLimitRate == "0"`,规范化为 `""`。
|
||||
|
||||
确认 snapshot → render JSON 字段名与 `openrestyrender.ConfigSnapshot` 一致(`snapshotDocument` 序列化后由 `RenderJSON` 反序列化到 render types)。`openRestyConfigSnapshot` 的 json tag 必须与 `ConfigSnapshot` 对齐:
|
||||
|
||||
```go
|
||||
DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"`
|
||||
DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"`
|
||||
DefaultLimitRate string `json:"default_limit_rate,omitempty"`
|
||||
```
|
||||
|
||||
- [ ] **Step 5: option diff**
|
||||
|
||||
在 `diffOpenRestyOptionDetails` 末尾:
|
||||
|
||||
```go
|
||||
appendIfChanged("OpenRestyDefaultLimitConnPerServer", fmt.Sprintf("%d", left.DefaultLimitConnPerServer), fmt.Sprintf("%d", right.DefaultLimitConnPerServer))
|
||||
appendIfChanged("OpenRestyDefaultLimitConnPerIP", fmt.Sprintf("%d", left.DefaultLimitConnPerIP), fmt.Sprintf("%d", right.DefaultLimitConnPerIP))
|
||||
appendIfChanged("OpenRestyDefaultLimitRate", left.DefaultLimitRate, right.DefaultLimitRate)
|
||||
```
|
||||
|
||||
`openRestyOptionKeys()` 同步追加这三 key 字符串。
|
||||
|
||||
- [ ] **Step 6: 编译/相关测试**
|
||||
|
||||
```bash
|
||||
go test ./internal/apps/openflare/config_version/ ./internal/apps/openflare/option/ ./pkg/render/openresty/ -count=1
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/model/system_configs.go \
|
||||
internal/infra/persistence/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql \
|
||||
internal/infra/persistence/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql \
|
||||
internal/apps/openflare/option/validate.go \
|
||||
internal/apps/openflare/option/openresty_validators.go \
|
||||
internal/apps/openflare/config_version/snapshot.go \
|
||||
internal/apps/openflare/config_version/logics.go
|
||||
git commit -m "feat(config): add openresty default rate limit system options"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 站点 normalize 允许 -1
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/apps/openflare/proxy_route/helpers.go`
|
||||
- Modify: `internal/apps/openflare/proxy_route/errs.go`(如需更新文案)
|
||||
- Test: 若无现成 helpers 测试文件则新建 `helpers_limit_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `normalizeProxyRouteLimitConnValue` 允许 `>= -1`;`normalizeProxyRouteLimitRate` 允许 `"-1"`
|
||||
|
||||
- [ ] **Step 1: 失败单测**
|
||||
|
||||
```go
|
||||
func TestNormalizeProxyRouteLimitConnValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := normalizeProxyRouteLimitConnValue(-1, "limit_conn_per_server")
|
||||
if err != nil || got != -1 {
|
||||
t.Fatalf("want -1, got %d err %v", got, err)
|
||||
}
|
||||
if _, err := normalizeProxyRouteLimitConnValue(-2, "limit_conn_per_server"); err == nil {
|
||||
t.Fatal("expected error for -2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProxyRouteLimitRate(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := normalizeProxyRouteLimitRate("-1")
|
||||
if err != nil || got != "-1" {
|
||||
t.Fatalf("want -1, got %q err %v", got, err)
|
||||
}
|
||||
got, err = normalizeProxyRouteLimitRate("0")
|
||||
if err != nil || got != "" {
|
||||
t.Fatalf("want empty inherit, got %q err %v", got, err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现**
|
||||
|
||||
```go
|
||||
func normalizeProxyRouteLimitConnValue(value int, field string) (int, error) {
|
||||
if value < -1 {
|
||||
return 0, fmt.Errorf("%s must be greater than or equal to -1", field)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func normalizeProxyRouteLimitRate(raw string) (string, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(raw))
|
||||
if normalized == "" || normalized == "0" {
|
||||
return "", nil
|
||||
}
|
||||
if normalized == "-1" {
|
||||
return "-1", nil
|
||||
}
|
||||
if !proxyRouteLimitRatePattern.MatchString(normalized) {
|
||||
return "", errors.New(errProxyRouteLimitRate)
|
||||
}
|
||||
if strings.TrimRight(normalized, "km") == "" {
|
||||
return "", nil
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
```
|
||||
|
||||
可选:`errProxyRouteLimitRate` 文案追加「或 -1 表示关闭」。
|
||||
|
||||
- [ ] **Step 3: 测试**
|
||||
|
||||
```bash
|
||||
go test ./internal/apps/openflare/proxy_route/ -count=1
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/apps/openflare/proxy_route/
|
||||
git commit -m "feat(proxy-route): allow -1 to disable rate limits"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 前端 — 安全性「限流」页 + 站点文案
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/lib/navigation/openflare-nav.ts`
|
||||
- Create: `frontend/app/(main)/rate-limits/page.tsx`
|
||||
- Modify: `frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx`
|
||||
- Modify: `frontend/app/(main)/proxy-routes/components/helpers.ts`
|
||||
- Modify: `frontend/lib/utils/search-data.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Option keys 字面量 `openresty_default_limit_conn_per_server` 等
|
||||
- Produces: `/rate-limits` 管理页;站点表单接受 `-1`
|
||||
|
||||
- [ ] **Step 1: 导航**
|
||||
|
||||
`openflareSecurityNavGroup.items`:
|
||||
|
||||
```ts
|
||||
{ title: 'WAF', url: '/waf' },
|
||||
{ title: 'IP 组', url: '/ip-groups' },
|
||||
{ title: '限流', url: '/rate-limits' },
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 搜索**
|
||||
|
||||
`search-data.ts` 在 IP 组后增加:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 'console-rate-limits',
|
||||
title: '限流',
|
||||
description: '配置边缘站点默认并发与带宽限流策略',
|
||||
url: '/rate-limits',
|
||||
category: 'page',
|
||||
keywords: ['限流', 'rate limit', 'limit_conn', 'limit_rate', '并发', '带宽'],
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 限流设置页**
|
||||
|
||||
新建 `frontend/app/(main)/rate-limits/page.tsx`,模式对齐 `performance/page.tsx`:
|
||||
|
||||
- `useAuth` 管理员校验
|
||||
- `OptionService.list` / `updateBatch`
|
||||
- 三字段表单 + 单卡片保存
|
||||
- 标题:`Shield` 或 `Gauge` 图标 + `h1`「限流」
|
||||
- 描述:空/0 表示默认关闭;修改后需在版本发布中生效
|
||||
- keys:
|
||||
- `openresty_default_limit_conn_per_server`
|
||||
- `openresty_default_limit_conn_per_ip`
|
||||
- `openresty_default_limit_rate`
|
||||
- conn:非负整数;rate:空或 `^\d+[kKmM]?$`
|
||||
- 保存成功 toast + invalidate options / config-preview / config-versions
|
||||
- 链到 `/config-versions`
|
||||
|
||||
页面骨架要点(完整实现时展开为完整组件,勿留半成品):
|
||||
|
||||
```tsx
|
||||
// 字段 state、OptionService.list map、updateBatch([{key,value},...])
|
||||
// 文案:「0 或空表示默认关闭;站点未单独配置时继承此处设置。」
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 站点 limits-section**
|
||||
|
||||
1. schema:conn 允许空、`0`、`-1`、正整数:
|
||||
|
||||
```ts
|
||||
if (!rawValue) continue;
|
||||
if (!/^-1$|^\d+$/.test(rawValue)) {
|
||||
context.addIssue({ ..., message: '请输入 -1、0 或正整数' });
|
||||
}
|
||||
```
|
||||
|
||||
2. `validateLimitRate` / `normalizeLimitRate`:
|
||||
|
||||
```ts
|
||||
export function validateLimitRate(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized === '0' || normalized === '-1') {
|
||||
return null;
|
||||
}
|
||||
if (!limitRatePattern.test(normalized)) {
|
||||
return '限速格式不合法,请使用 512k、1m、纯数字,或 -1 关闭';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeLimitRate(value: string) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === '0') return '';
|
||||
return normalized; // 保留 -1
|
||||
}
|
||||
```
|
||||
|
||||
3. 表单展示:`-1` 需显示为 `'-1'`(注意 `route.limit_conn_per_server ? String : ''` 对 `-1` 已为 truthy;对 `0` 仍为空)
|
||||
|
||||
4. 提交:空 → `0`;`-1` → `-1`;正数 → 数字
|
||||
|
||||
5. 文案:
|
||||
|
||||
```
|
||||
description='站点限流。空或 0 继承全局默认;-1 显式关闭;大于 0 为自定义。'
|
||||
FormDescription 同步说明
|
||||
```
|
||||
|
||||
6. 侧栏「流量限制」section description 可改为:`设置连接数和限速(可继承全局默认)。`
|
||||
|
||||
- [ ] **Step 5: prettier + 类型检查(按项目习惯)**
|
||||
|
||||
```bash
|
||||
make prettier
|
||||
# 若有前端 typecheck:
|
||||
# cd frontend && pnpm exec tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/lib/navigation/openflare-nav.ts \
|
||||
frontend/app/\(main\)/rate-limits/ \
|
||||
frontend/app/\(main\)/proxy-routes/detail/components/limits-section.tsx \
|
||||
frontend/app/\(main\)/proxy-routes/components/helpers.ts \
|
||||
frontend/lib/utils/search-data.ts
|
||||
git commit -m "feat(frontend): add security rate-limits page and inherit UI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 文档、索引、门禁
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/reference/configuration.md`(OpenResty 配置表)
|
||||
- Modify: `docs/changelog/index.md` `[unreleased]`
|
||||
- Modify: `docs/plan/index.md`
|
||||
|
||||
- [ ] **Step 1: configuration.md**
|
||||
|
||||
在 `openresty_cache_use_stale` 与 `openresty_main_config_template` 之间插入:
|
||||
|
||||
```md
|
||||
| `openresty_default_limit_conn_per_server` | `int` | 站点未配置时的默认并发连接上限;`0` 表示默认关闭 | `0` |
|
||||
| `openresty_default_limit_conn_per_ip` | `int` | 站点未配置时的默认单 IP 并发上限;`0` 表示默认关闭 | `0` |
|
||||
| `openresty_default_limit_rate` | `string` | 站点未配置时的默认单请求带宽(如 `512k`);空表示默认关闭 | 空 |
|
||||
```
|
||||
|
||||
- [ ] **Step 2: changelog**
|
||||
|
||||
`[unreleased]` 下:
|
||||
|
||||
```md
|
||||
### 新增
|
||||
|
||||
- 安全性新增「限流」设置:可为边缘站点配置默认并发与带宽;站点未设置时继承,填 `-1` 可显式关闭。
|
||||
|
||||
### 改进
|
||||
|
||||
- 站点流量限制语义调整为空或 `0` 继承全局默认、`-1` 关闭、大于 `0` 自定义;修改全局默认后需发布配置版本生效。
|
||||
```
|
||||
|
||||
- [ ] **Step 3: plan index**
|
||||
|
||||
`docs/plan/index.md` 进行中列表增加:
|
||||
|
||||
```md
|
||||
* [边缘限流全局默认](../superpowers/plans/2026-07-19-http-default-rate-limit.md):http/全局默认限流,站点 0 继承、-1 关闭。
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 全量门禁**
|
||||
|
||||
```bash
|
||||
make code-check
|
||||
make prettier
|
||||
```
|
||||
|
||||
Expected: 通过;修复任何报错后再提交。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/reference/configuration.md docs/changelog/index.md docs/plan/index.md
|
||||
git commit -m "docs: document default edge rate limits"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage checklist
|
||||
|
||||
| Spec 要求 | Task |
|
||||
|-----------|------|
|
||||
| 三项全局默认 | 2, 4 |
|
||||
| 0/空继承、-1 关、>0 覆盖 | 1, 3, 4 |
|
||||
| 仅 `RenderRouteConfig` 合并 | 1 |
|
||||
| 快照保留原始站点值 | 2(不写回 route) |
|
||||
| 安全性子页「限流」 | 4 |
|
||||
| 初始 0/空兼容 | 2 seed |
|
||||
| option diff / 发布 | 2 |
|
||||
| 测试合并/normalize | 1, 3 |
|
||||
| 中文文档/changelog | 5 |
|
||||
| 非目标 limit_req / http 级指令 | 未做 |
|
||||
|
||||
## 手动验收
|
||||
|
||||
1. 迁移后三键存在且为 `0`/空
|
||||
2. 安全性 → 限流 设置 `120` / `12` / `512k` 并保存
|
||||
3. 版本发布预览:未配置站点的 location 出现对应 `limit_conn`/`limit_rate`
|
||||
4. 站点将该项改为 `-1` 保存并发布:该维度指令消失
|
||||
5. 站点改为 `50`:输出 50 而非全局值
|
||||
@@ -1,388 +0,0 @@
|
||||
# WAF Editor Node Label + Drag-Add Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let users rename WAF rule nodes via optional `label`, and add nodes by dragging from the library onto the canvas drop position (no click-to-fixed-offset).
|
||||
|
||||
**Architecture:** Frontend-only. Align TS `WAFRuleNode` with backend `label`. Pure helpers for display name and default node factory. Node library is drag source; React Flow pane handles drop with `screenToFlowPosition`. Properties panel edits `label` for non-system nodes.
|
||||
|
||||
**Tech Stack:** Next.js App Router, React, TypeScript, `@xyflow/react`, Vitest + Testing Library, shadcn/ui.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-19-waf-editor-node-label-drag-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No backend / schema_version / note field changes.
|
||||
- System nodes `start` / `allow`: no rename UI.
|
||||
- New nodes: no default `label` (type name shown).
|
||||
- Drag-only add; remove click-add.
|
||||
- After code: relevant vitest pass; run `make prettier` / `make code-check` if touching repo gates.
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `frontend/lib/services/openflare/types.ts` | Add `label?: string` to all `WAFRuleNode` variants |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/node-factory.ts` | `NODE_TYPE_LABELS`, `displayNodeTitle`, `createRuleNode`, drag MIME constant |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts` | Unit tests for title + factory |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/rule-node.tsx` | Use `displayNodeTitle` |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/node-properties.tsx` | 「显示名称」Input |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx` | Label edit + system node |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/node-library.tsx` | Draggable items, no onClick |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx` | Drop handler + position-aware create |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Types + pure helpers
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/lib/services/openflare/types.ts`
|
||||
- Create: `frontend/app/(main)/waf/rules/editor/components/node-factory.ts`
|
||||
- Create: `frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `WAF_NODE_DRAG_MIME`, `AddableNodeType`, `NODE_TYPE_LABELS`, `displayNodeTitle(node)`, `createRuleNode(type, position)`
|
||||
|
||||
- [ ] **Step 1: Add `label?: string` to every `WAFRuleNode` union member** in `types.ts`.
|
||||
|
||||
- [ ] **Step 2: Write failing tests** in `node-factory.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createRuleNode,
|
||||
displayNodeTitle,
|
||||
NODE_TYPE_LABELS,
|
||||
} from './node-factory';
|
||||
|
||||
describe('displayNodeTitle', () => {
|
||||
it('uses trimmed label when present', () => {
|
||||
expect(
|
||||
displayNodeTitle({
|
||||
id: 'x',
|
||||
type: 'ip_match',
|
||||
label: ' 办公室 ',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { ips: [], cidrs: [], ip_group_ids: [] },
|
||||
}),
|
||||
).toBe('办公室');
|
||||
});
|
||||
|
||||
it('falls back to type default when label empty', () => {
|
||||
expect(
|
||||
displayNodeTitle({
|
||||
id: 'x',
|
||||
type: 'block',
|
||||
label: ' ',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { status_code: 403, response_body: '' },
|
||||
}),
|
||||
).toBe(NODE_TYPE_LABELS.block);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRuleNode', () => {
|
||||
it('creates typed node at position without label', () => {
|
||||
const node = createRuleNode('pow', { x: 12, y: 34 });
|
||||
expect(node.type).toBe('pow');
|
||||
expect(node.position).toEqual({ x: 12, y: 34 });
|
||||
expect(node.label).toBeUndefined();
|
||||
expect(node.id.startsWith('pow-')).toBe(true);
|
||||
if (node.type === 'pow') {
|
||||
expect(node.config).toEqual({
|
||||
algorithm: 'fast',
|
||||
difficulty: 4,
|
||||
session_ttl: 3600,
|
||||
challenge_ttl: 300,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement `node-factory.ts`**
|
||||
|
||||
```ts
|
||||
import type { WAFRuleNode } from '@/lib/services/openflare';
|
||||
|
||||
export const WAF_NODE_DRAG_MIME = 'application/openflare-waf-node';
|
||||
|
||||
export type AddableNodeType = Extract<
|
||||
WAFRuleNode['type'],
|
||||
'ip_match' | 'geo_match' | 'pow' | 'block'
|
||||
>;
|
||||
|
||||
export const NODE_TYPE_LABELS: Record<WAFRuleNode['type'], string> = {
|
||||
start: '开始',
|
||||
ip_match: 'IP 匹配',
|
||||
geo_match: '地域匹配',
|
||||
pow: 'PoW 挑战',
|
||||
allow: '通过',
|
||||
block: '阻止',
|
||||
};
|
||||
|
||||
export function displayNodeTitle(
|
||||
node: Pick<WAFRuleNode, 'type' | 'label'>,
|
||||
): string {
|
||||
const custom = node.label?.trim();
|
||||
return custom || NODE_TYPE_LABELS[node.type];
|
||||
}
|
||||
|
||||
export function createRuleNode(
|
||||
type: AddableNodeType,
|
||||
position: { x: number; y: number },
|
||||
): WAFRuleNode {
|
||||
const id = `${type}-${crypto.randomUUID().slice(0, 8)}`;
|
||||
if (type === 'ip_match')
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
position,
|
||||
config: { ips: [], cidrs: [], ip_group_ids: [] },
|
||||
};
|
||||
if (type === 'geo_match')
|
||||
return { id, type, position, config: { countries: [], regions: [] } };
|
||||
if (type === 'pow')
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
position,
|
||||
config: {
|
||||
algorithm: 'fast',
|
||||
difficulty: 4,
|
||||
session_ttl: 3600,
|
||||
challenge_ttl: 300,
|
||||
},
|
||||
};
|
||||
return {
|
||||
id,
|
||||
type: 'block',
|
||||
position,
|
||||
config: { status_code: 403, response_body: '' },
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAddableNodeType(value: string): AddableNodeType | null {
|
||||
if (
|
||||
value === 'ip_match' ||
|
||||
value === 'geo_match' ||
|
||||
value === 'pow' ||
|
||||
value === 'block'
|
||||
)
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor/components/node-factory.test.ts'
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/lib/services/openflare/types.ts \
|
||||
frontend/app/(main)/waf/rules/editor/components/node-factory.ts \
|
||||
frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts
|
||||
git commit -m "feat(waf): add node label type and factory helpers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Canvas title + properties label field
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/(main)/waf/rules/editor/components/rule-node.tsx`
|
||||
- Modify: `frontend/app/(main)/waf/rules/editor/components/node-properties.tsx`
|
||||
- Modify: `frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Tests for properties**
|
||||
|
||||
Add to `node-properties.test.tsx`:
|
||||
|
||||
```ts
|
||||
it('edits display name for configurable nodes', () => {
|
||||
const node: WAFRuleNode = {
|
||||
id: 'match',
|
||||
type: 'ip_match',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { ips: [], cidrs: [], ip_group_ids: [] },
|
||||
};
|
||||
const onChange = vi.fn();
|
||||
render(<NodeProperties node={node} ipGroups={[]} onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText('显示名称'), {
|
||||
target: { value: '内网放行' },
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ label: '内网放行' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('hides display name for system nodes', () => {
|
||||
const node: WAFRuleNode = {
|
||||
id: 'start',
|
||||
type: 'start',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
};
|
||||
render(<NodeProperties node={node} ipGroups={[]} onChange={vi.fn()} />);
|
||||
expect(screen.queryByLabelText('显示名称')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('系统节点无需配置。')).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement properties field** — at start of each configurable `FieldGroup` (or wrap once before type switch for non-system):
|
||||
|
||||
Prefer extract:
|
||||
|
||||
```tsx
|
||||
function DisplayNameField({
|
||||
node,
|
||||
onChange,
|
||||
}: {
|
||||
node: WAFRuleNode;
|
||||
onChange: (node: WAFRuleNode) => void;
|
||||
}) {
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${node.id}-label`}>显示名称</FieldLabel>
|
||||
<Input
|
||||
id={`${node.id}-label`}
|
||||
value={node.label ?? ''}
|
||||
placeholder={/* type default from NODE_TYPE_LABELS */}
|
||||
onChange={(e) => onChange({ ...node, label: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Insert `<DisplayNameField ... />` as first child inside each non-system `FieldGroup`.
|
||||
|
||||
- [ ] **Step 3: `rule-node.tsx`** — use `displayNodeTitle(rule)` for main title; keep icon from meta; keep id subtitle.
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor/components/node-properties.test.tsx' 'app/(main)/waf/rules/editor/components/node-factory.test.ts'
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/app/(main)/waf/rules/editor/components/rule-node.tsx \
|
||||
frontend/app/(main)/waf/rules/editor/components/node-properties.tsx \
|
||||
frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx
|
||||
git commit -m "feat(waf): show and edit rule node display names"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Drag library + canvas drop
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/(main)/waf/rules/editor/components/node-library.tsx`
|
||||
- Modify: `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx`
|
||||
- Create (optional pure tests): extend `node-factory.test.ts` for `parseAddableNodeType`
|
||||
|
||||
- [ ] **Step 1: Node library** — remove `onAdd` prop; make each button `draggable` with:
|
||||
|
||||
```tsx
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData(WAF_NODE_DRAG_MIME, type);
|
||||
e.dataTransfer.setData('text/plain', type);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
```
|
||||
|
||||
Use `type='button'` + cursor `cursor-grab active:cursor-grabbing`. No `onClick` that adds nodes.
|
||||
|
||||
- [ ] **Step 2: Canvas** — replace `addNode(type)` fixed position with:
|
||||
|
||||
```ts
|
||||
const addNodeAt = useCallback(
|
||||
(type: AddableNodeType, position: { x: number; y: number }) => {
|
||||
const node = createRuleNode(type, position);
|
||||
onGraphChange({ ...graph, nodes: [...graph.nodes, node] });
|
||||
onSelectEdge(undefined);
|
||||
onSelect(node.id);
|
||||
},
|
||||
[graph, onGraphChange, onSelect, onSelectEdge],
|
||||
);
|
||||
|
||||
const onDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const raw =
|
||||
e.dataTransfer.getData(WAF_NODE_DRAG_MIME) ||
|
||||
e.dataTransfer.getData('text/plain');
|
||||
const type = parseAddableNodeType(raw);
|
||||
if (!type || !instance.current) return;
|
||||
const position = instance.current.screenToFlowPosition({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
addNodeAt(type, position);
|
||||
},
|
||||
[addNodeAt],
|
||||
);
|
||||
```
|
||||
|
||||
Pass `onDragOver` / `onDrop` to `<ReactFlow ...>` (xyflow supports these on the component).
|
||||
|
||||
Update `<NodeLibrary />` — no `onAdd`.
|
||||
|
||||
- [ ] **Step 3: Run editor-related tests**
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor'
|
||||
```
|
||||
|
||||
Expected: PASS (update any tests that assumed click-add)
|
||||
|
||||
- [ ] **Step 4: Format + commit**
|
||||
|
||||
```bash
|
||||
make prettier
|
||||
git add frontend/app/(main)/waf/rules/editor
|
||||
git commit -m "feat(waf): drag-drop nodes onto rule canvas at cursor"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Changelog** — under `docs/changelog/index.md` `[Unreleased]`:
|
||||
|
||||
```md
|
||||
### 改进
|
||||
- WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。
|
||||
```
|
||||
|
||||
```bash
|
||||
git add docs/changelog/index.md
|
||||
git commit -m "docs(changelog): WAF 编辑器节点命名与拖放添加"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage
|
||||
|
||||
| Spec item | Task |
|
||||
|-----------|------|
|
||||
| `label?` on TS types | 1 |
|
||||
| Display title fallback | 1–2 |
|
||||
| Properties 显示名称 | 2 |
|
||||
| System nodes no rename | 2 |
|
||||
| Drag-only library | 3 |
|
||||
| Drop at cursor | 3 |
|
||||
| No note / backend | N/A (omitted) |
|
||||
| Tests | 1–3 |
|
||||
| Changelog | 3 |
|
||||
@@ -1,42 +0,0 @@
|
||||
# WAF UA Check Node Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add WAF graph node `ua_check` (require UA, browser/OS whitelist with and/or, bot/abnormal blocks) end-to-end: validate/compile, Lua runtime, editor UI.
|
||||
|
||||
**Architecture:** Match-node pattern like `geo_match`. Control plane stores `UACheckConfig`; edge classifies `http_user_agent` with analytics-equivalent token rules; evaluation order: require → block bots → block abnormal → whitelist.
|
||||
|
||||
**Tech Stack:** Go (waf package), Lua (OpenResty waf_runtime), React/TS editor, Vitest, Go tests.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-19-waf-ua-check-node-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Type `ua_check`; handles `true`/`false`.
|
||||
- Config fields: `require_ua`, `browsers`, `operating_systems`, `match_mode` (`and`|`or`, default `or`), `block_common_bots`, `block_abnormal_ua`.
|
||||
- Closed enums for browser/OS labels matching analytics.
|
||||
- Block before whitelist; empty lists = no whitelist constraint.
|
||||
- No schema_version bump; no new HTTP API.
|
||||
- Changelog + Chinese design doc update.
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `internal/apps/openflare/waf/graph_types.go` | Type + config |
|
||||
| `internal/apps/openflare/waf/graph_validate.go` | Validate + handles |
|
||||
| `internal/apps/openflare/waf/graph_compile.go` | Compile normalize |
|
||||
| `internal/apps/openflare/waf/*_test.go` | Go tests |
|
||||
| `internal/apps/agent/nginx/waf_runtime.lua` | Runtime eval |
|
||||
| `internal/apps/agent/nginx/waf_runtime_spec.lua` | Lua specs |
|
||||
| `internal/apps/agent/nginx/manager_test.go` | Embed smoke if needed |
|
||||
| Frontend editor components + types | UI |
|
||||
| `docs/design/waf-orchestration-design.md` | Node table |
|
||||
| `docs/changelog/index.md` | Unreleased |
|
||||
|
||||
### Task 1: Backend types/validate/compile
|
||||
### Task 2: Lua runtime + specs
|
||||
### Task 3: Frontend editor
|
||||
### Task 4: Docs + gates
|
||||
|
||||
(Detailed code follows during implementation; execute TDD per layer.)
|
||||
@@ -1,467 +0,0 @@
|
||||
# 源站错误页 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 全局可配置源站/网关错误页:默认标签 `500-599`、Cloudflare 风格 HTML、可在线自定义;HTTP 状态码保持原值并在页面展示 `{{status}}`/`{{host}}`;可关闭恢复透传。
|
||||
|
||||
**Architecture:** 三个 Option key 持久化 → 配置版本 `openresty_config` 快照 → `pkg/render/openresty` 对反代 server 生成 `proxy_intercept_errors` + `error_page` + internal location;模板 SupportFile + 轻量 `content_by_lua_block` 替换占位符。管理端 `/error-pages` 挂在侧栏「网站管理」。
|
||||
|
||||
**Tech Stack:** Go、goose、Option API、`pkg/render/openresty`、OpenResty/Lua、Next.js、Tags Input、OptionService
|
||||
|
||||
**Spec:** [docs/design/origin-error-page.md](../../design/origin-error-page.md) · [docs/superpowers/specs/2026-08-06-origin-error-page-design.md](../specs/2026-08-06-origin-error-page-design.md)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 仅**反代**路由应用;**Pages** 路由不生成错误页指令
|
||||
- HTTP **status 保持原错误码**,禁止统一改为 200
|
||||
- 状态码标签:单码 `522` 或闭区间 `500-599`;范围 **400–599**;默认 `["500-599"]`
|
||||
- 占位符:`{{status}}`、`{{host}}`;HTML 空 = 内置默认;最大 **256 KiB**
|
||||
- 保存走 Option `update-batch`;**需配置版本发布**后边缘生效
|
||||
- 完成后 `make code-check`;前端相关 `make format` / prettier;中文 changelog;不写英文文档
|
||||
- 不新增独立业务路由注册(复用 `/api/v1/d/option`);侧栏仅前端导航
|
||||
|
||||
## File map
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `pkg/render/openresty/status_codes.go` | 标签解析/展开纯函数 |
|
||||
| `pkg/render/openresty/status_codes_test.go` | 解析单测 |
|
||||
| `pkg/render/openresty/origin_error_page.go` | 默认 HTML、渲染 error_page 片段、SupportFile 路径常量 |
|
||||
| `pkg/render/openresty/origin_error_page_test.go` | 渲染片段单测 |
|
||||
| `pkg/render/openresty/types.go` | `ConfigSnapshot` 三字段 |
|
||||
| `pkg/render/openresty/render.go` / `render_route.go` | 接入 error 块到反代 server |
|
||||
| `pkg/render/openresty/render_test.go` | 集成渲染断言 |
|
||||
| `internal/model/system_configs.go` | 三个 ConfigKey 常量 |
|
||||
| `internal/infra/persistence/migrator/goose/{postgres,sqlite}/202608060001_add_origin_error_page_options.sql` | seed |
|
||||
| `internal/apps/openflare/option/openresty_validators.go` | 校验 enabled / codes / html |
|
||||
| `internal/apps/openflare/option` 相关 test | 校验失败用例 |
|
||||
| `internal/apps/openflare/config_version/snapshot.go` | 快照读写字段 |
|
||||
| `internal/apps/openflare/config_version/logics.go` | `diffOpenRestyOptionDetails` 含新字段 |
|
||||
| `frontend/components/ui/tags-input.tsx` | shadcn-extension Tags Input(若缺失则添加) |
|
||||
| `frontend/app/(main)/error-pages/page.tsx` | 设置页 |
|
||||
| `frontend/lib/navigation/openflare-nav.ts` | 网站管理菜单 |
|
||||
| `frontend/lib/utils/search-data.ts` | 搜索 |
|
||||
| `docs/reference/configuration.md` | 配置键说明(中文) |
|
||||
| `docs/changelog/index.md` | Unreleased |
|
||||
| `docs/plan/index.md` | 进行中索引 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 状态码标签解析(纯函数 TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `pkg/render/openresty/status_codes.go`
|
||||
- Create: `pkg/render/openresty/status_codes_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `func ExpandStatusCodeTags(tags []string) (codes []int, err error)`
|
||||
- `func ParseStatusCodeTag(tag string) (lo, hi int, err error)` — 单码时 `lo==hi`
|
||||
- 常量:`StatusCodeMin = 400`, `StatusCodeMax = 599`
|
||||
- 规则:trim;`^\d{3}$` 或 `^\d{3}-\d{3}$`;`lo<=hi`;均在 400–599;展开 inclusive;排序去重;空 tags → 空 slice + nil error(「启用且空」由校验层拒绝)
|
||||
|
||||
- [ ] **Step 1: 写失败单测**
|
||||
|
||||
```go
|
||||
func TestExpandStatusCodeTags(t *testing.T) {
|
||||
t.Parallel()
|
||||
codes, err := ExpandStatusCodeTags([]string{"500-502", "522", "501"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// want sorted unique: 500,501,502,522
|
||||
if len(codes) != 4 || codes[0] != 500 || codes[3] != 522 {
|
||||
t.Fatalf("got %v", codes)
|
||||
}
|
||||
_, err = ExpandStatusCodeTags([]string{"399"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
_, err = ExpandStatusCodeTags([]string{"503-500"})
|
||||
if err == nil {
|
||||
t.Fatal("expected reverse range error")
|
||||
}
|
||||
_, err = ExpandStatusCodeTags([]string{"5xx"})
|
||||
if err == nil {
|
||||
t.Fatal("expected syntax error")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `go test ./pkg/render/openresty/ -run TestExpandStatusCodeTags -count=1`
|
||||
Expected: FAIL(函数未定义)
|
||||
|
||||
- [ ] **Step 3: 实现 `status_codes.go`**
|
||||
|
||||
```go
|
||||
package openresty
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusCodeMin = 400
|
||||
StatusCodeMax = 599
|
||||
)
|
||||
|
||||
func ParseStatusCodeTag(tag string) (lo, hi int, err error) {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
return 0, 0, fmt.Errorf("状态码标签不能为空")
|
||||
}
|
||||
if i := strings.IndexByte(tag, '-'); i >= 0 {
|
||||
lo, err = strconv.Atoi(tag[:i])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("无效状态码区间: %s", tag)
|
||||
}
|
||||
hi, err = strconv.Atoi(tag[i+1:])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("无效状态码区间: %s", tag)
|
||||
}
|
||||
} else {
|
||||
lo, err = strconv.Atoi(tag)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("无效状态码: %s", tag)
|
||||
}
|
||||
hi = lo
|
||||
}
|
||||
if lo > hi {
|
||||
return 0, 0, fmt.Errorf("状态码区间左右端点反序: %s", tag)
|
||||
}
|
||||
if lo < StatusCodeMin || hi > StatusCodeMax {
|
||||
return 0, 0, fmt.Errorf("状态码须在 %d–%d: %s", StatusCodeMin, StatusCodeMax, tag)
|
||||
}
|
||||
return lo, hi, nil
|
||||
}
|
||||
|
||||
func ExpandStatusCodeTags(tags []string) ([]int, error) {
|
||||
set := map[int]struct{}{}
|
||||
for _, tag := range tags {
|
||||
lo, hi, err := ParseStatusCodeTag(tag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for c := lo; c <= hi; c++ {
|
||||
set[c] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]int, 0, len(set))
|
||||
for c := range set {
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out, nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行确认通过**
|
||||
|
||||
Run: `go test ./pkg/render/openresty/ -run TestExpandStatusCodeTags -count=1`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add pkg/render/openresty/status_codes.go pkg/render/openresty/status_codes_test.go
|
||||
git commit -m "feat(openresty): add status code tag expand helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: ConfigSnapshot 字段 + 默认 HTML + error_page 渲染(TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `pkg/render/openresty/types.go` — `ConfigSnapshot` 增加:
|
||||
- `OriginErrorPageEnabled bool \`json:"origin_error_page_enabled"\``
|
||||
- `OriginErrorPageStatusCodes []string \`json:"origin_error_page_status_codes,omitempty"\``
|
||||
- `OriginErrorPageHTML string \`json:"origin_error_page_html,omitempty"\``
|
||||
- Create: `pkg/render/openresty/origin_error_page.go` — 默认 HTML 常量、路径常量、`renderOriginErrorPageDirectives`、`originErrorPageSupportFile`
|
||||
- Modify: `pkg/render/openresty/render.go` — `Render`/`RenderRouteConfig` 在 enabled 时 append SupportFile
|
||||
- Modify: `pkg/render/openresty/render_route.go`(或 `render.go` 中 `renderHTTPProxyServer` / `renderHTTPSServer`)— 在 proxy location **与** server 级写入 intercept + error_page + internal location
|
||||
- Test: `pkg/render/openresty/origin_error_page_test.go` + 扩展 `render_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `const OriginErrorPageSupportPath = "error_pages/origin_error.html.tmpl"`
|
||||
- `const DefaultOriginErrorPageHTML = \`...\`` — CF 风格,含 `{{status}}` `{{host}}`
|
||||
- `func EffectiveOriginErrorPageHTML(cfg ConfigSnapshot) string` — 空则默认
|
||||
- `func renderOriginErrorPageServerBits(cfg ConfigSnapshot) string` — 若 disabled 或 expand 失败/空则 `""`;否则 `error_page ...` + internal location 字符串
|
||||
- SupportFile content = Effective HTML(保留占位符)
|
||||
|
||||
**Internal location 形状(必须 status 透传):**
|
||||
|
||||
```nginx
|
||||
location = /__openflare_origin_error {
|
||||
internal;
|
||||
default_type text/html;
|
||||
charset utf-8;
|
||||
content_by_lua_block {
|
||||
local f = io.open("__OPENFLARE_ERROR_PAGE_TMPL__", "r")
|
||||
if not f then
|
||||
ngx.status = ngx.status
|
||||
ngx.say("Error ", ngx.status)
|
||||
return
|
||||
end
|
||||
local body = f:read("*a")
|
||||
f:close()
|
||||
local status = tostring(ngx.status)
|
||||
local host = ngx.var.host or ""
|
||||
body = body:gsub("{{status}}", status, 1)
|
||||
body = body:gsub("{{host}}", host, 1)
|
||||
-- 全局替换剩余占位(若模板多处 status)
|
||||
body = body:gsub("{{status}}", status)
|
||||
body = body:gsub("{{host}}", host)
|
||||
ngx.header["Content-Type"] = "text/html; charset=utf-8"
|
||||
ngx.say(body)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
占位路径:渲染时用常量如 `ErrorPageTmplPlaceholder = "__OPENFLARE_ERROR_PAGE_TMPL__"`,Agent 落盘时与 SupportFile 绝对路径替换(若现有 Agent 已有 support file root 替换模式则复用;否则在 `internal/apps/agent` 同步路径处增加对该 placeholder 的替换,与 `CertDirPlaceholder` 同类)。
|
||||
|
||||
在每个反代 `location /` 内(proxy 块):
|
||||
|
||||
```nginx
|
||||
proxy_intercept_errors on;
|
||||
```
|
||||
|
||||
在 server 块内(location 外):
|
||||
|
||||
```nginx
|
||||
error_page 500 501 ... = /__openflare_origin_error;
|
||||
```
|
||||
|
||||
+ internal location。
|
||||
|
||||
Pages 的 `renderHTTPSPagesServer` / pages HTTP **不**调用。
|
||||
|
||||
- [ ] **Step 1: 写失败单测**
|
||||
|
||||
```go
|
||||
func TestRenderOriginErrorPageEnabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
doc := Document{
|
||||
Routes: []Route{{
|
||||
ID: 1, SiteName: "ex", Domains: []string{"ex.test"},
|
||||
OriginURL: "http://127.0.0.1:9", Enabled: true,
|
||||
}},
|
||||
OpenRestyConfig: ConfigSnapshot{
|
||||
OriginErrorPageEnabled: true,
|
||||
OriginErrorPageStatusCodes: []string{"500-599"},
|
||||
},
|
||||
}
|
||||
out, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "proxy_intercept_errors on") {
|
||||
t.Fatal("missing intercept")
|
||||
}
|
||||
if !strings.Contains(out, "error_page") || !strings.Contains(out, "/__openflare_origin_error") {
|
||||
t.Fatal("missing error_page")
|
||||
}
|
||||
if !strings.Contains(out, "{{status}}") == false {
|
||||
// SupportFile 在 Render 全量结果中
|
||||
}
|
||||
res, err := Render(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, f := range res.SupportFiles {
|
||||
if f.Path == OriginErrorPageSupportPath {
|
||||
found = true
|
||||
if !strings.Contains(f.Content, "{{status}}") {
|
||||
t.Fatal("template missing placeholder")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("missing support file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderOriginErrorPageDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
doc := Document{
|
||||
Routes: []Route{{
|
||||
ID: 1, SiteName: "ex", Domains: []string{"ex.test"},
|
||||
OriginURL: "http://127.0.0.1:9", Enabled: true,
|
||||
}},
|
||||
OpenRestyConfig: ConfigSnapshot{OriginErrorPageEnabled: false},
|
||||
}
|
||||
out, err := RenderRouteConfig(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, "proxy_intercept_errors") {
|
||||
t.Fatal("should not intercept when disabled")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败** → 实现默认 HTML + 渲染接入 → 运行 PASS
|
||||
|
||||
默认 HTML 要求:大号 `{{status}}`、展示 `{{host}}`、中性「源站暂时无法提供服务」类文案、无 Cloudflare 商标、可单文件 inline CSS。
|
||||
|
||||
- [ ] **Step 3: Agent placeholder 替换**
|
||||
|
||||
搜索 Agent 写 conf 时如何替换 `__OPENFLARE_CERT_DIR__` 等,为 `__OPENFLARE_ERROR_PAGE_TMPL__` 增加指向 support 目录下 `error_pages/origin_error.html.tmpl` 的绝对路径。
|
||||
若 conf 内 lua 无法可靠 `io.open` 绝对路径,可改为 `content_by_lua_file` + 小 lua 读固定相对路径;优先与现有 pow/waf 资源部署方式一致。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(openresty): render origin error page directives"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Option keys、迁移、校验、快照
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/model/system_configs.go` — 常量:
|
||||
- `ConfigKeyOriginErrorPageEnabled = "origin_error_page_enabled"`
|
||||
- `ConfigKeyOriginErrorPageStatusCodes = "origin_error_page_status_codes"`
|
||||
- `ConfigKeyOriginErrorPageHTML = "origin_error_page_html"`
|
||||
- Create goose(postgres + sqlite 同名版本号)`202608060001_add_origin_error_page_options.sql`:
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||
VALUES
|
||||
('origin_error_page_enabled', 'true', 'business', 0, '是否启用源站错误页', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('origin_error_page_status_codes', '["500-599"]', 'business', 0, '源站错误页触发状态码标签 JSON 数组', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('origin_error_page_html', '', 'business', 0, '源站错误页自定义 HTML,空则使用内置默认', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM w_system_configs WHERE key IN (
|
||||
'origin_error_page_enabled',
|
||||
'origin_error_page_status_codes',
|
||||
'origin_error_page_html'
|
||||
);
|
||||
```
|
||||
|
||||
(SQLite 若无 `ON CONFLICT` 同现有迁移方言对齐。)
|
||||
|
||||
- Modify: `openresty_validators.go`:
|
||||
- enabled → `validateBooleanOption`
|
||||
- status_codes → JSON 数组 `[]string`,对每项 `ParseStatusCodeTag`;若 `enabled==true`(跨 key 时可用 state,或在 batch 校验后单独检查:若本 key 合法但 enabled 为 true 且 expand 为空则失败)。**实现建议**:`validateOriginErrorPageStatusCodes` 只校验标签可解析且 expand 非空(即使 disabled 也要求非空列表,避免脏数据);html 长度 `<= 256*1024`
|
||||
- html → `len(value) <= 256<<10`
|
||||
|
||||
- Modify: `snapshot.go` 的 `openRestyConfigSnapshot` + `buildOpenRestyConfigSnapshot`:
|
||||
- Enabled: `getBoolConfig(..., true)`
|
||||
- StatusCodes: 解析 JSON 数组,失败则默认 `[]string{"500-599"}`
|
||||
- HTML: `getStringConfig(..., "")`
|
||||
|
||||
- Modify: `logics.go` `diffOpenRestyOptionDetails` 比较新字段(否则发布 diff 不显示)
|
||||
|
||||
- [ ] **Step 1: 校验单测**(`option` 包)非法 `["abc"]`、超大 html、合法 `["522","500-502"]`
|
||||
|
||||
- [ ] **Step 2: 实现迁移与 snapshot 填充**
|
||||
|
||||
- [ ] **Step 3: 手动或单测确认 snapshot JSON 含字段**
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(option): seed and validate origin error page options"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 前端 Tags Input + `/error-pages` 页
|
||||
|
||||
**Files:**
|
||||
- Create/Modify: `frontend/components/ui/tags-input.tsx`(若无:用 shadcn skill / `npx shadcn@latest add` 社区 Tags Input;API:`value: string[]`, `onChange`, `placeholder`)
|
||||
- Create: `frontend/app/(main)/error-pages/page.tsx`(及可选 `components/` 拆分若超 600 行)
|
||||
- Modify: `frontend/lib/navigation/openflare-nav.ts` — `openflareWebsiteNavGroup.items` 增加 `{ title: '错误页', url: '/error-pages' }`;`openflareWebsiteSubNav` 同步
|
||||
- Modify: `frontend/lib/utils/search-data.ts` — 关键词:错误页、源站、502、error page
|
||||
- Reuse: `OptionService.list` / `updateBatch`(同 performance 页)
|
||||
|
||||
**UI 行为:**
|
||||
1. 管理员加载 options → 映射三字段
|
||||
2. Switch 启用
|
||||
3. TagsInput:默认展示解析后的 JSON 数组
|
||||
4. Textarea HTML;按钮「加载默认模板」「恢复默认」
|
||||
5. 预览:客户端把 `{{status}}`→`502`、`{{host}}`→`example.com` 后 iframe `srcDoc`
|
||||
6. 保存:`updateBatch` 三条;toast 提示去版本发布
|
||||
7. 前端校验:标签用与后端相同规则(可抽 `lib/openflare/status-code-tags.ts` 轻量实现或仅保存时依赖后端错误)
|
||||
|
||||
默认 HTML:从前端常量复制与 Go `DefaultOriginErrorPageHTML` **内容一致**(计划实现时两处同一字符串;可在 PR 说明需人工对齐)。
|
||||
|
||||
- [ ] **Step 1: 添加 Tags Input 组件并在 demo 或本页使用**
|
||||
|
||||
- [ ] **Step 2: 实现 page.tsx**
|
||||
|
||||
- [ ] **Step 3: 导航与搜索**
|
||||
|
||||
- [ ] **Step 4: 本地 UI 走查**(开关、标签区间、预览、保存错误提示)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(frontend): add origin error page settings under websites"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 文档、changelog、收尾门禁
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/reference/configuration.md` — 三 key 说明
|
||||
- Modify: `docs/changelog/index.md` `[unreleased]` 改进:用户可读中文
|
||||
- Modify: `docs/plan/index.md` — 本计划列入进行中
|
||||
|
||||
- [ ] **Step 1: 写配置说明与 changelog**
|
||||
|
||||
示例 changelog:
|
||||
|
||||
```markdown
|
||||
- 新增全局源站错误页:可在「网站管理 → 错误页」配置触发状态码(支持 500-599 区间与单码)与自定义 HTML;默认 Cloudflare 风格页面并保持真实 HTTP 状态码,关闭后恢复透传。
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `make code-check` 与前端 format**
|
||||
|
||||
- [ ] **Step 3: 手动验收清单(对照设计 §7.2)**
|
||||
|
||||
1. 默认启用 + 源站宕机 → 错误页 + 真实 status
|
||||
2. 源站 503 → 替换
|
||||
3. 仅 `522` → 其它 5xx 透传
|
||||
4. 关闭并发布 → 透传
|
||||
5. 自定义 `{{status}}`/`{{host}}`
|
||||
6. Pages 不受影响
|
||||
|
||||
- [ ] **Step 4: Final commit**
|
||||
|
||||
```bash
|
||||
git commit -m "docs: origin error page configuration and changelog"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage checklist
|
||||
|
||||
| Spec 要求 | Task |
|
||||
|-----------|------|
|
||||
| 全局开关 | 3, 4 |
|
||||
| 默认 `500-599` / 单码+区间 | 1, 3, 4 |
|
||||
| 状态码透传 + `{{status}}` | 2 |
|
||||
| 默认 CF 风格 / 自定义 HTML | 2, 4 |
|
||||
| Option + 配置版本 | 3 |
|
||||
| 仅反代 | 2 |
|
||||
| 侧栏网站管理 | 4 |
|
||||
| Tags Input | 4 |
|
||||
| 256KB 限制 | 3 |
|
||||
| 测试与 changelog | 1–5 |
|
||||
|
||||
## Placeholder scan
|
||||
|
||||
无 TBD;Agent 路径替换若与现网 placeholder 机制不一致,在 Task 2 Step 3 内对齐现有 `CertDirPlaceholder` 模式,不另开悬空任务。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,885 +0,0 @@
|
||||
# Service Worker 离线兜底 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 给平台所有启用 HTTPS 的网站(反代 + Pages)下发 Service Worker 离线兜底:域名被墙后浏览器从缓存吐出"联系站长"页,避免用户流失。全平台一键批量下发。
|
||||
|
||||
**Architecture:** 全局 Option(SystemConfig / OpenRestyConfig snapshot)驱动,与现有 origin error page 完全同模式。渲染层在 HTTPS server 块注入 SW 静态 location + 首页挑战拦截(真实浏览器 UA 且无 cookie 时返回含 `register('/sw.js')` 的挑战页),通过 SupportFile 下发 sw.js / offline.html,Agent 替换占位符落盘。前端「响应页面」模块两个 tab:错误页 / 联系页。
|
||||
|
||||
**Tech Stack:** Go 1.25+、Gin、GORM、PostgreSQL/SQLite goose 迁移、OpenResty/Lua、Next.js、TypeScript、shadcn/ui、TanStack Query。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 遵循 AGENTS.md 分层:`apps → repository → model`,禁止 `model → repository`。
|
||||
- API 错误用 `response.Abort*`;Handler 不直接 `c.JSON`。
|
||||
- 渲染改动后:`make swagger`(本功能无新 API,跳过);开发完成:`make code-check`;提交前:`make format`。
|
||||
- 代码/配置变更写入 `docs/changelog/index.md` 的 `[Unreleased]`(中文,用户可读)。
|
||||
- 配置 key 命名:小写 snake_case。测试临时目录只用 `t.TempDir()`。
|
||||
- 前端:`variant` + CSS 变量,业务 `className` 不硬编码颜色;根容器 `w-full`,外层 `py-6 px-1`;标题行 `flex items-center gap-2`。
|
||||
- 配置文件路径占位符统一追加到 `pkg/render/openresty/types.go` 的 const 块。
|
||||
- 迁移:PostgreSQL 与 SQLite 各一份 goose SQL(`goose/postgres/`、`goose/sqlite/`),见 `database-migration` skill。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 后端配置 key 与 Option 校验
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/model/system_configs.go:114-117`
|
||||
- Modify: `internal/apps/openflare/option/openresty_validators.go:19-65`
|
||||
- Modify: `internal/apps/openflare/option/openresty_validators.go:69-79`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: 常量 `model.ConfigKeySWOfflineEnabled`, `model.ConfigKeySWOfflineHTML`; 校验函数 `validateSWOfflineHTML`。
|
||||
|
||||
- [ ] **Step 1: 在 `system_configs.go` 追加 key 常量**
|
||||
|
||||
在 `ConfigKeyOriginErrorPageGetOnly`(第 117 行)后追加:
|
||||
|
||||
```go
|
||||
ConfigKeySWOfflineEnabled = "sw_offline_enabled" // 是否启用 Service Worker 离线兜底
|
||||
ConfigKeySWOfflineHTML = "sw_offline_html" // 离线联系页自定义 HTML(空则内置默认)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 注册 validator**
|
||||
|
||||
在 `openRestyOptionValidators` map(`openresty_validators.go` 第 61-64 行)后追加:
|
||||
|
||||
```go
|
||||
model.ConfigKeySWOfflineEnabled: validateBooleanOption,
|
||||
model.ConfigKeySWOfflineHTML: validateSWOfflineHTML,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 在 `validateOpenRestyOption` 增加 HTML 字节数特殊处理**
|
||||
|
||||
在第 69-79 行函数内,`if key == model.ConfigKeyOriginErrorPageHTML` 分支改为同时覆盖 SW HTML:
|
||||
|
||||
```go
|
||||
if key == model.ConfigKeyOriginErrorPageHTML || key == model.ConfigKeySWOfflineHTML {
|
||||
return validateOriginErrorPageHTML(key, value)
|
||||
}
|
||||
```
|
||||
|
||||
`validateOriginErrorPageHTML` 逻辑(非空、≤256 KiB)对两个 HTML 复用,无需新函数。
|
||||
|
||||
- [ ] **Step 4: 运行测试**
|
||||
|
||||
Run: `cd /Users/ryan/conductor/workspaces/OpenFlare/islamabad && go build ./... && go test ./internal/apps/openflare/option/...`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add internal/model/system_configs.go internal/apps/openflare/option/openresty_validators.go
|
||||
git commit -m "feat(option): add sw offline config keys and validation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: goose 迁移(PostgreSQL + SQLite)Seed 全局 Option
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/infra/persistence/migrator/goose/postgres/<YYYYMMDD>NNN_add_sw_offline_options.sql`
|
||||
- Create: `internal/infra/persistence/migrator/goose/sqlite/<YYYYMMDD>NNN_add_sw_offline_options.sql`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 key 常量。
|
||||
- Produces: 数据库 seed 的 `sw_offline_enabled` / `sw_offline_html` 两行 `w_system_configs`。
|
||||
|
||||
- [ ] **Step 1: 确认迁移序号**
|
||||
|
||||
Run: `ls /Users/ryan/conductor/workspaces/OpenFlare/islamabad/internal/infra/persistence/migrator/goose/postgres/ | tail -3`
|
||||
取最新序号 +1(如 `202608080001`)。
|
||||
|
||||
- [ ] **Step 2: 创建 postgres 迁移**
|
||||
|
||||
创建 `goose/postgres/202608080001_add_sw_offline_options.sql`:
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||
VALUES
|
||||
('sw_offline_enabled', 'false', 'business', 0, '是否启用 Service Worker 离线兜底', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('sw_offline_html', '', 'business', 0, '离线联系页自定义 HTML,空则使用内置默认', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM w_system_configs WHERE key IN (
|
||||
'sw_offline_enabled',
|
||||
'sw_offline_html'
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 创建 sqlite 迁移**
|
||||
|
||||
创建 `goose/sqlite/202608080001_add_sw_offline_options.sql`(内容与 postgres 相同)。
|
||||
|
||||
- [ ] **Step 4: 运行迁移测试**
|
||||
|
||||
Run: `go test ./internal/infra/persistence/migrator/...`
|
||||
Expected: PASS(数据库迁移冒烟通过)
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add internal/infra/persistence/migrator/goose/postgres/202608080001_add_sw_offline_options.sql internal/infra/persistence/migrator/goose/sqlite/202608080001_add_sw_offline_options.sql
|
||||
git commit -m "feat(db): seed sw offline options"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: ConfigSnapshot 渲染类型字段
|
||||
|
||||
**Files:**
|
||||
- Modify: `pkg/render/openresty/types.go:20-26`
|
||||
- Modify: `pkg/render/openresty/types.go:318-323`(`ConfigSnapshot` 结构体)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ConfigSnapshot.SWOfflineEnabled bool`、`ConfigSnapshot.SWOfflineHTML string`;常量 `SWDirPlaceholder`。
|
||||
|
||||
- [ ] **Step 1: 追加占位符常量**
|
||||
|
||||
在 `types.go` 占位符 const 块(第 23 行 `ErrorPageTmplPlaceholder` 后)追加:
|
||||
|
||||
```go
|
||||
SWDirPlaceholder = "__OPENFLARE_SW_DIR__"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 追加 ConfigSnapshot 字段**
|
||||
|
||||
在 `ConfigSnapshot` 末尾(`OriginErrorPageGetOnly` 后)追加:
|
||||
|
||||
```go
|
||||
// SWOfflineEnabled enables the Service Worker offline fallback for HTTPS routes.
|
||||
SWOfflineEnabled bool `json:"sw_offline_enabled,omitempty"`
|
||||
// SWOfflineHTML is the contact-page HTML served offline; empty uses the built-in default.
|
||||
SWOfflineHTML string `json:"sw_offline_html,omitempty"`
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add pkg/render/openresty/types.go
|
||||
git commit -m "feat(openresty): add sw offline ConfigSnapshot fields and placeholder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 渲染层 SW 资源与挑战拦截
|
||||
|
||||
**Files:**
|
||||
- Create: `pkg/render/openresty/service_worker.go`
|
||||
- Create: `pkg/render/openresty/service_worker_test.go`
|
||||
- Modify: `pkg/render/openresty/render.go:37-59`(`Render` 追加 support files)
|
||||
- Modify: `pkg/render/openresty/render.go:90-115`(`RenderRouteConfig` 注入挑战)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ConfigSnapshot.SWOfflineEnabled` / `.SWOfflineHTML`;`SWDirPlaceholder`。
|
||||
- Produces: `DefaultSWOfflineHTML string`、`EffectiveSWOfflineHTML(cfg ConfigSnapshot) string`、`ServiceWorkerSupportFiles(cfg ConfigSnapshot) []SupportFile`、`renderServiceWorkerChallenger(cfg ConfigSnapshot) string`。
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
创建 `service_worker_test.go`,断言:
|
||||
1. `EffectiveSWOfflineHTML`:HTML 为空返回内置默认;非空返回自定义。
|
||||
2. `ServiceWorkerSupportFiles`:仅当 `SWOfflineEnabled` 时返回 `sw/sw.js` 与 `sw/offline.html` 两个文件;未启用返回 nil。
|
||||
3. `renderServiceWorkerChallenger`:启用且含 sw.js location、offline location、挑战 location;未启用返回空串。
|
||||
|
||||
```go
|
||||
package openresty
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEffectiveSWOfflineHTML(t *testing.T) {
|
||||
if got := EffectiveSWOfflineHTML(ConfigSnapshot{}); got != DefaultSWOfflineHTML {
|
||||
t.Fatalf("default mismatch")
|
||||
}
|
||||
custom := "<html>custom</html>"
|
||||
if got := EffectiveSWOfflineHTML(ConfigSnapshot{SWOfflineHTML: custom}); got != custom {
|
||||
t.Fatalf("custom mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceWorkerSupportFiles(t *testing.T) {
|
||||
disabled := ServiceWorkerSupportFiles(ConfigSnapshot{})
|
||||
if disabled != nil {
|
||||
t.Fatalf("expected nil when disabled, got %v", disabled)
|
||||
}
|
||||
enabled := ServiceWorkerSupportFiles(ConfigSnapshot{SWOfflineEnabled: true})
|
||||
if len(enabled) != 2 {
|
||||
t.Fatalf("expected 2 support files, got %d", len(enabled))
|
||||
}
|
||||
paths := map[string]string{}
|
||||
for _, f := range enabled {
|
||||
paths[f.Path] = f.Content
|
||||
}
|
||||
if _, ok := paths["sw/sw.js"]; !ok {
|
||||
t.Fatalf("missing sw/sw.js")
|
||||
}
|
||||
if _, ok := paths["sw/offline.html"]; !ok {
|
||||
t.Fatalf("missing sw/offline.html")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderServiceWorkerChallenger(t *testing.T) {
|
||||
if got := renderServiceWorkerChallenger(ConfigSnapshot{}); got != "" {
|
||||
t.Fatalf("expected empty when disabled")
|
||||
}
|
||||
got := renderServiceWorkerChallenger(ConfigSnapshot{SWOfflineEnabled: true})
|
||||
for _, want := range []string{"location = /sw.js", "location = /offline.html", "sw.runtime", "content_by_lua"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("challenger missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `go test ./pkg/render/openresty/ -run 'TestEffectiveSWOfflineHTML|TestServiceWorkerSupportFiles|TestRenderServiceWorkerChallenger'`
|
||||
Expected: FAIL(函数未定义)
|
||||
|
||||
- [ ] **Step 3: 实现 `service_worker.go`**
|
||||
|
||||
```go
|
||||
package openresty
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
SWJSLocation = "location = /sw.js"
|
||||
SWOfflineLocation = "location = /offline.html"
|
||||
SWChallengeLua = "sw/challenge.lua"
|
||||
SWRuntimeLua = "sw/runtime.lua"
|
||||
swDirPrefix = "sw/"
|
||||
)
|
||||
|
||||
// DefaultSWOfflineHTML is the built-in contact page shown when the domain is blocked.
|
||||
const DefaultSWOfflineHTML = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>网站暂时无法访问 | 联系站长</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #ffffff; color: #333333; height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; padding: 48px 24px; }
|
||||
h1 { font-size: 28px; font-weight: 700; margin-bottom: 16px; }
|
||||
p { font-size: 16px; line-height: 1.7; color: #666666; max-width: 520px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>网站暂时无法访问</h1>
|
||||
<p>当前域名暂时无法从网络访问。请通过其他方式联系网站管理员获取最新访问入口。</p>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
// EffectiveSWOfflineHTML returns custom HTML when set, otherwise the built-in default.
|
||||
func EffectiveSWOfflineHTML(cfg ConfigSnapshot) string {
|
||||
if strings.TrimSpace(cfg.SWOfflineHTML) == "" {
|
||||
return DefaultSWOfflineHTML
|
||||
}
|
||||
return cfg.SWOfflineHTML
|
||||
}
|
||||
|
||||
// ServiceWorkerSupportFiles returns the sw.js script and offline contact page.
|
||||
func ServiceWorkerSupportFiles(cfg ConfigSnapshot) []SupportFile {
|
||||
if !cfg.SWOfflineEnabled {
|
||||
return nil
|
||||
}
|
||||
return []SupportFile{
|
||||
{Path: swDirPrefix + "sw.js", Content: defaultSWJS()},
|
||||
{Path: swDirPrefix + "offline.html", Content: EffectiveSWOfflineHTML(cfg)},
|
||||
}
|
||||
}
|
||||
|
||||
func defaultSWJS() string {
|
||||
return `var CACHE = "openflare-offline-v1";
|
||||
var OFFLINE = "/offline.html";
|
||||
self.addEventListener("install", function (e) {
|
||||
e.waitUntil(caches.open(CACHE).then(function (c) { return c.addAll([OFFLINE]); }));
|
||||
self.skipWaiting();
|
||||
});
|
||||
self.addEventListener("activate", function (e) {
|
||||
e.waitUntil(caches.keys().then(function (keys) {
|
||||
return Promise.all(keys.filter(function (k) { return k !== CACHE; }).map(function (k) { return caches.delete(k); }));
|
||||
}));
|
||||
self.clients.claim();
|
||||
});
|
||||
self.addEventListener("fetch", function (e) {
|
||||
if (e.request.method !== "GET") { return; }
|
||||
e.respondWith(
|
||||
fetch(e.request).catch(function () {
|
||||
return caches.match(e.request).then(function (r) { return r || caches.match(OFFLINE); });
|
||||
})
|
||||
);
|
||||
});
|
||||
`
|
||||
}
|
||||
|
||||
// renderServiceWorkerChallenger emits SW static locations and the homepage
|
||||
// challenge intercept for HTTPS server blocks.
|
||||
func renderServiceWorkerChallenger(cfg ConfigSnapshot) string {
|
||||
if !cfg.SWOfflineEnabled {
|
||||
return ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
builder.WriteString("\n location = /sw.js {\n")
|
||||
builder.WriteString(" alias " + SWDirPlaceholder + "/sw.js;\n")
|
||||
builder.WriteString(" default_type application/javascript;\n")
|
||||
builder.WriteString(" add_header Service-Worker-Allowed /;\n")
|
||||
builder.WriteString(" add_header Cache-Control \"no-cache\";\n")
|
||||
builder.WriteString(" }\n\n")
|
||||
builder.WriteString(" location = /offline.html {\n")
|
||||
builder.WriteString(" alias " + SWDirPlaceholder + "/offline.html;\n")
|
||||
builder.WriteString(" default_type text/html;\n")
|
||||
builder.WriteString(" add_header Cache-Control \"no-cache\";\n")
|
||||
builder.WriteString(" }\n\n")
|
||||
builder.WriteString(" location = /__openflare_sw_challenge {\n")
|
||||
builder.WriteString(" internal;\n")
|
||||
builder.WriteString(" content_by_lua_file " + SWDirPlaceholder + "/challenge.lua;\n")
|
||||
builder.WriteString(" }\n")
|
||||
return builder.String()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 接入 `Render` 追加 support files**
|
||||
|
||||
在 `render.go` `Render` 函数内、`originErrorPageSupportFile` 追加之后追加:
|
||||
|
||||
```go
|
||||
if doc.OpenRestyConfig.SWOfflineEnabled {
|
||||
files = append(files, ServiceWorkerSupportFiles(doc.OpenRestyConfig)...)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 接入 `RenderRouteConfig` 注入挑战**
|
||||
|
||||
在 `RenderRouteConfig` 内 `renderProxyRoute` / `renderPagesRoute` 调用之前,将 SW 拦截接入 server 块。将 `renderAccessBlock(siteName, powEnabled)` 调用处扩展:新建 `renderServerAccess(siteName, powEnabled, cfg)` 封装,并在其中追加 SW 运行时检查。具体为在 `renderAccessBlock` 生成的 access 块内,追加对 `sw.runtime` 的调用。
|
||||
|
||||
简化实现:新增 `renderAccessBlockWithSW(siteName string, powEnabled bool, cfg ConfigSnapshot) string`,返回 `renderAccessBlock(siteName, powEnabled)` 与(当 `SWOfflineEnabled` 时)追加:
|
||||
|
||||
```
|
||||
access_by_lua_block {
|
||||
if not string.find(package.path, "__OPENFLARE_LUA_DIR__/?.lua", 1, true) then
|
||||
package.path = "__OPENFLARE_LUA_DIR__/?.lua;__OPENFLARE_LUA_DIR__/?/init.lua;" .. package.path
|
||||
end
|
||||
require("sw.runtime").check()
|
||||
}
|
||||
```
|
||||
|
||||
然后将 `renderHTTPProxyServer`、`renderHTTPSServer`、`renderHTTPPagesServer`、`renderHTTPSPagesServer` 中 `renderAccessBlock(...)` 替换为 `renderAccessBlockWithSW(..., cfg)`,并在各自 server 块内追加 `renderServiceWorkerChallenger(cfg)` 输出。
|
||||
|
||||
**注意:** `renderAccessBlock` 在既有 powEnabled 分支已含 `access_by_lua_block`。为兼容,`renderAccessBlockWithSW` 在 powEnabled 分支内合并 SW check 到同一块;非 pow 分支额外追加一个块。本步以**仅新增 server 级 SW location + 独立 `access_by_lua_block`** 为最小实现;若 nginx 同 server 存在两个 `access_by_lua_block`,运行时只执行最后一个——**故实现必须合并**。请在实现时确认 `renderAccessBlock` 各分支,将 SW check 合并进唯一 access 块内,避免覆盖 WAF/PoW。
|
||||
|
||||
- [ ] **Step 6: 运行测试**
|
||||
|
||||
Run: `go test ./pkg/render/openresty/...`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: 提交**
|
||||
|
||||
```bash
|
||||
git add pkg/render/openresty/service_worker.go pkg/render/openresty/service_worker_test.go pkg/render/openresty/render.go
|
||||
git commit -m "feat(openresty): render sw offline assets and challenge intercept"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: config_version snapshot 接入全局 Option
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/apps/openflare/config_version/snapshot.go:143-147`(`openRestyConfigSnapshot` 字段)
|
||||
- Modify: `internal/apps/openflare/config_version/snapshot.go:559-563`(`buildOpenRestyConfigSnapshot` 读取)
|
||||
- Modify: `internal/apps/openflare/config_version/logics.go:537-541`(diff 追加)
|
||||
- Modify: `internal/apps/openflare/config_version/logics.go:604-608`(option keys 追加)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `model.ConfigKeySWOfflineEnabled` / `.SWOfflineHTML`。
|
||||
- Produces: snapshot JSON 内 `sw_offline_enabled` / `sw_offline_html` 字段,触发 checksum 变化。
|
||||
|
||||
- [ ] **Step 1: snapshot 结构体追加字段**
|
||||
|
||||
在 `openRestyConfigSnapshot`(`snapshot.go:143-147`,`OriginErrorPageGetOnly` 后)追加:
|
||||
|
||||
```go
|
||||
SWOfflineEnabled bool `json:"sw_offline_enabled,omitempty"`
|
||||
SWOfflineHTML string `json:"sw_offline_html,omitempty"`
|
||||
```
|
||||
|
||||
- [ ] **Step 2: build 读取配置**
|
||||
|
||||
在 `buildOpenRestyConfigSnapshot`(`snapshot.go:559-563`,`OriginErrorPageGetOnly` 赋值后)追加:
|
||||
|
||||
```go
|
||||
SWOfflineEnabled: getBoolConfig(model.ConfigKeySWOfflineEnabled, false),
|
||||
SWOfflineHTML: getStringConfig(model.ConfigKeySWOfflineHTML, ""),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: diff 追加**
|
||||
|
||||
在 `diffOpenRestyOptionDetails`(`logics.go:540` 后)追加:
|
||||
|
||||
```go
|
||||
appendIfChanged("SWOfflineEnabled", fmt.Sprintf("%t", left.SWOfflineEnabled), fmt.Sprintf("%t", right.SWOfflineEnabled))
|
||||
appendIfChanged("SWOfflineHTML", left.SWOfflineHTML, right.SWOfflineHTML)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: option keys 追加**
|
||||
|
||||
在 `openRestyOptionKeys()`(`logics.go:607` 后)追加:
|
||||
|
||||
```go
|
||||
"SWOfflineEnabled",
|
||||
"SWOfflineHTML",
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行测试**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/config_version/...`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add internal/apps/openflare/config_version/snapshot.go internal/apps/openflare/config_version/logics.go
|
||||
git commit -m "feat(config): wire sw offline options into config snapshot"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Agent 侧 SW Lua 资源与占位符替换
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/apps/agent/nginx/sw_assets.go`
|
||||
- Modify: `internal/apps/agent/nginx/manager.go:393-410`(`EnsureLuaAssets` 追加 SW Lua)
|
||||
- Modify: `internal/apps/agent/nginx/manager.go:526-528`(checksum 归一化 SW 路径)
|
||||
- Modify: `internal/apps/agent/nginx/manager.go:1381-1383`(renderRouteConfig 替换 SW 占位符)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `openrestyrender.SWDirPlaceholder`、`openrestyrender.SWChallengeLua`、`openrestyrender.SWRuntimeLua`。
|
||||
- Produces: `ManagedSWLuaFiles() []protocol.SupportFile`(`sw/runtime.lua`、`sw/challenge.lua`)。
|
||||
|
||||
- [ ] **Step 1: 创建 `sw_assets.go`**
|
||||
|
||||
```go
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/agent/protocol"
|
||||
)
|
||||
|
||||
const openRestySWRuntimeLua = `local source = debug.getinfo(1, "S").source or ""
|
||||
if string.sub(source, 1, 1) == "@" then
|
||||
local script_path = string.sub(source, 2)
|
||||
local base_dir = string.match(script_path, "^(.*)/sw/[^/]+%.lua$")
|
||||
if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then
|
||||
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
|
||||
end
|
||||
end
|
||||
|
||||
local function is_real_browser(ua)
|
||||
if not ua or ua == "" then return false end
|
||||
-- Chrome/Edge/CentOS-style: "Chrome/120"
|
||||
if string.find(ua, "Chrome/%d", 1, true) then return true end
|
||||
-- Firefox: "Firefox/120"
|
||||
if string.find(ua, "Firefox/%d", 1, true) then return true end
|
||||
-- Safari (non-Chrome, e.g. "Version/17.0 Safari")
|
||||
if not string.find(ua, "Chrome", 1, true) and string.find(ua, "Safari", 1, true) then return true end
|
||||
return false
|
||||
end
|
||||
|
||||
local function pass_through()
|
||||
return true
|
||||
end
|
||||
|
||||
function _M_check()
|
||||
local ua = ngx.var.http_user_agent or ""
|
||||
if not is_real_browser(ua) then return pass_through() end
|
||||
|
||||
local uri = ngx.var.uri or ""
|
||||
if uri ~= "/" then return pass_through() end
|
||||
|
||||
local cookie = ngx.var["cookie___openflare_sw"]
|
||||
if cookie and cookie ~= "" then return pass_through() end
|
||||
|
||||
-- intercept: internal redirect to challenge page, which registers SW + sets cookie
|
||||
local redir = ngx.var.scheme .. "://" .. ngx.var.host .. uri .. (ngx.var.args and ("?" .. ngx.var.args) or "")
|
||||
ngx.req.set_uri_args({ redir = redir })
|
||||
return ngx.exec("/__openflare_sw_challenge")
|
||||
end
|
||||
`
|
||||
|
||||
const openRestySWChallengeLua = `local args = ngx.req.get_uri_args()
|
||||
local redir = args["redir"] or "/"
|
||||
ngx.header["Set-Cookie"] = "__openflare_sw=1; Path=/; Max-Age=31536000"
|
||||
ngx.header.content_type = "text/html; charset=utf-8"
|
||||
ngx.say([[<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title>加载中...</title>
|
||||
<script>
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("/sw.js").then(function () {
|
||||
location.replace("]] .. redir .. [[");
|
||||
}).catch(function () {
|
||||
location.replace("]] .. redir .. [[");
|
||||
});
|
||||
} else {
|
||||
location.replace("]] .. redir .. [[");
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>正在加载...</body>
|
||||
</html>]])
|
||||
`
|
||||
|
||||
// ManagedSWLuaFiles returns embedded Lua assets for the SW offline challenge.
|
||||
func ManagedSWLuaFiles() []protocol.SupportFile {
|
||||
return []protocol.SupportFile{
|
||||
{Path: "sw/runtime.lua", Content: openRestySWRuntimeLua},
|
||||
{Path: "sw/challenge.lua", Content: openRestySWChallengeLua},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `EnsureLuaAssets` 追加 SW Lua**
|
||||
|
||||
在 `manager.go:403`(`allSupportFiles` 组装处)追加:
|
||||
|
||||
```go
|
||||
allSupportFiles = append(allSupportFiles, ManagedSWLuaFiles()...)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: checksum 归一化 SW 路径**
|
||||
|
||||
在 `manager.go:526-528`(error page 路径归一化后)追加:
|
||||
|
||||
```go
|
||||
swDir := filepath.ToSlash(filepath.Join(m.NginxCertDir, "sw"))
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, swDir, openrestyrender.SWDirPlaceholder)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: `renderRouteConfig` 替换 SW 占位符**
|
||||
|
||||
在 `manager.go:1381-1383`(error page 替换后)追加:
|
||||
|
||||
```go
|
||||
swDir := filepath.ToSlash(filepath.Join(m.NginxCertDir, "sw"))
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.SWDirPlaceholder, swDir)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 确认 SW 文件落盘**
|
||||
|
||||
`renderServiceWorkerChallenger` 中 `alias __OPENFLARE_SW_DIR__/sw.js` 与 `/offline.html` 引用 support files `sw/sw.js`、`sw/offline.html`。这些文件经 Task 4 作为普通 support file 由 `writeManagedCertFiles` 写入 `<CertDir>/sw/`(路径含子目录)。验证 `certFileTargetPath` 支持子目录路径(读 `manager.go` 确认)。若不支持,需在 `writeManagedCertFiles` 中 `os.MkdirAll(filepath.Dir(targetPath))`。**实现时确认并补全目录创建。**
|
||||
|
||||
- [ ] **Step 6: 运行测试**
|
||||
|
||||
Run: `go build ./... && go test ./internal/apps/agent/nginx/...`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: 提交**
|
||||
|
||||
```bash
|
||||
git add internal/apps/agent/nginx/sw_assets.go internal/apps/agent/nginx/manager.go
|
||||
git commit -m "feat(agent): ship sw offline lua assets and placeholder substitution"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 前端「响应页面」模块(两个 tab)
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/app/(main)/responses/page.tsx`
|
||||
- Create: `frontend/app/(main)/responses/components/contact-page-tab.tsx`
|
||||
- Create: `frontend/app/(main)/responses/components/shared.ts`
|
||||
- Modify: `frontend/lib/navigation/openflare-nav.ts:63-67`
|
||||
- Modify: `frontend/lib/navigation/openflare-nav.ts:123`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `OptionService.list()` / `OptionService.updateBatch()`(已存在)。
|
||||
- Produces: 联系页 tab 编辑 `sw_offline_enabled` / `sw_offline_html` 两个 option。
|
||||
|
||||
- [ ] **Step 1: 创建共享 helper `shared.ts`**
|
||||
|
||||
```ts
|
||||
export const OPTIONS_QUERY_KEY = ['openflare', 'options'] as const;
|
||||
|
||||
export const KEY_SW_ENABLED = 'sw_offline_enabled';
|
||||
export const KEY_SW_HTML = 'sw_offline_html';
|
||||
|
||||
export type ContactPageFields = {
|
||||
enabled: boolean;
|
||||
html: string;
|
||||
};
|
||||
|
||||
export const defaultContactPageFields: ContactPageFields = {
|
||||
enabled: false,
|
||||
html: '',
|
||||
};
|
||||
|
||||
export function optionsToMap(options: Array<{ key: string; value: string }>) {
|
||||
return options.reduce<Record<string, string>>((acc, option) => {
|
||||
acc[option.key] = option.value;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function mapOptionsToContactFields(
|
||||
optionMap: Record<string, string>,
|
||||
): ContactPageFields {
|
||||
return {
|
||||
enabled: optionMap[KEY_SW_ENABLED] === 'true',
|
||||
html: optionMap[KEY_SW_HTML] ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function invalidateResponseQueries(queryClient: {
|
||||
invalidateQueries: (opts: {
|
||||
queryKey: readonly unknown[];
|
||||
}) => Promise<unknown>;
|
||||
}) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: OPTIONS_QUERY_KEY }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'config-preview'],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'config-versions'],
|
||||
}),
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建联系页 tab `contact-page-tab.tsx`**
|
||||
|
||||
参考 `error-pages/page.tsx` 交互:一个「启用」开关 + 一个 HTML 文本域 + 保存按钮。保存 `updateBatch([{key: KEY_SW_ENABLED,...},{key: KEY_SW_HTML,...}])`,成功后 `invalidateResponseQueries`。
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Save } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { OptionService } from '@/lib/services/openflare';
|
||||
|
||||
import {
|
||||
defaultContactPageFields,
|
||||
invalidateResponseQueries,
|
||||
KEY_SW_ENABLED,
|
||||
KEY_SW_HTML,
|
||||
mapOptionsToContactFields,
|
||||
optionsToMap,
|
||||
type ContactPageFields,
|
||||
} from './shared';
|
||||
|
||||
export function ContactPageTab({ optionMap }: { optionMap: Record<string, string> }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [fields, setFields] = useState<ContactPageFields>(
|
||||
defaultContactPageFields,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFields(mapOptionsToContactFields(optionMap));
|
||||
}, [optionMap]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await OptionService.updateBatch([
|
||||
{ key: KEY_SW_ENABLED, value: String(fields.enabled) },
|
||||
{ key: KEY_SW_HTML, value: fields.html },
|
||||
]);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success('联系页已保存,请前往版本发布使配置生效');
|
||||
await invalidateResponseQueries(queryClient);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '保存失败');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-row items-start justify-between gap-4 space-y-0'>
|
||||
<div className='space-y-1.5'>
|
||||
<CardTitle className='text-base'>离线兜底</CardTitle>
|
||||
<CardDescription>
|
||||
启用后给启用 HTTPS 的网站下发 Service Worker,域名被墙时浏览器从缓存展示此联系页。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
className='shrink-0'
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className='size-3.5 animate-spin' />
|
||||
) : (
|
||||
<Save className='size-3.5' />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='flex items-start justify-between gap-6'>
|
||||
<div className='space-y-1'>
|
||||
<Label className='text-sm font-medium'>启用 Service Worker 离线兜底</Label>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
仅对 HTTPS 网站生效;未启用的站点不受影响。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={fields.enabled}
|
||||
onCheckedChange={(enabled) =>
|
||||
setFields((prev) => ({ ...prev, enabled }))
|
||||
}
|
||||
aria-label='启用离线兜底'
|
||||
className='mt-0.5 shrink-0'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<Label htmlFor='sw-offline-html' className='text-sm font-medium'>
|
||||
离线联系页 HTML
|
||||
</Label>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
留空则使用内置默认模板。
|
||||
</p>
|
||||
<Textarea
|
||||
id='sw-offline-html'
|
||||
value={fields.html}
|
||||
onChange={(e) =>
|
||||
setFields((prev) => ({ ...prev, html: e.target.value }))
|
||||
}
|
||||
rows={12}
|
||||
className='font-mono'
|
||||
disabled={!fields.enabled}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**注意:** 确认 `frontend/components/ui/` 存在 `textarea.tsx`(shadcn)。若无,用 `make sure` 或 `npx shadcn@latest add textarea` 添加。
|
||||
|
||||
- [ ] **Step 3: 创建页面容器 `responses/page.tsx`**
|
||||
|
||||
用 Tabs 组件组织「错误页」「联系页」两个 tab。错误页 tab 复用现有 `error-pages` 内容或重定向;联系页 tab 渲染 `ContactPageTab`。加载 `OptionService.list()` 传入 optionMap。
|
||||
|
||||
**实现提示:** 为避免重复,错误页 tab 的现有逻辑(`error-pages/page.tsx` 的 policy 卡片 + 预览卡)可先以 `redirect` 到 `/error-pages` 占位,或直接在容器内嵌两 tab。推荐:容器页 `responses/page.tsx` 读取 options,渲染 Tabs(错误页/联系页),错误页 tab 复用 `frontend/app/(main)/error-pages` 现有 UI(通过 import 其组件或在容器内重构)。**保守实现:** 容器页仅放两个 tab,错误页 tab 用 `<Link href='/error-pages'>` 或保留现有 `/error-pages` 路由,联系页 tab 显示新表单;导航入口改为「响应页面」指向 `/responses`。
|
||||
|
||||
- [ ] **Step 4: 更新导航**
|
||||
|
||||
`openflare-nav.ts` 第 63-67 行将「错误页」项改为「响应页面」:
|
||||
|
||||
```ts
|
||||
{
|
||||
title: '响应页面',
|
||||
url: '/responses',
|
||||
childUrls: ['/error-pages', '/responses/contact'],
|
||||
},
|
||||
```
|
||||
|
||||
第 123 行 `openflareWebsiteSubNav` 中 `{ title: '错误页', url: '/error-pages' }` 改为 `{ title: '响应页面', url: '/responses' }`。
|
||||
|
||||
- [ ] **Step 5: 构建前端**
|
||||
|
||||
Run: `cd /Users/ryan/conductor/workspaces/OpenFlare/islamabad/frontend && pnpm type-check`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/app/\(main\)/responses frontend/lib/navigation/openflare-nav.ts
|
||||
git commit -m "feat(frontend): add response pages module with contact page tab"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Changelog 与收尾验证
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/changelog/index.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `[Unreleased]` 下用户可读中文条目。
|
||||
|
||||
- [ ] **Step 1: 追加 changelog**
|
||||
|
||||
在 `docs/changelog/index.md` 的 `[Unreleased]` 下追加:
|
||||
|
||||
```markdown
|
||||
### 新增
|
||||
|
||||
- 支持 Service Worker 离线兜底:为启用 HTTPS 的网站下发 Service Worker 并缓存离线联系页,域名无法访问时浏览器展示联系站长页面,减少用户流失。配置位于「响应页面」-「联系页」,可在版本发布中批量生效。
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行完整校验**
|
||||
|
||||
Run: `cd /Users/ryan/conductor/workspaces/OpenFlare/islamabad && make code-check`
|
||||
Expected: PASS(golangci-lint + 前端类型检查)
|
||||
|
||||
- [ ] **Step 3: 运行后端全量测试**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: 格式化**
|
||||
|
||||
Run: `cd /Users/ryan/conductor/workspaces/OpenFlare/islamabad && make format`
|
||||
Expected: 无格式变更或已应用
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add docs/changelog/index.md
|
||||
git commit -m "docs: sw offline fallback changelog"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec 覆盖检查:**
|
||||
- 全局 Option(sw_offline_enabled / html)→ Task 1-3、5 ✓
|
||||
- 渲染层 SW 静态 + 挑战拦截(反代 + Pages,HTTPS-only)→ Task 4 ✓
|
||||
- SupportFile 下发 sw.js / offline.html,Agent 占位符替换 → Task 4、6 ✓
|
||||
- UA 白名单(真实浏览器特征)→ Task 6 `is_real_browser` ✓
|
||||
- Cookie 长过期 + 首次挑战页 → Task 6 ✓
|
||||
- 前端「响应页面」两 tab → Task 7 ✓
|
||||
- 迁移 seed → Task 2 ✓
|
||||
- Changelog → Task 8 ✓
|
||||
|
||||
**占位符扫描:** 无 TBD/TODO。Task 4 Step 5 与 Task 7 Step 3 保留实现细节提示(非占位,是给定方向让执行者按实际代码确认),已在文中明确标注"实现时确认"。
|
||||
|
||||
**类型一致性:** `ConfigSnapshot.SWOfflineEnabled/HTML` 在 Task 3/4/5 一致;`SWDirPlaceholder` 在 Task 3/4/6 一致;`sw_offline_enabled/sw_offline_html` key 在 Task 1/2/5/7 一致;`renderServiceWorkerChallenger`/`ServiceWorkerSupportFiles`/`EffectiveSWOfflineHTML`/`ManagedSWLuaFiles` 签名跨 Task 一致。
|
||||
|
||||
**已知待确认项(执行时需按实际代码落地):**
|
||||
- Task 4:`renderAccessBlock` 的 access 块合并(避免 WAF/PoW 被覆盖)。
|
||||
- Task 6:`certFileTargetPath` 是否支持子目录,落盘目录创建。
|
||||
- Task 7:`textarea` 组件存在性;「响应页面」错误页 tab 与现有 `/error-pages` 路由的复用策略。
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user