refactor(repo): consolidate openflare-server to root and move subprojects to internal/apps

- Merge all files inside openflare-server to the repository root directory.
- Relocate agent, relay, and flared subprojects from internal/ to internal/apps/.
- Combine docker-compose files and update build context paths to root.
- Update GitHub workflows and Dockerfiles to refer to new directories and package names.
- Rewrite Go package imports across all files.
- Resolve database renew test race condition and clean up docs.
This commit is contained in:
ryan
2026-06-19 14:23:29 +08:00
parent 19d476ed7f
commit 63cd906cfc
1064 changed files with 366 additions and 1397 deletions
@@ -0,0 +1,334 @@
import type {
ProxyRoutePoWConfig,
WAFIPGroup,
WAFIPGroupPayload,
WAFRuleGroup,
WAFRuleGroupPayload,
} from '@/lib/services/openflare';
export type WAFTab = 'basic' | 'lists' | 'pow' | 'block';
export type RuleListType = 'whitelist' | 'blacklist';
export type RuleDimension = 'ip' | 'ip_group' | 'country';
export type ListFieldKey =
| 'ip_whitelist'
| 'ip_blacklist'
| 'ip_whitelist_group_ids'
| 'ip_blacklist_group_ids'
| 'country_whitelist'
| 'country_blacklist';
export type CountryOption = {
code: string;
zhName: string;
label: string;
searchText: string;
};
export type RuleModalState = {
open: boolean;
listType: RuleListType;
dimension: RuleDimension;
ipValue: string;
ipGroupIDs: number[];
countryValues: string[];
};
export type RuleListRenderable = Pick<
WAFRuleGroupPayload,
| 'ip_whitelist'
| 'ip_blacklist'
| 'ip_whitelist_group_ids'
| 'ip_blacklist_group_ids'
| 'country_whitelist'
| 'country_blacklist'
| 'region_whitelist'
| 'region_blacklist'
>;
export const defaultPowConfig: ProxyRoutePoWConfig = {
difficulty: 4,
algorithm: 'fast',
session_ttl: 600,
challenge_ttl: 300,
whitelist: {
ips: [],
ip_cidrs: [],
paths: [],
path_regexes: [],
user_agents: [],
},
blacklist: {
ips: [],
ip_cidrs: [],
paths: [],
path_regexes: [],
user_agents: [],
},
};
export const emptyRuleGroupDraft: WAFRuleGroupPayload = {
name: '',
enabled: true,
block_status_code: 418,
block_response_body: '',
ip_whitelist: [],
ip_blacklist: [],
ip_whitelist_group_ids: [],
ip_blacklist_group_ids: [],
country_whitelist: [],
country_blacklist: [],
region_whitelist: [],
region_blacklist: [],
pow_enabled: false,
pow_config: defaultPowConfig,
remark: '',
};
export const defaultRuleModalState: RuleModalState = {
open: false,
listType: 'blacklist',
dimension: 'ip',
ipValue: '',
ipGroupIDs: [],
countryValues: [],
};
export const wafTabItems: Array<{ id: WAFTab; label: string }> = [
{ id: 'basic', label: '基本信息' },
{ id: 'lists', label: '黑白名单' },
{ id: 'pow', label: 'PoW' },
{ id: 'block', label: '拦截返回' },
];
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '操作失败';
}
export function textToList(text: string) {
return text
.split(/[\n,,\s]+/)
.map((item) => item.trim())
.filter(Boolean);
}
export function listToText(items: string[] | undefined) {
return (items ?? []).join('\n');
}
export function parseTextareaList(text: string) {
return text
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
}
export function normalizeItems(items: string[]) {
return Array.from(
new Set(items.map((item) => item.trim()).filter(Boolean)),
).sort((left, right) => left.localeCompare(right));
}
export function buildRuleGroupDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload {
if (!group) {
return { ...emptyRuleGroupDraft };
}
return {
name: group.name,
enabled: group.enabled,
block_status_code: group.block_status_code || 418,
block_response_body: group.block_response_body ?? '',
ip_whitelist: group.ip_whitelist ?? [],
ip_blacklist: group.ip_blacklist ?? [],
ip_whitelist_group_ids: group.ip_whitelist_group_ids ?? [],
ip_blacklist_group_ids: group.ip_blacklist_group_ids ?? [],
country_whitelist: group.country_whitelist ?? [],
country_blacklist: group.country_blacklist ?? [],
region_whitelist: group.region_whitelist ?? [],
region_blacklist: group.region_blacklist ?? [],
pow_enabled: group.pow_enabled ?? false,
pow_config: group.pow_config ?? defaultPowConfig,
remark: group.remark ?? '',
};
}
export function countRuleEntries(group: RuleListRenderable) {
return (
group.ip_whitelist.length +
group.ip_blacklist.length +
group.ip_whitelist_group_ids.length +
group.ip_blacklist_group_ids.length +
group.country_whitelist.length +
group.country_blacklist.length +
group.region_whitelist.length +
group.region_blacklist.length
);
}
export function buildCountryOptions() {
const zhDisplayNames = new Intl.DisplayNames(['zh-CN'], { type: 'region' });
const enDisplayNames = new Intl.DisplayNames(['en'], { type: 'region' });
const options: CountryOption[] = [];
for (let first = 65; first <= 90; first += 1) {
for (let second = 65; second <= 90; second += 1) {
const code = String.fromCharCode(first, second);
const zhName = zhDisplayNames.of(code);
const enName = enDisplayNames.of(code);
if (
!zhName ||
zhName === code ||
/未知/.test(zhName) ||
!enName ||
enName === code ||
/Unknown/.test(enName)
) {
continue;
}
options.push({
code,
zhName,
label: `${code} ${zhName}`,
searchText: `${code} ${zhName} ${enName}`.toLowerCase(),
});
}
}
return options.sort((left, right) => left.code.localeCompare(right.code));
}
export function getListFieldKey(
listType: RuleListType,
dimension: RuleDimension,
): ListFieldKey {
if (dimension === 'ip') {
return listType === 'whitelist' ? 'ip_whitelist' : 'ip_blacklist';
}
if (dimension === 'ip_group') {
return listType === 'whitelist'
? 'ip_whitelist_group_ids'
: 'ip_blacklist_group_ids';
}
return listType === 'whitelist' ? 'country_whitelist' : 'country_blacklist';
}
export function updateDraftList(
draft: WAFRuleGroupPayload,
key: ListFieldKey,
updater: (items: string[]) => string[],
) {
switch (key) {
case 'ip_whitelist':
return { ...draft, ip_whitelist: updater(draft.ip_whitelist) };
case 'ip_blacklist':
return { ...draft, ip_blacklist: updater(draft.ip_blacklist) };
case 'ip_whitelist_group_ids':
return {
...draft,
ip_whitelist_group_ids: updater(
draft.ip_whitelist_group_ids.map(String),
).map(Number),
};
case 'ip_blacklist_group_ids':
return {
...draft,
ip_blacklist_group_ids: updater(
draft.ip_blacklist_group_ids.map(String),
).map(Number),
};
case 'country_whitelist':
return { ...draft, country_whitelist: updater(draft.country_whitelist) };
case 'country_blacklist':
return { ...draft, country_blacklist: updater(draft.country_blacklist) };
}
}
export function formatCountryItem(code: string, labelMap: Map<string, string>) {
return labelMap.get(code) ?? code;
}
export function parseAutomaticConfig(text: string): Record<string, unknown> {
const parsed = JSON.parse(text || '{}') as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new Error('自动配置必须是 JSON 对象。');
}
return parsed as Record<string, unknown>;
}
export const ipGroupTypeLabels = {
manual: '手动',
automatic: '自动',
subscription: '订阅',
} as const;
export const automaticPresetRules = [
{
name: '单 IP 404 高频扫描',
expr: 'request_count > 100 && StatusRatio(404) >= 0.8',
},
{
name: '单 IP 直连访问异常',
expr: 'ip_host_count > 50 && ip_host_ratio > 0.5',
},
];
export type IPGroupViewEntry = {
ip: string;
capturedAt?: string;
banRemaining?: string;
};
export function buildIPGroupPayloadFromGroup(
group: WAFIPGroup,
ipList: string[],
): WAFIPGroupPayload {
return {
name: group.name,
type: group.type,
enabled: group.enabled,
ip_list: ipList,
auto_config: group.auto_config ?? {},
subscription_url: group.subscription_url ?? '',
subscription_format: group.subscription_format ?? 'text',
subscription_mapping_rule: group.subscription_mapping_rule ?? '',
sync_interval_minutes: group.sync_interval_minutes || 1440,
remark: group.remark ?? '',
};
}
export function formatIPGroupBanRemaining(
capturedAt: string,
ttlSeconds: number,
now = new Date(),
): string {
if (ttlSeconds <= 0) {
return '永久';
}
const capturedDate = new Date(capturedAt);
if (Number.isNaN(capturedDate.getTime())) {
return '—';
}
const expireDate = new Date(capturedDate.getTime() + ttlSeconds * 1000);
if (expireDate.getTime() <= now.getTime()) {
return '已过期';
}
const diffMins = Math.round((expireDate.getTime() - now.getTime()) / (60 * 1000));
if (diffMins < 60) {
return `${diffMins} 分钟后`;
}
return `${Math.round(diffMins / 60)} 小时后`;
}
export function getIPGroupViewEntries(group: WAFIPGroup): IPGroupViewEntry[] {
if (group.type === 'automatic' && group.ext_ips && group.ext_ips.length > 0) {
const ttl =
typeof group.auto_config?.ttl === 'number' ? group.auto_config.ttl : -1;
return group.ext_ips.map((item) => ({
ip: item.ip,
capturedAt: item.captured_at,
banRemaining: formatIPGroupBanRemaining(item.captured_at, ttl),
}));
}
return (group.ip_list ?? []).map((ip) => ({ ip }));
}
@@ -0,0 +1,453 @@
'use client';
import {useEffect} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {Plus} from 'lucide-react';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {Button} from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@/components/ui/form';
import {Input} from '@/components/ui/input';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
import {Switch} from '@/components/ui/switch';
import {Textarea} from '@/components/ui/textarea';
import type {
WAFIPGroup,
WAFIPGroupPayload,
WAFIPGroupSubscriptionFormat,
WAFIPGroupType,
} from '@/lib/services/openflare';
import {automaticPresetRules, listToText, parseAutomaticConfig, parseTextareaList,} from './helpers';
const ipGroupSchema = z
.object({
name: z.string().trim().min(1, '请输入 IP 组名称').max(255),
type: z.enum(['manual', 'automatic', 'subscription']),
enabled: z.boolean(),
ip_list_text: z.string(),
auto_config_text: z.string(),
subscription_url: z.string(),
subscription_format: z.enum(['text', 'json']),
subscription_mapping_rule: z.string(),
sync_interval_minutes: z.number().int().min(5),
remark: z.string().max(500),
})
.superRefine((value, context) => {
if (value.type === 'subscription' && !value.subscription_url.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['subscription_url'],
message: '订阅类型需要填写订阅 URL',
});
}
if (value.type === 'automatic') {
try {
parseAutomaticConfig(value.auto_config_text);
} catch (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['auto_config_text'],
message: error instanceof Error ? error.message : '自动配置格式错误',
});
}
}
});
type IPGroupFormValues = z.infer<typeof ipGroupSchema>;
const defaultValues: IPGroupFormValues = {
name: '',
type: 'manual',
enabled: true,
ip_list_text: '',
auto_config_text: '{}',
subscription_url: '',
subscription_format: 'text',
subscription_mapping_rule: '',
sync_interval_minutes: 1440,
remark: '',
};
function buildFormValues(group: WAFIPGroup | null): IPGroupFormValues {
if (!group) return defaultValues;
return {
name: group.name,
type: group.type,
enabled: group.enabled,
ip_list_text: listToText(group.ip_list),
auto_config_text: JSON.stringify(group.auto_config ?? {}, null, 2),
subscription_url: group.subscription_url ?? '',
subscription_format: group.subscription_format ?? 'text',
subscription_mapping_rule: group.subscription_mapping_rule ?? '',
sync_interval_minutes: group.sync_interval_minutes || 1440,
remark: group.remark ?? '',
};
}
function buildPayload(values: IPGroupFormValues): WAFIPGroupPayload {
const autoConfig =
values.type === 'automatic'
? parseAutomaticConfig(values.auto_config_text)
: {};
return {
name: values.name.trim(),
type: values.type,
enabled: values.enabled,
ip_list: parseTextareaList(values.ip_list_text),
auto_config: autoConfig,
subscription_url: values.subscription_url.trim(),
subscription_format: values.subscription_format,
subscription_mapping_rule: values.subscription_mapping_rule.trim(),
sync_interval_minutes: values.sync_interval_minutes,
remark: values.remark.trim(),
};
}
function appendAutomaticPresetRule(
autoConfigText: string,
rule: (typeof automaticPresetRules)[number],
) {
const config = parseAutomaticConfig(autoConfigText);
const rules = Array.isArray(config.rules) ? config.rules : [];
const exists = rules.some(
(item) =>
item &&
typeof item === 'object' &&
'expr' in item &&
(item as { expr?: unknown }).expr === rule.expr,
);
const nextRules = exists ? rules : [...rules, rule];
return JSON.stringify(
{
lookback_minutes:
typeof config.lookback_minutes === 'number' ? config.lookback_minutes : 60,
...config,
rules: nextRules,
},
null,
2,
);
}
interface IPGroupDialogProps {
open: boolean;
group: WAFIPGroup | null;
submitting: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: WAFIPGroupPayload) => Promise<void>;
}
export function IPGroupDialog({
open,
group,
submitting,
onOpenChange,
onSubmit,
}: IPGroupDialogProps) {
const form = useForm<IPGroupFormValues>({
resolver: zodResolver(ipGroupSchema),
defaultValues,
});
const type = form.watch('type');
useEffect(() => {
if (!open) return;
form.reset(buildFormValues(group));
}, [form, group, open]);
const handleSubmit = form.handleSubmit(async (values) => {
try {
await onSubmit(buildPayload(values));
onOpenChange(false);
} catch (error) {
form.setError('root', {
message: error instanceof Error ? error.message : '保存失败',
});
}
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{group ? `编辑 ${group.name}` : '新建 IP 组'}</DialogTitle>
<DialogDescription>
维护可被 WAF IP 黑白名单引用的手动、自动与订阅 IP 集合。
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>IP 组名称</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>类型</FormLabel>
<Select
value={field.value}
onValueChange={(value) =>
field.onChange(value as WAFIPGroupType)
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="manual">手动</SelectItem>
<SelectItem value="automatic">自动</SelectItem>
<SelectItem value="subscription">订阅</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border border-dashed p-4 md:col-span-2">
<div className="space-y-0.5">
<FormLabel>启用 IP 组</FormLabel>
<FormDescription>
关闭后保留配置,但发布时不会展开到 WAF 运行时名单。
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="remark"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormLabel>备注</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{type === 'subscription' ? (
<div className="grid gap-4 md:grid-cols-2 rounded-lg border border-dashed p-4">
<FormField
control={form.control}
name="subscription_url"
render={({ field }) => (
<FormItem>
<FormLabel>订阅 URL</FormLabel>
<FormControl>
<Input placeholder="https://example.com/ip-list.txt" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subscription_format"
render={({ field }) => (
<FormItem>
<FormLabel>订阅格式</FormLabel>
<Select
value={field.value}
onValueChange={(value) =>
field.onChange(value as WAFIPGroupSubscriptionFormat)
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="text">文本列表</SelectItem>
<SelectItem value="json">JSON</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sync_interval_minutes"
render={({ field }) => (
<FormItem>
<FormLabel>同步间隔(分钟)</FormLabel>
<FormControl>
<Input
type="number"
min={5}
value={field.value}
onChange={(event) =>
field.onChange(Number(event.target.value))
}
/>
</FormControl>
<FormDescription>最小 5 分钟,默认 1440 分钟。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subscription_mapping_rule"
render={({ field }) => (
<FormItem>
<FormLabel>JSON 映射规则</FormLabel>
<FormControl>
<Input
disabled={form.watch('subscription_format') !== 'json'}
placeholder="留空表示根数组"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
) : null}
{type === 'automatic' ? (
<div className="space-y-4 rounded-lg border border-dashed p-4">
<FormField
control={form.control}
name="sync_interval_minutes"
render={({ field }) => (
<FormItem>
<FormLabel>同步间隔(分钟)</FormLabel>
<FormControl>
<Input
type="number"
min={5}
value={field.value}
onChange={(event) =>
field.onChange(Number(event.target.value))
}
/>
</FormControl>
<FormDescription>
定时从请求日志挖掘恶意 IP 的周期。最小 5 分钟。
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel>预设规则</FormLabel>
<div className="flex flex-wrap gap-2">
{automaticPresetRules.map((rule) => (
<Button
key={rule.expr}
type="button"
size="sm"
variant="outline"
onClick={() => {
const current = form.getValues('auto_config_text');
form.setValue(
'auto_config_text',
appendAutomaticPresetRule(current, rule),
);
}}
>
<Plus className="size-3.5 mr-1" />
{rule.name}
</Button>
))}
</div>
</div>
<FormField
control={form.control}
name="auto_config_text"
render={({ field }) => (
<FormItem>
<FormLabel>自动配置 JSON</FormLabel>
<FormControl>
<Textarea className="min-h-48 font-mono text-xs" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
) : null}
{type !== 'automatic' ? (
<FormField
control={form.control}
name="ip_list_text"
render={({ field }) => (
<FormItem>
<FormLabel>IP / IP 段</FormLabel>
<FormControl>
<Textarea
className="min-h-48 font-mono text-xs"
placeholder={'203.0.113.10\n198.51.100.0/24'}
{...field}
/>
</FormControl>
<FormDescription>
{type === 'subscription'
? '订阅同步会覆盖此列表;也可以先手动保存当前内容。'
: '支持单个 IP 或 CIDR,每行一个。'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : null}
{form.formState.errors.root ? (
<p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? '保存中...' : '保存 IP 组'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,70 @@
'use client';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {Button} from '@/components/ui/button';
import type {WAFIPGroupAutoTestResult} from '@/lib/services/openflare';
interface IPGroupTestDialogProps {
open: boolean;
loading: boolean;
result: WAFIPGroupAutoTestResult | null;
onOpenChange: (open: boolean) => void;
}
export function IPGroupTestDialog({
open,
loading,
result,
onOpenChange,
}: IPGroupTestDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>自动规则测试结果</DialogTitle>
<DialogDescription>
基于当前自动配置 JSON 对请求日志进行回看测试。
</DialogDescription>
</DialogHeader>
{loading ? (
<p className="text-sm text-muted-foreground py-6 text-center">测试中...</p>
) : result ? (
<div className="space-y-4">
<div className="rounded-lg border border-dashed p-4 text-sm">
<p>
回看 {result.lookback_minutes} 分钟 · 规则 {result.rule_count} 条 · 命中{' '}
{result.matched_count} 个 IP
</p>
<p className="text-xs text-muted-foreground mt-1">
测试时间:{new Date(result.tested_at).toLocaleString()}
</p>
</div>
{result.matched_count > 0 ? (
<pre className="max-h-64 overflow-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap break-all">
{result.matched_ips.join('\n')}
</pre>
) : (
<p className="text-sm text-muted-foreground">当前没有匹配到任何 IP。</p>
)}
</div>
) : (
<p className="text-sm text-muted-foreground py-6 text-center">暂无测试结果。</p>
)}
<DialogFooter>
<Button type="button" onClick={() => onOpenChange(false)}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,189 @@
'use client';
import {Loader2, Trash2} from 'lucide-react';
import {useMemo, useState} from 'react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {Button} from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
import type {WAFIPGroup} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
import {getIPGroupViewEntries, ipGroupTypeLabels, type IPGroupViewEntry} from './helpers';
interface IPGroupViewDialogProps {
open: boolean;
group: WAFIPGroup | null;
loading: boolean;
removingIp: string | null;
onOpenChange: (open: boolean) => void;
onRemoveIp: (ip: string) => Promise<void>;
}
export function IPGroupViewDialog({
open,
group,
loading,
removingIp,
onOpenChange,
onRemoveIp,
}: IPGroupViewDialogProps) {
const [deleteTarget, setDeleteTarget] = useState<IPGroupViewEntry | null>(null);
const entries = useMemo(
() => (group ? getIPGroupViewEntries(group) : []),
[group],
);
const showAutomaticMeta = group?.type === 'automatic';
return (
<>
<Dialog
open={open}
onOpenChange={(nextOpen) => {
onOpenChange(nextOpen);
if (!nextOpen) {
setDeleteTarget(null);
}
}}
>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{group ? `查看 ${group.name}` : '查看 IP 组'}</DialogTitle>
<DialogDescription>
{group
? `${ipGroupTypeLabels[group.type]} · 共 ${entries.length} 条 IP`
: '查看当前 IP 组中的 IP 列表,并可移除不需要的条目。'}
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
加载 IP 列表...
</div>
) : !group ? (
<p className="py-8 text-center text-sm text-muted-foreground">未选择 IP 组。</p>
) : entries.length === 0 ? (
<EmptyStateWithBorder
description={
group.type === 'automatic'
? '该自动规则暂未抓取任何 IP,可点击「立即执行」手动触发抓取。'
: '当前 IP 组暂无 IP 条目。'
}
/>
) : (
<div className="space-y-3">
{group.type === 'subscription' ? (
<p className="text-xs text-muted-foreground">
订阅类型在下次同步时可能重新拉取已删除的 IP。
</p>
) : null}
<div className="rounded-lg border border-dashed">
<Table>
<TableHeader>
<TableRow>
<TableHead>IP 地址</TableHead>
{showAutomaticMeta ? (
<>
<TableHead>抓取时间</TableHead>
<TableHead>封禁剩余</TableHead>
</>
) : null}
<TableHead className="w-[80px] text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.ip}>
<TableCell className="font-mono text-sm">{entry.ip}</TableCell>
{showAutomaticMeta ? (
<>
<TableCell className="text-sm text-muted-foreground">
{entry.capturedAt ? formatDateTime(entry.capturedAt) : '—'}
</TableCell>
<TableCell className="text-sm">{entry.banRemaining ?? '—'}</TableCell>
</>
) : null}
<TableCell className="text-right">
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-destructive hover:text-destructive"
disabled={removingIp === entry.ip}
onClick={() => setDeleteTarget(entry)}
>
{removingIp === entry.ip ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
<span className="sr-only">删除 {entry.ip}</span>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(nextOpen) => !nextOpen && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除 IP</AlertDialogTitle>
<AlertDialogDescription>
确认从 IP 组「{group?.name}」中移除 {deleteTarget?.ip} 吗?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={Boolean(removingIp)}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
disabled={Boolean(removingIp)}
onClick={async () => {
if (!deleteTarget) return;
await onRemoveIp(deleteTarget.ip);
setDeleteTarget(null);
}}
>
{removingIp ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,126 @@
'use client';
import {Download, Eye, MoreHorizontal, Pencil, Play, Trash2} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
import type {WAFIPGroup} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
import {ipGroupTypeLabels} from './helpers';
interface IPGroupsTableProps {
groups: WAFIPGroup[];
syncingId: number | null;
onView: (group: WAFIPGroup) => void;
onEdit: (group: WAFIPGroup) => void;
onDelete: (group: WAFIPGroup) => void;
onSync: (group: WAFIPGroup) => void;
onTest: (group: WAFIPGroup) => void;
}
export function IPGroupsTable({
groups,
syncingId,
onView,
onEdit,
onDelete,
onSync,
onTest,
}: IPGroupsTableProps) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>名称</TableHead>
<TableHead>类型</TableHead>
<TableHead>状态</TableHead>
<TableHead>IP 数</TableHead>
<TableHead>引用次数</TableHead>
<TableHead>同步状态</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="w-[80px] text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-medium">{group.name}</TableCell>
<TableCell>
<Badge variant="outline">{ipGroupTypeLabels[group.type]}</Badge>
</TableCell>
<TableCell>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
{group.enabled ? '启用' : '停用'}
</Badge>
</TableCell>
<TableCell>{group.ip_list.length}</TableCell>
<TableCell>{group.referenced_by_rule_count}</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-[200px] truncate">
{group.last_sync_status
? `${group.last_sync_status}: ${group.last_sync_message}`
: '尚无同步记录'}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{group.updated_at ? formatDateTime(group.updated_at) : '—'}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onView(group)}>
<Eye className="size-4 mr-2" />
查看
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(group)}>
<Pencil className="size-4 mr-2" />
编辑
</DropdownMenuItem>
{group.type === 'automatic' ? (
<DropdownMenuItem onClick={() => onTest(group)}>
<Play className="size-4 mr-2" />
测试规则
</DropdownMenuItem>
) : null}
{group.type === 'subscription' || group.type === 'automatic' ? (
<DropdownMenuItem
disabled={syncingId === group.id}
onClick={() => onSync(group)}
>
<Download className="size-4 mr-2" />
{syncingId === group.id
? '同步中...'
: group.type === 'automatic'
? '立即执行'
: '立即同步'}
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete(group)}
>
<Trash2 className="size-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
@@ -0,0 +1,244 @@
'use client';
import {useEffect, useState} from 'react';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {Switch} from '@/components/ui/switch';
import {Textarea} from '@/components/ui/textarea';
import type {ProxyRoutePoWConfig} from '@/lib/services/openflare';
import {listToText, parseTextareaList} from './helpers';
interface PowConfigPanelProps {
enabled: boolean;
config: ProxyRoutePoWConfig;
onChange: (enabled: boolean, config: ProxyRoutePoWConfig) => void;
}
type PowListDraft = {
whitelist: Record<keyof ProxyRoutePoWConfig['whitelist'], string>;
blacklist: Record<keyof ProxyRoutePoWConfig['blacklist'], string>;
};
function buildDraft(config: ProxyRoutePoWConfig): PowListDraft {
return {
whitelist: {
ips: listToText(config.whitelist?.ips),
ip_cidrs: listToText(config.whitelist?.ip_cidrs),
paths: listToText(config.whitelist?.paths),
path_regexes: listToText(config.whitelist?.path_regexes),
user_agents: listToText(config.whitelist?.user_agents),
},
blacklist: {
ips: listToText(config.blacklist?.ips),
ip_cidrs: listToText(config.blacklist?.ip_cidrs),
paths: listToText(config.blacklist?.paths),
path_regexes: listToText(config.blacklist?.path_regexes),
user_agents: listToText(config.blacklist?.user_agents),
},
};
}
function applyDraft(
config: ProxyRoutePoWConfig,
draft: PowListDraft,
): ProxyRoutePoWConfig {
return {
...config,
whitelist: {
ips: parseTextareaList(draft.whitelist.ips),
ip_cidrs: parseTextareaList(draft.whitelist.ip_cidrs),
paths: parseTextareaList(draft.whitelist.paths),
path_regexes: parseTextareaList(draft.whitelist.path_regexes),
user_agents: parseTextareaList(draft.whitelist.user_agents),
},
blacklist: {
ips: parseTextareaList(draft.blacklist.ips),
ip_cidrs: parseTextareaList(draft.blacklist.ip_cidrs),
paths: parseTextareaList(draft.blacklist.paths),
path_regexes: parseTextareaList(draft.blacklist.path_regexes),
user_agents: parseTextareaList(draft.blacklist.user_agents),
},
};
}
function PowListCard({
title,
scope,
draft,
onUpdate,
}: {
title: string;
scope: keyof PowListDraft;
draft: PowListDraft;
onUpdate: (
scope: keyof PowListDraft,
key: keyof ProxyRoutePoWConfig['whitelist'],
value: string,
) => void;
}) {
const fields: Array<{
key: keyof ProxyRoutePoWConfig['whitelist'];
label: string;
placeholder: string;
}> = [
{key: 'ips', label: 'IP', placeholder: '每行一个 IP'},
{key: 'ip_cidrs', label: 'IP CIDR', placeholder: '每行一个网段,如 10.0.0.0/8'},
{key: 'paths', label: '路径', placeholder: '每行一个路径前缀'},
{key: 'path_regexes', label: '路径正则', placeholder: '每行一个正则表达式'},
{key: 'user_agents', label: 'User-Agent', placeholder: '每行一个 UA 关键词或正则'},
];
return (
<div className="space-y-3 rounded-lg border border-dashed p-4">
<p className="text-sm font-medium">{title}</p>
<div className="space-y-3">
{fields.map((field) => (
<div key={field.key} className="space-y-1.5">
<Label className="text-xs text-muted-foreground">{field.label}</Label>
<Textarea
value={draft[scope][field.key]}
onChange={(event) => onUpdate(scope, field.key, event.target.value)}
placeholder={field.placeholder}
className="min-h-16 resize-y font-mono text-xs"
/>
</div>
))}
</div>
</div>
);
}
export function PowConfigPanel({enabled, config, onChange}: PowConfigPanelProps) {
const [draft, setDraft] = useState(() => buildDraft(config));
useEffect(() => {
setDraft(buildDraft(config));
}, [config]);
const emitChange = (
nextEnabled: boolean,
nextConfig: Partial<ProxyRoutePoWConfig>,
nextDraft?: PowListDraft,
) => {
const merged = {
...config,
...nextConfig,
};
onChange(nextEnabled, nextDraft ? applyDraft(merged, nextDraft) : merged);
};
const updateList = (
scope: keyof PowListDraft,
key: keyof ProxyRoutePoWConfig['whitelist'],
value: string,
) => {
const nextDraft: PowListDraft = {
...draft,
[scope]: {
...draft[scope],
[key]: value,
},
};
setDraft(nextDraft);
emitChange(enabled, {}, nextDraft);
};
return (
<div className="space-y-5">
<div className="flex items-center justify-between rounded-lg border border-dashed p-4">
<div className="space-y-1 pr-4">
<Label htmlFor="pow-enabled">启用 PoW 防护</Label>
<p className="text-xs text-muted-foreground">
启用后,命中该规则组的请求需要完成浏览器计算挑战。
</p>
</div>
<Switch
id="pow-enabled"
checked={enabled}
onCheckedChange={(checked) => emitChange(checked, {})}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">算法</Label>
<Select
value={config.algorithm}
onValueChange={(value: 'fast' | 'slow') =>
emitChange(enabled, {algorithm: value})
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fast">Fast</SelectItem>
<SelectItem value="slow">Slow</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">难度</Label>
<Input
type="number"
min={1}
max={16}
value={config.difficulty}
onChange={(event) =>
emitChange(enabled, {difficulty: Number(event.target.value) || 1})
}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">会话 TTL (秒)</Label>
<Input
type="number"
min={60}
value={config.session_ttl}
onChange={(event) =>
emitChange(enabled, {session_ttl: Number(event.target.value) || 60})
}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">挑战 TTL (秒)</Label>
<Input
type="number"
min={30}
value={config.challenge_ttl}
onChange={(event) =>
emitChange(enabled, {challenge_ttl: Number(event.target.value) || 30})
}
/>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<PowListCard
title="白名单(跳过 PoW)"
scope="whitelist"
draft={draft}
onUpdate={updateList}
/>
<PowListCard
title="黑名单(必须 PoW)"
scope="blacklist"
draft={draft}
onUpdate={updateList}
/>
</div>
</div>
);
}
@@ -0,0 +1,367 @@
'use client';
import {useEffect, useMemo, useState} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {Search} from 'lucide-react';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {Button} from '@/components/ui/button';
import {Checkbox} from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Textarea} from '@/components/ui/textarea';
import {cn} from '@/lib/utils';
import type {WAFIPGroup} from '@/lib/services/openflare';
import {type CountryOption, normalizeItems, type RuleDimension, type RuleListType, textToList,} from './helpers';
const ruleEntrySchema = z
.object({
listType: z.enum(['whitelist', 'blacklist']),
dimension: z.enum(['ip', 'ip_group', 'country']),
ipValue: z.string(),
ipGroupIDs: z.array(z.number()),
countryValues: z.array(z.string()),
})
.superRefine((value, context) => {
if (value.dimension === 'ip') {
if (textToList(value.ipValue).length === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['ipValue'],
message: '请输入至少一个 IP 或 IP 段',
});
}
return;
}
if (value.dimension === 'ip_group') {
if (value.ipGroupIDs.length === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['ipGroupIDs'],
message: '请选择至少一个 IP 组',
});
}
return;
}
if (normalizeItems(value.countryValues).length === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['countryValues'],
message: '请选择至少一个国家/地区',
});
}
});
export type RuleEntryFormValues = z.infer<typeof ruleEntrySchema>;
const defaultRuleEntryValues: RuleEntryFormValues = {
listType: 'blacklist',
dimension: 'ip',
ipValue: '',
ipGroupIDs: [],
countryValues: [],
};
interface RuleEntryDialogProps {
open: boolean;
countryOptions: CountryOption[];
ipGroups: WAFIPGroup[];
onOpenChange: (open: boolean) => void;
onSubmit: (values: RuleEntryFormValues) => void;
}
export function RuleEntryDialog({
open,
countryOptions,
ipGroups,
onOpenChange,
onSubmit,
}: RuleEntryDialogProps) {
const [keyword, setKeyword] = useState('');
const form = useForm<RuleEntryFormValues>({
resolver: zodResolver(ruleEntrySchema),
defaultValues: defaultRuleEntryValues,
});
const listType = form.watch('listType');
const dimension = form.watch('dimension');
const ipValue = form.watch('ipValue');
const ipGroupIDs = form.watch('ipGroupIDs');
const countryValues = form.watch('countryValues');
useEffect(() => {
if (!open) return;
form.reset(defaultRuleEntryValues);
setKeyword('');
}, [form, open]);
useEffect(() => {
if (!open) return;
setKeyword('');
}, [dimension, open]);
const selectedCountrySet = useMemo(
() => new Set(countryValues),
[countryValues],
);
const selectedIPGroupSet = useMemo(
() => new Set(ipGroupIDs),
[ipGroupIDs],
);
const filteredCountries = useMemo(() => {
const normalized = keyword.trim().toLowerCase();
return countryOptions
.filter((option) => !normalized || option.searchText.includes(normalized))
.sort((left, right) => {
const leftSelected = selectedCountrySet.has(left.code) ? 1 : 0;
const rightSelected = selectedCountrySet.has(right.code) ? 1 : 0;
return rightSelected - leftSelected || left.code.localeCompare(right.code);
});
}, [countryOptions, keyword, selectedCountrySet]);
const toggleCountry = (code: string) => {
const values = selectedCountrySet.has(code)
? countryValues.filter((item) => item !== code)
: normalizeItems([...countryValues, code]);
form.setValue('countryValues', values, { shouldValidate: true });
};
const toggleIPGroup = (id: number) => {
const values = selectedIPGroupSet.has(id)
? ipGroupIDs.filter((item) => item !== id)
: [...ipGroupIDs, id].sort((left, right) => left - right);
form.setValue('ipGroupIDs', values, { shouldValidate: true });
};
const typeLabel = listType === 'blacklist' ? '黑名单' : '白名单';
const dimensionLabel =
dimension === 'ip'
? 'IP'
: dimension === 'ip_group'
? 'IP 组'
: '地域';
const handleSubmit = form.handleSubmit((values) => {
onSubmit(values);
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>添加{typeLabel}规则</DialogTitle>
<DialogDescription>
当前准备新增 {dimensionLabel} 维度的{typeLabel}项。
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>类型</Label>
<div className="grid grid-cols-2 gap-2">
{[
{ value: 'blacklist', label: '黑名单' },
{ value: 'whitelist', label: '白名单' },
].map((option) => (
<Button
key={option.value}
type="button"
variant={listType === option.value ? 'default' : 'outline'}
onClick={() =>
form.setValue('listType', option.value as RuleListType)
}
>
{option.label}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label>维度</Label>
<div className="grid grid-cols-3 gap-2">
{[
{ value: 'ip', label: 'IP' },
{ value: 'ip_group', label: 'IP 组' },
{ value: 'country', label: '地域' },
].map((option) => (
<Button
key={option.value}
type="button"
size="sm"
variant={dimension === option.value ? 'default' : 'outline'}
onClick={() =>
form.setValue('dimension', option.value as RuleDimension)
}
>
{option.label}
</Button>
))}
</div>
</div>
</div>
{dimension === 'ip' ? (
<div className="space-y-2">
<Label>IP / IP 段</Label>
<Textarea
value={ipValue}
placeholder="例如 1.1.1.1 或 192.168.0.0/24"
onChange={(event) =>
form.setValue('ipValue', event.target.value, { shouldValidate: true })
}
/>
<p className="text-xs text-muted-foreground">
支持单个 IP、CIDR,或使用换行/逗号一次添加多个。
</p>
{form.formState.errors.ipValue ? (
<p className="text-xs text-destructive">
{form.formState.errors.ipValue.message}
</p>
) : null}
</div>
) : null}
{dimension === 'ip_group' ? (
<div className="space-y-3 rounded-lg border border-dashed p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">选择 IP 组</p>
<p className="text-xs text-muted-foreground">
发布版本只保存引用 ID,IP 组成员由 Agent 按 checksum 差异同步。
</p>
</div>
<span className="text-xs text-muted-foreground">
已选 {ipGroupIDs.length}
</span>
</div>
<div className="max-h-64 space-y-2 overflow-y-auto">
{ipGroups.length > 0 ? (
ipGroups.map((group) => {
const selected = selectedIPGroupSet.has(group.id);
return (
<label
key={group.id}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-md border px-3 py-2',
selected && 'border-primary bg-muted/50',
)}
>
<Checkbox
checked={selected}
onCheckedChange={() => toggleIPGroup(group.id)}
/>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium truncate">
{group.name}
</span>
<span className="block text-xs text-muted-foreground">
{group.type} · {group.ip_list.length} 条 ·{' '}
{group.enabled ? '启用' : '停用'}
</span>
</span>
</label>
);
})
) : (
<p className="text-sm text-muted-foreground">暂无 IP 组,请先创建。</p>
)}
</div>
{form.formState.errors.ipGroupIDs ? (
<p className="text-xs text-destructive">
{form.formState.errors.ipGroupIDs.message}
</p>
) : null}
</div>
) : null}
{dimension === 'country' ? (
<div className="space-y-3">
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
<Search className="size-4 text-muted-foreground" />
<Input
value={keyword}
placeholder="搜索国家代码或中文名"
className="border-0 shadow-none focus-visible:ring-0"
onChange={(event) => setKeyword(event.target.value)}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
form.setValue(
'countryValues',
normalizeItems([
...countryValues,
...filteredCountries.map((option) => option.code),
]),
{ shouldValidate: true },
)
}
>
全选当前
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
form.setValue('countryValues', [], { shouldValidate: true })
}
>
清空
</Button>
</div>
<div className="max-h-64 space-y-2 overflow-y-auto rounded-lg border border-dashed p-3">
{filteredCountries.map((option) => {
const selected = selectedCountrySet.has(option.code);
return (
<label
key={option.code}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-md border px-3 py-2',
selected && 'border-primary bg-muted/50',
)}
>
<Checkbox
checked={selected}
onCheckedChange={() => toggleCountry(option.code)}
/>
<span className="text-sm">{option.label}</span>
</label>
);
})}
</div>
{form.formState.errors.countryValues ? (
<p className="text-xs text-destructive">
{form.formState.errors.countryValues.message}
</p>
) : null}
</div>
) : null}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button type="button" onClick={() => void handleSubmit()}>
添加
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,399 @@
'use client';
import {useEffect, useMemo, useState} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {Plus} from 'lucide-react';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {Button} from '@/components/ui/button';
import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@/components/ui/form';
import {Input} from '@/components/ui/input';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import {Switch} from '@/components/ui/switch';
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
import {Textarea} from '@/components/ui/textarea';
import type {WAFIPGroup, WAFRuleGroup, WAFRuleGroupPayload} from '@/lib/services/openflare';
import {
buildCountryOptions,
buildRuleGroupDraft,
countRuleEntries,
emptyRuleGroupDraft,
formatCountryItem,
getListFieldKey,
type ListFieldKey,
normalizeItems,
textToList,
updateDraftList,
} from './helpers';
import {PowConfigPanel} from './pow-config-panel';
import {RuleEntryDialog, type RuleEntryFormValues} from './rule-entry-dialog';
import {RuleListSection} from './rule-list-section';
const ruleGroupSchema = z.object({
name: z.string().trim().min(1, '请输入规则组名称').max(255, '名称不能超过 255 个字符'),
enabled: z.boolean(),
block_status_code: z.number().int().min(400).max(599),
block_response_body: z.string(),
remark: z.string().max(500, '备注不能超过 500 个字符'),
pow_enabled: z.boolean(),
});
type RuleGroupFormValues = z.infer<typeof ruleGroupSchema>;
interface RuleGroupSheetProps {
open: boolean;
group: WAFRuleGroup | null;
ipGroups: WAFIPGroup[];
submitting: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: WAFRuleGroupPayload) => Promise<void>;
}
export function RuleGroupSheet({
open,
group,
ipGroups,
submitting,
onOpenChange,
onSubmit,
}: RuleGroupSheetProps) {
const [listsDraft, setListsDraft] = useState<WAFRuleGroupPayload>(emptyRuleGroupDraft);
const [ruleEntryOpen, setRuleEntryOpen] = useState(false);
const countryOptions = useMemo(() => buildCountryOptions(), []);
const countryLabelMap = useMemo(
() => new Map(countryOptions.map((option) => [option.code, option.label])),
[countryOptions],
);
const form = useForm<RuleGroupFormValues>({
resolver: zodResolver(ruleGroupSchema),
defaultValues: {
name: '',
enabled: true,
block_status_code: 418,
block_response_body: '',
remark: '',
pow_enabled: false,
},
});
useEffect(() => {
if (!open) return;
const draft = buildRuleGroupDraft(group);
setListsDraft(draft);
form.reset({
name: draft.name,
enabled: draft.enabled,
block_status_code: draft.block_status_code,
block_response_body: draft.block_response_body,
remark: draft.remark,
pow_enabled: draft.pow_enabled,
});
setRuleEntryOpen(false);
}, [form, group, open]);
const ipGroupByID = new Map(ipGroups.map((item) => [item.id, item]));
const whitelistGroupItems = listsDraft.ip_whitelist_group_ids
.map((id) => ipGroupByID.get(id))
.filter((item): item is WAFIPGroup => Boolean(item));
const blacklistGroupItems = listsDraft.ip_blacklist_group_ids
.map((id) => ipGroupByID.get(id))
.filter((item): item is WAFIPGroup => Boolean(item));
const removeRuleItem = (key: ListFieldKey, value: string) => {
setListsDraft((current) =>
updateDraftList(current, key, (items) => items.filter((item) => item !== value)),
);
};
const removeRuleGroup = (key: ListFieldKey, id: number) => {
setListsDraft((current) =>
updateDraftList(current, key, (items) =>
items.filter((item) => item !== String(id)),
),
);
};
const applyRuleEntry = (entry: RuleEntryFormValues) => {
const values =
entry.dimension === 'ip'
? textToList(entry.ipValue)
: entry.dimension === 'ip_group'
? entry.ipGroupIDs.map(String)
: normalizeItems(entry.countryValues);
const listKey = getListFieldKey(entry.listType, entry.dimension);
setListsDraft((current) =>
updateDraftList(current, listKey, (items) =>
normalizeItems([...items, ...values]),
),
);
setRuleEntryOpen(false);
};
const handleSubmit = form.handleSubmit(async (values) => {
const payload: WAFRuleGroupPayload = {
...listsDraft,
name: values.name,
enabled: values.enabled,
block_status_code: values.block_status_code,
block_response_body: values.block_response_body,
remark: values.remark,
pow_enabled: values.pow_enabled,
};
try {
await onSubmit(payload);
onOpenChange(false);
} catch (error) {
form.setError('root', {
message: error instanceof Error ? error.message : '保存失败',
});
}
});
const isGlobal = group?.is_global ?? false;
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex h-svh w-full flex-col gap-0 p-0 sm:max-w-4xl">
<SheetHeader className="border-b px-4 py-4">
<SheetTitle>{group ? `编辑 ${group.name}` : '新建规则组'}</SheetTitle>
<SheetDescription>
白名单命中后直接放行;未命中白名单时继续判断黑名单。当前规则数{' '}
{countRuleEntries(listsDraft)} 条。
</SheetDescription>
</SheetHeader>
<Form {...form}>
<form
id="rule-group-form"
onSubmit={handleSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-4">
<Tabs defaultValue="basic">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="basic">基本信息</TabsTrigger>
<TabsTrigger value="lists">黑白名单</TabsTrigger>
<TabsTrigger value="pow">PoW</TabsTrigger>
<TabsTrigger value="block">拦截返回</TabsTrigger>
</TabsList>
<TabsContent value="basic" className="mt-4 space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>规则组名称</FormLabel>
<FormControl>
<Input {...field} disabled={isGlobal} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border border-dashed p-4">
<div className="space-y-0.5">
<FormLabel>启用规则组</FormLabel>
<FormDescription>关闭后保留配置,但不会参与匹配。</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="remark"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormLabel>备注</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</TabsContent>
<TabsContent value="lists" className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
通过弹窗添加 IP、IP 组或地域规则。
</p>
<Button type="button" size="sm" onClick={() => setRuleEntryOpen(true)}>
<Plus className="mr-1 size-3.5" />
添加规则
</Button>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<RuleListSection
title="IP 白名单"
description="命中后直接放行,不再继续判断黑名单。"
items={listsDraft.ip_whitelist}
groupItems={whitelistGroupItems}
tone="whitelist"
emptyText="暂无 IP 白名单规则。"
onRemove={(item) => removeRuleItem('ip_whitelist', item)}
onRemoveGroup={(id) => removeRuleGroup('ip_whitelist_group_ids', id)}
/>
<RuleListSection
title="IP 黑名单"
description="未命中白名单时,命中这些 IP / IP 段将被拦截。"
items={listsDraft.ip_blacklist}
groupItems={blacklistGroupItems}
tone="blacklist"
emptyText="暂无 IP 黑名单规则。"
onRemove={(item) => removeRuleItem('ip_blacklist', item)}
onRemoveGroup={(id) => removeRuleGroup('ip_blacklist_group_ids', id)}
/>
<RuleListSection
title="地域白名单"
description="显示格式为国家代码与中文名,命中后直接放行。"
items={listsDraft.country_whitelist.map((code) =>
formatCountryItem(code, countryLabelMap),
)}
tone="whitelist"
emptyText="暂无地域白名单规则。"
onRemove={(item) => {
const code = item.split(' ')[0] ?? item;
removeRuleItem('country_whitelist', code);
}}
/>
<RuleListSection
title="地域黑名单"
description="当请求未命中白名单时,命中这些地域将被拦截。"
items={listsDraft.country_blacklist.map((code) =>
formatCountryItem(code, countryLabelMap),
)}
tone="blacklist"
emptyText="暂无地域黑名单规则。"
onRemove={(item) => {
const code = item.split(' ')[0] ?? item;
removeRuleItem('country_blacklist', code);
}}
/>
</div>
</TabsContent>
<TabsContent value="pow" className="mt-4">
<PowConfigPanel
enabled={form.watch('pow_enabled')}
config={listsDraft.pow_config}
onChange={(enabled, config) => {
form.setValue('pow_enabled', enabled);
setListsDraft((current) => ({
...current,
pow_enabled: enabled,
pow_config: config,
}));
}}
/>
</TabsContent>
<TabsContent value="block" className="mt-4 space-y-4">
<div className="grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
<FormField
control={form.control}
name="block_status_code"
render={({ field }) => (
<FormItem>
<FormLabel>拦截状态码</FormLabel>
<FormControl>
<Input
type="number"
min={400}
max={599}
value={field.value}
onChange={(event) =>
field.onChange(Number(event.target.value))
}
/>
</FormControl>
<div className="flex flex-wrap gap-2 pt-2">
{[403, 418, 451, 503].map((code) => (
<Button
key={code}
type="button"
size="sm"
variant={field.value === code ? 'default' : 'outline'}
onClick={() => field.onChange(code)}
>
{code}
</Button>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="block_response_body"
render={({ field }) => (
<FormItem>
<FormLabel>拦截页面</FormLabel>
<FormControl>
<Textarea
className="min-h-48"
placeholder="<html><body><h1>Request blocked</h1></body></html>"
{...field}
/>
</FormControl>
<FormDescription>留空时只返回状态码。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</TabsContent>
</Tabs>
{form.formState.errors.root ? (
<p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
) : null}
</div>
<SheetFooter className="flex-row justify-end border-t px-4 py-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? '保存中...' : '保存规则组'}
</Button>
</SheetFooter>
</form>
</Form>
</SheetContent>
</Sheet>
<RuleEntryDialog
open={ruleEntryOpen}
countryOptions={countryOptions}
ipGroups={ipGroups}
onOpenChange={setRuleEntryOpen}
onSubmit={applyRuleEntry}
/>
</>
);
}
@@ -0,0 +1,114 @@
'use client';
import {Globe2, MoreHorizontal, Pencil, ShieldCheck, Trash2, Users} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
import type {WAFRuleGroup} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
import {countRuleEntries} from './helpers';
interface RuleGroupsTableProps {
groups: WAFRuleGroup[];
onEdit: (group: WAFRuleGroup) => void;
onDelete: (group: WAFRuleGroup) => void;
onBindSites: (group: WAFRuleGroup) => void;
}
export function RuleGroupsTable({
groups,
onEdit,
onDelete,
onBindSites,
}: RuleGroupsTableProps) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>名称</TableHead>
<TableHead>类型</TableHead>
<TableHead>状态</TableHead>
<TableHead>规则数</TableHead>
<TableHead>应用范围</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="w-[80px] text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group) => (
<TableRow key={group.id}>
<TableCell>
<div className="flex items-center gap-2">
{group.is_global ? (
<Globe2 className="size-4 text-primary shrink-0" />
) : (
<ShieldCheck className="size-4 text-muted-foreground shrink-0" />
)}
<span className="font-medium">{group.name}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{group.is_global ? '全局' : '自定义'}</Badge>
</TableCell>
<TableCell>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
{group.enabled ? '启用' : '停用'}
</Badge>
</TableCell>
<TableCell>{countRuleEntries(group)}</TableCell>
<TableCell>
{group.is_global
? '全部网站'
: `${group.applied_site_count} 个网站`}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{group.updated_at ? formatDateTime(group.updated_at) : '—'}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(group)}>
<Pencil className="size-4 mr-2" />
编辑
</DropdownMenuItem>
{!group.is_global ? (
<DropdownMenuItem onClick={() => onBindSites(group)}>
<Users className="size-4 mr-2" />
绑定网站
</DropdownMenuItem>
) : null}
{!group.is_global ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete(group)}
>
<Trash2 className="size-4 mr-2" />
删除
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
@@ -0,0 +1,78 @@
'use client';
import {X} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
interface RuleListSectionProps {
title: string;
description: string;
items: string[];
groupItems?: Array<{ id: number; name: string; enabled: boolean }>;
tone: 'whitelist' | 'blacklist';
emptyText: string;
onRemove: (item: string) => void;
onRemoveGroup?: (id: number) => void;
}
export function RuleListSection({
title,
description,
items,
groupItems = [],
tone,
emptyText,
onRemove,
onRemoveGroup,
}: RuleListSectionProps) {
const total = items.length + groupItems.length;
const badgeVariant = tone === 'whitelist' ? 'secondary' : 'destructive';
return (
<div className="rounded-lg border border-dashed p-4 space-y-3">
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-sm font-semibold">{title}</h4>
<p className="text-xs text-muted-foreground mt-1">{description}</p>
</div>
<Badge variant="outline">{total}</Badge>
</div>
{total > 0 ? (
<div className="flex flex-wrap gap-2">
{groupItems.map((group) => (
<Badge key={`group-${group.id}`} variant={badgeVariant} className="gap-1 pr-1">
IP组: {group.name}
{!group.enabled ? ' (停用)' : ''}
<Button
type="button"
variant="ghost"
size="icon"
className="size-4 hover:bg-transparent"
onClick={() => onRemoveGroup?.(group.id)}
>
<X className="size-3" />
</Button>
</Badge>
))}
{items.map((item) => (
<Badge key={item} variant={badgeVariant} className="gap-1 pr-1 font-mono text-xs">
{item}
<Button
type="button"
variant="ghost"
size="icon"
className="size-4 hover:bg-transparent"
onClick={() => onRemove(item)}
>
<X className="size-3" />
</Button>
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">{emptyText}</p>
)}
</div>
);
}
@@ -0,0 +1,135 @@
'use client';
import {useEffect, useMemo, useState} from 'react';
import {Check, Search} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,} from '@/components/ui/sheet';
import {cn} from '@/lib/utils';
import type {ProxyRouteItem, WAFRuleGroup} from '@/lib/services/openflare';
interface SiteBindingSheetProps {
group: WAFRuleGroup | null;
routes: ProxyRouteItem[];
open: boolean;
pending: boolean;
onOpenChange: (open: boolean) => void;
onSave: (ids: number[]) => void;
}
export function SiteBindingSheet({
group,
routes,
open,
pending,
onOpenChange,
onSave,
}: SiteBindingSheetProps) {
const [keyword, setKeyword] = useState('');
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
useEffect(() => {
setSelectedIDs(group?.applied_site_ids ?? []);
setKeyword('');
}, [group, open]);
const filteredRoutes = useMemo(() => {
const normalized = keyword.trim().toLowerCase();
if (!normalized) return routes;
return routes.filter((route) =>
[route.site_name, route.primary_domain, ...route.domains]
.join(' ')
.toLowerCase()
.includes(normalized),
);
}, [keyword, routes]);
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]);
const toggleID = (id: number) => {
setSelectedIDs((current) =>
current.includes(id)
? current.filter((item) => item !== id)
: [...current, id].sort((left, right) => left - right),
);
};
const selectFiltered = () => {
const next = new Set(selectedIDs);
filteredRoutes.forEach((route) => next.add(route.id));
setSelectedIDs([...next].sort((left, right) => left - right));
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetTitle>{group ? `绑定 ${group.name}` : '绑定规则组'}</SheetTitle>
<SheetDescription>
选择这个自定义规则组要叠加到哪些网站。
</SheetDescription>
</SheetHeader>
<div className="space-y-4 px-4 pb-4">
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
<Search className="size-4 text-muted-foreground" />
<Input
value={keyword}
placeholder="搜索网站或域名"
className="border-0 shadow-none focus-visible:ring-0"
onChange={(event) => setKeyword(event.target.value)}
/>
<Button type="button" variant="ghost" size="sm" onClick={selectFiltered}>
全选当前
</Button>
</div>
<div className="space-y-2">
{filteredRoutes.map((route) => (
<button
key={route.id}
type="button"
onClick={() => toggleID(route.id)}
className={cn(
'flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left transition',
selectedSet.has(route.id) && 'border-primary bg-muted/50',
)}
>
<span
className={cn(
'flex size-5 items-center justify-center rounded border',
selectedSet.has(route.id) && 'border-primary bg-primary text-primary-foreground',
)}
>
{selectedSet.has(route.id) ? <Check className="size-3" /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{route.site_name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{route.domains.join(', ')}
</span>
</span>
</button>
))}
</div>
</div>
<SheetFooter className="px-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button
type="button"
disabled={!group || pending}
onClick={() => onSave(selectedIDs)}
>
{pending ? '保存中...' : '保存应用范围'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}