Files
OpenFlare/docs/superpowers/plans/2026-08-06-origin-error-page.md
T
ryan 1f5bebd18a docs(plan): add origin error page implementation plan
拆分为状态码解析、OpenResty 渲染、Option/快照、前端设置页与文档验收五步任务。
2026-08-06 13:44:45 +08:00

468 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 源站错误页 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` 模式,不另开悬空任务。