diff --git a/frontend/app/(main)/proxy-routes/detail/components/waf-section.tsx b/frontend/app/(main)/proxy-routes/detail/components/waf-section.tsx index 96aac6f9..568a4d89 100644 --- a/frontend/app/(main)/proxy-routes/detail/components/waf-section.tsx +++ b/frontend/app/(main)/proxy-routes/detail/components/waf-section.tsx @@ -1,16 +1,43 @@ 'use client'; import {useEffect, useMemo, useState} from 'react'; +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import {CSS} from '@dnd-kit/utilities'; import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; +import {ArrowDown, ArrowUp, GripVertical} from 'lucide-react'; import {toast} from 'sonner'; import {Badge} from '@/components/ui/badge'; +import {Button} from '@/components/ui/button'; +import {Checkbox} from '@/components/ui/checkbox'; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, +} from '@/components/ui/field'; import {EmptyStateWithBorder} from '@/components/layout/empty'; import {ErrorInline} from '@/components/layout/error'; import {LoadingStateWithBorder} from '@/components/layout/loading'; import type {ProxyRouteItem} from '@/lib/services/openflare'; import {WafService} from '@/lib/services/openflare'; -import {cn} from '@/lib/utils'; import {getErrorMessage} from '../../components/helpers'; import {proxyRouteFormIds} from '../helpers'; @@ -21,9 +48,70 @@ interface WafSectionProps { onSavingChange?: (saving: boolean) => void; } +interface SortableRuleProps { + id: number; + name: string; + index: number; + total: number; + onMove: (from: number, to: number) => void; +} + +export function reorderRuleIDs(ids: number[], activeID: number, overID: number) { + const from = ids.indexOf(activeID); + const to = ids.indexOf(overID); + return from < 0 || to < 0 || from === to ? ids : arrayMove(ids, from, to); +} + +function SortableRule({id, name, index, total, onMove}: SortableRuleProps) { + const {attributes, listeners, setNodeRef, transform, transition} = useSortable({id}); + return ( +
+ + {name} + + +
+ ); +} + export function WafSection({ route, onSavingChange }: WafSectionProps) { const queryClient = useQueryClient(); const [selectedIDs, setSelectedIDs] = useState([]); + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, {coordinateGetter: sortableKeyboardCoordinates}), + ); const wafQuery = useQuery({ queryKey: ['openflare', 'waf', 'site-rule-groups', route.id], @@ -61,6 +149,22 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) { }, [wafQuery.data]); const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]); + const ruleMap = useMemo( + () => new Map((wafQuery.data?.rule_groups ?? []).map((rule) => [rule.id, rule])), + [wafQuery.data?.rule_groups], + ); + + const moveRule = (from: number, to: number) => { + if (to < 0 || to >= selectedIDs.length) return; + setSelectedIDs((current) => arrayMove(current, from, to)); + }; + + const handleDragEnd = ({active, over}: DragEndEvent) => { + if (!over || active.id === over.id) return; + setSelectedIDs((current) => + reorderRuleIDs(current, Number(active.id), Number(over.id)), + ); + }; return ( { event.preventDefault(); wafMutation.mutate(selectedIDs); }} > {wafQuery.data?.global_rule_group ? ( -
+

@@ -101,38 +205,77 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {

) : null} -
- {(wafQuery.data?.rule_groups ?? []).map((group) => ( -
+ ) : null} {(wafQuery.data?.rule_groups ?? []).length === 0 ? ( diff --git a/frontend/app/(main)/waf/components/helpers.ts b/frontend/app/(main)/waf/components/helpers.ts index a59a1859..26d4ea34 100644 --- a/frontend/app/(main)/waf/components/helpers.ts +++ b/frontend/app/(main)/waf/components/helpers.ts @@ -1,115 +1,12 @@ import type { - ProxyRoutePoWConfig, WAFIPGroup, WAFIPGroupPayload, - WAFRuleGroup, - WAFRuleGroupPayload, } from '@/lib/services/openflare'; -export type WAFTab = 'basic' | 'lists' | 'pow' | 'block'; -export type RuleListType = 'whitelist' | 'blacklist'; -export type RuleDimension = 'ip' | 'ip_group' | 'country'; -export type ListFieldKey = - | 'ip_whitelist' - | 'ip_blacklist' - | 'ip_whitelist_group_ids' - | 'ip_blacklist_group_ids' - | '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; - ipGroupIDs: number[]; - countryValues: string[]; -}; - -export type RuleListRenderable = Pick< - WAFRuleGroupPayload, - | 'ip_whitelist' - | 'ip_blacklist' - | 'ip_whitelist_group_ids' - | 'ip_blacklist_group_ids' - | 'country_whitelist' - | 'country_blacklist' - | 'region_whitelist' - | 'region_blacklist' ->; - -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 emptyRuleGroupDraft: WAFRuleGroupPayload = { - name: '', - enabled: true, - block_status_code: 418, - block_response_body: '', - ip_whitelist: [], - ip_blacklist: [], - ip_whitelist_group_ids: [], - ip_blacklist_group_ids: [], - country_whitelist: [], - country_blacklist: [], - region_whitelist: [], - region_blacklist: [], - pow_enabled: false, - pow_config: defaultPowConfig, -}; - -export const defaultRuleModalState: RuleModalState = { - open: false, - listType: 'blacklist', - dimension: 'ip', - ipValue: '', - ipGroupIDs: [], - countryValues: [], -}; - -export const wafTabItems: 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'); } @@ -121,130 +18,6 @@ export function parseTextareaList(text: string) { .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 buildRuleGroupDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload { - if (!group) { - return { ...emptyRuleGroupDraft }; - } - 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 ?? [], - ip_whitelist_group_ids: group.ip_whitelist_group_ids ?? [], - ip_blacklist_group_ids: group.ip_blacklist_group_ids ?? [], - 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, - }; -} - -export function countRuleEntries(group: RuleListRenderable) { - return ( - group.ip_whitelist.length + - group.ip_blacklist.length + - group.ip_whitelist_group_ids.length + - group.ip_blacklist_group_ids.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'; - } - if (dimension === 'ip_group') { - return listType === 'whitelist' - ? 'ip_whitelist_group_ids' - : 'ip_blacklist_group_ids'; - } - 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 'ip_whitelist_group_ids': - return { - ...draft, - ip_whitelist_group_ids: updater( - draft.ip_whitelist_group_ids.map(String), - ).map(Number), - }; - case 'ip_blacklist_group_ids': - return { - ...draft, - ip_blacklist_group_ids: updater( - draft.ip_blacklist_group_ids.map(String), - ).map(Number), - }; - 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; -} export function parseAutomaticConfig(text: string): Record { const parsed = JSON.parse(text || '{}') as unknown; diff --git a/frontend/app/(main)/waf/components/pow-config-panel.tsx b/frontend/app/(main)/waf/components/pow-config-panel.tsx deleted file mode 100644 index 7a46ea17..00000000 --- a/frontend/app/(main)/waf/components/pow-config-panel.tsx +++ /dev/null @@ -1,244 +0,0 @@ -'use client'; - -import {useEffect, useState} from 'react'; - -import {Input} from '@/components/ui/input'; -import {Label} from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import {Switch} from '@/components/ui/switch'; -import {Textarea} from '@/components/ui/textarea'; -import type {ProxyRoutePoWConfig} from '@/lib/services/openflare'; - -import {listToText, parseTextareaList} from './helpers'; - -interface PowConfigPanelProps { - enabled: boolean; - config: ProxyRoutePoWConfig; - onChange: (enabled: boolean, config: ProxyRoutePoWConfig) => void; -} - -type PowListDraft = { - whitelist: Record; - blacklist: Record; -}; - -function buildDraft(config: ProxyRoutePoWConfig): PowListDraft { - return { - 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), - }, - }; -} - -function applyDraft( - config: ProxyRoutePoWConfig, - draft: PowListDraft, -): ProxyRoutePoWConfig { - return { - ...config, - whitelist: { - ips: parseTextareaList(draft.whitelist.ips), - ip_cidrs: parseTextareaList(draft.whitelist.ip_cidrs), - paths: parseTextareaList(draft.whitelist.paths), - path_regexes: parseTextareaList(draft.whitelist.path_regexes), - user_agents: parseTextareaList(draft.whitelist.user_agents), - }, - blacklist: { - ips: parseTextareaList(draft.blacklist.ips), - ip_cidrs: parseTextareaList(draft.blacklist.ip_cidrs), - paths: parseTextareaList(draft.blacklist.paths), - path_regexes: parseTextareaList(draft.blacklist.path_regexes), - user_agents: parseTextareaList(draft.blacklist.user_agents), - }, - }; -} - -function PowListCard({ - title, - scope, - draft, - onUpdate, -}: { - title: string; - scope: keyof PowListDraft; - draft: PowListDraft; - onUpdate: ( - scope: keyof PowListDraft, - key: keyof ProxyRoutePoWConfig['whitelist'], - value: string, - ) => void; -}) { - const fields: Array<{ - key: keyof ProxyRoutePoWConfig['whitelist']; - label: string; - placeholder: string; - }> = [ - {key: 'ips', label: 'IP', placeholder: '每行一个 IP'}, - {key: 'ip_cidrs', label: 'IP CIDR', placeholder: '每行一个网段,如 10.0.0.0/8'}, - {key: 'paths', label: '路径', placeholder: '每行一个路径前缀'}, - {key: 'path_regexes', label: '路径正则', placeholder: '每行一个正则表达式'}, - {key: 'user_agents', label: 'User-Agent', placeholder: '每行一个 UA 关键词或正则'}, - ]; - - return ( -
-

{title}

-
- {fields.map((field) => ( -
- -