mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
feat(option): add sw offline
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)
This commit is contained in:
@@ -20,6 +20,12 @@ sidebar: false
|
||||
> - Pages 无法迁移, 升级前请先手动下载并备份 Pages 静态站点的 ZIP 包,升级后重新创建。
|
||||
> - 性能调优参数重置, 升级后请重新配置
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 新增
|
||||
|
||||
- 支持 Service Worker 离线兜底:为启用 HTTPS 的网站下发 Service Worker 并缓存离线联系页,域名无法访问时浏览器展示联系站长页面,减少用户流失。可指定生效域名范围(仅对选中的 HTTPS 域名生效),配置位于「响应页面」-「联系页」,可在版本发布中批量生效。
|
||||
|
||||
## [v3.4.5] - 2026-08-08
|
||||
|
||||
### 改进
|
||||
|
||||
@@ -0,0 +1,885 @@
|
||||
# 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
@@ -0,0 +1,117 @@
|
||||
# Service Worker 离线兜底设计(issue #23)
|
||||
|
||||
- 日期:2026-08-08
|
||||
- 状态:设计已确认
|
||||
- 范围:Proxy Route(反代)+ Pages 静态托管 全覆盖
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当 CDN 域名被墙、浏览器对所有网络请求失败时,用户会直接流失。本功能通过给网站下发 Service Worker,缓存一个"联系站长"离线页;域名被墙后,SW 从缓存吐出该页,保留用户并引导联系站长。
|
||||
|
||||
核心约束:
|
||||
|
||||
- 平台一键批量下发,避免逐个 Agent 配置。
|
||||
- 不改源页代码,全部在 OpenResty 边缘层完成。
|
||||
- 覆盖反代(Proxy Route)与 Pages 静态托管两种网站类型。
|
||||
|
||||
## 2. 机制总览
|
||||
|
||||
采用「首次挑战页 + Cookie 放行 + UA 白名单」模式,替代 `sub_filter` 响应体重写。
|
||||
|
||||
| 环节 | 行为 |
|
||||
|---|---|
|
||||
| 真实浏览器 UA(含特征版本,如 `Chrome/120`)首次访问首页 | 返回 SW 挑战页(内嵌 `register('/sw.js')` 与离线页预缓存),设置长过期 Cookie |
|
||||
| 带 Cookie 的请求 | 直接放行到上游,正常返回真实页面 |
|
||||
| 未知 UA(爬虫、curl,无真实浏览器特征) | 直接放过,交给 WAF 处理,拿到真实内容 |
|
||||
|
||||
### 为什么不用 sub_filter
|
||||
|
||||
`sub_filter` 需处理上游 gzip / Content-Type / 大响应扫描 / 流式缓冲等多处坑。本方案不改上游 body,整体替换首次响应,以上问题全部规避;且爬虫(不匹配真实浏览器 UA)天然绕过挑战页,不伤 SEO。
|
||||
|
||||
## 3. 分层职责
|
||||
|
||||
```
|
||||
apps/proxy_route ─┐
|
||||
apps/pages ─┼─ model → repository → 渲染(pkg/render/openresty) → Agent(OpenResty)
|
||||
前端设置卡 ─┘ ↑ SW 挑战页 + sw.js/offline 落盘
|
||||
```
|
||||
|
||||
### 后端数据(全局 Option,与 origin error page 同模式)
|
||||
|
||||
`sw_offline` 相关配置作为**全局 SystemConfig / OpenRestyConfig snapshot 字段**,对所有启用 HTTPS 的路由生效,实现"一键批量下发"。新增字段:
|
||||
|
||||
- `sw_offline_enabled`:是否启用 SW 离线兜底
|
||||
- `sw_offline_html`:联系站长离线页 HTML 内容(默认提供内置模板)
|
||||
|
||||
### 渲染层(`pkg/render/openresty`)
|
||||
|
||||
新增 `renderServiceWorkerChallenger(cfg ConfigSnapshot)` 工具,为真实提供内容的 HTTPS server 块(`sw_offline_enabled` 且 `EnableHTTPS` 时)输出:
|
||||
|
||||
```nginx
|
||||
# SW 脚本 + 离线页(作为 support file 落盘)
|
||||
location = /sw.js { alias .../sw.js; add_header Service-Worker-Allowed /; }
|
||||
location = /offline.html { alias .../offline.html; }
|
||||
|
||||
# 仅首页拦截:真实浏览器 UA 且无 cookie → 返回 SW 挑战页
|
||||
# 否则(带 cookie / 未知 UA)→ 放行到上游
|
||||
location = / {
|
||||
if (真实浏览器UA && 无cookie) { content_by_lua 返回 SW 挑战页; }
|
||||
放行到上游;
|
||||
}
|
||||
```
|
||||
|
||||
- SW 逻辑:`install` 阶段缓存 `/offline.html`;`fetch` 事件在网络失败时返回 `caches.match('/offline.html')`。
|
||||
- 仅在 `EnableHTTPS` 时注入(SW 要求 HTTPS 安全上下文)。
|
||||
- 多域名 server 块:`/sw.js`、`/offline.html`、挑战页在各 `server_name` 下同源可达。
|
||||
- 仅对首页 `location = /` 触发;js/css/图片/API/子页面请求不拦,零额外开销。
|
||||
|
||||
## 4. 数据流
|
||||
|
||||
```
|
||||
用户首次访问首页(真实UA, 无cookie)
|
||||
→ OpenResty 判断:真实UA && 无cookie
|
||||
→ 返回 SW 挑战页 (内嵌 register + 预缓存 offline.html)
|
||||
→ 浏览器执行 → 注册 SW → 设置长过期 cookie
|
||||
→ 用户再次请求(带cookie)
|
||||
→ 放行到上游,正常返回真实页面
|
||||
域名被墙后
|
||||
→ 所有请求失败 → SW fetch 兜底 → 从缓存返回 /offline.html(联系页)
|
||||
```
|
||||
|
||||
## 5. 边界与风险
|
||||
|
||||
| 项 | 处理 |
|
||||
|---|---|
|
||||
| 首次即被墙的用户 | SW 未注册,兜底无效(所有 SW 方案共性,接受) |
|
||||
| HTTP-only 站点 | 跳过注入(SW 需 HTTPS) |
|
||||
| 反代多域名 | 各域名同源提供 sw.js / offline.html / 挑战页 |
|
||||
| Cookie 过期 | 设长过期(约 1 年),过期后重新走一次挑战页 |
|
||||
| 未知 UA | 放过并交给 WAF 处理,不重复拦截 |
|
||||
| 资源/API 请求 | 不拦,仅首页触发 |
|
||||
|
||||
## 6. 测试
|
||||
|
||||
- 渲染层单元测试:
|
||||
- `sw_offline_enabled` 时输出 sw.js / offline.html / 挑战页 location
|
||||
- 非 HTTPS 或未启用时不输出
|
||||
- 仅首页触发,子路径/资源不触发
|
||||
- UA 判定:真实浏览器 / 爬虫 / curl 三种 UA 的放行分支。
|
||||
- Cookie 有无的放行分支。
|
||||
- 现有 config snapshot checksum / rebind 测试不回归。
|
||||
|
||||
## 7. 前端命名与入口
|
||||
|
||||
离线联系页设置与现有 origin error page 设置合并为同一个功能模块,命名为**「响应页面」**(路由 `responses`),内含两个 tab:
|
||||
|
||||
- **错误页设置**:源站错误兜底页(现有 origin error page)
|
||||
- **联系页设置**:SW 离线兜底联系页(本功能)
|
||||
|
||||
两者同属「边缘层兜底展示页」语义,统一管理与入口。
|
||||
|
||||
## 8. 待实现确认项(写 plan 时细化)
|
||||
|
||||
- SW 挑战页与 sw.js 的具体 Lua 实现与落盘路径(对齐现有 support file 机制)。
|
||||
- `sw_offline_html` 默认内置模板样式(参考 origin error page 内置模板)。
|
||||
- 「响应页面」前端模块下错误页/联系页两个 tab 的具体位置与交互。
|
||||
- UA 白名单默认真实浏览器特征集合(Chrome / Firefox / Safari / Edge + 版本号正则)。
|
||||
- SW 落盘路径:sw.js / offline.html 通过 SupportFile 下发,Agent 替换占位符(类似 ErrorPageTmplPlaceholder 机制)。
|
||||
@@ -0,0 +1,191 @@
|
||||
# SW 离线兜底生效范围(域名作用域)设计
|
||||
|
||||
- 日期:2026-08-08
|
||||
- 状态:设计已确认
|
||||
- 前置:issue #23 Service Worker 离线兜底(`docs/superpowers/specs/2026-08-08-service-worker-offline-design.md`)
|
||||
- 范围:SW 注入从「全局所有 HTTPS 站点」细化为「总开关 + 域名作用域」
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
issue #23 实现后,`sw_offline_enabled` 为全局布尔开关:开启后对所有启用 HTTPS 的路由注入 Service Worker 离线兜底。本需求将其细化为可选的**生效域名范围**:
|
||||
|
||||
- 保留总开关(`sw_offline_enabled`)。
|
||||
- 新增作用域:管理员选择需要生效的域名,仅作用域内域名注入 SW。
|
||||
- 域名选择交互参考 `/cloudflare/groups/1` 的「添加域名成员」弹窗(搜索筛选、按 Zone 分组、批量勾选),但**与 Cloudflare 完全解耦**——仅复用交互模式,数据源为平台自身 zones/zone_domains,不涉及 A 记录同步。
|
||||
|
||||
核心约束:
|
||||
|
||||
- 语义为「总开关 && 域名 ∈ 作用域」交集:总开关关 → 全部不注入;总开关开 + 作用域空 → 不注入;总开关开 + 域名命中 → 注入。
|
||||
- 与 Cloudflare 指向分组(A 记录)无任何关联。
|
||||
- 联系页 HTML(`sw_offline_html`)仍为全局单份,不分域名定制。
|
||||
|
||||
## 2. 机制总览
|
||||
|
||||
```
|
||||
sw_offline_enabled (bool, 已有) 总开关
|
||||
sw_offline_html (string, 已有) 联系页 HTML(全局一份)
|
||||
sw_offline_domains (JSON 字符串数组, 新增) 生效域名作用域
|
||||
|
||||
渲染: routeSWEnabled(routeDomains, cfg)
|
||||
= SWOfflineEnabled && routeDomains ∩ SWOfflineDomains ≠ ∅
|
||||
命中 → HTTPS server 块注入 access 检查 + SW location
|
||||
未命中 → 与 feature 前字节一致
|
||||
```
|
||||
|
||||
Support files(`sw/sw.js`、`sw/offline.html`)仅在「总开关开 && 作用域非空」时下发,避免空作用域产生无用资源。
|
||||
|
||||
## 3. 数据层
|
||||
|
||||
### 3.1 配置 key
|
||||
|
||||
`model.ConfigKeySWOfflineDomains = "sw_offline_domains"`(business 类型,visibility 0),值存 JSON 域名字符串数组:
|
||||
|
||||
```json
|
||||
["example.com", "api.example.com"]
|
||||
```
|
||||
|
||||
### 3.2 goose 迁移(postgres + sqlite 各一份)
|
||||
|
||||
`INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) VALUES ('sw_offline_domains', '[]', 'business', 0, 'SW 离线兜底生效域名列表(JSON 数组,空则仅总开关无效)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT (key) DO NOTHING;`
|
||||
|
||||
Down 删除该 key。migrator 测试计数 92 → 93,并更新注释。
|
||||
|
||||
### 3.3 validator
|
||||
|
||||
`validateSWOfflineDomains(key, value string) error`,注册进 `openRestyOptionValidators`:
|
||||
|
||||
- JSON 解析为 `[]string`,失败报「必须为 JSON 字符串数组」
|
||||
- 元素去重(重复报错)
|
||||
- 元素非空、小写规范化校验(复用/对齐 zone `normalizeDomain` 的域名格式约束:无 `*`、无 `://` `/` `?` `#` `@`、`publicsuffix.EffectiveTLDPlusOne` 可解析)
|
||||
- 数量上限 `maxSWOfflineDomains = 1000`(防滥用)
|
||||
|
||||
### 3.4 config_version snapshot
|
||||
|
||||
- `openRestyConfigSnapshot`(`snapshot.go`)新增 `SWOfflineDomains []string json:"sw_offline_domains,omitempty"`。
|
||||
- `buildOpenRestyConfigSnapshot` 新增 `getStringSliceConfig(key string, defaultVal []string) []string`(解析 JSON 数组,失败回退默认),赋值 `SWOfflineDomains: getStringSliceConfig(model.ConfigKeySWOfflineDomains, nil)`。
|
||||
- `logics.go`:`diffOpenRestyOptionDetails` 追加 `appendIfChanged("SWOfflineDomains", ...)`;`openRestyOptionKeys()` 追加 `"SWOfflineDomains"`。
|
||||
|
||||
## 4. 渲染层(pkg/render/openresty)
|
||||
|
||||
### 4.1 ConfigSnapshot
|
||||
|
||||
`types.go` 的 `ConfigSnapshot` 新增:
|
||||
|
||||
```go
|
||||
// SWOfflineDomains restricts the offline fallback to matching HTTPS routes.
|
||||
SWOfflineDomains []string `json:"sw_offline_domains,omitempty"`
|
||||
```
|
||||
|
||||
### 4.2 作用域判断
|
||||
|
||||
```go
|
||||
// routeSWEnabled returns true when SW offline fallback applies to this route.
|
||||
func routeSWEnabled(routeDomains []string, cfg ConfigSnapshot) bool {
|
||||
if !cfg.SWOfflineEnabled || len(cfg.SWOfflineDomains) == 0 {
|
||||
return false
|
||||
}
|
||||
scope := make(map[string]struct{}, len(cfg.SWOfflineDomains))
|
||||
for _, d := range cfg.SWOfflineDomains {
|
||||
scope[d] = struct{}{}
|
||||
}
|
||||
for _, d := range routeDomains {
|
||||
if _, ok := scope[d]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
域名精确匹配(存储时已小写规范化)。
|
||||
|
||||
### 4.3 server 渲染签名扩展
|
||||
|
||||
- `RenderRouteConfig`:每 route 计算 `swEnabled := routeSWEnabled(domains, doc.OpenRestyConfig)`,传入 `renderProxyRoute` / `renderPagesRoute`(新增 `swEnabled bool` 参数)。
|
||||
- 下传链路:`renderProxyRouteHTTPS` / `renderPagesRouteHTTPS` / `renderHTTPSServer` / `renderHTTPSPagesServer` 均新增 `swEnabled bool` 参数。
|
||||
- `swEnabled=true` → `renderAccessBlockWithSW(siteName, powEnabled, cfg)` + 追加 `renderServiceWorkerChallenger(cfg)`(现行为,两函数内部不再判断 `SWOfflineEnabled`,条件已上移到 route 层)。
|
||||
- `swEnabled=false` → 纯 `renderAccessBlock`,无 challenger(与 feature 前字节一致)。
|
||||
- HTTP(80)server 块保持不注入(issue #23 已定 HTTPS-only)。
|
||||
- `renderAccessBlockWithSW` / `renderServiceWorkerChallenger` 保留 `cfg` 参数(HTML 内容来自 `cfg.SWOfflineHTML`),仅移除其内部开关判断。
|
||||
|
||||
### 4.4 Support files
|
||||
|
||||
`Render` 中生成条件从 `if doc.OpenRestyConfig.SWOfflineEnabled` 改为:
|
||||
|
||||
```go
|
||||
if doc.OpenRestyConfig.SWOfflineEnabled && len(doc.OpenRestyConfig.SWOfflineDomains) > 0 {
|
||||
files = append(files, ServiceWorkerSupportFiles(doc.OpenRestyConfig)...)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 测试
|
||||
|
||||
- `routeSWEnabled`:开关关 / 作用域空 / 无交集 / 单域名交集 / 多域名部分交集。
|
||||
- HTTPS server 渲染:命中 → 含 `require("sw.runtime").check()` + 三个 SW location;未命中 → 与旧输出字节一致。
|
||||
- `Render`:空作用域不下发 `sw/*` support files。
|
||||
- 现有 `TestRenderAccessBlockWithSWMergesSingleBlock` 等适配新签名(`cfg` 语义变化:禁用时不再由内部判断,改由上层传 `swEnabled`)。
|
||||
|
||||
## 5. 前端(frontend/app/(main)/responses)
|
||||
|
||||
### 5.1 联系页 tab 布局
|
||||
|
||||
联系页 tab 两张卡片:
|
||||
|
||||
**卡片 1:离线兜底(总开关)**
|
||||
- 标题「离线兜底」+ 描述。
|
||||
- 右上角「保存」按钮。
|
||||
- 「启用 Service Worker 离线兜底」Switch(`sw_offline_enabled`)。
|
||||
- 「生效范围」区块:当前已选域名 badge 列表(可移除)+「添加域名」按钮打开弹窗;开关关闭时整卡禁用/置灰。
|
||||
- 保存时 `updateBatch` 一次性提交三个 key:
|
||||
```ts
|
||||
{ key: KEY_SW_ENABLED, value: String(fields.enabled) },
|
||||
{ key: KEY_SW_HTML, value: fields.html },
|
||||
{ key: KEY_SW_DOMAINS, value: JSON.stringify(fields.domains) },
|
||||
```
|
||||
- 保存成功后 `invalidateResponseQueries`(toast 提示「请前往版本发布使配置生效」不变)。
|
||||
|
||||
**卡片 2:联系页 HTML**
|
||||
- 复用 `HtmlEditorWorkspace`(见 5.3),无占位符,实时预览原样 HTML。
|
||||
|
||||
### 5.2 域名选择弹窗(scope-domain-dialog.tsx)
|
||||
|
||||
- 交互复用 `member-add-dialog.tsx`:搜索框(域名/zone 模糊匹配)、按 Zone 分组折叠、组内勾选/取消、全选可见/清空、已选计数。
|
||||
- 无橙云开关、无 Cloudflare 依赖。
|
||||
- 数据源:`ZoneService.list()` + 每 zone `ZoneService.getOverview(id)` 并行拉取(`Promise.all`),zone 根域并入对应分组。**不新增后端 API**。
|
||||
- 弹窗预勾选当前已生效域名;确认后返回选中的域名字符串数组(覆盖式替换本地 fields.domains)。
|
||||
- 空态:无 zone 时提示「暂无可用域名,请先在 Zone 管理中注册」。
|
||||
|
||||
### 5.3 HtmlEditorWorkspace 复用(泛化)
|
||||
|
||||
`frontend/app/(main)/error-pages/components/html-editor-workspace.tsx` 泛化并移至 `frontend/components/common/html-editor-workspace.tsx`:
|
||||
|
||||
- Props 扩展:
|
||||
- `maxBytes?: number`(默认 `ORIGIN_ERROR_PAGE_HTML_MAX_BYTES` = 256 KiB,SW 同为 256 KiB 常量可共用)
|
||||
- `preview?: (html: string) => string`(默认 `previewOriginErrorPageHTML`;SW 传 `(html) => html` 原样预览)
|
||||
- `footerHint?: React.ReactNode`(预览 footer 提示文案,默认错误页的「`{{status}}`→502 · `{{host}}`→example.com」;SW 传 `null`)
|
||||
- 错误页 `edit/page.tsx` 改 import 路径,行为不变。
|
||||
- `frontend/components/common/` 若不存在则创建目录。
|
||||
|
||||
### 5.4 shared.ts 与表单
|
||||
|
||||
- `KEY_SW_DOMAINS = 'sw_offline_domains'`。
|
||||
- `ContactPageFields` 增加 `domains: string[]`;`defaultContactPageFields.domains = []`。
|
||||
- `mapOptionsToContactFields` 解析 `sw_offline_domains` JSON(容错:非法 JSON → `[]`)。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
- 后端:`go test ./pkg/render/openresty/... ./internal/apps/openflare/option/... ./internal/apps/openflare/config_version/... ./internal/infra/persistence/migrator/...`
|
||||
- 前端:`pnpm tsc --noEmit` + `eslint`(联系页新字段/弹窗/多 zone 并行拉取)
|
||||
- 全量:`go test ./...`、`make code-check`、`make format`
|
||||
- `make swagger`:无新 API(验证无变更即可)
|
||||
|
||||
## 7. Changelog
|
||||
|
||||
`docs/changelog/index.md` `[Unreleased]` 更新 SW 条目:新增「可指定生效域名范围(仅对选中的 HTTPS 域名生效)」。
|
||||
|
||||
## 8. 已知边界
|
||||
|
||||
- 作用域存域名字符串数组:域名从 zone/zone_domain 改名后需手动同步作用域(与 `route.Domains` 精确匹配)。
|
||||
- 联系页 HTML 全局单份,不分域名定制。
|
||||
- 空作用域 + 总开关开 → 不注入(前端置灰提示先选域名)。
|
||||
- 匹配为精确匹配,不跨子域通配(选 `example.com` 不自动覆盖 `api.example.com`,需显式加入)。
|
||||
@@ -39,8 +39,8 @@ import {
|
||||
} from '@/lib/openflare/origin-error-page-templates';
|
||||
import { OptionService } from '@/lib/services/openflare';
|
||||
|
||||
import { HtmlEditorWorkspace } from '@/components/common/html-editor-workspace';
|
||||
import { ErrorPageGate } from '../components/error-page-gate';
|
||||
import { HtmlEditorWorkspace } from '../components/html-editor-workspace';
|
||||
import {
|
||||
invalidateErrorPageQueries,
|
||||
KEY_HTML,
|
||||
|
||||
@@ -152,8 +152,7 @@ export default function ErrorPagesPage() {
|
||||
<div className='space-y-1'>
|
||||
<Label className='text-sm font-medium'>仅针对 GET 请求</Label>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
开启后仅对 GET
|
||||
请求的匹配错误状态码返回自定义错误页;POST/PUT
|
||||
开启后仅对 GET 请求的匹配错误状态码返回自定义错误页;POST/PUT
|
||||
等其它方法直接透传源站响应。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Plus, Save, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
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 { HtmlEditorWorkspace } from '@/components/common/html-editor-workspace';
|
||||
import {
|
||||
OptionService,
|
||||
ZoneService,
|
||||
zoneQueryKey,
|
||||
} from '@/lib/services/openflare';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { ScopeDomainDialog } from './scope-domain-dialog';
|
||||
import {
|
||||
defaultContactPageFields,
|
||||
invalidateResponseQueries,
|
||||
KEY_SW_DOMAINS,
|
||||
KEY_SW_ENABLED,
|
||||
KEY_SW_HTML,
|
||||
mapOptionsToContactFields,
|
||||
type ContactPageFields,
|
||||
} from './shared';
|
||||
|
||||
export function ContactPageTab({
|
||||
optionMap,
|
||||
}: {
|
||||
optionMap: Record<string, string>;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [fields, setFields] = useState<ContactPageFields>(
|
||||
defaultContactPageFields,
|
||||
);
|
||||
const [scopeOpen, setScopeOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFields(mapOptionsToContactFields(optionMap));
|
||||
}, [optionMap]);
|
||||
|
||||
const zonesQuery = useQuery({
|
||||
queryKey: [...zoneQueryKey, 'sw-scope'],
|
||||
queryFn: async () => {
|
||||
const zones = await ZoneService.list();
|
||||
const overviews = await Promise.all(
|
||||
zones.map((zone) => ZoneService.getOverview(zone.id)),
|
||||
);
|
||||
return overviews.map((ov) => ({
|
||||
zoneDomain: ov.zone.domain,
|
||||
domains: [ov.zone.domain, ...ov.domains.map((d) => d.domain)],
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await OptionService.updateBatch([
|
||||
{ key: KEY_SW_ENABLED, value: String(fields.enabled) },
|
||||
{ key: KEY_SW_HTML, value: fields.html },
|
||||
{ key: KEY_SW_DOMAINS, value: JSON.stringify(fields.domains) },
|
||||
]);
|
||||
},
|
||||
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='space-y-2'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div>
|
||||
<Label className='text-sm font-medium'>生效范围</Label>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
仅对选中的 HTTPS 域名生效;留空则不注入。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
variant='outline'
|
||||
disabled={!fields.enabled || saveMutation.isPending}
|
||||
onClick={() => setScopeOpen(true)}
|
||||
>
|
||||
<Plus className='size-3.5' />
|
||||
添加域名
|
||||
</Button>
|
||||
</div>
|
||||
{fields.domains.length === 0 ? (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
{fields.enabled
|
||||
? '尚未选择生效域名,保存后不注入任何站点。'
|
||||
: '启用离线兜底后可选择生效域名。'}
|
||||
</p>
|
||||
) : (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{fields.domains.map((domain) => (
|
||||
<Badge
|
||||
key={domain}
|
||||
variant='secondary'
|
||||
className='gap-1 font-normal'
|
||||
>
|
||||
{domain}
|
||||
<button
|
||||
type='button'
|
||||
className='hover:text-destructive'
|
||||
disabled={!fields.enabled}
|
||||
aria-label={`移除 ${domain}`}
|
||||
onClick={() =>
|
||||
setFields((prev) => ({
|
||||
...prev,
|
||||
domains: prev.domains.filter((d) => d !== domain),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<X className='size-3' />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>联系页 HTML</CardTitle>
|
||||
<CardDescription>留空则使用内置默认模板。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
className={cn(!fields.enabled && 'pointer-events-none opacity-60')}
|
||||
>
|
||||
<HtmlEditorWorkspace
|
||||
value={fields.html}
|
||||
onChange={(v) => setFields((prev) => ({ ...prev, html: v }))}
|
||||
preview={(html) => html}
|
||||
footerHint={null}
|
||||
showPreviewLink={false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ScopeDomainDialog
|
||||
open={scopeOpen}
|
||||
onOpenChange={setScopeOpen}
|
||||
zones={zonesQuery.data ?? []}
|
||||
selected={fields.domains}
|
||||
pending={saveMutation.isPending}
|
||||
onSubmit={(domains) => {
|
||||
setFields((prev) => ({ ...prev, domains }));
|
||||
setScopeOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Check, ChevronDown, Search } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ScopeZoneGroup = {
|
||||
zoneDomain: string;
|
||||
domains: string[];
|
||||
};
|
||||
|
||||
export function ScopeDomainDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
zones,
|
||||
selected,
|
||||
pending,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
zones: ScopeZoneGroup[];
|
||||
selected: string[];
|
||||
pending: boolean;
|
||||
onSubmit: (domains: string[]) => void;
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [selectedSet, setSelectedSet] = useState<Set<string>>(() => new Set());
|
||||
const [collapsedZones, setCollapsedZones] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
const selectedRef = useRef(selected);
|
||||
selectedRef.current = selected;
|
||||
const prevOpenRef = useRef(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !prevOpenRef.current) {
|
||||
setKeyword('');
|
||||
setSelectedSet(new Set(selectedRef.current));
|
||||
setCollapsedZones(new Set());
|
||||
}
|
||||
prevOpenRef.current = open;
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
if (!normalized) return zones;
|
||||
return zones
|
||||
.map((group) => ({
|
||||
zoneDomain: group.zoneDomain,
|
||||
domains: group.domains.filter(
|
||||
(d) =>
|
||||
d.toLowerCase().includes(normalized) ||
|
||||
group.zoneDomain.toLowerCase().includes(normalized),
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.domains.length > 0);
|
||||
}, [zones, keyword]);
|
||||
|
||||
const visibleDomains = useMemo(
|
||||
() => filtered.flatMap((group) => group.domains),
|
||||
[filtered],
|
||||
);
|
||||
const allVisibleSelected =
|
||||
visibleDomains.length > 0 &&
|
||||
visibleDomains.every((d) => selectedSet.has(d));
|
||||
|
||||
const toggleOne = (domain: string) => {
|
||||
setSelectedSet((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(domain)) next.delete(domain);
|
||||
else next.add(domain);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleGroup = (domains: string[]) => {
|
||||
setSelectedSet((prev) => {
|
||||
const next = new Set(prev);
|
||||
const allSelected = domains.every((d) => next.has(d));
|
||||
for (const d of domains) {
|
||||
if (allSelected) next.delete(d);
|
||||
else next.add(d);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAllVisible = () => {
|
||||
setSelectedSet((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (allVisibleSelected) {
|
||||
for (const d of visibleDomains) next.delete(d);
|
||||
} else {
|
||||
for (const d of visibleDomains) next.add(d);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleCollapsed = (zoneDomain: string) => {
|
||||
setCollapsedZones((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(zoneDomain)) next.delete(zoneDomain);
|
||||
else next.add(zoneDomain);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择生效域名</DialogTitle>
|
||||
<DialogDescription>
|
||||
仅对选中的 HTTPS 域名注入离线兜底;搜索筛选与批量勾选。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor='sw-scope-search'>域名</FieldLabel>
|
||||
<div className='space-y-2'>
|
||||
<div className='relative'>
|
||||
<Search className='pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground' />
|
||||
<Input
|
||||
id='sw-scope-search'
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder='搜索域名或顶级域…'
|
||||
className='pl-8'
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
|
||||
<Badge variant='secondary' className='font-normal'>
|
||||
已选 {selectedSet.size}
|
||||
</Badge>
|
||||
<span>可见 {visibleDomains.length}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
disabled={pending || visibleDomains.length === 0}
|
||||
onClick={toggleAllVisible}
|
||||
>
|
||||
{allVisibleSelected ? '取消全选' : '全选可见'}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
disabled={pending || selectedSet.size === 0}
|
||||
onClick={() => setSelectedSet(new Set())}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{zones.length === 0 ? (
|
||||
<div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'>
|
||||
暂无可用域名,请先在 Zone 管理中注册域名。
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'>
|
||||
没有匹配的域名
|
||||
</div>
|
||||
) : (
|
||||
<div className='max-h-72 space-y-1 overflow-y-auto rounded-lg border p-2'>
|
||||
{filtered.map((group) => {
|
||||
const groupSelected = group.domains.filter((d) =>
|
||||
selectedSet.has(d),
|
||||
);
|
||||
const allSelected =
|
||||
groupSelected.length === group.domains.length;
|
||||
const someSelected =
|
||||
groupSelected.length > 0 && !allSelected;
|
||||
const open = !collapsedZones.has(group.zoneDomain);
|
||||
return (
|
||||
<Collapsible
|
||||
key={group.zoneDomain}
|
||||
open={open}
|
||||
onOpenChange={() => toggleCollapsed(group.zoneDomain)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md',
|
||||
(allSelected || someSelected) && 'bg-muted/30',
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center gap-1 px-1 py-0.5'>
|
||||
<Checkbox
|
||||
checked={
|
||||
allSelected
|
||||
? true
|
||||
: someSelected
|
||||
? 'indeterminate'
|
||||
: false
|
||||
}
|
||||
disabled={pending}
|
||||
onCheckedChange={() => toggleGroup(group.domains)}
|
||||
aria-label={`选择顶级域 ${group.zoneDomain}`}
|
||||
className='ml-1'
|
||||
/>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='flex min-w-0 flex-1 items-center gap-2 rounded-md px-1.5 py-1.5 text-left text-sm font-medium hover:bg-muted/60'
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-muted-foreground transition-transform',
|
||||
!open && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
<span className='truncate'>
|
||||
{group.zoneDomain}
|
||||
</span>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='ml-auto shrink-0 font-normal text-[10px]'
|
||||
>
|
||||
{groupSelected.length}/{group.domains.length}
|
||||
</Badge>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<div className='ml-4 space-y-0.5 border-l border-border/70 py-0.5 pl-2'>
|
||||
{group.domains.map((domain) => {
|
||||
const checked = selectedSet.has(domain);
|
||||
const isApex = domain === group.zoneDomain;
|
||||
return (
|
||||
<label
|
||||
key={domain}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-muted/60',
|
||||
checked && 'bg-muted/40',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={pending}
|
||||
onCheckedChange={() => toggleOne(domain)}
|
||||
/>
|
||||
<span className='min-w-0 flex-1 truncate text-sm'>
|
||||
{domain}
|
||||
</span>
|
||||
{isApex ? (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='shrink-0 text-[10px] font-normal'
|
||||
>
|
||||
顶级域
|
||||
</Badge>
|
||||
) : null}
|
||||
{checked ? (
|
||||
<Check className='size-3.5 shrink-0 text-primary' />
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={pending}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={pending}
|
||||
onClick={() => onSubmit([...selectedSet].sort())}
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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 const KEY_SW_DOMAINS = 'sw_offline_domains';
|
||||
|
||||
export type ContactPageFields = {
|
||||
enabled: boolean;
|
||||
html: string;
|
||||
domains: string[];
|
||||
};
|
||||
|
||||
export const defaultContactPageFields: ContactPageFields = {
|
||||
enabled: false,
|
||||
html: '',
|
||||
domains: [],
|
||||
};
|
||||
|
||||
export function optionsToMap(options: Array<{ key: string; value: string }>) {
|
||||
return options.reduce<Record<string, string>>((acc, option) => {
|
||||
acc[option.key] = option.value;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function parseDomains(raw: string | undefined): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function mapOptionsToContactFields(
|
||||
optionMap: Record<string, string>,
|
||||
): ContactPageFields {
|
||||
return {
|
||||
enabled: optionMap[KEY_SW_ENABLED] === 'true',
|
||||
html: optionMap[KEY_SW_HTML] ?? '',
|
||||
domains: parseDomains(optionMap[KEY_SW_DOMAINS]),
|
||||
};
|
||||
}
|
||||
|
||||
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'],
|
||||
}),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { FileWarning, MessageSquareText } from 'lucide-react';
|
||||
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { OptionService } from '@/lib/services/openflare';
|
||||
|
||||
import { ContactPageTab } from './components/contact-page-tab';
|
||||
import { OPTIONS_QUERY_KEY, optionsToMap } from './components/shared';
|
||||
|
||||
export default function ResponsesPage() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState('error');
|
||||
|
||||
const optionsQuery = useQuery({
|
||||
queryKey: OPTIONS_QUERY_KEY,
|
||||
queryFn: () => OptionService.list(),
|
||||
enabled: !!user?.is_admin,
|
||||
});
|
||||
|
||||
const optionMap = useMemo(
|
||||
() => optionsToMap(optionsQuery.data ?? []),
|
||||
[optionsQuery.data],
|
||||
);
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className='w-full py-6 px-1'>
|
||||
<LoadingStateWithBorder
|
||||
icon={MessageSquareText}
|
||||
description='加载权限信息...'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user?.is_admin) {
|
||||
return (
|
||||
<div className='w-full py-6 px-1'>
|
||||
<EmptyStateWithBorder
|
||||
icon={MessageSquareText}
|
||||
title='权限不足'
|
||||
description='只有管理员可以访问响应页面设置。'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (optionsQuery.isLoading) {
|
||||
return (
|
||||
<div className='w-full py-6 px-1'>
|
||||
<LoadingStateWithBorder
|
||||
icon={MessageSquareText}
|
||||
description='加载响应页面配置...'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (optionsQuery.isError) {
|
||||
return (
|
||||
<div className='w-full py-6 px-1'>
|
||||
<ErrorInline
|
||||
message={
|
||||
optionsQuery.error instanceof Error
|
||||
? optionsQuery.error.message
|
||||
: '加载失败'
|
||||
}
|
||||
onRetry={() => void optionsQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!optionsQuery.data) return null;
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-6 py-6 px-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<MessageSquareText className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>响应页面</h1>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
配置源站错误页与离线兜底联系页。保存后需发布配置版本后生效。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className='w-full'>
|
||||
<TabsList variant='line' className='mb-6 inline-flex w-fit gap-8'>
|
||||
<TabsTrigger
|
||||
value='error'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
错误页
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='contact'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
联系页
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value='error'
|
||||
className='space-y-4 focus-visible:outline-none'
|
||||
>
|
||||
<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>
|
||||
配置源站/网关错误响应时的统一 HTML 页面与触发策略。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size='sm' className='shrink-0' asChild>
|
||||
<Link href='/error-pages'>
|
||||
<FileWarning className='size-3.5' />
|
||||
前往错误页设置
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
错误页的触发策略、模板编辑与页面预览仍在「错误页」页面维护。
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='contact' className='focus-visible:outline-none'>
|
||||
<ContactPageTab optionMap={optionMap} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+32
-17
@@ -17,22 +17,33 @@ type HtmlEditorWorkspaceProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
toolbarRight?: React.ReactNode;
|
||||
maxBytes?: number;
|
||||
preview?: (html: string) => string;
|
||||
footerHint?: React.ReactNode;
|
||||
showPreviewLink?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* SQL 查询终端同款工作区:整体边框 + 工具栏 + 可拖拽分隔;
|
||||
* 本页为左代码 / 右实时预览横向布局。
|
||||
* 通用 HTML 编辑器工作区:左代码 / 右实时预览横向布局,可拖拽分隔。
|
||||
*/
|
||||
export function HtmlEditorWorkspace({
|
||||
value,
|
||||
onChange,
|
||||
toolbarRight,
|
||||
maxBytes = ORIGIN_ERROR_PAGE_HTML_MAX_BYTES,
|
||||
preview = previewOriginErrorPageHTML,
|
||||
footerHint = (
|
||||
<>
|
||||
{'{{status}}'}→502 · {'{{host}}'}→example.com
|
||||
</>
|
||||
),
|
||||
showPreviewLink = true,
|
||||
}: HtmlEditorWorkspaceProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [editorWidthPercent, setEditorWidthPercent] = useState(48);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const cmTheme = resolvedTheme === 'dark' ? 'dark' : 'light';
|
||||
const previewSrcDoc = previewOriginErrorPageHTML(value);
|
||||
const previewSrcDoc = preview(value);
|
||||
const htmlBytes = new TextEncoder().encode(value).length;
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
@@ -68,7 +79,7 @@ export function HtmlEditorWorkspace({
|
||||
<Terminal className='size-4 text-primary shrink-0' />
|
||||
<span className='text-xs font-semibold'>HTML 编辑器</span>
|
||||
<span className='text-[11px] text-muted-foreground font-mono truncate'>
|
||||
{htmlBytes} / {ORIGIN_ERROR_PAGE_HTML_MAX_BYTES} 字节
|
||||
{htmlBytes} / {maxBytes} 字节
|
||||
{value.trim() === '' ? ' · 空则使用内置默认' : ''}
|
||||
</span>
|
||||
</div>
|
||||
@@ -112,20 +123,24 @@ export function HtmlEditorWorkspace({
|
||||
<div className='flex items-center justify-between px-4 py-1.5 border-b bg-muted/20 shrink-0 text-[11px] text-muted-foreground font-mono gap-2'>
|
||||
<span className='font-semibold'>实时预览</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='hidden sm:inline'>
|
||||
{'{{status}}'}→502 · {'{{host}}'}→example.com
|
||||
</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-[11px]'
|
||||
asChild
|
||||
<span
|
||||
className={footerHint === null ? 'hidden' : 'hidden sm:inline'}
|
||||
>
|
||||
<Link href='/error-pages/preview'>
|
||||
<Expand className='size-3' />
|
||||
真实预览
|
||||
</Link>
|
||||
</Button>
|
||||
{footerHint ?? null}
|
||||
</span>
|
||||
{showPreviewLink ? (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-[11px]'
|
||||
asChild
|
||||
>
|
||||
<Link href='/error-pages/preview'>
|
||||
<Expand className='size-3' />
|
||||
真实预览
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-1 min-h-0 overflow-hidden bg-background'>
|
||||
@@ -61,9 +61,9 @@ export const openflareWebsiteNavGroup: OpenFlareNavGroup = {
|
||||
},
|
||||
{ title: '源站地址', url: '/origins', childUrls: ['/origins/detail'] },
|
||||
{
|
||||
title: '错误页',
|
||||
url: '/error-pages',
|
||||
childUrls: ['/error-pages/edit', '/error-pages/preview'],
|
||||
title: '响应页面',
|
||||
url: '/responses',
|
||||
childUrls: ['/error-pages', '/responses/contact'],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -120,7 +120,7 @@ export const openflareWebsiteSubNav = [
|
||||
{ title: '证书', url: '/certificates' },
|
||||
{ title: 'DNS 账号', url: '/dns-accounts' },
|
||||
{ title: 'Cloudflare', url: '/cloudflare' },
|
||||
{ title: '错误页', url: '/error-pages' },
|
||||
{ title: '响应页面', url: '/responses' },
|
||||
] as const;
|
||||
|
||||
const nonConsoleRoutePrefixes = [
|
||||
|
||||
@@ -401,6 +401,7 @@ func (m *Manager) EnsureLuaAssets() error {
|
||||
return fmt.Errorf("load pow static files: %w", err)
|
||||
}
|
||||
allSupportFiles = append(allSupportFiles, powStaticFiles...)
|
||||
allSupportFiles = append(allSupportFiles, ManagedSWLuaFiles()...)
|
||||
files := make([]managedFile, 0, len(allSupportFiles))
|
||||
for _, file := range allSupportFiles {
|
||||
targetPath, err := luaFileTargetPath(m.LuaDir, file.Path)
|
||||
@@ -525,6 +526,8 @@ func (m *Manager) CurrentChecksum() (string, error) {
|
||||
// Longer error-page path must be restored before the cert-dir prefix rewrite.
|
||||
errorPagePath := filepath.ToSlash(filepath.Join(m.NginxCertDir, openrestyrender.OriginErrorPageSupportPath))
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, errorPagePath, openrestyrender.ErrorPageTmplPlaceholder)
|
||||
swDir := filepath.ToSlash(filepath.Join(m.NginxCertDir, "sw"))
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, swDir, openrestyrender.SWDirPlaceholder)
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, openrestyrender.CertDirPlaceholder)
|
||||
}
|
||||
if luaDir := m.luaRuntimePath(); luaDir != "" {
|
||||
@@ -1022,6 +1025,7 @@ func (m *Manager) writeSourceConfig(supportFiles []protocol.SupportFile) error {
|
||||
}
|
||||
|
||||
func (m *Manager) writeManagedCertFiles(certFiles []protocol.SupportFile) error {
|
||||
certFiles = append(certFiles, ManagedSWLuaFiles()...)
|
||||
files := make([]managedFile, 0, len(certFiles))
|
||||
for _, file := range certFiles {
|
||||
if file.Path == powConfigFileName || file.Path == "waf_config.json" || file.Path == openrestyrender.SourceConfigFileName {
|
||||
@@ -1115,6 +1119,20 @@ func (m *Manager) readManagedSupportFiles() ([]protocol.SupportFile, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Agent-shipped SW Lua assets are not part of the rendered bundle, so they
|
||||
// must stay out of the bundle checksum to keep it aligned with the server.
|
||||
swManaged := make(map[string]struct{}, len(ManagedSWLuaFiles()))
|
||||
for _, file := range ManagedSWLuaFiles() {
|
||||
swManaged[file.Path] = struct{}{}
|
||||
}
|
||||
kept := files[:0]
|
||||
for _, file := range files {
|
||||
if _, skip := swManaged[file.Path]; skip {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, file)
|
||||
}
|
||||
files = kept
|
||||
powConfig, err := m.readPowConfigFile()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1380,6 +1398,8 @@ func (m *Manager) renderRouteConfig(content string) string {
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.CertDirPlaceholder, m.NginxCertDir)
|
||||
errorPagePath := filepath.ToSlash(filepath.Join(m.NginxCertDir, openrestyrender.OriginErrorPageSupportPath))
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.ErrorPageTmplPlaceholder, errorPagePath)
|
||||
swDir := filepath.ToSlash(filepath.Join(m.NginxCertDir, "sw"))
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.SWDirPlaceholder, swDir)
|
||||
}
|
||||
if luaDir := m.luaRuntimePath(); luaDir != "" {
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.LuaDirPlaceholder, luaDir)
|
||||
|
||||
@@ -337,6 +337,85 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerApplyShipsSWAssetsAndReplacesSWDirPlaceholder(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
manager := &Manager{
|
||||
MainConfigPath: filepath.Join(tempDir, "nginx.conf"),
|
||||
RouteConfigPath: filepath.Join(tempDir, "routes.conf"),
|
||||
CertDir: filepath.Join(tempDir, "certs"),
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: filepath.Join(tempDir, "lua"),
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
Executor: &fakeExecutor{},
|
||||
}
|
||||
|
||||
outcome := manager.Apply(
|
||||
context.Background(),
|
||||
"include __OPENFLARE_ROUTE_CONFIG__;",
|
||||
"alias __OPENFLARE_SW_DIR__/sw.js;\nalias __OPENFLARE_SW_DIR__/offline.html;\ncontent_by_lua_file __OPENFLARE_SW_DIR__/challenge.lua;\nrequire(\"__OPENFLARE_LUA_DIR__\")",
|
||||
[]protocol.SupportFile{
|
||||
{Path: "sw/sw.js", Content: "js"},
|
||||
{Path: "sw/offline.html", Content: "html"},
|
||||
},
|
||||
)
|
||||
if outcome.Status != ApplyStatusSuccess {
|
||||
t.Fatalf("Apply failed: %#v", outcome)
|
||||
}
|
||||
|
||||
routeData, err := os.ReadFile(manager.RouteConfigPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read route config: %v", err)
|
||||
}
|
||||
rendered := string(routeData)
|
||||
for _, want := range []string{
|
||||
"/etc/nginx/openflare-certs/sw/sw.js",
|
||||
"/etc/nginx/openflare-certs/sw/offline.html",
|
||||
"/etc/nginx/openflare-certs/sw/challenge.lua",
|
||||
} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("route config missing %q, got %s", want, rendered)
|
||||
}
|
||||
}
|
||||
if strings.Contains(rendered, openrestyrender.SWDirPlaceholder) {
|
||||
t.Fatalf("route config still contains SW dir placeholder: %s", rendered)
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
filepath.Join(manager.CertDir, "sw", "sw.js"),
|
||||
filepath.Join(manager.CertDir, "sw", "offline.html"),
|
||||
filepath.Join(manager.CertDir, "sw", "challenge.lua"),
|
||||
filepath.Join(manager.CertDir, "sw", "runtime.lua"),
|
||||
} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected SW asset %s to exist: %v", path, err)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{
|
||||
filepath.Join(manager.LuaDir, "sw", "challenge.lua"),
|
||||
filepath.Join(manager.LuaDir, "sw", "runtime.lua"),
|
||||
} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected SW lua asset %s to exist: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
checksum, err := manager.CurrentChecksum()
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentChecksum failed: %v", err)
|
||||
}
|
||||
expected := bundleChecksum(
|
||||
"include __OPENFLARE_ROUTE_CONFIG__;",
|
||||
"alias __OPENFLARE_SW_DIR__/sw.js;\nalias __OPENFLARE_SW_DIR__/offline.html;\ncontent_by_lua_file __OPENFLARE_SW_DIR__/challenge.lua;\nrequire(\"__OPENFLARE_LUA_DIR__\")",
|
||||
[]protocol.SupportFile{
|
||||
{Path: "sw/sw.js", Content: "js"},
|
||||
{Path: "sw/offline.html", Content: "html"},
|
||||
},
|
||||
)
|
||||
if checksum != expected {
|
||||
t.Fatalf("unexpected checksum: got %s want %s", checksum, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRenderMainConfigInitializesWAFRuntimeInWorker(t *testing.T) {
|
||||
manager := &Manager{NginxLuaDir: "/etc/nginx/openflare-lua"}
|
||||
rendered := manager.renderMainConfig("events {}\nhttp {\n lua_shared_dict openflare_waf_config 1m;\n server {}\n}\n")
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/agent/protocol"
|
||||
)
|
||||
|
||||
const openRestySWRuntimeLua = `local _M = {}
|
||||
|
||||
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" (pattern mode: %d = digit)
|
||||
if string.find(ua, "Chrome/%d", 1) then return true end
|
||||
-- Firefox: "Firefox/120"
|
||||
if string.find(ua, "Firefox/%d", 1) 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
|
||||
|
||||
if ngx.req.get_method and ngx.req.get_method() ~= "GET" 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
|
||||
|
||||
return _M
|
||||
`
|
||||
|
||||
const openRestySWChallengeLua = `local args = ngx.req.get_uri_args()
|
||||
local redir = args["redir"] or "/"
|
||||
|
||||
-- Escape redir for embedding inside a JS string literal within an HTML
|
||||
-- <script> element. Backslashes first so later escapes stay escaped, then
|
||||
-- double quotes (string-literal break-out), then "<" (prevents a raw
|
||||
-- "</script" sequence ending the element, which the HTML parser matches
|
||||
-- case-insensitively), then CR/LF (a raw newline would end the literal).
|
||||
local function escape_redir(value)
|
||||
local escaped = string.gsub(value, "\\", "\\\\")
|
||||
escaped = string.gsub(escaped, '"', '\\"')
|
||||
escaped = string.gsub(escaped, "<", "\\x3C")
|
||||
escaped = string.gsub(escaped, "\r", "\\r")
|
||||
escaped = string.gsub(escaped, "\n", "\\n")
|
||||
return escaped
|
||||
end
|
||||
redir = escape_redir(redir)
|
||||
|
||||
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 () {
|
||||
document.cookie = "__openflare_sw=1; Path=/; Max-Age=31536000; Secure; SameSite=Lax";
|
||||
location.replace("]] .. redir .. [[");
|
||||
}).catch(function () {
|
||||
location.replace("]] .. redir .. [[");
|
||||
});
|
||||
} else {
|
||||
document.cookie = "__openflare_sw=1; Path=/; Max-Age=31536000; Secure; SameSite=Lax";
|
||||
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},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
func TestSWRuntimeAndChallenge(t *testing.T) {
|
||||
state := lua.NewState()
|
||||
defer state.Close()
|
||||
|
||||
runtimePath := filepath.Join(t.TempDir(), "runtime.lua")
|
||||
if err := os.WriteFile(runtimePath, []byte(openRestySWRuntimeLua), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
challengePath := filepath.Join(t.TempDir(), "challenge.lua")
|
||||
if err := os.WriteFile(challengePath, []byte(openRestySWChallengeLua), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
specPath, err := filepath.Abs("sw_runtime_spec.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state.SetGlobal("SW_RUNTIME_PATH", lua.LString(runtimePath))
|
||||
state.SetGlobal("SW_CHALLENGE_PATH", lua.LString(challengePath))
|
||||
if err := state.DoFile(specPath); err != nil {
|
||||
t.Fatalf("SW runtime/challenge specification failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
local runtime_path = assert(SW_RUNTIME_PATH, "SW_RUNTIME_PATH is required")
|
||||
local challenge_path = assert(SW_CHALLENGE_PATH, "SW_CHALLENGE_PATH is required")
|
||||
|
||||
local function assert_equal(actual, expected, message)
|
||||
if actual ~= expected then
|
||||
error((message or "values differ") .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual), 2)
|
||||
end
|
||||
end
|
||||
|
||||
-- Stable tables: never rebind `exec_calls` / `redir_args` (closures capture
|
||||
-- the upvalue slot; rebinding can leave stale values visible under
|
||||
-- gopher-lua across long test sequences). Clear them in place instead.
|
||||
local output = {}
|
||||
local exec_calls = {}
|
||||
local redir_args = {}
|
||||
|
||||
local function clear_state()
|
||||
for i = 1, #exec_calls do exec_calls[i] = nil end
|
||||
redir_args.redir = nil
|
||||
end
|
||||
|
||||
ngx = {
|
||||
var = {},
|
||||
header = {},
|
||||
exec = function(uri)
|
||||
exec_calls[#exec_calls + 1] = uri
|
||||
return true
|
||||
end,
|
||||
say = function(body) output.body = body end,
|
||||
req = {
|
||||
get_uri_args = function() return redir_args end,
|
||||
set_uri_args = function(args) redir_args.redir = args.redir end,
|
||||
},
|
||||
}
|
||||
|
||||
local function load_runtime()
|
||||
local chunk = assert(loadfile(runtime_path))
|
||||
return chunk()
|
||||
end
|
||||
|
||||
local function reset_request(user_agent, uri, cookie, args, method)
|
||||
clear_state()
|
||||
ngx.var = {
|
||||
http_user_agent = user_agent,
|
||||
uri = uri or "/",
|
||||
scheme = "https",
|
||||
host = "example.com",
|
||||
args = args,
|
||||
["cookie___openflare_sw"] = cookie,
|
||||
}
|
||||
ngx.req.get_method = function() return method or "GET" end
|
||||
end
|
||||
|
||||
local function test_module_contract()
|
||||
local runtime = load_runtime()
|
||||
assert_equal(type(runtime), "table", "sw.runtime must return a module table, not true/nil")
|
||||
assert_equal(type(runtime.check), "function", "sw.runtime must export check()")
|
||||
end
|
||||
|
||||
local function test_non_browser_ua_passes_through()
|
||||
local runtime = load_runtime()
|
||||
reset_request("curl/8.0.1")
|
||||
assert_equal(runtime.check(), true, "non-browser UA passes through")
|
||||
assert_equal(#exec_calls, 0, "non-browser UA must not intercept")
|
||||
|
||||
reset_request("")
|
||||
assert_equal(runtime.check(), true, "empty UA passes through")
|
||||
|
||||
reset_request(nil)
|
||||
assert_equal(runtime.check(), true, "missing UA passes through")
|
||||
end
|
||||
|
||||
local function test_browser_ua_non_get_passes_through()
|
||||
local runtime = load_runtime()
|
||||
reset_request(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"/",
|
||||
nil,
|
||||
nil,
|
||||
"POST"
|
||||
)
|
||||
assert_equal(runtime.check(), true, "non-GET request passes through")
|
||||
assert_equal(#exec_calls, 0, "non-GET request must not be intercepted")
|
||||
end
|
||||
|
||||
local function test_browser_ua_with_cookie_passes_through()
|
||||
local runtime = load_runtime()
|
||||
reset_request(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"/",
|
||||
"1"
|
||||
)
|
||||
assert_equal(runtime.check(), true, "browser UA with cookie passes through")
|
||||
assert_equal(#exec_calls, 0, "cookie holder must not be intercepted")
|
||||
end
|
||||
|
||||
local function test_browser_ua_root_without_cookie_intercepts()
|
||||
local runtime = load_runtime()
|
||||
local chrome = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
reset_request(chrome, "/")
|
||||
runtime.check()
|
||||
assert_equal(#exec_calls, 1, "browser without cookie on / must be intercepted once")
|
||||
assert_equal(exec_calls[1], "/__openflare_sw_challenge", "intercept targets the challenge page")
|
||||
assert_equal(redir_args.redir, "https://example.com/", "redir arg preserves scheme+host+uri")
|
||||
|
||||
reset_request(chrome, "/", nil, "a=1&b=2")
|
||||
runtime.check()
|
||||
assert_equal(#exec_calls, 1, "second request also intercepted")
|
||||
assert_equal(redir_args.redir, "https://example.com/?a=1&b=2", "redir arg keeps the query string")
|
||||
|
||||
reset_request("Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0", "/")
|
||||
runtime.check()
|
||||
assert_equal(exec_calls[1], "/__openflare_sw_challenge", "Firefox intercepted")
|
||||
|
||||
reset_request(
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
||||
"/"
|
||||
)
|
||||
runtime.check()
|
||||
assert_equal(exec_calls[1], "/__openflare_sw_challenge", "Safari intercepted")
|
||||
end
|
||||
|
||||
local function test_browser_ua_non_root_passes_through()
|
||||
local runtime = load_runtime()
|
||||
reset_request(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"/about"
|
||||
)
|
||||
assert_equal(runtime.check(), true, "non-root uri passes through")
|
||||
assert_equal(#exec_calls, 0, "non-root uri must not be intercepted")
|
||||
end
|
||||
|
||||
local function run_challenge(redir_value)
|
||||
output.body = nil
|
||||
ngx.header = {}
|
||||
redir_args.redir = redir_value
|
||||
local chunk = assert(loadfile(challenge_path))
|
||||
chunk()
|
||||
return output.body
|
||||
end
|
||||
|
||||
local function test_challenge_embeds_plain_redir()
|
||||
local body = run_challenge("https://example.com/page?a=1&b=2")
|
||||
assert_equal(
|
||||
string.find(body, 'location.replace("https://example.com/page?a=1&b=2")', 1, true) ~= nil,
|
||||
true,
|
||||
"plain redir embedded verbatim"
|
||||
)
|
||||
end
|
||||
|
||||
local function test_challenge_escapes_script_breakout()
|
||||
local payload = '"/><script>alert(1)</script>'
|
||||
local body = run_challenge(payload)
|
||||
assert_equal(string.find(body, '"><script>', 1, true), nil, "raw breakout sequence must not appear")
|
||||
assert_equal(string.find(body, '\\x3C/script>', 1, true) ~= nil, true, "less-than must be hex-escaped")
|
||||
assert_equal(string.find(body, '\\"', 1, true) ~= nil, true, "double quote must be backslash-escaped")
|
||||
end
|
||||
|
||||
local function test_challenge_escapes_backslash_and_newline()
|
||||
local payload = 'a\\b";' .. string.char(13, 10)
|
||||
local body = run_challenge(payload)
|
||||
assert_equal(string.find(body, 'a\\\\b\\";\\r\\n', 1, true) ~= nil, true, "backslash, quote and CRLF escaped")
|
||||
end
|
||||
|
||||
test_module_contract()
|
||||
test_non_browser_ua_passes_through()
|
||||
test_browser_ua_non_get_passes_through()
|
||||
test_browser_ua_with_cookie_passes_through()
|
||||
test_browser_ua_root_without_cookie_intercepts()
|
||||
test_browser_ua_non_root_passes_through()
|
||||
test_challenge_embeds_plain_redir()
|
||||
test_challenge_escapes_script_breakout()
|
||||
test_challenge_escapes_backslash_and_newline()
|
||||
|
||||
return true
|
||||
@@ -538,6 +538,9 @@ func diffOpenRestyOptionDetails(left openRestyConfigSnapshot, right openRestyCon
|
||||
appendIfChanged("OriginErrorPageStatusCodes", encodeOriginErrorPageStatusCodes(left.OriginErrorPageStatusCodes), encodeOriginErrorPageStatusCodes(right.OriginErrorPageStatusCodes))
|
||||
appendIfChanged("OriginErrorPageHTML", left.OriginErrorPageHTML, right.OriginErrorPageHTML)
|
||||
appendIfChanged("OriginErrorPageGetOnly", fmt.Sprintf("%t", left.OriginErrorPageGetOnly), fmt.Sprintf("%t", right.OriginErrorPageGetOnly))
|
||||
appendIfChanged("SWOfflineEnabled", fmt.Sprintf("%t", left.SWOfflineEnabled), fmt.Sprintf("%t", right.SWOfflineEnabled))
|
||||
appendIfChanged("SWOfflineHTML", left.SWOfflineHTML, right.SWOfflineHTML)
|
||||
appendIfChanged("SWOfflineDomains", strings.Join(left.SWOfflineDomains, ","), strings.Join(right.SWOfflineDomains, ","))
|
||||
return changes
|
||||
}
|
||||
|
||||
@@ -605,5 +608,8 @@ func openRestyOptionKeys() []string {
|
||||
"OriginErrorPageStatusCodes",
|
||||
"OriginErrorPageHTML",
|
||||
"OriginErrorPageGetOnly",
|
||||
"SWOfflineEnabled",
|
||||
"SWOfflineHTML",
|
||||
"SWOfflineDomains",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,9 @@ type openRestyConfigSnapshot struct {
|
||||
OriginErrorPageStatusCodes []string `json:"origin_error_page_status_codes,omitempty"`
|
||||
OriginErrorPageHTML string `json:"origin_error_page_html,omitempty"`
|
||||
OriginErrorPageGetOnly bool `json:"origin_error_page_get_only,omitempty"`
|
||||
SWOfflineEnabled bool `json:"sw_offline_enabled,omitempty"`
|
||||
SWOfflineHTML string `json:"sw_offline_html,omitempty"`
|
||||
SWOfflineDomains []string `json:"sw_offline_domains,omitempty"`
|
||||
}
|
||||
|
||||
type snapshotDocument struct {
|
||||
@@ -514,6 +517,18 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot {
|
||||
return config.Value
|
||||
}
|
||||
|
||||
getStringSliceConfig := func(key string, defaultVal []string) []string {
|
||||
config, err := repository.GetSystemConfigByKey(ctx, key)
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
var values []string
|
||||
if err := json.Unmarshal([]byte(config.Value), &values); err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
snapshot := openRestyConfigSnapshot{
|
||||
DefaultServerReturnStatus: getIntConfig(model.ConfigKeyOpenRestyDefaultServerReturnStatus, defaultOpenRestyReturnStatus),
|
||||
WorkerProcesses: getStringConfig(model.ConfigKeyOpenRestyWorkerProcesses, "auto"),
|
||||
@@ -560,6 +575,9 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot {
|
||||
OriginErrorPageStatusCodes: parseOriginErrorPageStatusCodes(getStringConfig(model.ConfigKeyOriginErrorPageStatusCodes, `["500-599"]`)),
|
||||
OriginErrorPageHTML: getStringConfig(model.ConfigKeyOriginErrorPageHTML, ""),
|
||||
OriginErrorPageGetOnly: getBoolConfig(model.ConfigKeyOriginErrorPageGetOnly, false),
|
||||
SWOfflineEnabled: getBoolConfig(model.ConfigKeySWOfflineEnabled, false),
|
||||
SWOfflineHTML: getStringConfig(model.ConfigKeySWOfflineHTML, ""),
|
||||
SWOfflineDomains: getStringSliceConfig(model.ConfigKeySWOfflineDomains, nil),
|
||||
}
|
||||
if snapshot.DefaultLimitRate == "0" {
|
||||
snapshot.DefaultLimitRate = ""
|
||||
|
||||
@@ -14,7 +14,10 @@ import (
|
||||
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
|
||||
)
|
||||
|
||||
const maxOriginErrorPageHTMLBytes = 256 << 10 // 256 KiB
|
||||
const (
|
||||
maxOriginErrorPageHTMLBytes = 256 << 10 // 256 KiB
|
||||
maxSWOfflineDomains = 1000
|
||||
)
|
||||
|
||||
var openRestyOptionValidators = map[string]func(key, value string) error{
|
||||
model.ConfigKeyOpenRestyDefaultServerReturnStatus: validateOpenRestyDefaultServerReturnStatus,
|
||||
@@ -62,13 +65,16 @@ var openRestyOptionValidators = map[string]func(key, value string) error{
|
||||
model.ConfigKeyOriginErrorPageStatusCodes: validateOriginErrorPageStatusCodes,
|
||||
model.ConfigKeyOriginErrorPageHTML: validateOriginErrorPageHTML,
|
||||
model.ConfigKeyOriginErrorPageGetOnly: validateBooleanOption,
|
||||
model.ConfigKeySWOfflineEnabled: validateBooleanOption,
|
||||
model.ConfigKeySWOfflineHTML: validateSWOfflineHTML,
|
||||
model.ConfigKeySWOfflineDomains: validateSWOfflineDomains,
|
||||
}
|
||||
|
||||
var openRestyDefaultLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`)
|
||||
|
||||
func validateOpenRestyOption(key, value string) error {
|
||||
// HTML 按原始字节长度校验,避免 TrimSpace 影响上限判断
|
||||
if key == model.ConfigKeyOriginErrorPageHTML {
|
||||
if key == model.ConfigKeyOriginErrorPageHTML || key == model.ConfigKeySWOfflineHTML {
|
||||
return validateOriginErrorPageHTML(key, value)
|
||||
}
|
||||
trimmed := strings.TrimSpace(value)
|
||||
@@ -247,3 +253,29 @@ func validateOriginErrorPageHTML(key, value string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSWOfflineHTML(key, value string) error {
|
||||
return validateOriginErrorPageHTML(key, value)
|
||||
}
|
||||
|
||||
func validateSWOfflineDomains(key, value string) error {
|
||||
var domains []string
|
||||
if err := json.Unmarshal([]byte(value), &domains); err != nil {
|
||||
return fmt.Errorf("%s 必须为 JSON 字符串数组", key)
|
||||
}
|
||||
if len(domains) > maxSWOfflineDomains {
|
||||
return fmt.Errorf("%s 最多支持 %d 个域名", key, maxSWOfflineDomains)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(domains))
|
||||
for _, raw := range domains {
|
||||
domain := strings.ToLower(strings.TrimSpace(raw))
|
||||
if domain == "" {
|
||||
return fmt.Errorf("%s 包含空域名", key)
|
||||
}
|
||||
if _, ok := seen[domain]; ok {
|
||||
return fmt.Errorf("%s 包含重复域名 %s", key, domain)
|
||||
}
|
||||
seen[domain] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package option
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -94,3 +95,31 @@ func TestValidateOriginErrorPageEnabled(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "true 或 false")
|
||||
}
|
||||
|
||||
func TestValidateSWOfflineDomains(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
value string
|
||||
ok bool
|
||||
}{
|
||||
{"empty array", `[]`, true},
|
||||
{"single", `["example.com"]`, true},
|
||||
{"multiple", `["example.com","api.example.com"]`, true},
|
||||
{"invalid json", `not-json`, false},
|
||||
{"empty element", `[""]`, false},
|
||||
{"duplicate", `["example.com","example.com"]`, false},
|
||||
{"whitespace dedup", `[" Example.com ","example.com"]`, false},
|
||||
{"over limit", fmt.Sprintf(`[%s]`, strings.Repeat(`"a.com",`, maxSWOfflineDomains)+`"a.com"`), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateSWOfflineDomains("sw_offline_domains", tc.value)
|
||||
if tc.ok && err != nil {
|
||||
t.Fatalf("want ok, got %v", err)
|
||||
}
|
||||
if !tc.ok && err == nil {
|
||||
t.Fatal("want error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
-- +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'
|
||||
);
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||
VALUES ('sw_offline_domains', '[]', 'business', 0, 'SW 离线兜底生效域名列表(JSON 数组,空则仅总开关无效)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM w_system_configs WHERE key = 'sw_offline_domains';
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
-- +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'
|
||||
);
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||
VALUES ('sw_offline_domains', '[]', 'business', 0, 'SW 离线兜底生效域名列表(JSON 数组,空则仅总开关无效)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM w_system_configs WHERE key = 'sw_offline_domains';
|
||||
@@ -21,8 +21,10 @@ import (
|
||||
|
||||
// expectedMigratedSystemConfigCount 包含初始 32 项系统配置、202606220004
|
||||
// 从 of_options 迁移过来的 48 项业务配置、Pages 的 2 项业务配置、
|
||||
// OpenResty 默认限流的 3 项业务配置,以及单 IP 请求频率限制 1 项业务配置。
|
||||
const expectedMigratedSystemConfigCount = 86
|
||||
// OpenResty 默认限流的 3 项业务配置、单 IP 请求频率限制 1 项业务配置、
|
||||
// 源站错误页的 4 项业务配置,以及 Service Worker 离线兜底的 2 项业务配置、
|
||||
// SW 离线兜底生效域名的 1 项业务配置。
|
||||
const expectedMigratedSystemConfigCount = 93
|
||||
|
||||
func TestMigrateInitializesSQLiteDatabase(t *testing.T) {
|
||||
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
|
||||
@@ -115,6 +115,11 @@ const (
|
||||
ConfigKeyOriginErrorPageStatusCodes = "origin_error_page_status_codes" // 源站错误页触发状态码标签 JSON 数组
|
||||
ConfigKeyOriginErrorPageHTML = "origin_error_page_html" // 源站错误页自定义 HTML(空则内置默认)
|
||||
ConfigKeyOriginErrorPageGetOnly = "origin_error_page_get_only" // 是否仅对 GET 请求返回自定义错误页
|
||||
|
||||
// Service Worker 离线兜底
|
||||
ConfigKeySWOfflineEnabled = "sw_offline_enabled" // 是否启用 Service Worker 离线兜底
|
||||
ConfigKeySWOfflineHTML = "sw_offline_html" // 离线联系页自定义 HTML(空则内置默认)
|
||||
ConfigKeySWOfflineDomains = "sw_offline_domains" // 离线兜底生效域名列表(JSON 数组,空则仅总开关无效)
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -49,6 +49,9 @@ func Render(doc Document, certificateFiles []SupportFile) (*Result, error) {
|
||||
if doc.OpenRestyConfig.OriginErrorPageEnabled {
|
||||
files = append(files, originErrorPageSupportFile(doc.OpenRestyConfig))
|
||||
}
|
||||
if doc.OpenRestyConfig.SWOfflineEnabled && len(doc.OpenRestyConfig.SWOfflineDomains) > 0 {
|
||||
files = append(files, ServiceWorkerSupportFiles(doc.OpenRestyConfig)...)
|
||||
}
|
||||
files = DedupeSupportFiles(files)
|
||||
return &Result{
|
||||
MainConfig: mainConfig,
|
||||
@@ -272,7 +275,7 @@ func renderOpenRestyObservabilityTemplateBlock() string {
|
||||
return fmt.Sprintf(" lua_shared_dict openflare_observability 10m;\n lua_shared_dict openflare_pow_challenges 10m;\n lua_shared_dict openflare_pow_sessions 10m;\n lua_shared_dict openflare_pow_config 1m;\n lua_shared_dict openflare_waf_config 1m;\n lua_shared_dict openflare_waf_ip_groups 64m;\n init_worker_by_lua_file %s/observability/init.lua;\n log_by_lua_file %s/observability/log.lua;\n\n server {\n listen %s;\n server_name openflare-observability;\n access_log off;\n\n location = /openflare/stub_status {\n stub_status;\n }\n\n location = /openflare/observability {\n default_type application/json;\n content_by_lua_file %s/observability/read.lua;\n }\n }\n\n", LuaDirPlaceholder, LuaDirPlaceholder, ObservabilityListenPlaceholder, LuaDirPlaceholder)
|
||||
}
|
||||
|
||||
func renderHTTPProxyServer(serverNames string, siteName string, originURL string, originHost string, customHeaders []CustomHeader, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, cfg ConfigSnapshot) string {
|
||||
func renderHTTPProxyServer(serverNames string, siteName string, originURL string, originHost string, customHeaders []CustomHeader, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, _ bool, cfg ConfigSnapshot) string {
|
||||
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s%s location / {\n%s%s%s%s%s%s }\n%s%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderOriginErrorPageIntercept(cfg), renderProxyPassBlock(originURL, upstreamConfig), renderOriginErrorPageServerBits(cfg), renderPowStaticLocationBlock(powEnabled))
|
||||
}
|
||||
|
||||
@@ -319,7 +322,7 @@ func renderPagesAPIProxyLocationBlock(deployment *PagesDeployment) string {
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func renderHTTPPagesServer(serverNames string, siteName string, deployment *PagesDeployment, limitConfig routeLimitConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string) string {
|
||||
func renderHTTPPagesServer(serverNames string, siteName string, deployment *PagesDeployment, limitConfig routeLimitConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, _ bool, _ ConfigSnapshot) string {
|
||||
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s%s root %s;\n index %s;%s%s\n\n location / {\n%s%s }\n%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderPagesAPIProxyLocationBlock(deployment), renderPagesRootLocationBlock(deployment, limitConfig, basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled))
|
||||
}
|
||||
|
||||
@@ -327,7 +330,7 @@ func renderHTTPRedirectServer(serverNames string) string {
|
||||
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n return 301 https://$host$request_uri;\n}\n\n", serverNames)
|
||||
}
|
||||
|
||||
func renderHTTPSServer(serverNames string, siteName string, originURL string, originHost string, certificateID uint, customHeaders []CustomHeader, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, cfg ConfigSnapshot) string {
|
||||
func renderHTTPSServer(serverNames string, siteName string, originURL string, originHost string, certificateID uint, customHeaders []CustomHeader, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, swEnabled bool, cfg ConfigSnapshot) string {
|
||||
certPath := fmt.Sprintf("%s/%d.crt", CertDirPlaceholder, certificateID)
|
||||
keyPath := fmt.Sprintf("%s/%d.key", CertDirPlaceholder, certificateID)
|
||||
var h3Listen string
|
||||
@@ -336,10 +339,13 @@ func renderHTTPSServer(serverNames string, siteName string, originURL string, or
|
||||
h3Listen = " listen 443 quic;\n"
|
||||
h3Header = " add_header Alt-Svc 'h3=\":443\"; ma=86400';\n"
|
||||
}
|
||||
if swEnabled {
|
||||
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s location / {\n%s%s%s%s%s%s }\n%s%s%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlockWithSW(siteName, powEnabled, cfg), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderOriginErrorPageIntercept(cfg), renderProxyPassBlock(originURL, upstreamConfig), renderOriginErrorPageServerBits(cfg), renderPowStaticLocationBlock(powEnabled), renderServiceWorkerChallenger(cfg))
|
||||
}
|
||||
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s location / {\n%s%s%s%s%s%s }\n%s%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderOriginErrorPageIntercept(cfg), renderProxyPassBlock(originURL, upstreamConfig), renderOriginErrorPageServerBits(cfg), renderPowStaticLocationBlock(powEnabled))
|
||||
}
|
||||
|
||||
func renderHTTPSPagesServer(serverNames string, siteName string, certificateID uint, deployment *PagesDeployment, limitConfig routeLimitConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, cfg ConfigSnapshot) string {
|
||||
func renderHTTPSPagesServer(serverNames string, siteName string, certificateID uint, deployment *PagesDeployment, limitConfig routeLimitConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, swEnabled bool, cfg ConfigSnapshot) string {
|
||||
certPath := fmt.Sprintf("%s/%d.crt", CertDirPlaceholder, certificateID)
|
||||
keyPath := fmt.Sprintf("%s/%d.key", CertDirPlaceholder, certificateID)
|
||||
var h3Listen string
|
||||
@@ -348,6 +354,9 @@ func renderHTTPSPagesServer(serverNames string, siteName string, certificateID u
|
||||
h3Listen = " listen 443 quic;\n"
|
||||
h3Header = " add_header Alt-Svc 'h3=\":443\"; ma=86400';\n"
|
||||
}
|
||||
if swEnabled {
|
||||
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s root %s;\n index %s;%s%s\n\n location / {\n%s%s }\n%s%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlockWithSW(siteName, powEnabled, cfg), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderPagesAPIProxyLocationBlock(deployment), renderPagesRootLocationBlock(deployment, limitConfig, basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled), renderServiceWorkerChallenger(cfg))
|
||||
}
|
||||
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s root %s;\n index %s;%s%s\n\n location / {\n%s%s }\n%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderPagesAPIProxyLocationBlock(deployment), renderPagesRootLocationBlock(deployment, limitConfig, basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled))
|
||||
}
|
||||
|
||||
@@ -464,6 +473,42 @@ func renderAccessBlock(siteName string, powEnabled bool) string {
|
||||
`, escapedSiteName, LuaDirPlaceholder, LuaDirPlaceholder, LuaDirPlaceholder)
|
||||
}
|
||||
|
||||
// renderAccessBlockWithSW emits the access phase directives for a server block,
|
||||
// merging the Service Worker runtime check into the single access directive.
|
||||
// nginx runs only the last access_by_lua* directive in a scope, so the SW check
|
||||
// must never be emitted as a second directive; otherwise it would silently
|
||||
// override (or be overridden by) the WAF/PoW check.
|
||||
func renderAccessBlockWithSW(siteName string, powEnabled bool, _ ConfigSnapshot) string {
|
||||
escapedSiteName := escapeNginxString(siteName)
|
||||
if !powEnabled {
|
||||
return fmt.Sprintf(` set $openflare_waf_site "%s";
|
||||
access_by_lua_block {
|
||||
if not string.find(package.path, "%s/?.lua", 1, true) then
|
||||
package.path = "%s/?.lua;%s/?/init.lua;" .. package.path
|
||||
end
|
||||
require("waf.runtime").check()
|
||||
if ngx.ctx.openflare_waf_blocked then
|
||||
return
|
||||
end
|
||||
require("sw.runtime").check()
|
||||
}
|
||||
`, escapedSiteName, LuaDirPlaceholder, LuaDirPlaceholder, LuaDirPlaceholder)
|
||||
}
|
||||
return fmt.Sprintf(` set $openflare_waf_site "%s";
|
||||
access_by_lua_block {
|
||||
if not string.find(package.path, "%s/?.lua", 1, true) then
|
||||
package.path = "%s/?.lua;%s/?/init.lua;" .. package.path
|
||||
end
|
||||
require("waf.runtime").check()
|
||||
if ngx.ctx.openflare_waf_blocked then
|
||||
return
|
||||
end
|
||||
require("pow.runtime").check()
|
||||
require("sw.runtime").check()
|
||||
}
|
||||
`, escapedSiteName, LuaDirPlaceholder, LuaDirPlaceholder, LuaDirPlaceholder)
|
||||
}
|
||||
|
||||
func renderBasicAuthBlock(enabled bool, username, password string) string {
|
||||
if !enabled || username == "" || password == "" {
|
||||
return ""
|
||||
|
||||
@@ -55,7 +55,7 @@ func renderPagesRouteHTTPS(
|
||||
) {
|
||||
if route.RedirectHTTP {
|
||||
if len(partition.httpOnlyDomains) > 0 {
|
||||
builder.WriteString(renderHTTPPagesServer(renderServerNames(partition.httpOnlyDomains), displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword))
|
||||
builder.WriteString(renderHTTPPagesServer(renderServerNames(partition.httpOnlyDomains), displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, false, cfg))
|
||||
}
|
||||
for _, certID := range certIDs {
|
||||
if assignedDomains := partition.domainsByCertID[certID]; len(assignedDomains) > 0 {
|
||||
@@ -63,11 +63,11 @@ func renderPagesRouteHTTPS(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
builder.WriteString(renderHTTPPagesServer(serverNames, displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword))
|
||||
builder.WriteString(renderHTTPPagesServer(serverNames, displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, false, cfg))
|
||||
}
|
||||
for _, certID := range certIDs {
|
||||
if assignedDomains := partition.domainsByCertID[certID]; len(assignedDomains) > 0 {
|
||||
builder.WriteString(renderHTTPSPagesServer(renderServerNames(assignedDomains), displayName, certID, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
|
||||
builder.WriteString(renderHTTPSPagesServer(renderServerNames(assignedDomains), displayName, certID, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, routeSWEnabled(assignedDomains, cfg), cfg))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ func renderProxyRouteHTTPS(
|
||||
) {
|
||||
if route.RedirectHTTP {
|
||||
if len(partition.httpOnlyDomains) > 0 {
|
||||
builder.WriteString(renderHTTPProxyServer(renderServerNames(partition.httpOnlyDomains), displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
|
||||
builder.WriteString(renderHTTPProxyServer(renderServerNames(partition.httpOnlyDomains), displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, false, cfg))
|
||||
}
|
||||
for _, certID := range certIDs {
|
||||
if assignedDomains := partition.domainsByCertID[certID]; len(assignedDomains) > 0 {
|
||||
@@ -94,11 +94,11 @@ func renderProxyRouteHTTPS(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
|
||||
builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, false, cfg))
|
||||
}
|
||||
for _, certID := range certIDs {
|
||||
if assignedDomains := partition.domainsByCertID[certID]; len(assignedDomains) > 0 {
|
||||
builder.WriteString(renderHTTPSServer(renderServerNames(assignedDomains), displayName, route.OriginURL, route.OriginHost, certID, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
|
||||
builder.WriteString(renderHTTPSServer(renderServerNames(assignedDomains), displayName, route.OriginURL, route.OriginHost, certID, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, routeSWEnabled(assignedDomains, cfg), cfg))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ func renderPagesRoute(builder *strings.Builder, route Route, displayName, server
|
||||
return fmt.Errorf("route %s pages deployment is missing", route.SiteName)
|
||||
}
|
||||
if !route.EnableHTTPS {
|
||||
builder.WriteString(renderHTTPPagesServer(serverNames, displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword))
|
||||
builder.WriteString(renderHTTPPagesServer(serverNames, displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, false, cfg))
|
||||
return nil
|
||||
}
|
||||
certIDs := certificateIDsFromDomainCertIDs(route.DomainCertIDs)
|
||||
@@ -134,7 +134,7 @@ func renderProxyRoute(builder *strings.Builder, route Route, displayName, server
|
||||
builder.WriteString(renderNamedUpstreamBlock(upstreamConfig))
|
||||
}
|
||||
if !route.EnableHTTPS {
|
||||
builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
|
||||
builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, false, cfg))
|
||||
return nil
|
||||
}
|
||||
certIDs := certificateIDsFromDomainCertIDs(route.DomainCertIDs)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package openresty
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SW location strings and Lua module paths used by the Service Worker offline fallback.
|
||||
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.
|
||||
// The sw.js content is derived from the offline HTML (see defaultSWJS) so that
|
||||
// HTML-only edits change the script, forcing browsers to re-install the worker
|
||||
// and re-cache the updated page.
|
||||
func ServiceWorkerSupportFiles(cfg ConfigSnapshot) []SupportFile {
|
||||
if !cfg.SWOfflineEnabled {
|
||||
return nil
|
||||
}
|
||||
html := EffectiveSWOfflineHTML(cfg)
|
||||
return []SupportFile{
|
||||
{Path: swDirPrefix + "sw.js", Content: defaultSWJS(html)},
|
||||
{Path: swDirPrefix + "offline.html", Content: html},
|
||||
}
|
||||
}
|
||||
|
||||
// swJSTemplate is the service worker body. The cache name is replaced with a
|
||||
// version derived from the offline HTML: editing the HTML changes the cache
|
||||
// name, which changes the sw.js bytes, which makes the browser re-install the
|
||||
// worker (sw.js is served with Cache-Control: no-cache) and fetch the new
|
||||
// /offline.html into the fresh cache during install.
|
||||
const swJSTemplate = `var CACHE = "__CACHE_NAME__";
|
||||
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.indexOf("openflare-offline-") === 0 && k !== CACHE; }).map(function (k) { return caches.delete(k); }));
|
||||
}));
|
||||
self.clients.claim();
|
||||
});
|
||||
self.addEventListener("fetch", function (e) {
|
||||
if (e.request.method !== "GET" || e.request.mode !== "navigate") { return; }
|
||||
e.respondWith(
|
||||
fetch(e.request).catch(function () {
|
||||
return caches.match(e.request).then(function (r) { return r || caches.match(OFFLINE); });
|
||||
})
|
||||
);
|
||||
});
|
||||
`
|
||||
|
||||
func defaultSWJS(offlineHTML string) string {
|
||||
sum := sha256.Sum256([]byte(offlineHTML))
|
||||
version := hex.EncodeToString(sum[:])[:12]
|
||||
return strings.ReplaceAll(swJSTemplate, "__CACHE_NAME__", "openflare-offline-"+version)
|
||||
}
|
||||
|
||||
// routeSWEnabled returns true when SW offline fallback applies to this route.
|
||||
func routeSWEnabled(routeDomains []string, cfg ConfigSnapshot) bool {
|
||||
if !cfg.SWOfflineEnabled || len(cfg.SWOfflineDomains) == 0 {
|
||||
return false
|
||||
}
|
||||
scope := make(map[string]struct{}, len(cfg.SWOfflineDomains))
|
||||
for _, d := range cfg.SWOfflineDomains {
|
||||
scope[d] = struct{}{}
|
||||
}
|
||||
for _, d := range routeDomains {
|
||||
if _, ok := scope[d]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// renderServiceWorkerChallenger emits SW static locations and the homepage
|
||||
// challenge intercept for HTTPS server blocks.
|
||||
func renderServiceWorkerChallenger(_ ConfigSnapshot) string {
|
||||
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(" # hit when sw.runtime.check() intercepts the homepage in the access phase\n")
|
||||
builder.WriteString(" content_by_lua_file " + SWDirPlaceholder + "/challenge.lua;\n")
|
||||
builder.WriteString(" }\n")
|
||||
return builder.String()
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package openresty
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
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")
|
||||
}
|
||||
if paths["sw/offline.html"] != DefaultSWOfflineHTML {
|
||||
t.Fatalf("expected built-in offline html, got %q", paths["sw/offline.html"])
|
||||
}
|
||||
if !strings.Contains(paths["sw/sw.js"], `var OFFLINE = "/offline.html";`) {
|
||||
t.Fatalf("offline path must stay stable (exact location match), got:\n%s", paths["sw/sw.js"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSWJSCacheNameTracksOfflineHTML(t *testing.T) {
|
||||
htmlA := "<html>page-a</html>"
|
||||
htmlB := "<html>page-b</html>"
|
||||
jsA := defaultSWJS(htmlA)
|
||||
jsB := defaultSWJS(htmlB)
|
||||
if jsA == jsB {
|
||||
t.Fatal("sw.js content must change when the offline HTML changes")
|
||||
}
|
||||
extractCache := func(js string) string {
|
||||
const prefix = `var CACHE = "`
|
||||
start := strings.Index(js, prefix)
|
||||
if start < 0 {
|
||||
t.Fatalf("missing cache name in:\n%s", js)
|
||||
}
|
||||
rest := js[start+len(prefix):]
|
||||
end := strings.Index(rest, `"`)
|
||||
if end < 0 {
|
||||
t.Fatalf("unterminated cache name in:\n%s", js)
|
||||
}
|
||||
return rest[:end]
|
||||
}
|
||||
cacheA := extractCache(jsA)
|
||||
cacheB := extractCache(jsB)
|
||||
if cacheA == cacheB {
|
||||
t.Fatalf("cache names must differ per HTML, got %q", cacheA)
|
||||
}
|
||||
if !strings.HasPrefix(cacheA, "openflare-offline-") {
|
||||
t.Fatalf("unexpected cache name %q", cacheA)
|
||||
}
|
||||
for _, js := range []string{jsA, jsB} {
|
||||
if strings.Contains(js, "openflare-offline-v1") {
|
||||
t.Fatalf("static cache name must not remain, got:\n%s", js)
|
||||
}
|
||||
if strings.Contains(js, "__CACHE_NAME__") {
|
||||
t.Fatalf("template placeholder leaked into sw.js:\n%s", js)
|
||||
}
|
||||
}
|
||||
// Same HTML must produce identical sw.js (deterministic checksum).
|
||||
if defaultSWJS(htmlA) != jsA {
|
||||
t.Fatal("sw.js must be deterministic for identical HTML")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAccessBlockWithSWMergesSingleBlock(t *testing.T) {
|
||||
for _, powEnabled := range []bool{false, true} {
|
||||
name := "pow-disabled"
|
||||
if powEnabled {
|
||||
name = "pow-enabled"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := renderAccessBlockWithSW("example.com", powEnabled, ConfigSnapshot{})
|
||||
if n := strings.Count(got, "access_by_lua_block"); n != 1 {
|
||||
t.Fatalf("expected exactly 1 access_by_lua_block, got %d:\n%s", n, got)
|
||||
}
|
||||
if !strings.Contains(got, `require("sw.runtime").check()`) {
|
||||
t.Fatalf("expected sw.runtime check, got:\n%s", got)
|
||||
}
|
||||
wafIdx := strings.Index(got, `require("waf.runtime").check()`)
|
||||
swIdx := strings.Index(got, `require("sw.runtime").check()`)
|
||||
if wafIdx < 0 || swIdx < 0 || wafIdx > swIdx {
|
||||
t.Fatalf("expected waf.runtime before sw.runtime, got:\n%s", got)
|
||||
}
|
||||
if powEnabled {
|
||||
powIdx := strings.Index(got, `require("pow.runtime").check()`)
|
||||
if powIdx < 0 || wafIdx > powIdx || powIdx > swIdx {
|
||||
t.Fatalf("expected waf.runtime before pow.runtime before sw.runtime, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderServiceWorkerChallengerHTTPExclusion(t *testing.T) {
|
||||
cfg := ConfigSnapshot{SWOfflineEnabled: true}
|
||||
for name, rendered := range map[string]string{
|
||||
"proxy": renderHTTPProxyServer("example.com", "example.com", "http://127.0.0.1:8080", "", nil, routeCacheConfig{}, routeLimitConfig{}, routeUpstreamConfig{}, false, false, "", "", false, cfg),
|
||||
"pages": renderHTTPPagesServer("example.com", "example.com", nil, routeLimitConfig{}, false, false, "", "", false, cfg),
|
||||
"https": renderHTTPSServer("example.com", "example.com", "http://127.0.0.1:8080", "", 1, nil, routeCacheConfig{}, routeLimitConfig{}, routeUpstreamConfig{}, false, false, "", "", true, cfg),
|
||||
"hpages": renderHTTPSPagesServer("example.com", "example.com", 1, nil, routeLimitConfig{}, false, false, "", "", true, cfg),
|
||||
} {
|
||||
if strings.Contains(rendered, "access_by_lua_block") && strings.Count(rendered, "access_by_lua_block") != 1 {
|
||||
t.Fatalf("%s: expected at most one access block, got:\n%s", name, rendered)
|
||||
}
|
||||
}
|
||||
httpProxy := renderHTTPProxyServer("example.com", "example.com", "http://127.0.0.1:8080", "", nil, routeCacheConfig{}, routeLimitConfig{}, routeUpstreamConfig{}, false, false, "", "", false, cfg)
|
||||
if strings.Contains(httpProxy, "sw.runtime") || strings.Contains(httpProxy, "openflare_sw_challenge") || strings.Contains(httpProxy, "location = /sw.js") {
|
||||
t.Fatalf("HTTP proxy server must not carry SW intercept, got:\n%s", httpProxy)
|
||||
}
|
||||
httpPages := renderHTTPPagesServer("example.com", "example.com", nil, routeLimitConfig{}, false, false, "", "", false, cfg)
|
||||
if strings.Contains(httpPages, "sw.runtime") || strings.Contains(httpPages, "openflare_sw_challenge") || strings.Contains(httpPages, "location = /sw.js") {
|
||||
t.Fatalf("HTTP pages server must not carry SW intercept, got:\n%s", httpPages)
|
||||
}
|
||||
httpsProxy := renderHTTPSServer("example.com", "example.com", "http://127.0.0.1:8080", "", 1, nil, routeCacheConfig{}, routeLimitConfig{}, routeUpstreamConfig{}, false, false, "", "", true, cfg)
|
||||
for _, want := range []string{"sw.runtime", "location = /sw.js", "location = /offline.html", "__openflare_sw_challenge"} {
|
||||
if !strings.Contains(httpsProxy, want) {
|
||||
t.Fatalf("HTTPS proxy server missing %q, got:\n%s", want, httpsProxy)
|
||||
}
|
||||
}
|
||||
httpsPages := renderHTTPSPagesServer("example.com", "example.com", 1, nil, routeLimitConfig{}, false, false, "", "", true, cfg)
|
||||
for _, want := range []string{"sw.runtime", "location = /sw.js", "location = /offline.html", "__openflare_sw_challenge"} {
|
||||
if !strings.Contains(httpsPages, want) {
|
||||
t.Fatalf("HTTPS pages server missing %q, got:\n%s", want, httpsPages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteSWEnabled(t *testing.T) {
|
||||
cfgOff := ConfigSnapshot{SWOfflineEnabled: false, SWOfflineDomains: []string{"example.com"}}
|
||||
if routeSWEnabled([]string{"example.com"}, cfgOff) {
|
||||
t.Fatal("expected false when master switch off")
|
||||
}
|
||||
cfgEmpty := ConfigSnapshot{SWOfflineEnabled: true, SWOfflineDomains: nil}
|
||||
if routeSWEnabled([]string{"example.com"}, cfgEmpty) {
|
||||
t.Fatal("expected false when scope empty")
|
||||
}
|
||||
cfgHit := ConfigSnapshot{SWOfflineEnabled: true, SWOfflineDomains: []string{"example.com", "other.com"}}
|
||||
if !routeSWEnabled([]string{"api.example.com", "example.com"}, cfgHit) {
|
||||
t.Fatal("expected true on single domain intersection")
|
||||
}
|
||||
if routeSWEnabled([]string{"api.example.com", "third.com"}, cfgHit) {
|
||||
t.Fatal("expected false on no intersection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHTTPSServerSWScope(t *testing.T) {
|
||||
render := func(swEnabled bool) string {
|
||||
return renderHTTPSServer("example.com", "example.com", "http://127.0.0.1:8080", "", 1, nil, routeCacheConfig{}, routeLimitConfig{}, routeUpstreamConfig{}, false, false, "", "", swEnabled, ConfigSnapshot{SWOfflineEnabled: true})
|
||||
}
|
||||
hit := render(routeSWEnabled([]string{"example.com"}, ConfigSnapshot{SWOfflineEnabled: true, SWOfflineDomains: []string{"example.com"}}))
|
||||
for _, want := range []string{`require("sw.runtime").check()`, "location = /sw.js", "location = /offline.html", "__openflare_sw_challenge"} {
|
||||
if !strings.Contains(hit, want) {
|
||||
t.Fatalf("scoped HTTPS server missing %q, got:\n%s", want, hit)
|
||||
}
|
||||
}
|
||||
miss := render(routeSWEnabled([]string{"example.com"}, ConfigSnapshot{SWOfflineEnabled: true, SWOfflineDomains: []string{"other.com"}}))
|
||||
for _, notWant := range []string{`require("sw.runtime").check()`, "location = /sw.js", "location = /offline.html", "__openflare_sw_challenge"} {
|
||||
if strings.Contains(miss, notWant) {
|
||||
t.Fatalf("out-of-scope HTTPS server must not carry %q, got:\n%s", notWant, miss)
|
||||
}
|
||||
}
|
||||
if miss != renderHTTPSServer("example.com", "example.com", "http://127.0.0.1:8080", "", 1, nil, routeCacheConfig{}, routeLimitConfig{}, routeUpstreamConfig{}, false, false, "", "", false, ConfigSnapshot{}) {
|
||||
t.Fatalf("out-of-scope HTTPS server must match pre-feature bytes, got:\n%s", miss)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRouteConfigSWSCOPEPerCertPartition(t *testing.T) {
|
||||
doc := Document{
|
||||
OpenRestyConfig: ConfigSnapshot{
|
||||
SWOfflineEnabled: true,
|
||||
SWOfflineDomains: []string{"a.com"},
|
||||
},
|
||||
Routes: []Route{{
|
||||
ID: 1,
|
||||
SiteName: "multi.example.com",
|
||||
Domains: []string{"a.com", "b.com"},
|
||||
OriginURL: "http://127.0.0.1:8080",
|
||||
EnableHTTPS: true,
|
||||
DomainCertIDs: []uint{11, 22},
|
||||
}},
|
||||
}
|
||||
certFiles := []SupportFile{
|
||||
{Path: "11.crt", Content: testCertificatePEMForDomain(t, "a.com")},
|
||||
{Path: "22.crt", Content: testCertificatePEMForDomain(t, "b.com")},
|
||||
}
|
||||
rendered, err := RenderRouteConfig(doc, certFiles)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRouteConfig() error = %v", err)
|
||||
}
|
||||
inScope := httpsServerBlockForCert(t, rendered, 11)
|
||||
if !strings.Contains(inScope, "server_name a.com;") {
|
||||
t.Fatalf("cert 11 block must serve a.com, got:\n%s", inScope)
|
||||
}
|
||||
for _, want := range []string{`require("sw.runtime").check()`, "location = /sw.js", "location = /offline.html", "__openflare_sw_challenge"} {
|
||||
if !strings.Contains(inScope, want) {
|
||||
t.Fatalf("in-scope cert partition (a.com) missing %q, got:\n%s", want, inScope)
|
||||
}
|
||||
}
|
||||
outOfScope := httpsServerBlockForCert(t, rendered, 22)
|
||||
if !strings.Contains(outOfScope, "server_name b.com;") {
|
||||
t.Fatalf("cert 22 block must serve b.com, got:\n%s", outOfScope)
|
||||
}
|
||||
for _, notWant := range []string{`require("sw.runtime").check()`, "location = /sw.js", "location = /offline.html", "__openflare_sw_challenge"} {
|
||||
if strings.Contains(outOfScope, notWant) {
|
||||
t.Fatalf("out-of-scope cert partition (b.com) must not carry %q, got:\n%s", notWant, outOfScope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func httpsServerBlockForCert(t *testing.T, rendered string, certID uint) string {
|
||||
t.Helper()
|
||||
marker := fmt.Sprintf("ssl_certificate %s/%d.crt;", CertDirPlaceholder, certID)
|
||||
for _, block := range strings.Split(rendered, "server {") {
|
||||
if strings.Contains(block, marker) {
|
||||
return "server {" + block
|
||||
}
|
||||
}
|
||||
t.Fatalf("no server block found for cert %d in:\n%s", certID, rendered)
|
||||
return ""
|
||||
}
|
||||
|
||||
func testCertificatePEMForDomain(t *testing.T, domain string) string {
|
||||
t.Helper()
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa.GenerateKey() error = %v", err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{CommonName: domain},
|
||||
DNSNames: []string{domain},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
t.Fatalf("x509.CreateCertificate() error = %v", err)
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
||||
}
|
||||
|
||||
func TestRenderServiceWorkerChallenger(t *testing.T) {
|
||||
got := renderServiceWorkerChallenger(ConfigSnapshot{SWOfflineEnabled: true})
|
||||
for _, want := range []string{"location = /sw.js", "location = /offline.html", "challenge.lua", "content_by_lua"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("challenger missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const (
|
||||
PowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__"
|
||||
PagesDirPlaceholder = "__OPENFLARE_PAGES_DIR__"
|
||||
ErrorPageTmplPlaceholder = "__OPENFLARE_ERROR_PAGE_TMPL__"
|
||||
SWDirPlaceholder = "__OPENFLARE_SW_DIR__"
|
||||
|
||||
SourceConfigFileName = "openresty_config.json"
|
||||
)
|
||||
@@ -320,6 +321,12 @@ type ConfigSnapshot struct {
|
||||
OriginErrorPageHTML string `json:"origin_error_page_html,omitempty"`
|
||||
// OriginErrorPageGetOnly limits custom error HTML to GET requests; other methods pass through.
|
||||
OriginErrorPageGetOnly bool `json:"origin_error_page_get_only,omitempty"`
|
||||
// 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"`
|
||||
// SWOfflineDomains restricts the offline fallback to matching HTTPS routes.
|
||||
SWOfflineDomains []string `json:"sw_offline_domains,omitempty"`
|
||||
}
|
||||
|
||||
// Document is the top-level input structure for the OpenResty renderer,
|
||||
|
||||
Reference in New Issue
Block a user