diff --git a/openflare_server/model/waf.go b/openflare_server/model/waf.go index ab7a4ca3..45874449 100644 --- a/openflare_server/model/waf.go +++ b/openflare_server/model/waf.go @@ -15,8 +15,8 @@ type WAFRuleGroup struct { CountryBlacklist string `json:"country_blacklist" gorm:"type:text;not null;default:'[]'"` RegionWhitelist string `json:"region_whitelist" gorm:"type:text;not null;default:'[]'"` RegionBlacklist string `json:"region_blacklist" gorm:"type:text;not null;default:'[]'"` - PoWEnabled bool `json:"pow_enabled" gorm:"not null;default:false"` - PoWConfig string `json:"pow_config" gorm:"type:text;not null;default:'{}'"` + PoWEnabled bool `json:"pow_enabled" gorm:"column:pow_enabled;not null;default:false"` + PoWConfig string `json:"pow_config" gorm:"column:pow_config;type:text;not null;default:'{}'"` Remark string `json:"remark" gorm:"size:255"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/openflare_server/service/config_version.go b/openflare_server/service/config_version.go index f987831b..c83196ce 100644 --- a/openflare_server/service/config_version.go +++ b/openflare_server/service/config_version.go @@ -1033,11 +1033,31 @@ func openRestyOptionKeys() []string { } } +func getPoWConfigForRoute(routeID uint, snapshot snapshotWAFDocument) (bool, *ProxyRoutePoWConfig) { + for _, binding := range snapshot.Bindings { + if binding.RouteID == routeID { + for _, groupID := range binding.RuleGroupIDs { + for _, group := range snapshot.RuleGroups { + if group.ID == groupID && group.PoWEnabled { + return true, group.PoWConfig + } + } + } + break + } + } + for _, group := range snapshot.RuleGroups { + if group.IsGlobal && group.PoWEnabled { + return true, group.PoWConfig + } + } + return false, nil +} + func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot, wafSnapshot snapshotWAFDocument) (string, []SupportFile, error) { var builder strings.Builder builder.WriteString("# This file is generated by OpenFlare. Do not edit manually.\n") supportFiles := make([]SupportFile, 0) - powEnabledByRoute := wafPowEnabledByRoute(wafSnapshot) for _, route := range routes { domains, err := decodeStoredDomains(route.Domains, route.Domain) if err != nil { @@ -1074,8 +1094,9 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot, if upstreamConfig.UsesNamedUpstream { builder.WriteString(renderNamedUpstreamBlock(upstreamConfig)) } + powEnabled, _ := getPoWConfigForRoute(route.ID, wafSnapshot) if !route.EnableHTTPS { - builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg)) + builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg)) continue } certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID) @@ -1136,7 +1157,7 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot, if route.RedirectHTTP { if len(httpOnlyDomains) > 0 { - builder.WriteString(renderHTTPProxyServer(renderServerNames(httpOnlyDomains), displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg)) + builder.WriteString(renderHTTPProxyServer(renderServerNames(httpOnlyDomains), displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg)) } for _, certID := range certIDs { assignedDomains := domainsByCertID[certID] @@ -1146,14 +1167,14 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot, builder.WriteString(renderHTTPRedirectServer(renderServerNames(assignedDomains), displayName)) } } else { - builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg)) + builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg)) } for _, certID := range certIDs { assignedDomains := domainsByCertID[certID] if len(assignedDomains) == 0 { continue } - builder.WriteString(renderHTTPSServer(renderServerNames(assignedDomains), displayName, route.OriginURL, route.OriginHost, certID, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg)) + builder.WriteString(renderHTTPSServer(renderServerNames(assignedDomains), displayName, route.OriginURL, route.OriginHost, certID, customHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg)) } } return builder.String(), dedupeSupportFiles(supportFiles), nil @@ -1818,16 +1839,17 @@ func dedupeSupportFiles(files []SupportFile) []SupportFile { return result } -func renderPowConfigBundle(routes []*model.ProxyRoute) (string, []SupportFile, error) { +func renderPowConfigBundle(routes []*model.ProxyRoute, wafSnapshot snapshotWAFDocument) (string, []SupportFile, error) { type domainEntry struct { - Domains []string `json:"domains"` - Enabled bool `json:"enabled"` - Config map[string]interface{} `json:"config"` + Domains []string `json:"domains"` + Enabled bool `json:"enabled"` + Config *ProxyRoutePoWConfig `json:"config"` } entries := make([]domainEntry, 0) hasPow := false for _, route := range routes { - if !route.PoWEnabled { + powEnabled, powConfig := getPoWConfigForRoute(route.ID, wafSnapshot) + if !powEnabled { continue } hasPow = true @@ -1835,14 +1857,10 @@ func renderPowConfigBundle(routes []*model.ProxyRoute) (string, []SupportFile, e if err != nil { return "", nil, err } - var cfg map[string]interface{} - if err := json.Unmarshal([]byte(route.PoWConfig), &cfg); err != nil { - return "", nil, fmt.Errorf("route %s pow_config is invalid", route.Domain) - } entries = append(entries, domainEntry{ Domains: domains, Enabled: true, - Config: cfg, + Config: powConfig, }) } if !hasPow { diff --git a/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx b/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx index 06800449..7c4b3731 100644 --- a/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx +++ b/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx @@ -767,7 +767,7 @@ function WAFBindingSection({ route }: { route: ProxyRouteItem }) { return ( @@ -779,7 +779,14 @@ function WAFBindingSection({ route }: { route: ProxyRouteItem }) { description={getErrorMessage(wafQuery.error)} /> ) : ( -
+
{ + e.preventDefault(); + wafMutation.mutate(selectedIDs); + }} + > {wafMutation.isError ? ( ) : null} - -
- wafMutation.mutate(selectedIDs)} - > - {wafMutation.isPending ? '保存中...' : '保存 WAF 绑定'} - -
-
+ )}
); diff --git a/openflare_server/web/features/waf/components/helpers.ts b/openflare_server/web/features/waf/components/helpers.ts new file mode 100644 index 00000000..e63c1793 --- /dev/null +++ b/openflare_server/web/features/waf/components/helpers.ts @@ -0,0 +1,191 @@ +import type { ProxyRoutePoWConfig } from '@/features/proxy-routes/types'; +import type { WAFRuleGroup, WAFRuleGroupPayload } from '@/features/waf/types'; +import type { + CountryOption, + ListFieldKey, + RuleDimension, + RuleListRenderable, + RuleListType, + RuleModalState, + WAFTab, +} from './types'; + +export const defaultPowConfig: ProxyRoutePoWConfig = { + difficulty: 4, + algorithm: 'fast', + session_ttl: 600, + challenge_ttl: 300, + whitelist: { + ips: [], + ip_cidrs: [], + paths: [], + path_regexes: [], + user_agents: [], + }, + blacklist: { + ips: [], + ip_cidrs: [], + paths: [], + path_regexes: [], + user_agents: [], + }, +}; + +export const emptyDraft: WAFRuleGroupPayload = { + name: '', + enabled: true, + block_status_code: 418, + block_response_body: '', + ip_whitelist: [], + ip_blacklist: [], + country_whitelist: [], + country_blacklist: [], + region_whitelist: [], + region_blacklist: [], + pow_enabled: false, + pow_config: defaultPowConfig, + remark: '', +}; + +export const defaultRuleModalState: RuleModalState = { + open: false, + listType: 'whitelist', + dimension: 'ip', + ipValue: '', + countryValues: [], +}; + +export const tabItems: Array<{ + id: WAFTab; + label: string; +}> = [ + { id: 'basic', label: '基本信息' }, + { id: 'lists', label: '黑白名单' }, + { id: 'pow', label: 'PoW' }, + { id: 'block', label: '拦截返回' }, +]; + +export function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : '操作失败'; +} + +export function textToList(text: string) { + return text + .split(/[\n,,\s]+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +export function listToText(items: string[] | undefined) { + return (items ?? []).join('\n'); +} + +export function parseTextareaList(text: string) { + return text + .split(/\r?\n/) + .map((item) => item.trim()) + .filter(Boolean); +} + +export function normalizeItems(items: string[]) { + return Array.from( + new Set(items.map((item) => item.trim()).filter(Boolean)), + ).sort((left, right) => left.localeCompare(right)); +} + +export function buildDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload { + if (!group) { + return { ...emptyDraft }; + } + return { + name: group.name, + enabled: group.enabled, + block_status_code: group.block_status_code || 418, + block_response_body: group.block_response_body ?? '', + ip_whitelist: group.ip_whitelist ?? [], + ip_blacklist: group.ip_blacklist ?? [], + country_whitelist: group.country_whitelist ?? [], + country_blacklist: group.country_blacklist ?? [], + region_whitelist: group.region_whitelist ?? [], + region_blacklist: group.region_blacklist ?? [], + pow_enabled: group.pow_enabled ?? false, + pow_config: group.pow_config ?? defaultPowConfig, + remark: group.remark ?? '', + }; +} + +export function countRuleEntries(group: RuleListRenderable) { + return ( + group.ip_whitelist.length + + group.ip_blacklist.length + + group.country_whitelist.length + + group.country_blacklist.length + + group.region_whitelist.length + + group.region_blacklist.length + ); +} + +export function buildCountryOptions() { + const zhDisplayNames = new Intl.DisplayNames(['zh-CN'], { type: 'region' }); + const enDisplayNames = new Intl.DisplayNames(['en'], { type: 'region' }); + const options: CountryOption[] = []; + + for (let first = 65; first <= 90; first += 1) { + for (let second = 65; second <= 90; second += 1) { + const code = String.fromCharCode(first, second); + const zhName = zhDisplayNames.of(code); + const enName = enDisplayNames.of(code); + + if ( + !zhName || + zhName === code || + /未知/.test(zhName) || + !enName || + enName === code || + /Unknown/.test(enName) + ) { + continue; + } + + options.push({ + code, + zhName, + label: `${code} ${zhName}`, + searchText: `${code} ${zhName} ${enName}`.toLowerCase(), + }); + } + } + + return options.sort((left, right) => left.code.localeCompare(right.code)); +} + +export function getListFieldKey( + listType: RuleListType, + dimension: RuleDimension, +): ListFieldKey { + if (dimension === 'ip') { + return listType === 'whitelist' ? 'ip_whitelist' : 'ip_blacklist'; + } + return listType === 'whitelist' ? 'country_whitelist' : 'country_blacklist'; +} + +export function updateDraftList( + draft: WAFRuleGroupPayload, + key: ListFieldKey, + updater: (items: string[]) => string[], +) { + switch (key) { + case 'ip_whitelist': + return { ...draft, ip_whitelist: updater(draft.ip_whitelist) }; + case 'ip_blacklist': + return { ...draft, ip_blacklist: updater(draft.ip_blacklist) }; + case 'country_whitelist': + return { ...draft, country_whitelist: updater(draft.country_whitelist) }; + case 'country_blacklist': + return { ...draft, country_blacklist: updater(draft.country_blacklist) }; + } +} + +export function formatCountryItem(code: string, labelMap: Map) { + return labelMap.get(code) ?? code; +} diff --git a/openflare_server/web/features/waf/components/pow-tab-panel.tsx b/openflare_server/web/features/waf/components/pow-tab-panel.tsx new file mode 100644 index 00000000..afb537a5 --- /dev/null +++ b/openflare_server/web/features/waf/components/pow-tab-panel.tsx @@ -0,0 +1,215 @@ +import { useEffect, useState } from 'react'; +import type { ProxyRoutePoWConfig } from '@/features/proxy-routes/types'; +import { + ResourceField, + ResourceInput, + ResourceTextarea, + ToggleField, +} from '@/features/shared/components/resource-primitives'; +import { listToText, parseTextareaList } from './helpers'; + +export function PowTabPanel({ + enabled, + config, + onChange, +}: { + enabled: boolean; + config: ProxyRoutePoWConfig; + onChange: (enabled: boolean, config: ProxyRoutePoWConfig) => void; +}) { + const [draft, setDraft] = useState(() => ({ + whitelist: { + ips: listToText(config.whitelist?.ips), + ip_cidrs: listToText(config.whitelist?.ip_cidrs), + paths: listToText(config.whitelist?.paths), + path_regexes: listToText(config.whitelist?.path_regexes), + user_agents: listToText(config.whitelist?.user_agents), + }, + blacklist: { + ips: listToText(config.blacklist?.ips), + ip_cidrs: listToText(config.blacklist?.ip_cidrs), + paths: listToText(config.blacklist?.paths), + path_regexes: listToText(config.blacklist?.path_regexes), + user_agents: listToText(config.blacklist?.user_agents), + }, + })); + + useEffect(() => { + setDraft({ + whitelist: { + ips: listToText(config.whitelist?.ips), + ip_cidrs: listToText(config.whitelist?.ip_cidrs), + paths: listToText(config.whitelist?.paths), + path_regexes: listToText(config.whitelist?.path_regexes), + user_agents: listToText(config.whitelist?.user_agents), + }, + blacklist: { + ips: listToText(config.blacklist?.ips), + ip_cidrs: listToText(config.blacklist?.ip_cidrs), + paths: listToText(config.blacklist?.paths), + path_regexes: listToText(config.blacklist?.path_regexes), + user_agents: listToText(config.blacklist?.user_agents), + }, + }); + }, [config]); + + const updateConfig = ( + newEnabled: boolean, + newConfig: Partial, + newDraft?: typeof draft, + ) => { + const nextConfig = { ...config, ...newConfig }; + if (newDraft) { + setDraft(newDraft); + nextConfig.whitelist = { + ips: parseTextareaList(newDraft.whitelist.ips), + ip_cidrs: parseTextareaList(newDraft.whitelist.ip_cidrs), + paths: parseTextareaList(newDraft.whitelist.paths), + path_regexes: parseTextareaList(newDraft.whitelist.path_regexes), + user_agents: parseTextareaList(newDraft.whitelist.user_agents), + }; + nextConfig.blacklist = { + ips: parseTextareaList(newDraft.blacklist.ips), + ip_cidrs: parseTextareaList(newDraft.blacklist.ip_cidrs), + paths: parseTextareaList(newDraft.blacklist.paths), + path_regexes: parseTextareaList(newDraft.blacklist.path_regexes), + user_agents: parseTextareaList(newDraft.blacklist.user_agents), + }; + } + onChange(newEnabled, nextConfig); + }; + + const updateList = ( + scope: 'whitelist' | 'blacklist', + key: keyof ProxyRoutePoWConfig['whitelist'], + value: string, + ) => { + const nextDraft = { + ...draft, + [scope]: { + ...draft[scope], + [key]: value, + }, + }; + updateConfig(enabled, {}, nextDraft); + }; + + return ( +
+ updateConfig(newEnabled, {})} + /> + +
+ + + + + + updateConfig(enabled, { difficulty: Number(event.target.value) }) + } + /> + + + + updateConfig(enabled, { session_ttl: Number(event.target.value) }) + } + /> + + + + updateConfig(enabled, { challenge_ttl: Number(event.target.value) }) + } + /> + +
+ +
+ {(['whitelist', 'blacklist'] as const).map((scope) => ( +
+

+ {scope === 'whitelist' + ? '白名单(跳过 PoW)' + : '黑名单(必须 PoW)'} +

+
+ + + updateList(scope, 'ips', event.target.value) + } + /> + + + + updateList(scope, 'ip_cidrs', event.target.value) + } + /> + + + + updateList(scope, 'paths', event.target.value) + } + /> + + + + updateList(scope, 'path_regexes', event.target.value) + } + /> + + + + updateList(scope, 'user_agents', event.target.value) + } + /> + +
+
+ ))} +
+
+ ); +} diff --git a/openflare_server/web/features/waf/components/rule-chip.tsx b/openflare_server/web/features/waf/components/rule-chip.tsx new file mode 100644 index 00000000..06ec483b --- /dev/null +++ b/openflare_server/web/features/waf/components/rule-chip.tsx @@ -0,0 +1,32 @@ +import { cn } from '@/lib/utils/cn'; + +export function RuleChip({ + label, + tone, + onRemove, +}: { + label: string; + tone: 'whitelist' | 'blacklist'; + onRemove: () => void; +}) { + return ( + + {label} + + + ); +} diff --git a/openflare_server/web/features/waf/components/rule-entry-modal.tsx b/openflare_server/web/features/waf/components/rule-entry-modal.tsx new file mode 100644 index 00000000..462de57d --- /dev/null +++ b/openflare_server/web/features/waf/components/rule-entry-modal.tsx @@ -0,0 +1,234 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Search } from 'lucide-react'; +import { AppModal } from '@/components/ui/app-modal'; +import { + PrimaryButton, + ResourceField, + ResourceTextarea, + SecondaryButton, +} from '@/features/shared/components/resource-primitives'; +import { cn } from '@/lib/utils/cn'; +import { normalizeItems } from './helpers'; +import type { CountryOption, RuleListType, RuleDimension, RuleModalState } from './types'; + +export function RuleEntryModal({ + state, + countryOptions, + pending, + onClose, + onChange, + onSubmit, +}: { + state: RuleModalState; + countryOptions: CountryOption[]; + pending: boolean; + onClose: () => void; + onChange: (patch: Partial) => void; + onSubmit: () => void; +}) { + const [keyword, setKeyword] = useState(''); + + useEffect(() => { + if (!state.open) { + return; + } + setKeyword(''); + }, [state.dimension, state.open]); + + const selectedCountrySet = useMemo( + () => new Set(state.countryValues), + [state.countryValues], + ); + + const filteredCountries = useMemo(() => { + const normalized = keyword.trim().toLowerCase(); + + return countryOptions + .filter((option) => !normalized || option.searchText.includes(normalized)) + .sort((left, right) => { + const leftSelected = selectedCountrySet.has(left.code) ? 1 : 0; + const rightSelected = selectedCountrySet.has(right.code) ? 1 : 0; + return ( + rightSelected - leftSelected || left.code.localeCompare(right.code) + ); + }); + }, [countryOptions, keyword, selectedCountrySet]); + + const toggleCountry = (code: string) => { + const values = selectedCountrySet.has(code) + ? state.countryValues.filter((item) => item !== code) + : normalizeItems([...state.countryValues, code]); + onChange({ countryValues: values }); + }; + + const selectFiltered = () => { + onChange({ + countryValues: normalizeItems([ + ...state.countryValues, + ...filteredCountries.map((option) => option.code), + ]), + }); + }; + + const clearCountries = () => onChange({ countryValues: [] }); + + const typeLabel = state.listType === 'whitelist' ? '白名单' : '黑名单'; + const dimensionLabel = state.dimension === 'ip' ? 'IP' : '地域'; + + return ( + + + 取消 + + + {pending ? '处理中...' : '添加'} + + + } + > +
+
+ +
+ {[ + { value: 'blacklist', label: '黑名单' }, + { value: 'whitelist', label: '白名单' }, + ].map((option) => ( + + ))} +
+
+ +
+ {[ + { value: 'ip', label: 'IP' }, + { value: 'country', label: '地域' }, + ].map((option) => ( + + ))} +
+
+
+ + {state.dimension === 'ip' ? ( + + onChange({ ipValue: event.target.value })} + /> + + ) : ( +
+
+ + setKeyword(event.target.value)} + placeholder="搜索国家代码或中文名" + className="min-w-0 flex-1 bg-transparent text-sm text-[var(--foreground-primary)] outline-none placeholder:text-[var(--foreground-muted)]" + /> + + +
+ +
+
+
+

+ 地域多选 +

+

+ 选项显示为「国家代码 国家中文名」。 +

+
+ + 已选 {state.countryValues.length} + +
+ +
+ {filteredCountries.map((option) => { + const selected = selectedCountrySet.has(option.code); + return ( + + ); + })} +
+
+
+ )} +
+
+ ); +} diff --git a/openflare_server/web/features/waf/components/rule-list-section.tsx b/openflare_server/web/features/waf/components/rule-list-section.tsx new file mode 100644 index 00000000..ec718d01 --- /dev/null +++ b/openflare_server/web/features/waf/components/rule-list-section.tsx @@ -0,0 +1,51 @@ +import { RuleChip } from './rule-chip'; + +export function RuleListSection({ + title, + description, + items, + tone, + emptyText, + onRemove, +}: { + title: string; + description: string; + items: string[]; + tone: 'whitelist' | 'blacklist'; + emptyText: string; + onRemove: (item: string) => void; +}) { + return ( +
+
+
+

+ {title} +

+

+ {description} +

+
+ + {items.length} + +
+
+ {items.length > 0 ? ( +
+ {items.map((item) => ( + onRemove(item)} + /> + ))} +
+ ) : ( +

{emptyText}

+ )} +
+
+ ); +} diff --git a/openflare_server/web/features/waf/components/site-apply-drawer.tsx b/openflare_server/web/features/waf/components/site-apply-drawer.tsx new file mode 100644 index 00000000..c470a9af --- /dev/null +++ b/openflare_server/web/features/waf/components/site-apply-drawer.tsx @@ -0,0 +1,138 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Check, Search } from 'lucide-react'; +import { Drawer } from '@/components/ui/drawer'; +import type { ProxyRouteItem } from '@/features/proxy-routes/types'; +import { PrimaryButton, SecondaryButton } from '@/features/shared/components/resource-primitives'; +import type { WAFRuleGroup } from '@/features/waf/types'; +import { cn } from '@/lib/utils/cn'; + +export function SiteApplyDrawer({ + group, + routes, + open, + onOpenChange, + onSave, + pending, +}: { + group: WAFRuleGroup | null; + routes: ProxyRouteItem[]; + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: (ids: number[]) => void; + pending: boolean; +}) { + const [keyword, setKeyword] = useState(''); + const [selectedIDs, setSelectedIDs] = useState([]); + + useEffect(() => { + setSelectedIDs(group?.applied_site_ids ?? []); + setKeyword(''); + }, [group, open]); + + const filteredRoutes = useMemo(() => { + const normalized = keyword.trim().toLowerCase(); + if (!normalized) { + return routes; + } + return routes.filter((route) => + [route.site_name, route.primary_domain, ...route.domains] + .join(' ') + .toLowerCase() + .includes(normalized), + ); + }, [keyword, routes]); + + const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]); + const toggleID = (id: number) => { + setSelectedIDs((current) => + current.includes(id) + ? current.filter((item) => item !== id) + : [...current, id].sort((left, right) => left - right), + ); + }; + + const selectFiltered = () => { + const next = new Set(selectedIDs); + filteredRoutes.forEach((route) => next.add(route.id)); + setSelectedIDs([...next].sort((left, right) => left - right)); + }; + + return ( + + onOpenChange(false)}> + 取消 + + onSave(selectedIDs)} + > + {pending ? '保存中...' : '保存应用范围'} + + + } + > +
+
+ + setKeyword(event.target.value)} + placeholder="搜索网站或域名" + className="min-w-0 flex-1 bg-transparent text-sm text-[var(--foreground-primary)] outline-none placeholder:text-[var(--foreground-muted)]" + /> + +
+
+ {filteredRoutes.map((route) => ( + + ))} +
+
+
+ ); +} diff --git a/openflare_server/web/features/waf/components/stat-card.tsx b/openflare_server/web/features/waf/components/stat-card.tsx new file mode 100644 index 00000000..2e022823 --- /dev/null +++ b/openflare_server/web/features/waf/components/stat-card.tsx @@ -0,0 +1,36 @@ +import type { LucideIcon } from 'lucide-react'; + +export function StatCard({ + icon: Icon, + label, + value, + hint, +}: { + icon: LucideIcon; + label: string; + value: string; + hint?: string; +}) { + return ( +
+
+
+

+ {label} +

+

+ {value} +

+
+ + + +
+ {hint && ( +

+ {hint} +

+ )} +
+ ); +} diff --git a/openflare_server/web/features/waf/components/tab-button.tsx b/openflare_server/web/features/waf/components/tab-button.tsx new file mode 100644 index 00000000..e855865b --- /dev/null +++ b/openflare_server/web/features/waf/components/tab-button.tsx @@ -0,0 +1,28 @@ +import { cn } from '@/lib/utils/cn'; + +export function TabButton({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/openflare_server/web/features/waf/components/types.ts b/openflare_server/web/features/waf/components/types.ts new file mode 100644 index 00000000..67b206c5 --- /dev/null +++ b/openflare_server/web/features/waf/components/types.ts @@ -0,0 +1,40 @@ +import type { WAFRuleGroupPayload } from '@/features/waf/types'; + +export type FeedbackState = { + tone: 'success' | 'danger' | 'info'; + message: string; +}; + +export type WAFTab = 'basic' | 'lists' | 'pow' | 'block'; +export type RuleListType = 'whitelist' | 'blacklist'; +export type RuleDimension = 'ip' | 'country'; +export type ListFieldKey = + | 'ip_whitelist' + | 'ip_blacklist' + | 'country_whitelist' + | 'country_blacklist'; + +export type CountryOption = { + code: string; + zhName: string; + label: string; + searchText: string; +}; + +export type RuleModalState = { + open: boolean; + listType: RuleListType; + dimension: RuleDimension; + ipValue: string; + countryValues: string[]; +}; + +export type RuleListRenderable = Pick< + WAFRuleGroupPayload, + | 'ip_whitelist' + | 'ip_blacklist' + | 'country_whitelist' + | 'country_blacklist' + | 'region_whitelist' + | 'region_blacklist' +>; diff --git a/openflare_server/web/features/waf/components/waf-page.tsx b/openflare_server/web/features/waf/components/waf-page.tsx index 2330a0c9..cb9089ee 100644 --- a/openflare_server/web/features/waf/components/waf-page.tsx +++ b/openflare_server/web/features/waf/components/waf-page.tsx @@ -4,13 +4,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { ReactNode } from 'react'; import { useEffect, useMemo, useState } from 'react'; import { - Check, - Cpu, Globe2, - type LucideIcon, Plus, Save, - Search, ShieldCheck, Trash2, } from 'lucide-react'; @@ -21,17 +17,7 @@ import { InlineMessage } from '@/components/feedback/inline-message'; import { LoadingState } from '@/components/feedback/loading-state'; import { PageHeader } from '@/components/layout/page-header'; import { AppCard } from '@/components/ui/app-card'; -import { AppModal } from '@/components/ui/app-modal'; -import { Drawer } from '@/components/ui/drawer'; -import { - getProxyRoutes, - updateProxyRoute, -} from '@/features/proxy-routes/api/proxy-routes'; -import { buildPayloadFromRoute } from '@/features/proxy-routes/helpers'; -import type { - ProxyRouteItem, - ProxyRoutePoWConfig, -} from '@/features/proxy-routes/types'; +import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes'; import { DangerButton, PrimaryButton, @@ -51,940 +37,26 @@ import { import type { WAFRuleGroup, WAFRuleGroupPayload } from '@/features/waf/types'; import { cn } from '@/lib/utils/cn'; -type FeedbackState = { - tone: 'success' | 'danger' | 'info'; - message: string; -}; - -type WAFTab = 'basic' | 'lists' | 'pow' | 'block'; -type RuleListType = 'whitelist' | 'blacklist'; -type RuleDimension = 'ip' | 'country'; -type ListFieldKey = - | 'ip_whitelist' - | 'ip_blacklist' - | 'country_whitelist' - | 'country_blacklist'; - -type CountryOption = { - code: string; - zhName: string; - label: string; - searchText: string; -}; - -type RuleModalState = { - open: boolean; - listType: RuleListType; - dimension: RuleDimension; - ipValue: string; - countryValues: string[]; -}; - -type RuleListRenderable = Pick< - WAFRuleGroupPayload, - | 'ip_whitelist' - | 'ip_blacklist' - | 'country_whitelist' - | 'country_blacklist' - | 'region_whitelist' - | 'region_blacklist' ->; - -const defaultPowConfig: ProxyRoutePoWConfig = { - difficulty: 4, - algorithm: 'fast', - session_ttl: 600, - challenge_ttl: 300, - whitelist: { - ips: [], - ip_cidrs: [], - paths: [], - path_regexes: [], - user_agents: [], - }, - blacklist: { - ips: [], - ip_cidrs: [], - paths: [], - path_regexes: [], - user_agents: [], - }, -}; - -const emptyDraft: WAFRuleGroupPayload = { - name: '', - enabled: true, - block_status_code: 418, - block_response_body: '', - ip_whitelist: [], - ip_blacklist: [], - country_whitelist: [], - country_blacklist: [], - region_whitelist: [], - region_blacklist: [], - pow_enabled: false, - pow_config: defaultPowConfig, - remark: '', -}; - -const defaultRuleModalState: RuleModalState = { - open: false, - listType: 'whitelist', - dimension: 'ip', - ipValue: '', - countryValues: [], -}; - -const tabItems: Array<{ - id: WAFTab; - label: string; -}> = [ - { - id: 'basic', - label: '基本信息', - }, - { - id: 'lists', - label: '黑白名单', - }, - { - id: 'pow', - label: 'PoW', - }, - { - id: 'block', - label: '拦截返回', - }, -]; - -function getErrorMessage(error: unknown) { - return error instanceof Error ? error.message : '操作失败'; -} - -function textToList(text: string) { - return text - .split(/[\n,,\s]+/) - .map((item) => item.trim()) - .filter(Boolean); -} - -function listToText(items: string[] | undefined) { - return (items ?? []).join('\n'); -} - -function parseTextareaList(text: string) { - return text - .split(/\r?\n/) - .map((item) => item.trim()) - .filter(Boolean); -} - -function normalizeItems(items: string[]) { - return Array.from( - new Set(items.map((item) => item.trim()).filter(Boolean)), - ).sort((left, right) => left.localeCompare(right)); -} - -function buildDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload { - if (!group) { - return { ...emptyDraft }; - } - return { - name: group.name, - enabled: group.enabled, - block_status_code: group.block_status_code || 418, - block_response_body: group.block_response_body ?? '', - ip_whitelist: group.ip_whitelist ?? [], - ip_blacklist: group.ip_blacklist ?? [], - country_whitelist: group.country_whitelist ?? [], - country_blacklist: group.country_blacklist ?? [], - region_whitelist: group.region_whitelist ?? [], - region_blacklist: group.region_blacklist ?? [], - pow_enabled: group.pow_enabled ?? false, - pow_config: group.pow_config ?? defaultPowConfig, - remark: group.remark ?? '', - }; -} - -function countRuleEntries(group: RuleListRenderable) { - return ( - group.ip_whitelist.length + - group.ip_blacklist.length + - group.country_whitelist.length + - group.country_blacklist.length + - group.region_whitelist.length + - group.region_blacklist.length - ); -} - -function buildCountryOptions() { - const zhDisplayNames = new Intl.DisplayNames(['zh-CN'], { type: 'region' }); - const enDisplayNames = new Intl.DisplayNames(['en'], { type: 'region' }); - const options: CountryOption[] = []; - - for (let first = 65; first <= 90; first += 1) { - for (let second = 65; second <= 90; second += 1) { - const code = String.fromCharCode(first, second); - const zhName = zhDisplayNames.of(code); - const enName = enDisplayNames.of(code); - - if ( - !zhName || - zhName === code || - /未知/.test(zhName) || - !enName || - enName === code || - /Unknown/.test(enName) - ) { - continue; - } - - options.push({ - code, - zhName, - label: `${code} ${zhName}`, - searchText: `${code} ${zhName} ${enName}`.toLowerCase(), - }); - } - } - - return options.sort((left, right) => left.code.localeCompare(right.code)); -} - -function getListFieldKey( - listType: RuleListType, - dimension: RuleDimension, -): ListFieldKey { - if (dimension === 'ip') { - return listType === 'whitelist' ? 'ip_whitelist' : 'ip_blacklist'; - } - return listType === 'whitelist' ? 'country_whitelist' : 'country_blacklist'; -} - -function updateDraftList( - draft: WAFRuleGroupPayload, - key: ListFieldKey, - updater: (items: string[]) => string[], -) { - switch (key) { - case 'ip_whitelist': - return { ...draft, ip_whitelist: updater(draft.ip_whitelist) }; - case 'ip_blacklist': - return { ...draft, ip_blacklist: updater(draft.ip_blacklist) }; - case 'country_whitelist': - return { ...draft, country_whitelist: updater(draft.country_whitelist) }; - case 'country_blacklist': - return { ...draft, country_blacklist: updater(draft.country_blacklist) }; - } -} - -function formatCountryItem(code: string, labelMap: Map) { - return labelMap.get(code) ?? code; -} - -function StatCard({ - icon: Icon, - label, - value, - hint, -}: { - icon: LucideIcon; - label: string; - value: string; - hint?: string; -}) { - return ( -
-
-
-

- {label} -

-

- {value} -

-
- - - -
- {hint && ( -

- {hint} -

- )} -
- ); -} - -function TabButton({ - label, - active, - onClick, -}: { - label: string; - active: boolean; - onClick: () => void; -}) { - return ( - - ); -} - -function RuleChip({ - label, - tone, - onRemove, -}: { - label: string; - tone: 'whitelist' | 'blacklist'; - onRemove: () => void; -}) { - return ( - - {label} - - - ); -} - -function RuleListSection({ - title, - description, - items, - tone, - emptyText, - onRemove, -}: { - title: string; - description: string; - items: string[]; - tone: 'whitelist' | 'blacklist'; - emptyText: string; - onRemove: (item: string) => void; -}) { - return ( -
-
-
-

- {title} -

-

- {description} -

-
- - {items.length} - -
- {items.length > 0 ? ( -
- {items.map((item) => ( - onRemove(item)} - /> - ))} -
- ) : ( -
- {emptyText} -
- )} -
- ); -} - -function RuleEntryModal({ - state, - countryOptions, - pending, - onClose, - onChange, - onSubmit, -}: { - state: RuleModalState; - countryOptions: CountryOption[]; - pending: boolean; - onClose: () => void; - onChange: (patch: Partial) => void; - onSubmit: () => void; -}) { - const [keyword, setKeyword] = useState(''); - - useEffect(() => { - if (!state.open) { - return; - } - setKeyword(''); - }, [state.dimension, state.open]); - - const selectedCountrySet = useMemo( - () => new Set(state.countryValues), - [state.countryValues], - ); - - const filteredCountries = useMemo(() => { - const normalized = keyword.trim().toLowerCase(); - - return countryOptions - .filter((option) => !normalized || option.searchText.includes(normalized)) - .sort((left, right) => { - const leftSelected = selectedCountrySet.has(left.code) ? 1 : 0; - const rightSelected = selectedCountrySet.has(right.code) ? 1 : 0; - return ( - rightSelected - leftSelected || left.code.localeCompare(right.code) - ); - }); - }, [countryOptions, keyword, selectedCountrySet]); - - const toggleCountry = (code: string) => { - const values = selectedCountrySet.has(code) - ? state.countryValues.filter((item) => item !== code) - : normalizeItems([...state.countryValues, code]); - onChange({ countryValues: values }); - }; - - const selectFiltered = () => { - onChange({ - countryValues: normalizeItems([ - ...state.countryValues, - ...filteredCountries.map((option) => option.code), - ]), - }); - }; - - const clearCountries = () => onChange({ countryValues: [] }); - - const typeLabel = state.listType === 'whitelist' ? '白名单' : '黑名单'; - const dimensionLabel = state.dimension === 'ip' ? 'IP' : '地域'; - - return ( - - - 取消 - - - {pending ? '处理中...' : '添加'} - - - } - > -
-
- -
- {[ - { value: 'blacklist', label: '黑名单' }, - { value: 'whitelist', label: '白名单' }, - ].map((option) => ( - - ))} -
-
- -
- {[ - { value: 'ip', label: 'IP' }, - { value: 'country', label: '地域' }, - ].map((option) => ( - - ))} -
-
-
- - {state.dimension === 'ip' ? ( - - onChange({ ipValue: event.target.value })} - /> - - ) : ( -
-
- - setKeyword(event.target.value)} - placeholder="搜索国家代码或中文名" - className="min-w-0 flex-1 bg-transparent text-sm text-[var(--foreground-primary)] outline-none placeholder:text-[var(--foreground-muted)]" - /> - - -
- -
-
-
-

- 地域多选 -

-

- 选项显示为「国家代码 国家中文名」。 -

-
- - 已选 {state.countryValues.length} - -
- -
- {filteredCountries.map((option) => { - const selected = selectedCountrySet.has(option.code); - return ( - - ); - })} -
-
-
- )} -
-
- ); -} - -function SiteApplyDrawer({ - group, - routes, - open, - onOpenChange, - onSave, - pending, -}: { - group: WAFRuleGroup | null; - routes: ProxyRouteItem[]; - open: boolean; - onOpenChange: (open: boolean) => void; - onSave: (ids: number[]) => void; - pending: boolean; -}) { - const [keyword, setKeyword] = useState(''); - const [selectedIDs, setSelectedIDs] = useState([]); - - useEffect(() => { - setSelectedIDs(group?.applied_site_ids ?? []); - setKeyword(''); - }, [group, open]); - - const filteredRoutes = useMemo(() => { - const normalized = keyword.trim().toLowerCase(); - if (!normalized) { - return routes; - } - return routes.filter((route) => - [route.site_name, route.primary_domain, ...route.domains] - .join(' ') - .toLowerCase() - .includes(normalized), - ); - }, [keyword, routes]); - - const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]); - const toggleID = (id: number) => { - setSelectedIDs((current) => - current.includes(id) - ? current.filter((item) => item !== id) - : [...current, id].sort((left, right) => left - right), - ); - }; - - const selectFiltered = () => { - const next = new Set(selectedIDs); - filteredRoutes.forEach((route) => next.add(route.id)); - setSelectedIDs([...next].sort((left, right) => left - right)); - }; - - return ( - - onOpenChange(false)}> - 取消 - - onSave(selectedIDs)} - > - {pending ? '保存中...' : '保存应用范围'} - - - } - > -
-
- - setKeyword(event.target.value)} - placeholder="搜索网站或域名" - className="min-w-0 flex-1 bg-transparent text-sm text-[var(--foreground-primary)] outline-none placeholder:text-[var(--foreground-muted)]" - /> - -
-
- {filteredRoutes.map((route) => ( - - ))} -
-
-
- ); -} - - - -function PowTabPanel({ - enabled, - config, - onChange, -}: { - enabled: boolean; - config: ProxyRoutePoWConfig; - onChange: (enabled: boolean, config: ProxyRoutePoWConfig) => void; -}) { - const [draft, setDraft] = useState(() => ({ - whitelist: { - ips: listToText(config.whitelist?.ips), - ip_cidrs: listToText(config.whitelist?.ip_cidrs), - paths: listToText(config.whitelist?.paths), - path_regexes: listToText(config.whitelist?.path_regexes), - user_agents: listToText(config.whitelist?.user_agents), - }, - blacklist: { - ips: listToText(config.blacklist?.ips), - ip_cidrs: listToText(config.blacklist?.ip_cidrs), - paths: listToText(config.blacklist?.paths), - path_regexes: listToText(config.blacklist?.path_regexes), - user_agents: listToText(config.blacklist?.user_agents), - }, - })); - - useEffect(() => { - setDraft({ - whitelist: { - ips: listToText(config.whitelist?.ips), - ip_cidrs: listToText(config.whitelist?.ip_cidrs), - paths: listToText(config.whitelist?.paths), - path_regexes: listToText(config.whitelist?.path_regexes), - user_agents: listToText(config.whitelist?.user_agents), - }, - blacklist: { - ips: listToText(config.blacklist?.ips), - ip_cidrs: listToText(config.blacklist?.ip_cidrs), - paths: listToText(config.blacklist?.paths), - path_regexes: listToText(config.blacklist?.path_regexes), - user_agents: listToText(config.blacklist?.user_agents), - }, - }); - }, [config]); - - const updateConfig = ( - newEnabled: boolean, - newConfig: Partial, - newDraft?: typeof draft, - ) => { - const nextConfig = { ...config, ...newConfig }; - if (newDraft) { - setDraft(newDraft); - nextConfig.whitelist = { - ips: parseTextareaList(newDraft.whitelist.ips), - ip_cidrs: parseTextareaList(newDraft.whitelist.ip_cidrs), - paths: parseTextareaList(newDraft.whitelist.paths), - path_regexes: parseTextareaList(newDraft.whitelist.path_regexes), - user_agents: parseTextareaList(newDraft.whitelist.user_agents), - }; - nextConfig.blacklist = { - ips: parseTextareaList(newDraft.blacklist.ips), - ip_cidrs: parseTextareaList(newDraft.blacklist.ip_cidrs), - paths: parseTextareaList(newDraft.blacklist.paths), - path_regexes: parseTextareaList(newDraft.blacklist.path_regexes), - user_agents: parseTextareaList(newDraft.blacklist.user_agents), - }; - } - onChange(newEnabled, nextConfig); - }; - - const updateList = ( - scope: 'whitelist' | 'blacklist', - key: keyof ProxyRoutePoWConfig['whitelist'], - value: string, - ) => { - const nextDraft = { - ...draft, - [scope]: { - ...draft[scope], - [key]: value, - }, - }; - updateConfig(enabled, {}, nextDraft); - }; - - return ( -
- updateConfig(newEnabled, {})} - /> - -
- - - - - - updateConfig(enabled, { difficulty: Number(event.target.value) }) - } - /> - - - - updateConfig(enabled, { session_ttl: Number(event.target.value) }) - } - /> - - - - updateConfig(enabled, { challenge_ttl: Number(event.target.value) }) - } - /> - -
- -
- {(['whitelist', 'blacklist'] as const).map((scope) => ( -
-

- {scope === 'whitelist' - ? '白名单(跳过 PoW)' - : '黑名单(必须 PoW)'} -

-
- - - updateList(scope, 'ips', event.target.value) - } - /> - - - - updateList(scope, 'ip_cidrs', event.target.value) - } - /> - - - - updateList(scope, 'paths', event.target.value) - } - /> - - - - updateList(scope, 'path_regexes', event.target.value) - } - /> - - - - updateList(scope, 'user_agents', event.target.value) - } - /> - -
-
- ))} -
-
- ); -} +import { RuleEntryModal } from './rule-entry-modal'; +import { SiteApplyDrawer } from './site-apply-drawer'; +import { PowTabPanel } from './pow-tab-panel'; +import { RuleListSection } from './rule-list-section'; +import { TabButton } from './tab-button'; +import { + buildCountryOptions, + buildDraft, + countRuleEntries, + defaultRuleModalState, + emptyDraft, + formatCountryItem, + getErrorMessage, + getListFieldKey, + normalizeItems, + tabItems, + textToList, + updateDraftList, +} from './helpers'; +import type { FeedbackState, ListFieldKey, RuleModalState, WAFTab } from './types'; export function WAFPage() { const queryClient = useQueryClient(); @@ -1080,8 +152,6 @@ export function WAFPage() { }, }); - - if (groupsQuery.isLoading || routesQuery.isLoading) { return ; }