mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 02:26:36 +08:00
prettier
This commit is contained in:
@@ -2,52 +2,136 @@ import {describe, expect, it} from 'vitest';
|
||||
|
||||
import type {WAFRuleGraph} from '@/lib/services/openflare';
|
||||
|
||||
import {acceptedNodeChanges, filterRemovableNodeIds, findGraphErrorTarget, getHistoryTransition, isConnectionAllowed, isPersistentEdgeChange, isPersistentNodeChange} from './editor-behavior';
|
||||
import {
|
||||
acceptedNodeChanges,
|
||||
filterRemovableNodeIds,
|
||||
findGraphErrorTarget,
|
||||
getHistoryTransition,
|
||||
isConnectionAllowed,
|
||||
isPersistentEdgeChange,
|
||||
isPersistentNodeChange,
|
||||
} from './editor-behavior';
|
||||
|
||||
describe('React Flow persistence filtering', () => {
|
||||
it('ignores dimensions and selection changes', () => {
|
||||
expect(isPersistentNodeChange({type: 'dimensions', id: 'n', dimensions: {width: 10, height: 10}})).toBe(false);
|
||||
expect(isPersistentNodeChange({type: 'select', id: 'n', selected: true})).toBe(false);
|
||||
expect(isPersistentEdgeChange({type: 'select', id: 'e', selected: true})).toBe(false);
|
||||
expect(
|
||||
isPersistentNodeChange({
|
||||
type: 'dimensions',
|
||||
id: 'n',
|
||||
dimensions: { width: 10, height: 10 },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPersistentNodeChange({ type: 'select', id: 'n', selected: true }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPersistentEdgeChange({ type: 'select', id: 'e', selected: true }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('persists completed position changes and removals', () => {
|
||||
expect(isPersistentNodeChange({type: 'position', id: 'n', position: {x: 1, y: 2}, dragging: false})).toBe(true);
|
||||
expect(isPersistentNodeChange({type: 'remove', id: 'n'})).toBe(true);
|
||||
expect(isPersistentEdgeChange({type: 'remove', id: 'e'})).toBe(true);
|
||||
expect(
|
||||
isPersistentNodeChange({
|
||||
type: 'position',
|
||||
id: 'n',
|
||||
position: { x: 1, y: 2 },
|
||||
dragging: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isPersistentNodeChange({ type: 'remove', id: 'n' })).toBe(true);
|
||||
expect(isPersistentEdgeChange({ type: 'remove', id: 'e' })).toBe(true);
|
||||
});
|
||||
|
||||
it('forwards transient node changes to React Flow without persisting them', () => {
|
||||
const changes = [
|
||||
{type: 'dimensions' as const, id: 'match', dimensions: {width: 176, height: 64}},
|
||||
{type: 'select' as const, id: 'match', selected: true},
|
||||
{type: 'position' as const, id: 'match', position: {x: 24, y: 48}, dragging: true},
|
||||
{
|
||||
type: 'dimensions' as const,
|
||||
id: 'match',
|
||||
dimensions: { width: 176, height: 64 },
|
||||
},
|
||||
{ type: 'select' as const, id: 'match', selected: true },
|
||||
{
|
||||
type: 'position' as const,
|
||||
id: 'match',
|
||||
position: { x: 24, y: 48 },
|
||||
dragging: true,
|
||||
},
|
||||
];
|
||||
|
||||
const nodes: WAFRuleGraph['nodes'] = [
|
||||
{id: 'match', type: 'ip_match', position: {x: 0, y: 0}, config: {ips: [], cidrs: [], ip_group_ids: []}},
|
||||
{
|
||||
id: 'match',
|
||||
type: 'ip_match',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { ips: [], cidrs: [], ip_group_ids: [] },
|
||||
},
|
||||
];
|
||||
expect(acceptedNodeChanges(nodes, changes)).toEqual({changes, persistent: false});
|
||||
expect(acceptedNodeChanges(nodes, changes)).toEqual({
|
||||
changes,
|
||||
persistent: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor safety constraints', () => {
|
||||
const graph: WAFRuleGraph = {schema_version: 1, nodes: [
|
||||
{id: 'start', type: 'start', position: {x: 0, y: 0}, config: {}},
|
||||
{id: 'match', type: 'ip_match', position: {x: 0, y: 0}, config: {ips: [], cidrs: [], ip_group_ids: []}},
|
||||
{id: 'allow', type: 'allow', position: {x: 0, y: 0}, config: {}},
|
||||
], edges: [{id: 'start-match', source: 'start', source_handle: 'next', target: 'match'}]};
|
||||
const graph: WAFRuleGraph = {
|
||||
schema_version: 1,
|
||||
nodes: [
|
||||
{ id: 'start', type: 'start', position: { x: 0, y: 0 }, config: {} },
|
||||
{
|
||||
id: 'match',
|
||||
type: 'ip_match',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { ips: [], cidrs: [], ip_group_ids: [] },
|
||||
},
|
||||
{ id: 'allow', type: 'allow', position: { x: 0, y: 0 }, config: {} },
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'start-match',
|
||||
source: 'start',
|
||||
source_handle: 'next',
|
||||
target: 'match',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('protects start and allow from deletion', () => expect(filterRemovableNodeIds(graph.nodes, ['start', 'match', 'allow'])).toEqual(['match']));
|
||||
it('protects start and allow from deletion', () =>
|
||||
expect(
|
||||
filterRemovableNodeIds(graph.nodes, ['start', 'match', 'allow']),
|
||||
).toEqual(['match']));
|
||||
|
||||
it('does not persist a removal containing only protected nodes', () => {
|
||||
expect(acceptedNodeChanges(graph.nodes, [{type: 'remove', id: 'start'}, {type: 'remove', id: 'allow'}])).toEqual({changes: [], persistent: false});
|
||||
expect(
|
||||
acceptedNodeChanges(graph.nodes, [
|
||||
{ type: 'remove', id: 'start' },
|
||||
{ type: 'remove', id: 'allow' },
|
||||
]),
|
||||
).toEqual({ changes: [], persistent: false });
|
||||
});
|
||||
|
||||
it('rejects invalid and already-used source ports', () => {
|
||||
expect(isConnectionAllowed(graph, {source: 'match', sourceHandle: 'next', target: 'allow'})).toBe(false);
|
||||
expect(isConnectionAllowed(graph, {source: 'start', sourceHandle: 'next', target: 'allow'})).toBe(false);
|
||||
expect(isConnectionAllowed(graph, {source: 'match', sourceHandle: 'true', target: 'allow'})).toBe(true);
|
||||
expect(
|
||||
isConnectionAllowed(graph, {
|
||||
source: 'match',
|
||||
sourceHandle: 'next',
|
||||
target: 'allow',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isConnectionAllowed(graph, {
|
||||
source: 'start',
|
||||
sourceHandle: 'next',
|
||||
target: 'allow',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isConnectionAllowed(graph, {
|
||||
source: 'match',
|
||||
sourceHandle: 'true',
|
||||
target: 'allow',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,24 +140,73 @@ describe('server graph error targeting', () => {
|
||||
const edges = ['start-match'];
|
||||
|
||||
it('uses explicit node and edge ids from nested payloads', () => {
|
||||
expect(findGraphErrorTarget({details: {node_id: 'match-1'}}, nodes, edges)).toEqual({kind: 'node', id: 'match-1'});
|
||||
expect(findGraphErrorTarget({details: {edgeId: 'start-match'}}, nodes, edges)).toEqual({kind: 'edge', id: 'start-match'});
|
||||
expect(
|
||||
findGraphErrorTarget({ details: { node_id: 'match-1' } }, nodes, edges),
|
||||
).toEqual({ kind: 'node', id: 'match-1' });
|
||||
expect(
|
||||
findGraphErrorTarget(
|
||||
{ details: { edgeId: 'start-match' } },
|
||||
nodes,
|
||||
edges,
|
||||
),
|
||||
).toEqual({ kind: 'edge', id: 'start-match' });
|
||||
});
|
||||
|
||||
it('does not substring match unrelated error text', () => {
|
||||
expect(findGraphErrorTarget({message: 'restart operation failed'}, nodes, edges)).toBeUndefined();
|
||||
expect(
|
||||
findGraphErrorTarget(
|
||||
{ message: 'restart operation failed' },
|
||||
nodes,
|
||||
edges,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses the real API envelope with strict ID boundaries', () => {
|
||||
expect(findGraphErrorTarget({error_msg: '规则图无效: 节点 match-1 的 true 出口未连接', data: null}, nodes, edges)).toEqual({kind: 'node', id: 'match-1'});
|
||||
expect(findGraphErrorTarget(new Error('规则图无效: 边 start-match 的目标节点不存在'), nodes, edges)).toEqual({kind: 'edge', id: 'start-match'});
|
||||
expect(findGraphErrorTarget({error_msg: '节点 match-10 无效'}, nodes, edges)).toBeUndefined();
|
||||
expect(findGraphErrorTarget({error_msg: '规则图无效: 边 ID start-match 重复'}, nodes, edges)).toEqual({kind: 'edge', id: 'start-match'});
|
||||
expect(findGraphErrorTarget({error_msg: '边 ID start-matcher 重复'}, nodes, edges)).toBeUndefined();
|
||||
expect(
|
||||
findGraphErrorTarget(
|
||||
{
|
||||
error_msg: '规则图无效: 节点 match-1 的 true 出口未连接',
|
||||
data: null,
|
||||
},
|
||||
nodes,
|
||||
edges,
|
||||
),
|
||||
).toEqual({ kind: 'node', id: 'match-1' });
|
||||
expect(
|
||||
findGraphErrorTarget(
|
||||
new Error('规则图无效: 边 start-match 的目标节点不存在'),
|
||||
nodes,
|
||||
edges,
|
||||
),
|
||||
).toEqual({ kind: 'edge', id: 'start-match' });
|
||||
expect(
|
||||
findGraphErrorTarget({ error_msg: '节点 match-10 无效' }, nodes, edges),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
findGraphErrorTarget(
|
||||
{ error_msg: '规则图无效: 边 ID start-match 重复' },
|
||||
nodes,
|
||||
edges,
|
||||
),
|
||||
).toEqual({ kind: 'edge', id: 'start-match' });
|
||||
expect(
|
||||
findGraphErrorTarget(
|
||||
{ error_msg: '边 ID start-matcher 重复' },
|
||||
nodes,
|
||||
edges,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('calculates deterministic Back and Forward restoration deltas', () => {
|
||||
expect(getHistoryTransition(4, 3)).toEqual({direction: 'back', restoreDelta: 1});
|
||||
expect(getHistoryTransition(4, 6)).toEqual({direction: 'forward', restoreDelta: -2});
|
||||
expect(getHistoryTransition(4, 3)).toEqual({
|
||||
direction: 'back',
|
||||
restoreDelta: 1,
|
||||
});
|
||||
expect(getHistoryTransition(4, 6)).toEqual({
|
||||
direction: 'forward',
|
||||
restoreDelta: -2,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,70 +3,155 @@ import type {WAFRuleGraph, WAFRuleNode} from '@/lib/services/openflare';
|
||||
|
||||
import {wouldCreateCycle} from './graph-validation';
|
||||
|
||||
export type GraphErrorTarget = {kind: 'node' | 'edge'; id: string};
|
||||
export type GraphErrorTarget = { kind: 'node' | 'edge'; id: string };
|
||||
|
||||
export function isPersistentNodeChange<NodeType extends Node = Node>(change: NodeChange<NodeType>): boolean {
|
||||
return change.type === 'remove' || change.type === 'add' || change.type === 'replace' || (change.type === 'position' && change.dragging === false && Boolean(change.position));
|
||||
export function isPersistentNodeChange<NodeType extends Node = Node>(
|
||||
change: NodeChange<NodeType>,
|
||||
): boolean {
|
||||
return (
|
||||
change.type === 'remove' ||
|
||||
change.type === 'add' ||
|
||||
change.type === 'replace' ||
|
||||
(change.type === 'position' &&
|
||||
change.dragging === false &&
|
||||
Boolean(change.position))
|
||||
);
|
||||
}
|
||||
|
||||
export function isPersistentEdgeChange(change: EdgeChange): boolean {
|
||||
return change.type === 'remove' || change.type === 'add' || change.type === 'replace';
|
||||
return (
|
||||
change.type === 'remove' ||
|
||||
change.type === 'add' ||
|
||||
change.type === 'replace'
|
||||
);
|
||||
}
|
||||
|
||||
export function filterRemovableNodeIds(nodes: WAFRuleNode[], ids: string[]): string[] {
|
||||
return ids.filter((id) => !['start', 'allow'].includes(nodes.find((node) => node.id === id)?.type ?? ''));
|
||||
export function filterRemovableNodeIds(
|
||||
nodes: WAFRuleNode[],
|
||||
ids: string[],
|
||||
): string[] {
|
||||
return ids.filter(
|
||||
(id) =>
|
||||
!['start', 'allow'].includes(
|
||||
nodes.find((node) => node.id === id)?.type ?? '',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function acceptedNodeChanges<NodeType extends Node = Node>(nodes: WAFRuleNode[], changes: NodeChange<NodeType>[]): {changes: NodeChange<NodeType>[]; persistent: boolean} {
|
||||
const accepted = changes.filter((change) => change.type !== 'remove' || filterRemovableNodeIds(nodes, [change.id]).length === 1);
|
||||
return {changes: accepted, persistent: accepted.some(isPersistentNodeChange)};
|
||||
export function acceptedNodeChanges<NodeType extends Node = Node>(
|
||||
nodes: WAFRuleNode[],
|
||||
changes: NodeChange<NodeType>[],
|
||||
): { changes: NodeChange<NodeType>[]; persistent: boolean } {
|
||||
const accepted = changes.filter(
|
||||
(change) =>
|
||||
change.type !== 'remove' ||
|
||||
filterRemovableNodeIds(nodes, [change.id]).length === 1,
|
||||
);
|
||||
return {
|
||||
changes: accepted,
|
||||
persistent: accepted.some(isPersistentNodeChange),
|
||||
};
|
||||
}
|
||||
|
||||
export function getHistoryTransition(current: number, target: number): {direction: 'back' | 'forward'; restoreDelta: number} {
|
||||
return {direction: target < current ? 'back' : 'forward', restoreDelta: current - target};
|
||||
export function getHistoryTransition(
|
||||
current: number,
|
||||
target: number,
|
||||
): { direction: 'back' | 'forward'; restoreDelta: number } {
|
||||
return {
|
||||
direction: target < current ? 'back' : 'forward',
|
||||
restoreDelta: current - target,
|
||||
};
|
||||
}
|
||||
|
||||
export function isConnectionAllowed(graph: WAFRuleGraph, connection: {source?: string | null; sourceHandle?: string | null; target?: string | null}): boolean {
|
||||
if (!connection.source || !connection.target || !connection.sourceHandle) return false;
|
||||
export function isConnectionAllowed(
|
||||
graph: WAFRuleGraph,
|
||||
connection: {
|
||||
source?: string | null;
|
||||
sourceHandle?: string | null;
|
||||
target?: string | null;
|
||||
},
|
||||
): boolean {
|
||||
if (!connection.source || !connection.target || !connection.sourceHandle)
|
||||
return false;
|
||||
const source = graph.nodes.find((node) => node.id === connection.source);
|
||||
const handles: Partial<Record<WAFRuleNode['type'], string[]>> = {start: ['next'], ip_match: ['true', 'false'], geo_match: ['true', 'false'], pow: ['next']};
|
||||
return Boolean(source && (handles[source.type] ?? []).includes(connection.sourceHandle)) && !graph.edges.some((edge) => edge.source === connection.source && edge.source_handle === connection.sourceHandle) && !wouldCreateCycle(graph, connection.source, connection.target);
|
||||
const handles: Partial<Record<WAFRuleNode['type'], string[]>> = {
|
||||
start: ['next'],
|
||||
ip_match: ['true', 'false'],
|
||||
geo_match: ['true', 'false'],
|
||||
pow: ['next'],
|
||||
};
|
||||
return (
|
||||
Boolean(
|
||||
source && (handles[source.type] ?? []).includes(connection.sourceHandle),
|
||||
) &&
|
||||
!graph.edges.some(
|
||||
(edge) =>
|
||||
edge.source === connection.source &&
|
||||
edge.source_handle === connection.sourceHandle,
|
||||
) &&
|
||||
!wouldCreateCycle(graph, connection.source, connection.target)
|
||||
);
|
||||
}
|
||||
|
||||
export function findGraphErrorTarget(payload: unknown, nodeIds: string[], edgeIds: string[]): GraphErrorTarget | undefined {
|
||||
export function findGraphErrorTarget(
|
||||
payload: unknown,
|
||||
nodeIds: string[],
|
||||
edgeIds: string[],
|
||||
): GraphErrorTarget | undefined {
|
||||
const found = collectIdFields(payload);
|
||||
for (const {key, value} of found) {
|
||||
if ((key === 'node_id' || key === 'nodeId') && nodeIds.includes(value)) return {kind: 'node', id: value};
|
||||
if ((key === 'edge_id' || key === 'edgeId') && edgeIds.includes(value)) return {kind: 'edge', id: value};
|
||||
for (const { key, value } of found) {
|
||||
if ((key === 'node_id' || key === 'nodeId') && nodeIds.includes(value))
|
||||
return { kind: 'node', id: value };
|
||||
if ((key === 'edge_id' || key === 'edgeId') && edgeIds.includes(value))
|
||||
return { kind: 'edge', id: value };
|
||||
}
|
||||
const messages = collectMessages(payload);
|
||||
for (const message of messages) {
|
||||
const nodeId = findMessageId(message, '节点', nodeIds);
|
||||
if (nodeId) return {kind: 'node', id: nodeId};
|
||||
if (nodeId) return { kind: 'node', id: nodeId };
|
||||
const edgeId = findMessageId(message, '边', edgeIds);
|
||||
if (edgeId) return {kind: 'edge', id: edgeId};
|
||||
if (edgeId) return { kind: 'edge', id: edgeId };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectMessages(value: unknown): string[] {
|
||||
if (value instanceof Error) return [value.message, ...collectMessages(value.cause)];
|
||||
if (value instanceof Error)
|
||||
return [value.message, ...collectMessages(value.cause)];
|
||||
if (!value || typeof value !== 'object') return [];
|
||||
return Object.entries(value).flatMap(([key, child]) => key === 'error_msg' || key === 'message' ? typeof child === 'string' ? [child] : [] : collectMessages(child));
|
||||
return Object.entries(value).flatMap(([key, child]) =>
|
||||
key === 'error_msg' || key === 'message'
|
||||
? typeof child === 'string'
|
||||
? [child]
|
||||
: []
|
||||
: collectMessages(child),
|
||||
);
|
||||
}
|
||||
|
||||
function findMessageId(message: string, prefix: '节点' | '边', ids: string[]): string | undefined {
|
||||
function findMessageId(
|
||||
message: string,
|
||||
prefix: '节点' | '边',
|
||||
ids: string[],
|
||||
): string | undefined {
|
||||
const token = prefix === '边' ? '边(?:\\s+ID)?' : '节点';
|
||||
return [...ids].sort((a, b) => b.length - a.length).find((id) => new RegExp(`${token}\\s+${escapeRegExp(id)}(?=$|[\\s,。,::的])`).test(message));
|
||||
return [...ids]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.find((id) =>
|
||||
new RegExp(`${token}\\s+${escapeRegExp(id)}(?=$|[\\s,。,::的])`).test(
|
||||
message,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function collectIdFields(value: unknown): {key: string; value: string}[] {
|
||||
function collectIdFields(value: unknown): { key: string; value: string }[] {
|
||||
if (!value || typeof value !== 'object') return [];
|
||||
const result: {key: string; value: string}[] = [];
|
||||
const result: { key: string; value: string }[] = [];
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (typeof child === 'string') result.push({key, value: child});
|
||||
if (typeof child === 'string') result.push({ key, value: child });
|
||||
else result.push(...collectIdFields(child));
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -1,105 +1,265 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type {WAFRuleGraph} from '@/lib/services/openflare';
|
||||
import type { WAFRuleGraph } from '@/lib/services/openflare';
|
||||
|
||||
import {removeEdgeFromGraph, removeNodeFromGraph, validateGraph, wouldCreateCycle} from './graph-validation';
|
||||
import {
|
||||
removeEdgeFromGraph,
|
||||
removeNodeFromGraph,
|
||||
validateGraph,
|
||||
wouldCreateCycle,
|
||||
} from './graph-validation';
|
||||
|
||||
const validGraph = (): WAFRuleGraph => ({
|
||||
schema_version: 1,
|
||||
nodes: [
|
||||
{id: 'start', type: 'start', position: {x: 0, y: 0}, config: {}},
|
||||
{id: 'match', type: 'ip_match', position: {x: 240, y: 0}, config: {ips: ['127.0.0.1'], cidrs: [], ip_group_ids: []}},
|
||||
{id: 'allow', type: 'allow', position: {x: 520, y: -80}, config: {}},
|
||||
{id: 'block', type: 'block', position: {x: 520, y: 100}, config: {status_code: 403, response_body: ''}},
|
||||
{ id: 'start', type: 'start', position: { x: 0, y: 0 }, config: {} },
|
||||
{
|
||||
id: 'match',
|
||||
type: 'ip_match',
|
||||
position: { x: 240, y: 0 },
|
||||
config: { ips: ['127.0.0.1'], cidrs: [], ip_group_ids: [] },
|
||||
},
|
||||
{ id: 'allow', type: 'allow', position: { x: 520, y: -80 }, config: {} },
|
||||
{
|
||||
id: 'block',
|
||||
type: 'block',
|
||||
position: { x: 520, y: 100 },
|
||||
config: { status_code: 403, response_body: '' },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{id: 'start-match', source: 'start', source_handle: 'next', target: 'match'},
|
||||
{id: 'match-allow', source: 'match', source_handle: 'true', target: 'allow'},
|
||||
{id: 'match-block', source: 'match', source_handle: 'false', target: 'block'},
|
||||
{
|
||||
id: 'start-match',
|
||||
source: 'start',
|
||||
source_handle: 'next',
|
||||
target: 'match',
|
||||
},
|
||||
{
|
||||
id: 'match-allow',
|
||||
source: 'match',
|
||||
source_handle: 'true',
|
||||
target: 'allow',
|
||||
},
|
||||
{
|
||||
id: 'match-block',
|
||||
source: 'match',
|
||||
source_handle: 'false',
|
||||
target: 'block',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('validateGraph', () => {
|
||||
it('accepts a complete terminating graph', () => expect(validateGraph(validGraph())).toEqual([]));
|
||||
it('accepts a complete terminating graph', () =>
|
||||
expect(validateGraph(validGraph())).toEqual([]));
|
||||
|
||||
it('requires exactly one start and allow node', () => {
|
||||
const graph = validGraph();
|
||||
graph.nodes = graph.nodes.filter((node) => node.type !== 'allow');
|
||||
expect(validateGraph(graph).map((issue) => issue.code)).toContain('allow_count');
|
||||
expect(validateGraph(graph).map((issue) => issue.code)).toContain(
|
||||
'allow_count',
|
||||
);
|
||||
});
|
||||
|
||||
it('requires every source handle', () => {
|
||||
const graph = validGraph();
|
||||
graph.edges = graph.edges.filter((edge) => edge.source_handle !== 'false');
|
||||
expect(validateGraph(graph)).toContainEqual(expect.objectContaining({code: 'missing_handle', nodeId: 'match'}));
|
||||
expect(validateGraph(graph)).toContainEqual(
|
||||
expect.objectContaining({ code: 'missing_handle', nodeId: 'match' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects cycles', () => {
|
||||
const graph = validGraph();
|
||||
graph.edges.push({id: 'cycle', source: 'block', source_handle: 'next', target: 'match'});
|
||||
graph.edges.push({
|
||||
id: 'cycle',
|
||||
source: 'block',
|
||||
source_handle: 'next',
|
||||
target: 'match',
|
||||
});
|
||||
expect(validateGraph(graph).map((issue) => issue.code)).toContain('cycle');
|
||||
});
|
||||
|
||||
it('reports unreachable nodes and paths without a terminal', () => {
|
||||
const graph = validGraph();
|
||||
graph.nodes.push({id: 'orphan', type: 'pow', position: {x: 0, y: 200}, config: {algorithm: 'fast', difficulty: 4, session_ttl: 60, challenge_ttl: 30}});
|
||||
expect(validateGraph(graph)).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({code: 'unreachable', nodeId: 'orphan'}),
|
||||
expect.objectContaining({code: 'non_terminating', nodeId: 'orphan'}),
|
||||
]));
|
||||
graph.nodes.push({
|
||||
id: 'orphan',
|
||||
type: 'pow',
|
||||
position: { x: 0, y: 200 },
|
||||
config: {
|
||||
algorithm: 'fast',
|
||||
difficulty: 4,
|
||||
session_ttl: 60,
|
||||
challenge_ttl: 30,
|
||||
},
|
||||
});
|
||||
expect(validateGraph(graph)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'unreachable', nodeId: 'orphan' }),
|
||||
expect.objectContaining({ code: 'non_terminating', nodeId: 'orphan' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects duplicate identifiers and start incoming edges', () => {
|
||||
const graph = validGraph();
|
||||
graph.nodes.push({...graph.nodes[1]});
|
||||
graph.edges.push({...graph.edges[0]}, {id: 'into-start', source: 'match', source_handle: 'true', target: 'start'});
|
||||
expect(validateGraph(graph)).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({code: 'duplicate_node_id', nodeId: 'match'}),
|
||||
expect.objectContaining({code: 'duplicate_edge_id', edgeId: 'start-match'}),
|
||||
expect.objectContaining({code: 'start_incoming', edgeId: 'into-start'}),
|
||||
]));
|
||||
graph.nodes.push({ ...graph.nodes[1] });
|
||||
graph.edges.push(
|
||||
{ ...graph.edges[0] },
|
||||
{
|
||||
id: 'into-start',
|
||||
source: 'match',
|
||||
source_handle: 'true',
|
||||
target: 'start',
|
||||
},
|
||||
);
|
||||
expect(validateGraph(graph)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'duplicate_node_id', nodeId: 'match' }),
|
||||
expect.objectContaining({
|
||||
code: 'duplicate_edge_id',
|
||||
edgeId: 'start-match',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
code: 'start_incoming',
|
||||
edgeId: 'into-start',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('validates typed node configuration locally', () => {
|
||||
const graph = validGraph();
|
||||
graph.nodes = graph.nodes.map((node) => node.type === 'ip_match' ? {...node, config: {ips: ['999.1.1.1'], cidrs: ['broken'], ip_group_ids: [-1]}} : node.type === 'block' ? {...node, config: {status_code: 200, response_body: 'x'.repeat(65_537)}} : node);
|
||||
expect(validateGraph(graph).filter((issue) => issue.code === 'invalid_config').map((issue) => issue.nodeId)).toEqual(expect.arrayContaining(['match', 'block']));
|
||||
graph.nodes = graph.nodes.map((node) =>
|
||||
node.type === 'ip_match'
|
||||
? {
|
||||
...node,
|
||||
config: {
|
||||
ips: ['999.1.1.1'],
|
||||
cidrs: ['broken'],
|
||||
ip_group_ids: [-1],
|
||||
},
|
||||
}
|
||||
: node.type === 'block'
|
||||
? {
|
||||
...node,
|
||||
config: { status_code: 200, response_body: 'x'.repeat(65_537) },
|
||||
}
|
||||
: node,
|
||||
);
|
||||
expect(
|
||||
validateGraph(graph)
|
||||
.filter((issue) => issue.code === 'invalid_config')
|
||||
.map((issue) => issue.nodeId),
|
||||
).toEqual(expect.arrayContaining(['match', 'block']));
|
||||
});
|
||||
|
||||
it('validates PoW bounds and geography codes', () => {
|
||||
const graph = validGraph();
|
||||
graph.nodes.push({id: 'pow', type: 'pow', position: {x: 0, y: 0}, config: {algorithm: 'fast', difficulty: 0, session_ttl: 0, challenge_ttl: 0}});
|
||||
graph.nodes.push({id: 'geo', type: 'geo_match', position: {x: 0, y: 0}, config: {countries: ['china'], regions: ['']}});
|
||||
expect(validateGraph(graph)).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({code: 'invalid_config', nodeId: 'pow'}),
|
||||
expect.objectContaining({code: 'invalid_config', nodeId: 'geo'}),
|
||||
]));
|
||||
graph.nodes.push({
|
||||
id: 'pow',
|
||||
type: 'pow',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {
|
||||
algorithm: 'fast',
|
||||
difficulty: 0,
|
||||
session_ttl: 0,
|
||||
challenge_ttl: 0,
|
||||
},
|
||||
});
|
||||
graph.nodes.push({
|
||||
id: 'geo',
|
||||
type: 'geo_match',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { countries: ['china'], regions: [''] },
|
||||
});
|
||||
expect(validateGraph(graph)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'invalid_config', nodeId: 'pow' }),
|
||||
expect.objectContaining({ code: 'invalid_config', nodeId: 'geo' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-finite and fractional integer configuration', () => {
|
||||
const graph = validGraph();
|
||||
graph.nodes.push({id: 'pow', type: 'pow', position: {x: 0, y: 0}, config: {algorithm: 'fast', difficulty: 4.5, session_ttl: Number.NaN, challenge_ttl: 30}});
|
||||
graph.nodes.push({id: 'block-fraction', type: 'block', position: {x: 0, y: 0}, config: {status_code: 403.5, response_body: ''}});
|
||||
expect(validateGraph(graph)).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({code: 'invalid_config', nodeId: 'pow'}),
|
||||
expect.objectContaining({code: 'invalid_config', nodeId: 'block-fraction'}),
|
||||
]));
|
||||
graph.nodes.push({
|
||||
id: 'pow',
|
||||
type: 'pow',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {
|
||||
algorithm: 'fast',
|
||||
difficulty: 4.5,
|
||||
session_ttl: Number.NaN,
|
||||
challenge_ttl: 30,
|
||||
},
|
||||
});
|
||||
graph.nodes.push({
|
||||
id: 'block-fraction',
|
||||
type: 'block',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { status_code: 403.5, response_body: '' },
|
||||
});
|
||||
expect(validateGraph(graph)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'invalid_config', nodeId: 'pow' }),
|
||||
expect.objectContaining({
|
||||
code: 'invalid_config',
|
||||
nodeId: 'block-fraction',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('matches server IP and prefix parsing semantics', () => {
|
||||
const invalid = validGraph();
|
||||
invalid.nodes = invalid.nodes.map((node) => node.type === 'ip_match' ? {...node, config: {ips: ['2001:db8::1', '::::'], cidrs: ['2001:db8::/32', '10.0.0.0/33'], ip_group_ids: []}} : node);
|
||||
expect(validateGraph(invalid)).toContainEqual(expect.objectContaining({code: 'invalid_config', nodeId: 'match'}));
|
||||
invalid.nodes = invalid.nodes.map((node) =>
|
||||
node.type === 'ip_match'
|
||||
? {
|
||||
...node,
|
||||
config: {
|
||||
ips: ['2001:db8::1', '::::'],
|
||||
cidrs: ['2001:db8::/32', '10.0.0.0/33'],
|
||||
ip_group_ids: [],
|
||||
},
|
||||
}
|
||||
: node,
|
||||
);
|
||||
expect(validateGraph(invalid)).toContainEqual(
|
||||
expect.objectContaining({ code: 'invalid_config', nodeId: 'match' }),
|
||||
);
|
||||
const valid = validGraph();
|
||||
valid.nodes = valid.nodes.map((node) => node.type === 'ip_match' ? {...node, config: {ips: ['2001:db8::1', '192.0.2.1'], cidrs: ['2001:db8::/32', '10.0.0.0/8'], ip_group_ids: []}} : node);
|
||||
valid.nodes = valid.nodes.map((node) =>
|
||||
node.type === 'ip_match'
|
||||
? {
|
||||
...node,
|
||||
config: {
|
||||
ips: ['2001:db8::1', '192.0.2.1'],
|
||||
cidrs: ['2001:db8::/32', '10.0.0.0/8'],
|
||||
ip_group_ids: [],
|
||||
},
|
||||
}
|
||||
: node,
|
||||
);
|
||||
expect(validateGraph(valid)).toEqual([]);
|
||||
});
|
||||
|
||||
it('requires exactly one CIDR slash and rejects scoped IPv6 addresses', () => {
|
||||
for (const value of ['10.0.0.0/8/extra', 'fe80::1%en0', 'fe80::%en0/64']) {
|
||||
const graph = validGraph();
|
||||
graph.nodes = graph.nodes.map((node) => node.type === 'ip_match' ? {...node, config: value.includes('/') ? {ips: [], cidrs: [value], ip_group_ids: []} : {ips: [value], cidrs: [], ip_group_ids: []}} : node);
|
||||
expect(validateGraph(graph)).toContainEqual(expect.objectContaining({code: 'invalid_config', nodeId: 'match'}));
|
||||
graph.nodes = graph.nodes.map((node) =>
|
||||
node.type === 'ip_match'
|
||||
? {
|
||||
...node,
|
||||
config: value.includes('/')
|
||||
? { ips: [], cidrs: [value], ip_group_ids: [] }
|
||||
: { ips: [value], cidrs: [], ip_group_ids: [] },
|
||||
}
|
||||
: node,
|
||||
);
|
||||
expect(validateGraph(graph)).toContainEqual(
|
||||
expect.objectContaining({ code: 'invalid_config', nodeId: 'match' }),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import type {WAFRuleGraph, WAFRuleNode} from '@/lib/services/openflare';
|
||||
import type { WAFRuleGraph, WAFRuleNode } from '@/lib/services/openflare';
|
||||
|
||||
export type GraphIssueCode = 'schema' | 'size_limit' | 'empty_id' | 'duplicate_node_id' | 'duplicate_edge_id' | 'start_count' | 'allow_count' | 'start_incoming' | 'missing_handle' | 'duplicate_handle' | 'invalid_edge' | 'invalid_config' | 'cycle' | 'unreachable' | 'non_terminating';
|
||||
export type GraphIssueCode =
|
||||
| 'schema'
|
||||
| 'size_limit'
|
||||
| 'empty_id'
|
||||
| 'duplicate_node_id'
|
||||
| 'duplicate_edge_id'
|
||||
| 'start_count'
|
||||
| 'allow_count'
|
||||
| 'start_incoming'
|
||||
| 'missing_handle'
|
||||
| 'duplicate_handle'
|
||||
| 'invalid_edge'
|
||||
| 'invalid_config'
|
||||
| 'cycle'
|
||||
| 'unreachable'
|
||||
| 'non_terminating';
|
||||
|
||||
export interface GraphIssue {
|
||||
code: GraphIssueCode;
|
||||
@@ -10,47 +25,117 @@ export interface GraphIssue {
|
||||
}
|
||||
|
||||
const handles: Partial<Record<WAFRuleNode['type'], string[]>> = {
|
||||
start: ['next'], ip_match: ['true', 'false'], geo_match: ['true', 'false'], pow: ['next'],
|
||||
start: ['next'],
|
||||
ip_match: ['true', 'false'],
|
||||
geo_match: ['true', 'false'],
|
||||
pow: ['next'],
|
||||
};
|
||||
|
||||
export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
|
||||
const issues: GraphIssue[] = [];
|
||||
const nodeMap = new Map(graph.nodes.map((node) => [node.id, node]));
|
||||
if (graph.schema_version !== 1) issues.push({code: 'schema', message: '规则图 schema_version 必须为 1'});
|
||||
if (graph.nodes.length > 128 || graph.edges.length > 256 || new TextEncoder().encode(JSON.stringify(graph)).length > 256 * 1024) issues.push({code: 'size_limit', message: '规则图超过大小限制'});
|
||||
if (graph.schema_version !== 1)
|
||||
issues.push({ code: 'schema', message: '规则图 schema_version 必须为 1' });
|
||||
if (
|
||||
graph.nodes.length > 128 ||
|
||||
graph.edges.length > 256 ||
|
||||
new TextEncoder().encode(JSON.stringify(graph)).length > 256 * 1024
|
||||
)
|
||||
issues.push({ code: 'size_limit', message: '规则图超过大小限制' });
|
||||
const nodeIds = new Set<string>();
|
||||
for (const node of graph.nodes) {
|
||||
if (!node.id.trim()) issues.push({code: 'empty_id', message: '节点 ID 不能为空', nodeId: node.id});
|
||||
if (nodeIds.has(node.id)) issues.push({code: 'duplicate_node_id', message: `节点 ID ${node.id} 重复`, nodeId: node.id});
|
||||
if (!node.id.trim())
|
||||
issues.push({
|
||||
code: 'empty_id',
|
||||
message: '节点 ID 不能为空',
|
||||
nodeId: node.id,
|
||||
});
|
||||
if (nodeIds.has(node.id))
|
||||
issues.push({
|
||||
code: 'duplicate_node_id',
|
||||
message: `节点 ID ${node.id} 重复`,
|
||||
nodeId: node.id,
|
||||
});
|
||||
nodeIds.add(node.id);
|
||||
const configIssue = validateNodeConfig(node);
|
||||
if (configIssue) issues.push({code: 'invalid_config', message: configIssue, nodeId: node.id});
|
||||
if (configIssue)
|
||||
issues.push({
|
||||
code: 'invalid_config',
|
||||
message: configIssue,
|
||||
nodeId: node.id,
|
||||
});
|
||||
}
|
||||
const edgeIds = new Set<string>();
|
||||
for (const edge of graph.edges) {
|
||||
if (!edge.id.trim()) issues.push({code: 'empty_id', message: '连线 ID 不能为空', edgeId: edge.id});
|
||||
if (edgeIds.has(edge.id)) issues.push({code: 'duplicate_edge_id', message: `连线 ID ${edge.id} 重复`, edgeId: edge.id});
|
||||
if (!edge.id.trim())
|
||||
issues.push({
|
||||
code: 'empty_id',
|
||||
message: '连线 ID 不能为空',
|
||||
edgeId: edge.id,
|
||||
});
|
||||
if (edgeIds.has(edge.id))
|
||||
issues.push({
|
||||
code: 'duplicate_edge_id',
|
||||
message: `连线 ID ${edge.id} 重复`,
|
||||
edgeId: edge.id,
|
||||
});
|
||||
edgeIds.add(edge.id);
|
||||
if (nodeMap.get(edge.target)?.type === 'start') issues.push({code: 'start_incoming', message: '开始节点不能有入边', edgeId: edge.id, nodeId: edge.target});
|
||||
if (nodeMap.get(edge.target)?.type === 'start')
|
||||
issues.push({
|
||||
code: 'start_incoming',
|
||||
message: '开始节点不能有入边',
|
||||
edgeId: edge.id,
|
||||
nodeId: edge.target,
|
||||
});
|
||||
}
|
||||
if (graph.nodes.filter((node) => node.type === 'start').length !== 1) issues.push({code: 'start_count', message: '规则图必须恰好有一个开始节点'});
|
||||
if (graph.nodes.filter((node) => node.type === 'allow').length !== 1) issues.push({code: 'allow_count', message: '规则图必须恰好有一个通过节点'});
|
||||
if (graph.nodes.filter((node) => node.type === 'start').length !== 1)
|
||||
issues.push({
|
||||
code: 'start_count',
|
||||
message: '规则图必须恰好有一个开始节点',
|
||||
});
|
||||
if (graph.nodes.filter((node) => node.type === 'allow').length !== 1)
|
||||
issues.push({
|
||||
code: 'allow_count',
|
||||
message: '规则图必须恰好有一个通过节点',
|
||||
});
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
const source = nodeMap.get(edge.source);
|
||||
if (!source || !nodeMap.has(edge.target) || !(handles[source.type] ?? []).includes(edge.source_handle)) {
|
||||
issues.push({code: 'invalid_edge', message: `连线 ${edge.id} 的端点或出口无效`, edgeId: edge.id});
|
||||
if (
|
||||
!source ||
|
||||
!nodeMap.has(edge.target) ||
|
||||
!(handles[source.type] ?? []).includes(edge.source_handle)
|
||||
) {
|
||||
issues.push({
|
||||
code: 'invalid_edge',
|
||||
message: `连线 ${edge.id} 的端点或出口无效`,
|
||||
edgeId: edge.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const node of graph.nodes) {
|
||||
for (const handle of handles[node.type] ?? []) {
|
||||
const outgoing = graph.edges.filter((edge) => edge.source === node.id && edge.source_handle === handle);
|
||||
if (outgoing.length === 0) issues.push({code: 'missing_handle', message: `节点 ${node.id} 的 ${handle} 出口未连接`, nodeId: node.id});
|
||||
if (outgoing.length > 1) issues.push({code: 'duplicate_handle', message: `节点 ${node.id} 的 ${handle} 出口只能连接一次`, nodeId: node.id});
|
||||
const outgoing = graph.edges.filter(
|
||||
(edge) => edge.source === node.id && edge.source_handle === handle,
|
||||
);
|
||||
if (outgoing.length === 0)
|
||||
issues.push({
|
||||
code: 'missing_handle',
|
||||
message: `节点 ${node.id} 的 ${handle} 出口未连接`,
|
||||
nodeId: node.id,
|
||||
});
|
||||
if (outgoing.length > 1)
|
||||
issues.push({
|
||||
code: 'duplicate_handle',
|
||||
message: `节点 ${node.id} 的 ${handle} 出口只能连接一次`,
|
||||
nodeId: node.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const adjacency = new Map(graph.nodes.map((node) => [node.id, [] as string[]]));
|
||||
const adjacency = new Map(
|
||||
graph.nodes.map((node) => [node.id, [] as string[]]),
|
||||
);
|
||||
const reverse = new Map(graph.nodes.map((node) => [node.id, [] as string[]]));
|
||||
for (const edge of graph.edges) {
|
||||
adjacency.get(edge.source)?.push(edge.target);
|
||||
@@ -58,39 +143,110 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
|
||||
}
|
||||
const start = graph.nodes.find((node) => node.type === 'start');
|
||||
const reachable = walk(start ? [start.id] : [], adjacency);
|
||||
for (const node of graph.nodes) if (!reachable.has(node.id)) issues.push({code: 'unreachable', message: `节点 ${node.id} 无法从开始节点到达`, nodeId: node.id});
|
||||
const terminals = graph.nodes.filter((node) => node.type === 'allow' || node.type === 'block').map((node) => node.id);
|
||||
for (const node of graph.nodes)
|
||||
if (!reachable.has(node.id))
|
||||
issues.push({
|
||||
code: 'unreachable',
|
||||
message: `节点 ${node.id} 无法从开始节点到达`,
|
||||
nodeId: node.id,
|
||||
});
|
||||
const terminals = graph.nodes
|
||||
.filter((node) => node.type === 'allow' || node.type === 'block')
|
||||
.map((node) => node.id);
|
||||
const canTerminate = walk(terminals, reverse);
|
||||
for (const node of graph.nodes) if (!canTerminate.has(node.id)) issues.push({code: 'non_terminating', message: `节点 ${node.id} 无法抵达终止节点`, nodeId: node.id});
|
||||
if (hasCycle(graph)) issues.push({code: 'cycle', message: '规则图不能包含循环'});
|
||||
for (const node of graph.nodes)
|
||||
if (!canTerminate.has(node.id))
|
||||
issues.push({
|
||||
code: 'non_terminating',
|
||||
message: `节点 ${node.id} 无法抵达终止节点`,
|
||||
nodeId: node.id,
|
||||
});
|
||||
if (hasCycle(graph))
|
||||
issues.push({ code: 'cycle', message: '规则图不能包含循环' });
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateNodeConfig(node: WAFRuleNode): string | undefined {
|
||||
if (node.type === 'ip_match') {
|
||||
if (node.config.ips.some((value) => !isIP(value))) return `节点 ${node.id} 包含无效 IP`;
|
||||
if (node.config.cidrs.some((value) => { const parts = value.split('/'); if (parts.length !== 2) return true; const [ip, bits] = parts; return !isIP(ip) || !/^\d+$/.test(bits) || Number(bits) > (ip.includes(':') ? 128 : 32); })) return `节点 ${node.id} 包含无效 CIDR`;
|
||||
if (node.config.ip_group_ids.some((id) => !Number.isInteger(id) || id <= 0)) return `节点 ${node.id} 包含无效 IP 组`;
|
||||
if (node.config.ips.some((value) => !isIP(value)))
|
||||
return `节点 ${node.id} 包含无效 IP`;
|
||||
if (
|
||||
node.config.cidrs.some((value) => {
|
||||
const parts = value.split('/');
|
||||
if (parts.length !== 2) return true;
|
||||
const [ip, bits] = parts;
|
||||
return (
|
||||
!isIP(ip) ||
|
||||
!/^\d+$/.test(bits) ||
|
||||
Number(bits) > (ip.includes(':') ? 128 : 32)
|
||||
);
|
||||
})
|
||||
)
|
||||
return `节点 ${node.id} 包含无效 CIDR`;
|
||||
if (node.config.ip_group_ids.some((id) => !Number.isInteger(id) || id <= 0))
|
||||
return `节点 ${node.id} 包含无效 IP 组`;
|
||||
}
|
||||
if (node.type === 'geo_match' && (node.config.countries.some((code) => !/^[A-Z]{2}$/.test(code)) || node.config.regions.some((code) => !/^[A-Z]{2}-[A-Z0-9]{1,3}$/.test(code)))) return `节点 ${node.id} 包含无效地域代码`;
|
||||
if (node.type === 'pow' && (!['fast', 'slow'].includes(node.config.algorithm) || !isIntegerInRange(node.config.difficulty, 1, 16) || !isIntegerInRange(node.config.session_ttl, 60) || !isIntegerInRange(node.config.challenge_ttl, 30))) return `节点 ${node.id} 的 PoW 配置超出范围`;
|
||||
if (node.type === 'block' && (!isIntegerInRange(node.config.status_code, 400, 599) || new TextEncoder().encode(node.config.response_body).length > 16 * 1024)) return `节点 ${node.id} 的阻止响应配置无效`;
|
||||
if (
|
||||
node.type === 'geo_match' &&
|
||||
(node.config.countries.some((code) => !/^[A-Z]{2}$/.test(code)) ||
|
||||
node.config.regions.some(
|
||||
(code) => !/^[A-Z]{2}-[A-Z0-9]{1,3}$/.test(code),
|
||||
))
|
||||
)
|
||||
return `节点 ${node.id} 包含无效地域代码`;
|
||||
if (
|
||||
node.type === 'pow' &&
|
||||
(!['fast', 'slow'].includes(node.config.algorithm) ||
|
||||
!isIntegerInRange(node.config.difficulty, 1, 16) ||
|
||||
!isIntegerInRange(node.config.session_ttl, 60) ||
|
||||
!isIntegerInRange(node.config.challenge_ttl, 30))
|
||||
)
|
||||
return `节点 ${node.id} 的 PoW 配置超出范围`;
|
||||
if (
|
||||
node.type === 'block' &&
|
||||
(!isIntegerInRange(node.config.status_code, 400, 599) ||
|
||||
new TextEncoder().encode(node.config.response_body).length > 16 * 1024)
|
||||
)
|
||||
return `节点 ${node.id} 的阻止响应配置无效`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isIntegerInRange(value: number, min: number, max = Number.MAX_SAFE_INTEGER): boolean { return Number.isFinite(value) && Number.isInteger(value) && value >= min && value <= max; }
|
||||
function isIntegerInRange(
|
||||
value: number,
|
||||
min: number,
|
||||
max = Number.MAX_SAFE_INTEGER,
|
||||
): boolean {
|
||||
return (
|
||||
Number.isFinite(value) &&
|
||||
Number.isInteger(value) &&
|
||||
value >= min &&
|
||||
value <= max
|
||||
);
|
||||
}
|
||||
|
||||
function isIP(value: string): boolean {
|
||||
if (value.includes(':')) return isIPv6(value);
|
||||
const parts = value.split('.');
|
||||
return parts.length === 4 && parts.every((part) => /^(0|[1-9]\d{0,2})$/.test(part) && Number(part) <= 255);
|
||||
return (
|
||||
parts.length === 4 &&
|
||||
parts.every(
|
||||
(part) => /^(0|[1-9]\d{0,2})$/.test(part) && Number(part) <= 255,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isIPv6(value: string): boolean {
|
||||
if (!/^[0-9a-f:.]+$/i.test(value) || value.includes(':::') || value.split('::').length > 2) return false;
|
||||
if (
|
||||
!/^[0-9a-f:.]+$/i.test(value) ||
|
||||
value.includes(':::') ||
|
||||
value.split('::').length > 2
|
||||
)
|
||||
return false;
|
||||
const compressed = value.includes('::');
|
||||
const sections = value.split('::');
|
||||
const groups = sections.flatMap((section) => section ? section.split(':') : []);
|
||||
const groups = sections.flatMap((section) =>
|
||||
section ? section.split(':') : [],
|
||||
);
|
||||
let units = 0;
|
||||
for (let index = 0; index < groups.length; index++) {
|
||||
const group = groups[index];
|
||||
@@ -119,8 +275,12 @@ function walk(seeds: string[], links: Map<string, string[]>): Set<string> {
|
||||
|
||||
function hasCycle(graph: WAFRuleGraph): boolean {
|
||||
const indegree = new Map(graph.nodes.map((node) => [node.id, 0]));
|
||||
for (const edge of graph.edges) if (indegree.has(edge.target) && indegree.has(edge.source)) indegree.set(edge.target, (indegree.get(edge.target) ?? 0) + 1);
|
||||
const queue = [...indegree].filter(([, degree]) => degree === 0).map(([id]) => id);
|
||||
for (const edge of graph.edges)
|
||||
if (indegree.has(edge.target) && indegree.has(edge.source))
|
||||
indegree.set(edge.target, (indegree.get(edge.target) ?? 0) + 1);
|
||||
const queue = [...indegree]
|
||||
.filter(([, degree]) => degree === 0)
|
||||
.map(([id]) => id);
|
||||
let visited = 0;
|
||||
while (queue.length) {
|
||||
const id = queue.shift()!;
|
||||
@@ -134,17 +294,35 @@ function hasCycle(graph: WAFRuleGraph): boolean {
|
||||
return visited !== graph.nodes.length;
|
||||
}
|
||||
|
||||
export function wouldCreateCycle(graph: WAFRuleGraph, source: string, target: string): boolean {
|
||||
export function wouldCreateCycle(
|
||||
graph: WAFRuleGraph,
|
||||
source: string,
|
||||
target: string,
|
||||
): boolean {
|
||||
if (source === target) return true;
|
||||
const adjacency = new Map(graph.nodes.map((node) => [node.id, [] as string[]]));
|
||||
const adjacency = new Map(
|
||||
graph.nodes.map((node) => [node.id, [] as string[]]),
|
||||
);
|
||||
for (const edge of graph.edges) adjacency.get(edge.source)?.push(edge.target);
|
||||
return walk([target], adjacency).has(source);
|
||||
}
|
||||
|
||||
export function removeNodeFromGraph(graph: WAFRuleGraph, nodeId: string): WAFRuleGraph {
|
||||
return {...graph, nodes: graph.nodes.filter((node) => node.id !== nodeId), edges: graph.edges.filter((edge) => edge.source !== nodeId && edge.target !== nodeId)};
|
||||
export function removeNodeFromGraph(
|
||||
graph: WAFRuleGraph,
|
||||
nodeId: string,
|
||||
): WAFRuleGraph {
|
||||
return {
|
||||
...graph,
|
||||
nodes: graph.nodes.filter((node) => node.id !== nodeId),
|
||||
edges: graph.edges.filter(
|
||||
(edge) => edge.source !== nodeId && edge.target !== nodeId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function removeEdgeFromGraph(graph: WAFRuleGraph, edgeId: string): WAFRuleGraph {
|
||||
return {...graph, edges: graph.edges.filter((edge) => edge.id !== edgeId)};
|
||||
export function removeEdgeFromGraph(
|
||||
graph: WAFRuleGraph,
|
||||
edgeId: string,
|
||||
): WAFRuleGraph {
|
||||
return { ...graph, edges: graph.edges.filter((edge) => edge.id !== edgeId) };
|
||||
}
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import {Ban, Fingerprint, Globe2, Plus, ShieldCheck} from 'lucide-react';
|
||||
import { Ban, Fingerprint, Globe2, Plus, ShieldCheck } from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import type {WAFRuleNode} from '@/lib/services/openflare';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { WAFRuleNode } from '@/lib/services/openflare';
|
||||
|
||||
type AddableType = Extract<WAFRuleNode['type'], 'ip_match' | 'geo_match' | 'pow' | 'block'>;
|
||||
type AddableType = Extract<
|
||||
WAFRuleNode['type'],
|
||||
'ip_match' | 'geo_match' | 'pow' | 'block'
|
||||
>;
|
||||
const items = [
|
||||
{type: 'ip_match', label: 'IP 匹配', icon: Fingerprint}, {type: 'geo_match', label: '地域匹配', icon: Globe2},
|
||||
{type: 'pow', label: 'PoW 挑战', icon: ShieldCheck}, {type: 'block', label: '阻止', icon: Ban},
|
||||
] satisfies {type: AddableType; label: string; icon: typeof Plus}[];
|
||||
{ type: 'ip_match', label: 'IP 匹配', icon: Fingerprint },
|
||||
{ type: 'geo_match', label: '地域匹配', icon: Globe2 },
|
||||
{ type: 'pow', label: 'PoW 挑战', icon: ShieldCheck },
|
||||
{ type: 'block', label: '阻止', icon: Ban },
|
||||
] satisfies { type: AddableType; label: string; icon: typeof Plus }[];
|
||||
|
||||
export function NodeLibrary({onAdd}: {onAdd: (type: AddableType) => void}) {
|
||||
return <div className="flex items-center gap-2">{items.map(({type, label, icon: Icon}) => <Button key={type} variant="outline" size="sm" onClick={() => onAdd(type)}><Icon data-icon="inline-start" />{label}</Button>)}</div>;
|
||||
export function NodeLibrary({ onAdd }: { onAdd: (type: AddableType) => void }) {
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
{items.map(({ type, label, icon: Icon }) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onAdd(type)}
|
||||
>
|
||||
<Icon data-icon='inline-start' />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,64 @@
|
||||
import {fireEvent, render, screen} from '@testing-library/react';
|
||||
import {expect, it, vi} from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { expect, it, vi } from 'vitest';
|
||||
|
||||
import type {WAFIPGroup, WAFRuleNode} from '@/lib/services/openflare';
|
||||
import type { WAFIPGroup, WAFRuleNode } from '@/lib/services/openflare';
|
||||
|
||||
import {NodeProperties} from './node-properties';
|
||||
import { NodeProperties } from './node-properties';
|
||||
|
||||
it('edits IP group config through a typed multi-select', async () => {
|
||||
const node: WAFRuleNode = {id: 'match', type: 'ip_match', position: {x: 0, y: 0}, config: {ips: [], cidrs: [], ip_group_ids: []}};
|
||||
const group = {id: 7, name: '办公室出口'} as WAFIPGroup;
|
||||
const node: WAFRuleNode = {
|
||||
id: 'match',
|
||||
type: 'ip_match',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { ips: [], cidrs: [], ip_group_ids: [] },
|
||||
};
|
||||
const group = { id: 7, name: '办公室出口' } as WAFIPGroup;
|
||||
const onChange = vi.fn();
|
||||
render(<NodeProperties node={node} ipGroups={[group]} onChange={onChange}/>);
|
||||
fireEvent.click(screen.getByRole('button', {name: 'IP 组'}));
|
||||
render(<NodeProperties node={node} ipGroups={[group]} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'IP 组' }));
|
||||
fireEvent.click(await screen.findByText('办公室出口'));
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({config: expect.objectContaining({ip_group_ids: [7]})}));
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({ ip_group_ids: [7] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('associates numeric property labels and constrains server ranges', () => {
|
||||
const node: WAFRuleNode = {id: 'pow', type: 'pow', position: {x: 0, y: 0}, config: {algorithm: 'fast', difficulty: 4, session_ttl: 60, challenge_ttl: 30}};
|
||||
render(<NodeProperties node={node} ipGroups={[]} onChange={vi.fn()}/>);
|
||||
const node: WAFRuleNode = {
|
||||
id: 'pow',
|
||||
type: 'pow',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {
|
||||
algorithm: 'fast',
|
||||
difficulty: 4,
|
||||
session_ttl: 60,
|
||||
challenge_ttl: 30,
|
||||
},
|
||||
};
|
||||
render(<NodeProperties node={node} ipGroups={[]} onChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText('难度')).toHaveAttribute('min', '1');
|
||||
expect(screen.getByLabelText('难度')).toHaveAttribute('max', '16');
|
||||
expect(screen.getByLabelText('会话 TTL(秒)')).toHaveAttribute('min', '60');
|
||||
});
|
||||
|
||||
it('creates any normalized valid geography code', async () => {
|
||||
const node: WAFRuleNode = {id: 'geo', type: 'geo_match', position: {x: 0, y: 0}, config: {countries: [], regions: []}};
|
||||
const node: WAFRuleNode = {
|
||||
id: 'geo',
|
||||
type: 'geo_match',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { countries: [], regions: [] },
|
||||
};
|
||||
const onChange = vi.fn();
|
||||
render(<NodeProperties node={node} ipGroups={[]} onChange={onChange}/>);
|
||||
fireEvent.click(screen.getByRole('button', {name: '国家代码'}));
|
||||
fireEvent.change(await screen.findByPlaceholderText('输入代码并添加'), {target: {value: 'nz'}});
|
||||
fireEvent.click(screen.getByRole('button', {name: '添加代码'}));
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({config: expect.objectContaining({countries: ['NZ']})}));
|
||||
render(<NodeProperties node={node} ipGroups={[]} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '国家代码' }));
|
||||
fireEvent.change(await screen.findByPlaceholderText('输入代码并添加'), {
|
||||
target: { value: 'nz' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加代码' }));
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({ countries: ['NZ'] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,39 +1,387 @@
|
||||
import {Settings2} from 'lucide-react';
|
||||
import {useState} from 'react';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Checkbox} from '@/components/ui/checkbox';
|
||||
import {Field, FieldDescription, FieldGroup, FieldLabel} from '@/components/ui/field';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Popover, PopoverContent, PopoverTrigger} from '@/components/ui/popover';
|
||||
import {ScrollArea} from '@/components/ui/scroll-area';
|
||||
import {Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||
import {Separator} from '@/components/ui/separator';
|
||||
import {Textarea} from '@/components/ui/textarea';
|
||||
import type {WAFIPGroup, WAFRuleNode} from '@/lib/services/openflare';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { WAFIPGroup, WAFRuleNode } from '@/lib/services/openflare';
|
||||
|
||||
const countries = ['CN', 'US', 'JP', 'SG', 'DE', 'FR', 'GB', 'CA', 'AU', 'BR', 'IN', 'KR'].map((value) => ({value, label: value}));
|
||||
const regions = ['CN-BJ', 'CN-SH', 'CN-GD', 'CN-ZJ', 'US-CA', 'US-NY', 'US-TX', 'JP-13', 'DE-BE', 'GB-ENG'].map((value) => ({value, label: value}));
|
||||
const countries = [
|
||||
'CN',
|
||||
'US',
|
||||
'JP',
|
||||
'SG',
|
||||
'DE',
|
||||
'FR',
|
||||
'GB',
|
||||
'CA',
|
||||
'AU',
|
||||
'BR',
|
||||
'IN',
|
||||
'KR',
|
||||
].map((value) => ({ value, label: value }));
|
||||
const regions = [
|
||||
'CN-BJ',
|
||||
'CN-SH',
|
||||
'CN-GD',
|
||||
'CN-ZJ',
|
||||
'US-CA',
|
||||
'US-NY',
|
||||
'US-TX',
|
||||
'JP-13',
|
||||
'DE-BE',
|
||||
'GB-ENG',
|
||||
].map((value) => ({ value, label: value }));
|
||||
|
||||
export function NodeProperties({node, ipGroups, onChange}: {node: WAFRuleNode; ipGroups: WAFIPGroup[]; onChange: (node: WAFRuleNode) => void}) {
|
||||
return <aside className="w-80 shrink-0 border-l bg-card"><ScrollArea className="h-full"><div className="flex flex-col gap-5 p-5"><div className="flex items-center gap-2"><Settings2 className="size-5 text-primary"/><div><h2 className="text-sm font-semibold">节点属性</h2><p className="text-xs text-muted-foreground">配置当前处理单元</p></div></div><Separator /><PropertyFields node={node} ipGroups={ipGroups} onChange={onChange}/></div></ScrollArea></aside>;
|
||||
export function NodeProperties({
|
||||
node,
|
||||
ipGroups,
|
||||
onChange,
|
||||
}: {
|
||||
node: WAFRuleNode;
|
||||
ipGroups: WAFIPGroup[];
|
||||
onChange: (node: WAFRuleNode) => void;
|
||||
}) {
|
||||
return (
|
||||
<aside className='w-80 shrink-0 border-l bg-card'>
|
||||
<ScrollArea className='h-full'>
|
||||
<div className='flex flex-col gap-5 p-5'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Settings2 className='size-5 text-primary' />
|
||||
<div>
|
||||
<h2 className='text-sm font-semibold'>节点属性</h2>
|
||||
<p className='text-xs text-muted-foreground'>配置当前处理单元</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<PropertyFields node={node} ipGroups={ipGroups} onChange={onChange} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertyFields({node, ipGroups, onChange}: {node: WAFRuleNode; ipGroups: WAFIPGroup[]; onChange: (node: WAFRuleNode) => void}) {
|
||||
if (node.type === 'start' || node.type === 'allow') return <p className="text-sm text-muted-foreground">系统节点无需配置。</p>;
|
||||
if (node.type === 'ip_match') return <FieldGroup><CsvField id={`${node.id}-ips`} label="IP 地址" value={node.config.ips} onChange={(ips) => onChange({...node, config: {...node.config, ips}})}/><CsvField id={`${node.id}-cidrs`} label="CIDR 网段" value={node.config.cidrs} onChange={(cidrs) => onChange({...node, config: {...node.config, cidrs}})}/><MultiSelect id={`${node.id}-groups`} label="IP 组" options={ipGroups.map((group) => ({value: String(group.id), label: group.name}))} value={node.config.ip_group_ids.map(String)} onChange={(values) => onChange({...node, config: {...node.config, ip_group_ids: values.map(Number)}})}/></FieldGroup>;
|
||||
if (node.type === 'geo_match') return <FieldGroup><MultiSelect id={`${node.id}-countries`} label="国家代码" options={countries} value={node.config.countries} creatablePattern={/^[A-Z]{2}$/} onChange={(countries) => onChange({...node, config: {...node.config, countries}})}/><MultiSelect id={`${node.id}-regions`} label="地区代码" options={regions} value={node.config.regions} creatablePattern={/^[A-Z]{2}-[A-Z0-9]{1,3}$/} onChange={(regions) => onChange({...node, config: {...node.config, regions}})}/></FieldGroup>;
|
||||
if (node.type === 'pow') return <FieldGroup><Field><FieldLabel htmlFor={`${node.id}-algorithm`}>算法</FieldLabel><Select value={node.config.algorithm} onValueChange={(algorithm: 'fast' | 'slow') => onChange({...node, config: {...node.config, algorithm}})}><SelectTrigger id={`${node.id}-algorithm`} className="w-full"><SelectValue/></SelectTrigger><SelectContent><SelectGroup><SelectItem value="fast">快速</SelectItem><SelectItem value="slow">稳健</SelectItem></SelectGroup></SelectContent></Select></Field>{(['difficulty', 'session_ttl', 'challenge_ttl'] as const).map((key) => <NumberField id={`${node.id}-${key}`} key={key} min={{difficulty: 1, session_ttl: 60, challenge_ttl: 30}[key]} max={key === 'difficulty' ? 16 : undefined} label={{difficulty: '难度', session_ttl: '会话 TTL(秒)', challenge_ttl: '挑战 TTL(秒)'}[key]} value={node.config[key]} onChange={(value) => onChange({...node, config: {...node.config, [key]: value}})}/>)}</FieldGroup>;
|
||||
return <FieldGroup><NumberField id={`${node.id}-status`} min={400} max={599} label="HTTP 状态码" value={node.config.status_code} onChange={(status_code) => onChange({...node, config: {...node.config, status_code}})}/><Field><FieldLabel htmlFor={`${node.id}-body`}>HTML 响应体</FieldLabel><Textarea id={`${node.id}-body`} rows={9} value={node.config.response_body} onChange={(event) => onChange({...node, config: {...node.config, response_body: event.target.value}})}/><FieldDescription>{new TextEncoder().encode(node.config.response_body).length} / 16384 字节</FieldDescription></Field></FieldGroup>;
|
||||
function PropertyFields({
|
||||
node,
|
||||
ipGroups,
|
||||
onChange,
|
||||
}: {
|
||||
node: WAFRuleNode;
|
||||
ipGroups: WAFIPGroup[];
|
||||
onChange: (node: WAFRuleNode) => void;
|
||||
}) {
|
||||
if (node.type === 'start' || node.type === 'allow')
|
||||
return <p className='text-sm text-muted-foreground'>系统节点无需配置。</p>;
|
||||
if (node.type === 'ip_match')
|
||||
return (
|
||||
<FieldGroup>
|
||||
<CsvField
|
||||
id={`${node.id}-ips`}
|
||||
label='IP 地址'
|
||||
value={node.config.ips}
|
||||
onChange={(ips) =>
|
||||
onChange({ ...node, config: { ...node.config, ips } })
|
||||
}
|
||||
/>
|
||||
<CsvField
|
||||
id={`${node.id}-cidrs`}
|
||||
label='CIDR 网段'
|
||||
value={node.config.cidrs}
|
||||
onChange={(cidrs) =>
|
||||
onChange({ ...node, config: { ...node.config, cidrs } })
|
||||
}
|
||||
/>
|
||||
<MultiSelect
|
||||
id={`${node.id}-groups`}
|
||||
label='IP 组'
|
||||
options={ipGroups.map((group) => ({
|
||||
value: String(group.id),
|
||||
label: group.name,
|
||||
}))}
|
||||
value={node.config.ip_group_ids.map(String)}
|
||||
onChange={(values) =>
|
||||
onChange({
|
||||
...node,
|
||||
config: { ...node.config, ip_group_ids: values.map(Number) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
);
|
||||
if (node.type === 'geo_match')
|
||||
return (
|
||||
<FieldGroup>
|
||||
<MultiSelect
|
||||
id={`${node.id}-countries`}
|
||||
label='国家代码'
|
||||
options={countries}
|
||||
value={node.config.countries}
|
||||
creatablePattern={/^[A-Z]{2}$/}
|
||||
onChange={(countries) =>
|
||||
onChange({ ...node, config: { ...node.config, countries } })
|
||||
}
|
||||
/>
|
||||
<MultiSelect
|
||||
id={`${node.id}-regions`}
|
||||
label='地区代码'
|
||||
options={regions}
|
||||
value={node.config.regions}
|
||||
creatablePattern={/^[A-Z]{2}-[A-Z0-9]{1,3}$/}
|
||||
onChange={(regions) =>
|
||||
onChange({ ...node, config: { ...node.config, regions } })
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
);
|
||||
if (node.type === 'pow')
|
||||
return (
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${node.id}-algorithm`}>算法</FieldLabel>
|
||||
<Select
|
||||
value={node.config.algorithm}
|
||||
onValueChange={(algorithm: 'fast' | 'slow') =>
|
||||
onChange({ ...node, config: { ...node.config, algorithm } })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`${node.id}-algorithm`} className='w-full'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value='fast'>快速</SelectItem>
|
||||
<SelectItem value='slow'>稳健</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{(['difficulty', 'session_ttl', 'challenge_ttl'] as const).map(
|
||||
(key) => (
|
||||
<NumberField
|
||||
id={`${node.id}-${key}`}
|
||||
key={key}
|
||||
min={{ difficulty: 1, session_ttl: 60, challenge_ttl: 30 }[key]}
|
||||
max={key === 'difficulty' ? 16 : undefined}
|
||||
label={
|
||||
{
|
||||
difficulty: '难度',
|
||||
session_ttl: '会话 TTL(秒)',
|
||||
challenge_ttl: '挑战 TTL(秒)',
|
||||
}[key]
|
||||
}
|
||||
value={node.config[key]}
|
||||
onChange={(value) =>
|
||||
onChange({ ...node, config: { ...node.config, [key]: value } })
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</FieldGroup>
|
||||
);
|
||||
return (
|
||||
<FieldGroup>
|
||||
<NumberField
|
||||
id={`${node.id}-status`}
|
||||
min={400}
|
||||
max={599}
|
||||
label='HTTP 状态码'
|
||||
value={node.config.status_code}
|
||||
onChange={(status_code) =>
|
||||
onChange({ ...node, config: { ...node.config, status_code } })
|
||||
}
|
||||
/>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${node.id}-body`}>HTML 响应体</FieldLabel>
|
||||
<Textarea
|
||||
id={`${node.id}-body`}
|
||||
rows={9}
|
||||
value={node.config.response_body}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...node,
|
||||
config: { ...node.config, response_body: event.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FieldDescription>
|
||||
{new TextEncoder().encode(node.config.response_body).length} / 16384
|
||||
字节
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function CsvField({id, label, value, onChange}: {id: string; label: string; value: string[]; onChange: (value: string[]) => void}) { return <Field><FieldLabel htmlFor={id}>{label}</FieldLabel><Textarea id={id} value={value.join('\n')} onChange={(event) => onChange(event.target.value.split(/[\n,]/).map((item) => item.trim()).filter(Boolean))}/><FieldDescription>每行一个值</FieldDescription></Field>; }
|
||||
function NumberField({id, label, value, min, max, onChange}: {id: string; label: string; value: number; min?: number; max?: number; onChange: (value: number) => void}) { return <Field><FieldLabel htmlFor={id}>{label}</FieldLabel><Input id={id} min={min} max={max} type="number" value={value} onChange={(event) => onChange(Number(event.target.value))}/></Field>; }
|
||||
function CsvField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<Textarea
|
||||
id={id}
|
||||
value={value.join('\n')}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
event.target.value
|
||||
.split(/[\n,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<FieldDescription>每行一个值</FieldDescription>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
function NumberField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<Input
|
||||
id={id}
|
||||
min={min}
|
||||
max={max}
|
||||
type='number'
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiSelect({id, label, options, value, creatablePattern, onChange}: {id: string; label: string; options: {value: string; label: string}[]; value: string[]; creatablePattern?: RegExp; onChange: (value: string[]) => void}) {
|
||||
function MultiSelect({
|
||||
id,
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
creatablePattern,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
options: { value: string; label: string }[];
|
||||
value: string[];
|
||||
creatablePattern?: RegExp;
|
||||
onChange: (value: string[]) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState('');
|
||||
const normalized = draft.trim().toUpperCase();
|
||||
const visible = [...options, ...value.filter((selected) => !options.some((option) => option.value === selected)).map((selected) => ({value: selected, label: selected}))];
|
||||
const canCreate = Boolean(creatablePattern?.test(normalized) && !value.includes(normalized));
|
||||
return <Field><FieldLabel htmlFor={id}>{label}</FieldLabel><Popover><PopoverTrigger asChild><Button id={id} variant="outline" className="w-full justify-start">{value.length ? `已选择 ${value.length} 项` : '请选择'}</Button></PopoverTrigger><PopoverContent align="start" className="flex max-h-64 flex-col gap-2 overflow-y-auto">{creatablePattern && <div className="flex gap-2"><Input aria-label={`新建${label}`} placeholder="输入代码并添加" value={draft} onChange={(event) => setDraft(event.target.value)}/><Button size="sm" disabled={!canCreate} onClick={() => { onChange([...value, normalized]); setDraft(''); }}>添加代码</Button></div>}{visible.length === 0 ? <p className="text-sm text-muted-foreground">暂无可选项</p> : visible.map((option) => <label key={option.value} className="flex cursor-pointer items-center gap-2 text-sm"><Checkbox checked={value.includes(option.value)} onCheckedChange={(checked) => onChange(checked ? [...value, option.value] : value.filter((item) => item !== option.value))}/><span>{option.label}</span><span className="ml-auto font-mono text-xs text-muted-foreground">{option.value}</span></label>)}</PopoverContent></Popover></Field>;
|
||||
const visible = [
|
||||
...options,
|
||||
...value
|
||||
.filter(
|
||||
(selected) => !options.some((option) => option.value === selected),
|
||||
)
|
||||
.map((selected) => ({ value: selected, label: selected })),
|
||||
];
|
||||
const canCreate = Boolean(
|
||||
creatablePattern?.test(normalized) && !value.includes(normalized),
|
||||
);
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button id={id} variant='outline' className='w-full justify-start'>
|
||||
{value.length ? `已选择 ${value.length} 项` : '请选择'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align='start'
|
||||
className='flex max-h-64 flex-col gap-2 overflow-y-auto'
|
||||
>
|
||||
{creatablePattern && (
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
aria-label={`新建${label}`}
|
||||
placeholder='输入代码并添加'
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size='sm'
|
||||
disabled={!canCreate}
|
||||
onClick={() => {
|
||||
onChange([...value, normalized]);
|
||||
setDraft('');
|
||||
}}
|
||||
>
|
||||
添加代码
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{visible.length === 0 ? (
|
||||
<p className='text-sm text-muted-foreground'>暂无可选项</p>
|
||||
) : (
|
||||
visible.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className='flex cursor-pointer items-center gap-2 text-sm'
|
||||
>
|
||||
<Checkbox
|
||||
checked={value.includes(option.value)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange(
|
||||
checked
|
||||
? [...value, option.value]
|
||||
: value.filter((item) => item !== option.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
<span className='ml-auto font-mono text-xs text-muted-foreground'>
|
||||
{option.value}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import {useCallback, useEffect, useRef} from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
addEdge,
|
||||
applyEdgeChanges,
|
||||
@@ -17,11 +17,15 @@ import {
|
||||
type NodeChange,
|
||||
type ReactFlowInstance,
|
||||
} from '@xyflow/react';
|
||||
import {Trash2} from 'lucide-react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import type {WAFRuleEdge, WAFRuleGraph, WAFRuleNode} from '@/lib/services/openflare';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type {
|
||||
WAFRuleEdge,
|
||||
WAFRuleGraph,
|
||||
WAFRuleNode,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
import {
|
||||
acceptedNodeChanges,
|
||||
@@ -30,11 +34,15 @@ import {
|
||||
isConnectionAllowed,
|
||||
isPersistentEdgeChange,
|
||||
} from './editor-behavior';
|
||||
import {type GraphIssue, removeEdgeFromGraph, removeNodeFromGraph} from './graph-validation';
|
||||
import {NodeLibrary} from './node-library';
|
||||
import {RuleNode, type RuleFlowNodeData} from './rule-node';
|
||||
import {
|
||||
type GraphIssue,
|
||||
removeEdgeFromGraph,
|
||||
removeNodeFromGraph,
|
||||
} from './graph-validation';
|
||||
import { NodeLibrary } from './node-library';
|
||||
import { RuleNode, type RuleFlowNodeData } from './rule-node';
|
||||
|
||||
const nodeTypes = {rule: RuleNode};
|
||||
const nodeTypes = { rule: RuleNode };
|
||||
|
||||
type FlowNode = Node<RuleFlowNodeData>;
|
||||
type FlowEdge = Edge;
|
||||
@@ -61,119 +69,228 @@ export function RuleFlowCanvas({
|
||||
onSelectEdge,
|
||||
}: RuleFlowCanvasProps) {
|
||||
const instance = useRef<ReactFlowInstance<FlowNode, FlowEdge> | null>(null);
|
||||
const [nodes, setNodes, applyFlowNodeChanges] = useNodesState<FlowNode>(buildFlowNodes(graph, issues, selectedId));
|
||||
const [edges, setEdges, applyFlowEdgeChanges] = useEdgesState<FlowEdge>(buildFlowEdges(graph, selectedId, selectedEdgeId));
|
||||
const [nodes, setNodes, applyFlowNodeChanges] = useNodesState<FlowNode>(
|
||||
buildFlowNodes(graph, issues, selectedId),
|
||||
);
|
||||
const [edges, setEdges, applyFlowEdgeChanges] = useEdgesState<FlowEdge>(
|
||||
buildFlowEdges(graph, selectedId, selectedEdgeId),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setNodes((current) => buildFlowNodes(graph, issues, selectedId, current));
|
||||
}, [graph, issues, selectedId, setNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
setEdges((current) => buildFlowEdges(graph, selectedId, selectedEdgeId, current));
|
||||
setEdges((current) =>
|
||||
buildFlowEdges(graph, selectedId, selectedEdgeId, current),
|
||||
);
|
||||
}, [graph, selectedEdgeId, selectedId, setEdges]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusTarget || !instance.current) return;
|
||||
if (focusTarget.kind === 'node') {
|
||||
void instance.current.fitView({nodes: [{id: focusTarget.id}], duration: 350, maxZoom: 1.4});
|
||||
void instance.current.fitView({
|
||||
nodes: [{ id: focusTarget.id }],
|
||||
duration: 350,
|
||||
maxZoom: 1.4,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const edge = graph.edges.find((item) => item.id === focusTarget.id);
|
||||
if (edge) void instance.current.fitView({nodes: [{id: edge.source}, {id: edge.target}], duration: 350, maxZoom: 1.4});
|
||||
if (edge)
|
||||
void instance.current.fitView({
|
||||
nodes: [{ id: edge.source }, { id: edge.target }],
|
||||
duration: 350,
|
||||
maxZoom: 1.4,
|
||||
});
|
||||
}, [focusTarget, graph.edges]);
|
||||
|
||||
const onNodesChange = useCallback((changes: NodeChange<FlowNode>[]) => {
|
||||
const accepted = acceptedNodeChanges(graph.nodes, changes);
|
||||
if (accepted.changes.length === 0) return;
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange<FlowNode>[]) => {
|
||||
const accepted = acceptedNodeChanges(graph.nodes, changes);
|
||||
if (accepted.changes.length === 0) return;
|
||||
|
||||
applyFlowNodeChanges(accepted.changes);
|
||||
if (!accepted.persistent) return;
|
||||
applyFlowNodeChanges(accepted.changes);
|
||||
if (!accepted.persistent) return;
|
||||
|
||||
const removals = filterRemovableNodeIds(
|
||||
graph.nodes,
|
||||
accepted.changes.filter((change) => change.type === 'remove').map((change) => change.id),
|
||||
);
|
||||
let next = graph;
|
||||
for (const id of removals) next = removeNodeFromGraph(next, id);
|
||||
const removals = filterRemovableNodeIds(
|
||||
graph.nodes,
|
||||
accepted.changes
|
||||
.filter((change) => change.type === 'remove')
|
||||
.map((change) => change.id),
|
||||
);
|
||||
let next = graph;
|
||||
for (const id of removals) next = removeNodeFromGraph(next, id);
|
||||
|
||||
const positions = new Map<string, {x: number; y: number}>();
|
||||
for (const change of accepted.changes) {
|
||||
if (change.type === 'position' && change.dragging === false && change.position) {
|
||||
positions.set(change.id, change.position);
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
for (const change of accepted.changes) {
|
||||
if (
|
||||
change.type === 'position' &&
|
||||
change.dragging === false &&
|
||||
change.position
|
||||
) {
|
||||
positions.set(change.id, change.position);
|
||||
}
|
||||
}
|
||||
}
|
||||
next = {...next, nodes: next.nodes.map((node) => ({...node, position: positions.get(node.id) ?? node.position}))};
|
||||
onGraphChange(next);
|
||||
next = {
|
||||
...next,
|
||||
nodes: next.nodes.map((node) => ({
|
||||
...node,
|
||||
position: positions.get(node.id) ?? node.position,
|
||||
})),
|
||||
};
|
||||
onGraphChange(next);
|
||||
|
||||
if (selectedId && removals.includes(selectedId)) onSelect(undefined);
|
||||
}, [applyFlowNodeChanges, graph, onGraphChange, onSelect, selectedId]);
|
||||
if (selectedId && removals.includes(selectedId)) onSelect(undefined);
|
||||
},
|
||||
[applyFlowNodeChanges, graph, onGraphChange, onSelect, selectedId],
|
||||
);
|
||||
|
||||
const onEdgesChange = useCallback((changes: EdgeChange<FlowEdge>[]) => {
|
||||
applyFlowEdgeChanges(changes);
|
||||
const persistent = changes.filter(isPersistentEdgeChange);
|
||||
if (persistent.length === 0) return;
|
||||
const onEdgesChange = useCallback(
|
||||
(changes: EdgeChange<FlowEdge>[]) => {
|
||||
applyFlowEdgeChanges(changes);
|
||||
const persistent = changes.filter(isPersistentEdgeChange);
|
||||
if (persistent.length === 0) return;
|
||||
|
||||
const next = applyEdgeChanges(persistent, edges);
|
||||
onGraphChange({...graph, edges: next.map(toRuleEdge)});
|
||||
if (selectedEdgeId && persistent.some((change) => change.type === 'remove' && change.id === selectedEdgeId)) {
|
||||
const next = applyEdgeChanges(persistent, edges);
|
||||
onGraphChange({ ...graph, edges: next.map(toRuleEdge) });
|
||||
if (
|
||||
selectedEdgeId &&
|
||||
persistent.some(
|
||||
(change) => change.type === 'remove' && change.id === selectedEdgeId,
|
||||
)
|
||||
) {
|
||||
onSelectEdge(undefined);
|
||||
}
|
||||
},
|
||||
[
|
||||
applyFlowEdgeChanges,
|
||||
edges,
|
||||
graph,
|
||||
onGraphChange,
|
||||
onSelectEdge,
|
||||
selectedEdgeId,
|
||||
],
|
||||
);
|
||||
|
||||
const isValidConnection = useCallback(
|
||||
(connection: Edge | Connection) => {
|
||||
return isConnectionAllowed(graph, connection);
|
||||
},
|
||||
[graph],
|
||||
);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
if (!isValidConnection(connection)) return;
|
||||
const next = addEdge(
|
||||
{
|
||||
...connection,
|
||||
id: `${connection.source}-${connection.sourceHandle}-${connection.target}`,
|
||||
},
|
||||
edges,
|
||||
);
|
||||
setEdges(next);
|
||||
onGraphChange({ ...graph, edges: next.map(toRuleEdge) });
|
||||
},
|
||||
[edges, graph, isValidConnection, onGraphChange, setEdges],
|
||||
);
|
||||
|
||||
const addNode = useCallback(
|
||||
(type: 'ip_match' | 'geo_match' | 'pow' | 'block') => {
|
||||
const id = `${type}-${crypto.randomUUID().slice(0, 8)}`;
|
||||
const config =
|
||||
type === 'ip_match'
|
||||
? { ips: [], cidrs: [], ip_group_ids: [] }
|
||||
: type === 'geo_match'
|
||||
? { countries: [], regions: [] }
|
||||
: type === 'pow'
|
||||
? {
|
||||
algorithm: 'fast' as const,
|
||||
difficulty: 4,
|
||||
session_ttl: 3600,
|
||||
challenge_ttl: 300,
|
||||
}
|
||||
: { status_code: 403, response_body: '' };
|
||||
onGraphChange({
|
||||
...graph,
|
||||
nodes: [
|
||||
...graph.nodes,
|
||||
{
|
||||
id,
|
||||
type,
|
||||
position: { x: 240, y: 140 + graph.nodes.length * 24 },
|
||||
config,
|
||||
} as WAFRuleNode,
|
||||
],
|
||||
});
|
||||
onSelectEdge(undefined);
|
||||
}
|
||||
}, [applyFlowEdgeChanges, edges, graph, onGraphChange, onSelectEdge, selectedEdgeId]);
|
||||
|
||||
const isValidConnection = useCallback((connection: Edge | Connection) => {
|
||||
return isConnectionAllowed(graph, connection);
|
||||
}, [graph]);
|
||||
|
||||
const onConnect = useCallback((connection: Connection) => {
|
||||
if (!isValidConnection(connection)) return;
|
||||
const next = addEdge({...connection, id: `${connection.source}-${connection.sourceHandle}-${connection.target}`}, edges);
|
||||
setEdges(next);
|
||||
onGraphChange({...graph, edges: next.map(toRuleEdge)});
|
||||
}, [edges, graph, isValidConnection, onGraphChange, setEdges]);
|
||||
|
||||
const addNode = useCallback((type: 'ip_match' | 'geo_match' | 'pow' | 'block') => {
|
||||
const id = `${type}-${crypto.randomUUID().slice(0, 8)}`;
|
||||
const config = type === 'ip_match'
|
||||
? {ips: [], cidrs: [], ip_group_ids: []}
|
||||
: type === 'geo_match'
|
||||
? {countries: [], regions: []}
|
||||
: type === 'pow'
|
||||
? {algorithm: 'fast' as const, difficulty: 4, session_ttl: 3600, challenge_ttl: 300}
|
||||
: {status_code: 403, response_body: ''};
|
||||
onGraphChange({...graph, nodes: [...graph.nodes, {id, type, position: {x: 240, y: 140 + graph.nodes.length * 24}, config} as WAFRuleNode]});
|
||||
onSelectEdge(undefined);
|
||||
onSelect(id);
|
||||
}, [graph, onGraphChange, onSelect, onSelectEdge]);
|
||||
onSelect(id);
|
||||
},
|
||||
[graph, onGraphChange, onSelect, onSelectEdge],
|
||||
);
|
||||
|
||||
const selectedNode = graph.nodes.find((node) => node.id === selectedId);
|
||||
const canDeleteNode = Boolean(selectedNode && !['start', 'allow'].includes(selectedNode.type));
|
||||
const canDeleteEdge = Boolean(selectedEdgeId && graph.edges.some((edge) => edge.id === selectedEdgeId));
|
||||
const canDeleteNode = Boolean(
|
||||
selectedNode && !['start', 'allow'].includes(selectedNode.type),
|
||||
);
|
||||
const canDeleteEdge = Boolean(
|
||||
selectedEdgeId && graph.edges.some((edge) => edge.id === selectedEdgeId),
|
||||
);
|
||||
const canDelete = canDeleteNode || canDeleteEdge;
|
||||
const deleteLabel = canDeleteEdge ? '删除连线' : canDeleteNode ? '删除节点' : selectedNode ? '系统节点不可删除' : '请选择可删除项';
|
||||
const deleteLabel = canDeleteEdge
|
||||
? '删除连线'
|
||||
: canDeleteNode
|
||||
? '删除节点'
|
||||
: selectedNode
|
||||
? '系统节点不可删除'
|
||||
: '请选择可删除项';
|
||||
|
||||
const deleteSelection = useCallback(() => {
|
||||
if (canDeleteEdge && selectedEdgeId) {
|
||||
setEdges((current) => current.filter((edge) => edge.id !== selectedEdgeId));
|
||||
setEdges((current) =>
|
||||
current.filter((edge) => edge.id !== selectedEdgeId),
|
||||
);
|
||||
onGraphChange(removeEdgeFromGraph(graph, selectedEdgeId));
|
||||
onSelectEdge(undefined);
|
||||
return;
|
||||
}
|
||||
if (canDeleteNode && selectedId) {
|
||||
setNodes((current) => current.filter((node) => node.id !== selectedId));
|
||||
setEdges((current) => current.filter((edge) => edge.source !== selectedId && edge.target !== selectedId));
|
||||
setEdges((current) =>
|
||||
current.filter(
|
||||
(edge) => edge.source !== selectedId && edge.target !== selectedId,
|
||||
),
|
||||
);
|
||||
onGraphChange(removeNodeFromGraph(graph, selectedId));
|
||||
onSelect(undefined);
|
||||
}
|
||||
}, [canDeleteEdge, canDeleteNode, graph, onGraphChange, onSelect, onSelectEdge, selectedEdgeId, selectedId, setEdges, setNodes]);
|
||||
}, [
|
||||
canDeleteEdge,
|
||||
canDeleteNode,
|
||||
graph,
|
||||
onGraphChange,
|
||||
onSelect,
|
||||
onSelectEdge,
|
||||
selectedEdgeId,
|
||||
selectedId,
|
||||
setEdges,
|
||||
setNodes,
|
||||
]);
|
||||
|
||||
return (
|
||||
<section className="relative min-w-0 flex-1 bg-muted/20">
|
||||
<div className="absolute left-4 top-4 z-10 rounded-lg border bg-background/95 p-2 shadow-sm backdrop-blur">
|
||||
<NodeLibrary onAdd={addNode}/>
|
||||
<section className='relative min-w-0 flex-1 bg-muted/20'>
|
||||
<div className='absolute left-4 top-4 z-10 rounded-lg border bg-background/95 p-2 shadow-sm backdrop-blur'>
|
||||
<NodeLibrary onAdd={addNode} />
|
||||
</div>
|
||||
<div className="absolute right-4 top-4 z-10">
|
||||
<Button variant="destructive" size="sm" disabled={!canDelete} onClick={deleteSelection}>
|
||||
<Trash2 data-icon="inline-start"/>
|
||||
<div className='absolute right-4 top-4 z-10'>
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
disabled={!canDelete}
|
||||
onClick={deleteSelection}
|
||||
>
|
||||
<Trash2 data-icon='inline-start' />
|
||||
{deleteLabel}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -181,31 +298,52 @@ export function RuleFlowCanvas({
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onInit={(value) => { instance.current = value; }}
|
||||
onInit={(value) => {
|
||||
instance.current = value;
|
||||
}}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
isValidConnection={isValidConnection}
|
||||
onBeforeDelete={async ({nodes: deletingNodes}) => !deletingNodes.some((node) => ['start', 'allow'].includes(node.data.rule.type))}
|
||||
onNodeClick={(_, node) => { onSelectEdge(undefined); onSelect(node.id); }}
|
||||
onEdgeClick={(_, edge) => { onSelect(undefined); onSelectEdge(edge.id); }}
|
||||
onPaneClick={() => { onSelect(undefined); onSelectEdge(undefined); }}
|
||||
onBeforeDelete={async ({ nodes: deletingNodes }) =>
|
||||
!deletingNodes.some((node) =>
|
||||
['start', 'allow'].includes(node.data.rule.type),
|
||||
)
|
||||
}
|
||||
onNodeClick={(_, node) => {
|
||||
onSelectEdge(undefined);
|
||||
onSelect(node.id);
|
||||
}}
|
||||
onEdgeClick={(_, edge) => {
|
||||
onSelect(undefined);
|
||||
onSelectEdge(edge.id);
|
||||
}}
|
||||
onPaneClick={() => {
|
||||
onSelect(undefined);
|
||||
onSelectEdge(undefined);
|
||||
}}
|
||||
fitView
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
>
|
||||
<Background gap={20} size={1}/>
|
||||
<MiniMap pannable zoomable/>
|
||||
<Controls/>
|
||||
<Background gap={20} size={1} />
|
||||
<MiniMap pannable zoomable />
|
||||
<Controls />
|
||||
</ReactFlow>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function buildFlowNodes(graph: WAFRuleGraph, issues: GraphIssue[], selectedId?: string, current: FlowNode[] = []): FlowNode[] {
|
||||
function buildFlowNodes(
|
||||
graph: WAFRuleGraph,
|
||||
issues: GraphIssue[],
|
||||
selectedId?: string,
|
||||
current: FlowNode[] = [],
|
||||
): FlowNode[] {
|
||||
const existing = new Map(current.map((node) => [node.id, node]));
|
||||
const issueCounts = new Map<string, number>();
|
||||
for (const issue of issues) {
|
||||
if (issue.nodeId) issueCounts.set(issue.nodeId, (issueCounts.get(issue.nodeId) ?? 0) + 1);
|
||||
if (issue.nodeId)
|
||||
issueCounts.set(issue.nodeId, (issueCounts.get(issue.nodeId) ?? 0) + 1);
|
||||
}
|
||||
return graph.nodes.map((rule) => ({
|
||||
...existing.get(rule.id),
|
||||
@@ -213,11 +351,16 @@ function buildFlowNodes(graph: WAFRuleGraph, issues: GraphIssue[], selectedId?:
|
||||
type: 'rule',
|
||||
position: rule.position,
|
||||
selected: rule.id === selectedId,
|
||||
data: {rule, issues: issueCounts.get(rule.id) ?? 0},
|
||||
data: { rule, issues: issueCounts.get(rule.id) ?? 0 },
|
||||
}));
|
||||
}
|
||||
|
||||
function buildFlowEdges(graph: WAFRuleGraph, selectedId?: string, selectedEdgeId?: string, current: FlowEdge[] = []): FlowEdge[] {
|
||||
function buildFlowEdges(
|
||||
graph: WAFRuleGraph,
|
||||
selectedId?: string,
|
||||
selectedEdgeId?: string,
|
||||
current: FlowEdge[] = [],
|
||||
): FlowEdge[] {
|
||||
const existing = new Map(current.map((edge) => [edge.id, edge]));
|
||||
return graph.edges.map((edge) => ({
|
||||
...existing.get(edge.id),
|
||||
@@ -231,5 +374,10 @@ function buildFlowEdges(graph: WAFRuleGraph, selectedId?: string, selectedEdgeId
|
||||
}
|
||||
|
||||
function toRuleEdge(edge: Edge): WAFRuleEdge {
|
||||
return {id: edge.id, source: edge.source, source_handle: edge.sourceHandle ?? '', target: edge.target};
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
source_handle: edge.sourceHandle ?? '',
|
||||
target: edge.target,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,37 +1,74 @@
|
||||
import {Handle, Position, type NodeProps} from '@xyflow/react';
|
||||
import {Ban, Fingerprint, Flag, Globe2, Play, ShieldCheck} from 'lucide-react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import {
|
||||
Ban,
|
||||
Fingerprint,
|
||||
Flag,
|
||||
Globe2,
|
||||
Play,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {cn} from '@/lib/utils';
|
||||
import type {WAFRuleNode} from '@/lib/services/openflare';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WAFRuleNode } from '@/lib/services/openflare';
|
||||
|
||||
export interface RuleFlowNodeData extends Record<string, unknown> { rule: WAFRuleNode; issues: number }
|
||||
export interface RuleFlowNodeData extends Record<string, unknown> {
|
||||
rule: WAFRuleNode;
|
||||
issues: number;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
start: {label: '开始', icon: Play}, ip_match: {label: 'IP 匹配', icon: Fingerprint}, geo_match: {label: '地域匹配', icon: Globe2},
|
||||
pow: {label: 'PoW 挑战', icon: ShieldCheck}, allow: {label: '通过', icon: Flag}, block: {label: '阻止', icon: Ban},
|
||||
start: { label: '开始', icon: Play },
|
||||
ip_match: { label: 'IP 匹配', icon: Fingerprint },
|
||||
geo_match: { label: '地域匹配', icon: Globe2 },
|
||||
pow: { label: 'PoW 挑战', icon: ShieldCheck },
|
||||
allow: { label: '通过', icon: Flag },
|
||||
block: { label: '阻止', icon: Ban },
|
||||
} as const;
|
||||
|
||||
const outputHandles: Partial<Record<WAFRuleNode['type'], string[]>> = {start: ['next'], ip_match: ['true', 'false'], geo_match: ['true', 'false'], pow: ['next']};
|
||||
const outputHandles: Partial<Record<WAFRuleNode['type'], string[]>> = {
|
||||
start: ['next'],
|
||||
ip_match: ['true', 'false'],
|
||||
geo_match: ['true', 'false'],
|
||||
pow: ['next'],
|
||||
};
|
||||
|
||||
export function RuleNode({data, selected}: NodeProps) {
|
||||
export function RuleNode({ data, selected }: NodeProps) {
|
||||
const value = data as RuleFlowNodeData;
|
||||
const {rule, issues} = value;
|
||||
const {label, icon: Icon} = meta[rule.type];
|
||||
const { rule, issues } = value;
|
||||
const { label, icon: Icon } = meta[rule.type];
|
||||
return (
|
||||
<div className={cn('min-w-44 rounded-lg border bg-card shadow-sm transition-shadow', selected && 'ring-2 ring-ring', issues > 0 && 'border-destructive')}>
|
||||
{rule.type !== 'start' && <Handle type="target" position={Position.Left} />}
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<Icon className="size-5 text-primary" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">{rule.id}</span>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-44 rounded-lg border bg-card shadow-sm transition-shadow',
|
||||
selected && 'ring-2 ring-ring',
|
||||
issues > 0 && 'border-destructive',
|
||||
)}
|
||||
>
|
||||
{rule.type !== 'start' && (
|
||||
<Handle type='target' position={Position.Left} />
|
||||
)}
|
||||
<div className='flex items-center gap-3 px-4 py-3'>
|
||||
<Icon className='size-5 text-primary' />
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
|
||||
<span className='text-sm font-medium'>{label}</span>
|
||||
<span className='font-mono text-[10px] text-muted-foreground'>
|
||||
{rule.id}
|
||||
</span>
|
||||
</div>
|
||||
{issues > 0 && <Badge variant="destructive">{issues}</Badge>}
|
||||
{issues > 0 && <Badge variant='destructive'>{issues}</Badge>}
|
||||
</div>
|
||||
{(outputHandles[rule.type] ?? []).map((handle, index, all) => (
|
||||
<Handle key={handle} id={handle} type="source" position={Position.Right} style={{top: `${((index + 1) / (all.length + 1)) * 100}%`}}>
|
||||
<span className="absolute right-3 -translate-y-1/2 text-[9px] text-muted-foreground">{handle}</span>
|
||||
<Handle
|
||||
key={handle}
|
||||
id={handle}
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
style={{ top: `${((index + 1) / (all.length + 1)) * 100}%` }}
|
||||
>
|
||||
<span className='absolute right-3 -translate-y-1/2 text-[9px] text-muted-foreground'>
|
||||
{handle}
|
||||
</span>
|
||||
</Handle>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import {render} from '@testing-library/react';
|
||||
import {afterEach, expect, it, vi} from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
|
||||
import {UnsavedChanges} from './unsaved-changes';
|
||||
import { UnsavedChanges } from './unsaved-changes';
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('blocks same-origin application links when dirty and confirmation is declined', () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
const {container} = render(<><UnsavedChanges dirty/><a href="/waf">WAF</a></>);
|
||||
const event = new MouseEvent('click', {bubbles: true, cancelable: true, button: 0});
|
||||
const { container } = render(
|
||||
<>
|
||||
<UnsavedChanges dirty />
|
||||
<a href='/waf'>WAF</a>
|
||||
</>,
|
||||
);
|
||||
const event = new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
});
|
||||
container.querySelector('a')!.dispatchEvent(event);
|
||||
expect(window.confirm).toHaveBeenCalledOnce();
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
@@ -16,8 +25,17 @@ it('blocks same-origin application links when dirty and confirmation is declined
|
||||
|
||||
it('does not block application links without changes', () => {
|
||||
const confirm = vi.spyOn(window, 'confirm');
|
||||
const {getByRole} = render(<><UnsavedChanges dirty={false}/><a href="/waf">WAF</a></>);
|
||||
const event = new MouseEvent('click', {bubbles: true, cancelable: true, button: 0});
|
||||
const { getByRole } = render(
|
||||
<>
|
||||
<UnsavedChanges dirty={false} />
|
||||
<a href='/waf'>WAF</a>
|
||||
</>,
|
||||
);
|
||||
const event = new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
});
|
||||
event.preventDefault();
|
||||
getByRole('link').dispatchEvent(event);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
@@ -26,21 +44,33 @@ it('does not block application links without changes', () => {
|
||||
it('restores declined Back and Forward transitions by indexed delta', () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
const go = vi.spyOn(history, 'go').mockImplementation(() => undefined);
|
||||
history.replaceState({__wafEditorIndex: 4}, '');
|
||||
render(<UnsavedChanges dirty/>);
|
||||
window.dispatchEvent(new PopStateEvent('popstate', {state: {__wafEditorIndex: 3}}));
|
||||
history.replaceState({ __wafEditorIndex: 4 }, '');
|
||||
render(<UnsavedChanges dirty />);
|
||||
window.dispatchEvent(
|
||||
new PopStateEvent('popstate', { state: { __wafEditorIndex: 3 } }),
|
||||
);
|
||||
expect(go).toHaveBeenLastCalledWith(1);
|
||||
window.dispatchEvent(new PopStateEvent('popstate', {state: {__wafEditorIndex: 4}}));
|
||||
window.dispatchEvent(new PopStateEvent('popstate', {state: {__wafEditorIndex: 6}}));
|
||||
window.dispatchEvent(
|
||||
new PopStateEvent('popstate', { state: { __wafEditorIndex: 4 } }),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new PopStateEvent('popstate', { state: { __wafEditorIndex: 6 } }),
|
||||
);
|
||||
expect(go).toHaveBeenLastCalledWith(-2);
|
||||
});
|
||||
|
||||
it('prompts and restores the current URL for an unknown unindexed history entry', () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
history.replaceState({__wafEditorIndex: 4}, '', '/waf/rules/editor?id=9');
|
||||
history.replaceState({ __wafEditorIndex: 4 }, '', '/waf/rules/editor?id=9');
|
||||
const push = vi.spyOn(history, 'pushState');
|
||||
render(<UnsavedChanges dirty/>);
|
||||
window.dispatchEvent(new PopStateEvent('popstate', {state: {legacy: true}}));
|
||||
render(<UnsavedChanges dirty />);
|
||||
window.dispatchEvent(
|
||||
new PopStateEvent('popstate', { state: { legacy: true } }),
|
||||
);
|
||||
expect(window.confirm).toHaveBeenCalledOnce();
|
||||
expect(push).toHaveBeenCalledWith(expect.objectContaining({__wafEditorIndex: 4}), '', '/waf/rules/editor?id=9');
|
||||
expect(push).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ __wafEditorIndex: 4 }),
|
||||
'',
|
||||
'/waf/rules/editor?id=9',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,42 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect} from 'react';
|
||||
import {getHistoryTransition} from './editor-behavior';
|
||||
import { useEffect } from 'react';
|
||||
import { getHistoryTransition } from './editor-behavior';
|
||||
|
||||
const historyIndexKey = '__wafEditorIndex';
|
||||
|
||||
export function UnsavedChanges({dirty}: {dirty: boolean}) {
|
||||
export function UnsavedChanges({ dirty }: { dirty: boolean }) {
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const initialState = history.state && typeof history.state === 'object' ? history.state : {};
|
||||
let currentIndex = Number.isInteger(initialState[historyIndexKey]) ? initialState[historyIndexKey] as number : 0;
|
||||
const currentUrl = window.location.pathname + window.location.search + window.location.hash;
|
||||
history.replaceState({...initialState, [historyIndexKey]: currentIndex}, '');
|
||||
const initialState =
|
||||
history.state && typeof history.state === 'object' ? history.state : {};
|
||||
let currentIndex = Number.isInteger(initialState[historyIndexKey])
|
||||
? (initialState[historyIndexKey] as number)
|
||||
: 0;
|
||||
const currentUrl =
|
||||
window.location.pathname + window.location.search + window.location.hash;
|
||||
history.replaceState(
|
||||
{ ...initialState, [historyIndexKey]: currentIndex },
|
||||
'',
|
||||
);
|
||||
const originalPushState = history.pushState.bind(history);
|
||||
const originalReplaceState = history.replaceState.bind(history);
|
||||
history.pushState = (data, unused, url) => { currentIndex++; originalPushState({...data, [historyIndexKey]: currentIndex}, unused, url); };
|
||||
history.replaceState = (data, unused, url) => originalReplaceState({...data, [historyIndexKey]: currentIndex}, unused, url);
|
||||
history.pushState = (data, unused, url) => {
|
||||
currentIndex++;
|
||||
originalPushState(
|
||||
{ ...data, [historyIndexKey]: currentIndex },
|
||||
unused,
|
||||
url,
|
||||
);
|
||||
};
|
||||
history.replaceState = (data, unused, url) =>
|
||||
originalReplaceState(
|
||||
{ ...data, [historyIndexKey]: currentIndex },
|
||||
unused,
|
||||
url,
|
||||
);
|
||||
let restoring = false;
|
||||
const handler = (event: BeforeUnloadEvent) => { if (dirty) event.preventDefault(); };
|
||||
const handler = (event: BeforeUnloadEvent) => {
|
||||
if (dirty) event.preventDefault();
|
||||
};
|
||||
const clickHandler = (event: MouseEvent) => {
|
||||
if (!dirty || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
const link = (event.target as Element | null)?.closest('a[href]') as HTMLAnchorElement | null;
|
||||
if (!link || link.target === '_blank' || new URL(link.href, window.location.href).origin !== window.location.origin) return;
|
||||
if (!window.confirm('存在未保存的更改,确定离开吗?')) event.preventDefault();
|
||||
if (
|
||||
!dirty ||
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
)
|
||||
return;
|
||||
const link = (event.target as Element | null)?.closest(
|
||||
'a[href]',
|
||||
) as HTMLAnchorElement | null;
|
||||
if (
|
||||
!link ||
|
||||
link.target === '_blank' ||
|
||||
new URL(link.href, window.location.href).origin !==
|
||||
window.location.origin
|
||||
)
|
||||
return;
|
||||
if (!window.confirm('存在未保存的更改,确定离开吗?'))
|
||||
event.preventDefault();
|
||||
};
|
||||
const popstateHandler = (event: PopStateEvent) => {
|
||||
const hasTargetIndex = Number.isInteger(event.state?.[historyIndexKey]);
|
||||
const targetIndex = hasTargetIndex ? event.state[historyIndexKey] as number : currentIndex;
|
||||
if (restoring) { restoring = false; currentIndex = targetIndex; return; }
|
||||
if (window.confirm('存在未保存的更改,确定离开吗?')) { currentIndex = targetIndex; return; }
|
||||
if (!hasTargetIndex) { originalPushState({...initialState, [historyIndexKey]: currentIndex}, '', currentUrl); return; }
|
||||
const targetIndex = hasTargetIndex
|
||||
? (event.state[historyIndexKey] as number)
|
||||
: currentIndex;
|
||||
if (restoring) {
|
||||
restoring = false;
|
||||
currentIndex = targetIndex;
|
||||
return;
|
||||
}
|
||||
if (window.confirm('存在未保存的更改,确定离开吗?')) {
|
||||
currentIndex = targetIndex;
|
||||
return;
|
||||
}
|
||||
if (!hasTargetIndex) {
|
||||
originalPushState(
|
||||
{ ...initialState, [historyIndexKey]: currentIndex },
|
||||
'',
|
||||
currentUrl,
|
||||
);
|
||||
return;
|
||||
}
|
||||
restoring = true;
|
||||
history.go(getHistoryTransition(currentIndex, targetIndex).restoreDelta);
|
||||
};
|
||||
window.addEventListener('beforeunload', handler);
|
||||
document.addEventListener('click', clickHandler, true);
|
||||
window.addEventListener('popstate', popstateHandler);
|
||||
return () => { history.pushState = originalPushState; history.replaceState = originalReplaceState; window.removeEventListener('beforeunload', handler); document.removeEventListener('click', clickHandler, true); window.removeEventListener('popstate', popstateHandler); };
|
||||
return () => {
|
||||
history.pushState = originalPushState;
|
||||
history.replaceState = originalReplaceState;
|
||||
window.removeEventListener('beforeunload', handler);
|
||||
document.removeEventListener('click', clickHandler, true);
|
||||
window.removeEventListener('popstate', popstateHandler);
|
||||
};
|
||||
}, [dirty]);
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user