This commit is contained in:
ryan
2026-07-13 15:08:40 +08:00
parent 08e8eea932
commit 1eff7878a1
441 changed files with 26491 additions and 18405 deletions
@@ -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;
}