diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 084ab489..3ae7e38f 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -24,6 +24,7 @@ sidebar: false ### 新增 +- 新增全局源站错误页:可在「网站管理 → 错误页」配置开关、触发状态码(支持 `500-599` 区间与单码)与自定义 HTML;默认启用 Cloudflare 风格页面并保持真实 HTTP 状态码,修改后随配置版本发布下发到边缘,关闭后恢复透传。 - 新增 Cloudflare DNS 指向管理:可复用现有 Cloudflare DNS 账号或配置独立 Token,按分组将 ZoneDomain 的单条 A 记录异步同步到边缘节点 IPv4,并支持成员橙云、同步状态与节点 IP 变更联动。 ### 修复 diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 11c5d55f..ca367938 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -259,6 +259,16 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环 | `openresty_default_limit_rate` | `string` | 站点未配置时的默认单请求带宽(如 `512k`);空表示默认关闭 | 空 | | `openresty_main_config_template` | `string` | 允许用户完全重写整个 OpenResty nginx.conf 的底层结构大骨架模板 | 空 (内置缺省骨架) | +### 7. 源站错误页 (Origin Error Page) + +全局源站错误页配置,写入配置版本快照后随发布/回滚下发到边缘 Agent。仅作用于**反代**路由;Pages 静态路由不受影响。管理端入口:「网站管理 → 错误页」。设计说明见 [源站错误页设计](../design/origin-error-page.md)。 + +| 配置键 (Key) | 数据类型 | 作用说明 | 默认值 | +| --- | --- | --- | --- | +| `origin_error_page_enabled` | `bool` | 是否启用全局源站错误页。开启后,源站或网关返回的匹配状态码由自定义/默认 HTML 替换,**HTTP 状态码保持原值**;关闭后不生成相关指令,恢复透传。修改后需发布配置版本生效 | `true` | +| `origin_error_page_status_codes` | `json` | 触发错误页的状态码标签 JSON 数组。支持单码(如 `522`)与闭区间(如 `500-599`);单码与区间两端均须在 **400–599**,且 `lo ≤ hi`。启用时展开结果不能为空 | `["500-599"]` | +| `origin_error_page_html` | `string` | 错误页自定义 HTML。空字符串表示使用内置 Cloudflare 风格默认模板;支持占位符 `{{status}}`(与 HTTP 状态码一致)、`{{host}}`(请求 Host)。最大 **256 KiB**(按字节)。勿嵌入不可信第三方脚本 | 空 | + --- ## 前端构建环境变量 diff --git a/frontend/app/(main)/error-pages/page.tsx b/frontend/app/(main)/error-pages/page.tsx new file mode 100644 index 00000000..1bef975e --- /dev/null +++ b/frontend/app/(main)/error-pages/page.tsx @@ -0,0 +1,411 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + ExternalLink, + FileWarning, + Loader2, + RotateCcw, + Save, + Sparkles, +} from 'lucide-react'; +import { toast } from 'sonner'; + +import { useAuth } from '@/components/providers/auth-provider'; +import { EmptyStateWithBorder } from '@/components/layout/empty'; +import { ErrorInline } from '@/components/layout/error'; +import { LoadingStateWithBorder } from '@/components/layout/loading'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +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 { TagsInput } from '@/components/ui/tags-input'; +import { Textarea } from '@/components/ui/textarea'; +import { + DEFAULT_ORIGIN_ERROR_PAGE_HTML, + ORIGIN_ERROR_PAGE_HTML_MAX_BYTES, + previewOriginErrorPageHTML, +} from '@/lib/openflare/default-origin-error-page-html'; +import { + DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS, + parseStatusCodeTagsJSON, + validateStatusCodeTagMessage, + validateStatusCodeTags, +} from '@/lib/openflare/status-code-tags'; +import { OptionService } from '@/lib/services/openflare'; + +const optionsQueryKey = ['openflare', 'options'] as const; + +const KEY_ENABLED = 'origin_error_page_enabled'; +const KEY_STATUS_CODES = 'origin_error_page_status_codes'; +const KEY_HTML = 'origin_error_page_html'; + +type ErrorPageFields = { + enabled: boolean; + statusCodes: string[]; + html: string; +}; + +const defaultFields: ErrorPageFields = { + enabled: true, + statusCodes: [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS], + html: '', +}; + +function optionsToMap(options: Array<{ key: string; value: string }>) { + return options.reduce>((acc, option) => { + acc[option.key] = option.value; + return acc; + }, {}); +} + +function mapOptionsToFields( + optionMap: Record, +): ErrorPageFields { + const enabledRaw = optionMap[KEY_ENABLED]; + return { + enabled: enabledRaw === undefined ? true : enabledRaw === 'true', + statusCodes: parseStatusCodeTagsJSON(optionMap[KEY_STATUS_CODES]), + html: optionMap[KEY_HTML] ?? '', + }; +} + +function validateFields(fields: ErrorPageFields) { + validateStatusCodeTags(fields.statusCodes); + + const htmlBytes = new TextEncoder().encode(fields.html).length; + if (htmlBytes > ORIGIN_ERROR_PAGE_HTML_MAX_BYTES) { + throw new Error( + `HTML 超过最大长度限制(${ORIGIN_ERROR_PAGE_HTML_MAX_BYTES} 字节)`, + ); + } +} + +export default function ErrorPagesPage() { + const { user, loading: authLoading } = useAuth(); + const queryClient = useQueryClient(); + const [fields, setFields] = useState(defaultFields); + const [restoreOpen, setRestoreOpen] = useState(false); + const [tagError, setTagError] = useState(null); + + const optionsQuery = useQuery({ + queryKey: optionsQueryKey, + queryFn: () => OptionService.list(), + enabled: !!user?.is_admin, + }); + + useEffect(() => { + if (!optionsQuery.data) return; + setFields(mapOptionsToFields(optionsToMap(optionsQuery.data))); + setTagError(null); + }, [optionsQuery.data]); + + const previewSrcDoc = useMemo( + () => previewOriginErrorPageHTML(fields.html), + [fields.html], + ); + + const saveMutation = useMutation({ + mutationFn: async () => { + validateFields(fields); + await OptionService.updateBatch([ + { key: KEY_ENABLED, value: String(fields.enabled) }, + { + key: KEY_STATUS_CODES, + value: JSON.stringify(fields.statusCodes), + }, + { key: KEY_HTML, value: fields.html }, + ]); + }, + onSuccess: async () => { + toast.success('源站错误页已保存,请前往版本发布使配置生效'); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: optionsQueryKey }), + queryClient.invalidateQueries({ + queryKey: ['openflare', 'config-preview'], + }), + queryClient.invalidateQueries({ + queryKey: ['openflare', 'config-versions'], + }), + ]); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : '保存失败'); + }, + }); + + const handleStatusCodesChange = (statusCodes: string[]) => { + setFields((prev) => ({ ...prev, statusCodes })); + setTagError(null); + }; + + const handleValidateTag = (tag: string) => { + const message = validateStatusCodeTagMessage(tag); + if (message) { + setTagError(message); + toast.error(message); + return message; + } + setTagError(null); + return null; + }; + + const loadDefaultTemplate = () => { + setFields((prev) => ({ + ...prev, + html: DEFAULT_ORIGIN_ERROR_PAGE_HTML, + })); + toast.success('已加载默认 HTML 模板到编辑器'); + }; + + const restoreDefault = () => { + setFields((prev) => ({ + ...prev, + html: '', + statusCodes: [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS], + })); + setRestoreOpen(false); + setTagError(null); + toast.success('已恢复默认:状态码 500-599,HTML 使用服务端内置模板'); + }; + + if (authLoading) { + return ( +
+ +
+ ); + } + + if (!user?.is_admin) { + return ( +
+ +
+ ); + } + + if (optionsQuery.isLoading) { + return ( +
+ +
+ ); + } + + if (optionsQuery.isError) { + return ( +
+ void optionsQuery.refetch()} + /> +
+ ); + } + + return ( +
+
+
+ +
+

错误页

+

+ 配置源站/网关错误响应时的统一 HTML + 页面。保存后需发布配置版本后生效。 +

+
+
+
+ + +
+
+ + + +
+ 启用源站错误页 + + 关闭后透传源站或 Nginx 默认错误响应,不注入 error_page 指令。 + +
+ + setFields((prev) => ({ ...prev, enabled })) + } + aria-label='启用源站错误页' + /> +
+
+ + + + 触发状态码 + + 支持单码(如 502)或闭区间(如 500-599),范围 400–599。默认 + 500-599。 + + + + + + {tagError ? ( +

{tagError}

+ ) : ( +

+ 输入后按 Enter 或逗号添加;Backspace 可删除最后一个标签。 +

+ )} +
+
+ +
+ + +
+ HTML 模板 + + 支持占位符 {'{{status}}'} 与 {'{{host}}'} + 。留空则使用服务端内置默认页。 + +
+
+ + +
+
+ +