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:
ryan
2026-08-08 12:22:21 +08:00
parent 734fe45baa
commit ca21ff3a5b
33 changed files with 4021 additions and 40 deletions
@@ -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")
}
})
}
}