mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-01 06:36:38 +08:00
ca21ff3a5b
fix(openresty): scope sw injection per cert partition fix(lint): satisfy revive and biome format for sw offline feature docs: sw offline scope changelog fix(frontend): use scoped query key for sw scope zones fix(frontend): hide preview link in sw contact page editor feat(frontend): add sw scope domain picker and contact page fields refactor(frontend): generalize html editor workspace for reuse feat(openresty): scope sw offline injection by route domains feat(openresty): add sw offline domains snapshot field feat(option): add sw offline domains scope option docs: fill html editor workspace generalization detail docs: sw offline scope implementation plan docs: sw offline scope design test(openresty): assert single merged access block in sw enabled servers fix(openresty): restrict sw intercept to https server blocks fix(openresty): version sw offline cache by html content fix(agent): escape redir in sw challenge page to prevent xss fix(agent): return sw.runtime module table and add lua spec docs: sw offline fallback changelog fix(frontend): memoize option map to preserve unsaved contact page edits feat(frontend): add response pages module with contact page tab feat(agent): ship sw offline lua assets and placeholder substitution feat(config): wire sw offline options into config snapshot feat(openresty): render sw offline assets and challenge intercept feat(openresty): add sw offline ConfigSnapshot fields and placeholder feat(db): seed sw offline options feat(option): add sw offline config keys and validation docs: add service worker offline fallback implementation plan docs: adopt global-option pattern for SW offline fallback (matches origin error page) docs: unify offline contact page with error pages as response pages docs: service worker offline fallback design (issue #23)
886 lines
32 KiB
Markdown
886 lines
32 KiB
Markdown
# 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` 路由的复用策略。
|