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,261 @@
'use client';
import {useEffect, useState} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
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 type {ProxyRouteItem, ProxyRoutePoWConfig} from '@/lib/services/openflare';
import {defaultPowConfig} from '@/app/(main)/waf/components/helpers';
import {PowConfigPanel} from '@/app/(main)/waf/components/pow-config-panel';
import {proxyRouteFormIds} from '../helpers';
import {useRouteSectionSave} from '../hooks/use-route-section-save';
import {SectionShell} from './section-shell';
const authSchema = z
.object({
auth_mode: z.enum(['none', 'basic', 'pow']),
basic_auth_username: z.string(),
basic_auth_password: z.string(),
pow_enabled: z.boolean(),
pow_difficulty: z.string(),
pow_algorithm: z.enum(['fast', 'slow']),
pow_session_ttl: z.string(),
pow_challenge_ttl: z.string(),
})
.superRefine((value, context) => {
if (value.auth_mode === 'pow') {
const difficulty = Number(value.pow_difficulty);
if (!Number.isFinite(difficulty) || difficulty < 1 || difficulty > 16) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['pow_difficulty'],
message: '难度需为 1-16 的整数',
});
}
const sessionTtl = Number(value.pow_session_ttl);
if (!Number.isFinite(sessionTtl) || sessionTtl < 60) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['pow_session_ttl'],
message: '会话 TTL 需大于等于 60 秒',
});
}
const challengeTtl = Number(value.pow_challenge_ttl);
if (!Number.isFinite(challengeTtl) || challengeTtl < 30) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['pow_challenge_ttl'],
message: '挑战 TTL 需大于等于 30 秒',
});
}
}
if (value.auth_mode === 'basic') {
if (!value.basic_auth_username.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['basic_auth_username'],
message: '请输入账号',
});
}
if (!value.basic_auth_password.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['basic_auth_password'],
message: '请输入密码',
});
}
}
});
type AuthValues = z.infer<typeof authSchema>;
function resolveAuthMode(route: ProxyRouteItem): AuthValues['auth_mode'] {
if (route.basic_auth_enabled) {
return 'basic';
}
if (route.pow_enabled) {
return 'pow';
}
return 'none';
}
function syncPowFormValues(
setValue: ReturnType<typeof useForm<AuthValues>>['setValue'],
config: ProxyRoutePoWConfig,
) {
setValue('pow_difficulty', String(config.difficulty));
setValue('pow_algorithm', config.algorithm);
setValue('pow_session_ttl', String(config.session_ttl));
setValue('pow_challenge_ttl', String(config.challenge_ttl));
}
interface AuthSectionProps {
route: ProxyRouteItem;
onRouteUpdate: (route: ProxyRouteItem) => void;
onSavingChange?: (saving: boolean) => void;
}
export function AuthSection({ route, onRouteUpdate, onSavingChange }: AuthSectionProps) {
const { saving, save } = useRouteSectionSave(route, onRouteUpdate, onSavingChange);
const [powConfig, setPowConfig] = useState<ProxyRoutePoWConfig>(
route.pow_config ?? defaultPowConfig,
);
const form = useForm<AuthValues>({
resolver: zodResolver(authSchema),
defaultValues: {
auth_mode: resolveAuthMode(route),
basic_auth_username: route.basic_auth_username || '',
basic_auth_password: route.basic_auth_password || '',
pow_enabled: route.pow_enabled,
pow_difficulty: String(route.pow_config?.difficulty ?? defaultPowConfig.difficulty),
pow_algorithm: route.pow_config?.algorithm ?? defaultPowConfig.algorithm,
pow_session_ttl: String(route.pow_config?.session_ttl ?? defaultPowConfig.session_ttl),
pow_challenge_ttl: String(route.pow_config?.challenge_ttl ?? defaultPowConfig.challenge_ttl),
},
});
useEffect(() => {
const nextPowConfig = route.pow_config ?? defaultPowConfig;
setPowConfig(nextPowConfig);
form.reset({
auth_mode: resolveAuthMode(route),
basic_auth_username: route.basic_auth_username || '',
basic_auth_password: route.basic_auth_password || '',
pow_enabled: route.pow_enabled,
pow_difficulty: String(nextPowConfig.difficulty),
pow_algorithm: nextPowConfig.algorithm,
pow_session_ttl: String(nextPowConfig.session_ttl),
pow_challenge_ttl: String(nextPowConfig.challenge_ttl),
});
}, [form, route]);
const authMode = form.watch('auth_mode');
return (
<SectionShell
title="认证配置"
description="配置基础鉴权或 PoW 防护,限制未授权访问。"
formId={proxyRouteFormIds.auth}
saving={saving}
>
<Form {...form}>
<form
id={proxyRouteFormIds.auth}
className="space-y-5"
onSubmit={form.handleSubmit(async (values) => {
const nextPowConfig =
values.auth_mode === 'pow' ? powConfig : route.pow_config ?? defaultPowConfig;
const powEnabled = values.auth_mode === 'pow' || values.pow_enabled;
await save(
{
basic_auth_enabled: values.auth_mode === 'basic',
basic_auth_username:
values.auth_mode === 'basic' ? values.basic_auth_username.trim() : '',
basic_auth_password:
values.auth_mode === 'basic' ? values.basic_auth_password.trim() : '',
pow_enabled: powEnabled,
pow_config: JSON.stringify(
values.auth_mode === 'pow' ? nextPowConfig : route.pow_config ?? defaultPowConfig,
),
},
'认证配置已保存',
);
})}
>
<FormField
control={form.control}
name="auth_mode"
render={({ field }) => (
<FormItem>
<FormLabel>认证模式</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">无认证</SelectItem>
<SelectItem value="basic">Basic Auth</SelectItem>
<SelectItem value="pow">PoW 防护</SelectItem>
</SelectContent>
</Select>
<FormDescription>同一站点仅启用一种认证模式。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{authMode === 'basic' ? (
<>
<FormField
control={form.control}
name="basic_auth_username"
render={({ field }) => (
<FormItem>
<FormLabel>账号</FormLabel>
<FormControl>
<Input placeholder="admin" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="basic_auth_password"
render={({ field }) => (
<FormItem>
<FormLabel>密码</FormLabel>
<FormControl>
<Input type="text" placeholder="secret123" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
) : null}
{authMode === 'pow' ? (
<PowConfigPanel
enabled={form.watch('pow_enabled')}
config={powConfig}
onChange={(enabled, config) => {
form.setValue('pow_enabled', enabled);
setPowConfig(config);
syncPowFormValues(form.setValue, config);
}}
/>
) : null}
</form>
</Form>
</SectionShell>
);
}
@@ -0,0 +1,197 @@
'use client';
import {useEffect} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {Switch} from '@/components/ui/switch';
import {Textarea} from '@/components/ui/textarea';
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {linesFromTextarea, validateCacheRules} from '../../components/helpers';
import {proxyRouteFormIds} from '../helpers';
import {useRouteSectionSave} from '../hooks/use-route-section-save';
import {SectionShell} from './section-shell';
const cacheSchema = z
.object({
cache_enabled: z.boolean(),
cache_policy: z.enum(['url', 'suffix', 'path_prefix', 'path_exact']),
cache_rules_text: z.string(),
})
.superRefine((value, context) => {
if (!value.cache_enabled) {
return;
}
const rules = linesFromTextarea(value.cache_rules_text);
const error = validateCacheRules(value.cache_policy, rules);
if (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['cache_rules_text'],
message: error,
});
}
});
type CacheValues = z.infer<typeof cacheSchema>;
interface CacheSectionProps {
route: ProxyRouteItem;
onRouteUpdate: (route: ProxyRouteItem) => void;
onSavingChange?: (saving: boolean) => void;
}
export function CacheSection({ route, onRouteUpdate, onSavingChange }: CacheSectionProps) {
const { saving, save } = useRouteSectionSave(route, onRouteUpdate, onSavingChange);
const form = useForm<CacheValues>({
resolver: zodResolver(cacheSchema),
defaultValues: {
cache_enabled: route.cache_enabled,
cache_policy: (route.cache_policy || 'url') as CacheValues['cache_policy'],
cache_rules_text: route.cache_rule_list.join('\n'),
},
});
useEffect(() => {
form.reset({
cache_enabled: route.cache_enabled,
cache_policy: (route.cache_policy || 'url') as CacheValues['cache_policy'],
cache_rules_text: route.cache_rule_list.join('\n'),
});
}, [form, route]);
const watchedEnabled = form.watch('cache_enabled');
const watchedPolicy = form.watch('cache_policy');
const rulesHint =
watchedPolicy === 'suffix'
? '每行一个后缀,例如 jpg、css、js。'
: watchedPolicy === 'path_prefix'
? '每行一个路径前缀,例如 /assets、/static。'
: watchedPolicy === 'path_exact'
? '每行一个精确路径,例如 /robots.txt。'
: '按 URL 缓存时无需额外规则。';
const rulesPlaceholder =
watchedPolicy === 'suffix'
? 'jpg\ncss\njs'
: watchedPolicy === 'path_prefix'
? '/assets\n/static'
: watchedPolicy === 'path_exact'
? '/robots.txt\n/manifest.json'
: '按 URL 缓存时无需额外规则';
return (
<SectionShell
title="缓存"
description="保留现有安全绕过逻辑,只对当前站点生效。"
formId={proxyRouteFormIds.cache}
saving={saving}
>
<Form {...form}>
<form
id={proxyRouteFormIds.cache}
className="space-y-5"
onSubmit={form.handleSubmit(async (values) => {
const rules = linesFromTextarea(values.cache_rules_text);
await save(
{
cache_enabled: values.cache_enabled,
cache_policy: values.cache_enabled ? values.cache_policy : 'url',
cache_rules:
values.cache_enabled && values.cache_policy !== 'url' ? rules : [],
},
'缓存设置已保存',
);
})}
>
<FormField
control={form.control}
name="cache_enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>启用站点缓存</FormLabel>
<FormDescription>
系统仍会自动绕过非 GET、带 Authorization 或常见登录态 Cookie 的请求。
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="cache_policy"
render={({ field }) => (
<FormItem>
<FormLabel>缓存策略</FormLabel>
<Select
disabled={!watchedEnabled}
value={field.value}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="url">按 URL 缓存</SelectItem>
<SelectItem value="suffix">按后缀缓存</SelectItem>
<SelectItem value="path_prefix">按路径前缀缓存</SelectItem>
<SelectItem value="path_exact">按精确路径缓存</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cache_rules_text"
render={({ field }) => (
<FormItem>
<FormLabel>缓存规则</FormLabel>
<FormControl>
<Textarea
className="min-h-32"
disabled={!watchedEnabled || watchedPolicy === 'url'}
placeholder={rulesPlaceholder}
{...field}
/>
</FormControl>
<FormDescription>{rulesHint}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SectionShell>
);
}
@@ -0,0 +1,262 @@
'use client';
import {useId, useMemo} from 'react';
import {Link2, Minus, Plus} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
import type {TlsCertificateItem} from '@/lib/services/openflare';
import {WebsiteService} from '@/lib/services/openflare';
import {validateDomain} from '../../components/helpers';
import type {DomainListRow} from '../helpers';
function ensureRows(rows: DomainListRow[]) {
return rows.length > 0 ? rows : [{ domain: '', certificateId: '' }];
}
function buildSuggestionSources(domains: string[]) {
const values = new Set<string>();
for (const domain of domains) {
const normalized = domain.trim().toLowerCase().replace(/^\*\./, '');
if (!normalized) {
continue;
}
values.add(normalized);
const segments = normalized.split('.');
for (let index = 1; index < segments.length - 1; index += 1) {
values.add(segments.slice(index).join('.'));
}
}
return Array.from(values);
}
function buildDomainSuggestions(input: string, sources: string[], rows: DomainListRow[]) {
const normalizedInput = input.trim().toLowerCase();
if (!normalizedInput) {
return [];
}
const existingDomains = new Set(
rows
.map((row) => row.domain.trim().toLowerCase())
.filter((row) => row && row !== normalizedInput),
);
const suggestions: string[] = [];
for (const source of sources) {
if (source.startsWith(normalizedInput) && source !== normalizedInput) {
suggestions.push(source);
}
const separatorIndex = normalizedInput.lastIndexOf('.');
if (separatorIndex <= 0) {
continue;
}
const prefix = normalizedInput.slice(0, separatorIndex);
const suffixInput = normalizedInput.slice(separatorIndex + 1);
if (!suffixInput || source.startsWith(suffixInput)) {
suggestions.push(`${prefix}.${source}`);
}
}
return suggestions.filter((suggestion, index) => {
return (
suggestion !== normalizedInput &&
!existingDomains.has(suggestion) &&
suggestions.indexOf(suggestion) === index
);
});
}
interface DomainListInputProps {
rows: DomainListRow[];
onChange: (rows: DomainListRow[]) => void;
onBlur?: () => void;
suggestionSources?: string[];
certificates?: TlsCertificateItem[];
domainPlaceholder?: string;
}
export function DomainListInput({
rows,
onChange,
onBlur,
suggestionSources = [],
certificates = [],
domainPlaceholder = 'app.example.com',
}: DomainListInputProps) {
const listId = useId();
const safeRows = ensureRows(rows);
const normalizedSources = useMemo(
() => buildSuggestionSources(suggestionSources),
[suggestionSources],
);
const updateRows = (nextRows: DomainListRow[]) => {
onChange(ensureRows(nextRows));
};
const applyManagedDomainCertificate = async (index: number, domain: string) => {
const normalized = domain.trim().toLowerCase();
if (!normalized || validateDomain(normalized)) {
return;
}
try {
const result = await WebsiteService.match(normalized);
if (!result.matched || !result.candidate?.certificate_id) {
return;
}
const nextRows = safeRows.slice();
if (!nextRows[index].certificateId) {
nextRows[index] = {
...nextRows[index],
certificateId: String(result.candidate.certificate_id),
};
updateRows(nextRows);
}
} catch {
// Ignore match failures; user can still pick a certificate manually.
}
};
return (
<div className="space-y-4">
{safeRows.map((row, index) => {
const suggestions = buildDomainSuggestions(row.domain, normalizedSources, safeRows).slice(
0,
4,
);
return (
<div key={`${index}-${safeRows.length}`} className="space-y-2">
<div className="grid gap-3 md:grid-cols-[40px_minmax(0,1fr)_220px] md:items-start">
<Button
type="button"
variant="outline"
size="icon"
className="size-9 shrink-0"
disabled={safeRows.length === 1}
aria-label={`删除域名输入框 ${index + 1}`}
onClick={() => {
if (safeRows.length === 1) {
updateRows([{ domain: '', certificateId: '' }]);
return;
}
updateRows(safeRows.filter((_, rowIndex) => rowIndex !== index));
}}
>
<Minus className="size-3.5" />
</Button>
<div className="min-w-0 space-y-2">
<Input
value={row.domain}
list={`${listId}-${index}`}
aria-label={`域名 ${index + 1}`}
placeholder={index === 0 ? domainPlaceholder : 'www.example.com'}
onBlur={() => {
onBlur?.();
void applyManagedDomainCertificate(index, row.domain);
}}
onChange={(event) => {
const nextRows = safeRows.slice();
nextRows[index] = {
...nextRows[index],
domain: event.target.value,
};
updateRows(nextRows);
}}
/>
<datalist id={`${listId}-${index}`}>
{suggestions.map((suggestion) => (
<option key={suggestion} value={suggestion} />
))}
</datalist>
{suggestions.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className="rounded-full border px-2.5 py-0.5 text-[11px] text-muted-foreground transition hover:border-primary hover:text-foreground"
onClick={() => {
const nextRows = safeRows.slice();
nextRows[index] = {
...nextRows[index],
domain: suggestion,
};
updateRows(nextRows);
void applyManagedDomainCertificate(index, suggestion);
}}
>
{suggestion}
</button>
))}
</div>
) : null}
</div>
<Select
value={row.certificateId || 'none'}
onValueChange={(value) => {
const nextRows = safeRows.slice();
nextRows[index] = {
...nextRows[index],
certificateId: value === 'none' ? '' : value,
};
updateRows(nextRows);
}}
>
<SelectTrigger className="h-9 w-full">
<SelectValue
placeholder={certificates.length === 0 ? '暂无可选证书' : '选择证书'}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{certificates.length === 0 ? '暂无可选证书' : '不绑定证书'}
</SelectItem>
{certificates.map((certificate) => (
<SelectItem key={certificate.id} value={String(certificate.id)}>
{certificate.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
})}
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={() => {
updateRows([...safeRows, { domain: '', certificateId: '' }]);
}}
>
<Plus className="size-3.5" />
添加域名
</Button>
<Badge variant="outline" className="gap-1 text-[10px] font-normal">
<Link2 className="size-3" />
输入域名后将自动匹配托管域名证书
</Badge>
</div>
</div>
);
}
@@ -0,0 +1,237 @@
'use client';
import {useEffect, useMemo} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {useQuery} from '@tanstack/react-query';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@/components/ui/form';
import {Input} from '@/components/ui/input';
import {Switch} from '@/components/ui/switch';
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {TlsCertificateService, WebsiteService} from '@/lib/services/openflare';
import {validateDomains} from '../../components/helpers';
import {
buildDomainCertificateIDs,
buildDomainRows,
normalizeSelectedCertificateIDs,
proxyRouteFormIds,
} from '../helpers';
import {useRouteSectionSave} from '../hooks/use-route-section-save';
import {DomainListInput} from './domain-list-input';
import {SectionShell} from './section-shell';
const domainSettingsSchema = z
.object({
site_name: z
.string()
.trim()
.min(1, '请输入站点标识')
.max(255, '站点标识不能超过 255 个字符'),
domain_rows: z
.array(
z.object({
domain: z.string(),
certificateId: z.string(),
}),
)
.min(1),
enabled: z.boolean(),
redirect_http: z.boolean(),
})
.superRefine((value, context) => {
const domains = value.domain_rows
.map((item) => item.domain.trim().toLowerCase())
.filter(Boolean);
const error = validateDomains(domains);
if (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['domain_rows'],
message: error,
});
}
const selectedCertificateCount = new Set(
value.domain_rows
.map((item) => Number(item.certificateId))
.filter((item) => Number.isFinite(item) && item > 0),
).size;
if (value.redirect_http && selectedCertificateCount === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['redirect_http'],
message: '启用 HTTP 跳转前,请先为域名选择证书',
});
}
});
type DomainSettingsValues = z.infer<typeof domainSettingsSchema>;
interface DomainSectionProps {
route: ProxyRouteItem;
onRouteUpdate: (route: ProxyRouteItem) => void;
onSavingChange?: (saving: boolean) => void;
}
export function DomainSection({ route, onRouteUpdate, onSavingChange }: DomainSectionProps) {
const { saving, save } = useRouteSectionSave(route, onRouteUpdate, onSavingChange);
const certificatesQuery = useQuery({
queryKey: ['openflare', 'tls-certificates'],
queryFn: () => TlsCertificateService.list(),
});
const managedDomainsQuery = useQuery({
queryKey: ['openflare', 'managed-domains'],
queryFn: () => WebsiteService.list(),
});
const form = useForm<DomainSettingsValues>({
resolver: zodResolver(domainSettingsSchema),
defaultValues: {
site_name: route.site_name,
domain_rows: buildDomainRows(route),
enabled: route.enabled,
redirect_http: route.redirect_http,
},
});
useEffect(() => {
form.reset({
site_name: route.site_name,
domain_rows: buildDomainRows(route),
enabled: route.enabled,
redirect_http: route.redirect_http,
});
}, [form, route]);
const domainSuggestionSources = useMemo(
() => [
...(route.domains ?? []),
...(managedDomainsQuery.data?.map((item) => item.domain) ?? []),
],
[managedDomainsQuery.data, route.domains],
);
const selectedCertificateIDs = normalizeSelectedCertificateIDs(form.watch('domain_rows'));
return (
<SectionShell
title="域名设置"
description="在一个列表里同时维护域名、证书和 HTTPS 跳转。保存时会自动汇总站点证书集合。"
formId={proxyRouteFormIds.domains}
saving={saving}
>
<Form {...form}>
<form
id={proxyRouteFormIds.domains}
className="space-y-5"
onSubmit={form.handleSubmit(async (values) => {
const domains = values.domain_rows
.map((item) => item.domain.trim().toLowerCase())
.filter(Boolean);
const domainCertIDs = buildDomainCertificateIDs(values.domain_rows);
const certIDs = normalizeSelectedCertificateIDs(values.domain_rows);
await save(
{
site_name: values.site_name.trim(),
domain: domains[0],
domains,
enabled: values.enabled,
enable_https: certIDs.length > 0,
cert_id: certIDs[0] ?? null,
cert_ids: certIDs,
domain_cert_ids: domainCertIDs,
redirect_http: certIDs.length > 0 ? values.redirect_http : false,
},
'域名设置已保存',
);
})}
>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3">
<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="site_name"
render={({ field }) => (
<FormItem>
<FormLabel>站点标识</FormLabel>
<FormControl>
<Input placeholder="marketing-site" {...field} />
</FormControl>
<FormDescription>建议使用稳定、可读的业务标识,不必与域名完全一致。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="domain_rows"
render={({ field }) => (
<FormItem>
<FormLabel>域名列表</FormLabel>
<FormControl>
<DomainListInput
rows={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
suggestionSources={domainSuggestionSources}
certificates={certificatesQuery.data ?? []}
/>
</FormControl>
<FormDescription>
每行配置一个域名。可为不同域名选择不同证书,托管域名将自动匹配证书。
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="redirect_http"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>HTTP 自动跳转到 HTTPS</FormLabel>
<FormDescription>
{selectedCertificateIDs.length > 0
? '开启后会额外生成 80 端口重定向规则。'
: '至少为一个域名选择证书后才能启用。'}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
disabled={selectedCertificateIDs.length === 0}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SectionShell>
);
}
@@ -0,0 +1,150 @@
'use client';
import {useEffect} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@/components/ui/form';
import {Input} from '@/components/ui/input';
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {normalizeLimitRate, validateLimitRate} from '../../components/helpers';
import {proxyRouteFormIds} from '../helpers';
import {useRouteSectionSave} from '../hooks/use-route-section-save';
import {SectionShell} from './section-shell';
const rateLimitSchema = z
.object({
limit_conn_per_server: z.string(),
limit_conn_per_ip: z.string(),
limit_rate: z.string(),
})
.superRefine((value, context) => {
for (const field of ['limit_conn_per_server', 'limit_conn_per_ip'] as const) {
const rawValue = value[field].trim();
if (!rawValue) {
continue;
}
if (!/^\d+$/.test(rawValue)) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: [field],
message: '请输入大于等于 0 的整数',
});
}
}
const limitRateError = validateLimitRate(value.limit_rate);
if (limitRateError) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['limit_rate'],
message: limitRateError,
});
}
});
type RateLimitValues = z.infer<typeof rateLimitSchema>;
interface LimitsSectionProps {
route: ProxyRouteItem;
onRouteUpdate: (route: ProxyRouteItem) => void;
onSavingChange?: (saving: boolean) => void;
}
export function LimitsSection({ route, onRouteUpdate, onSavingChange }: LimitsSectionProps) {
const { saving, save } = useRouteSectionSave(route, onRouteUpdate, onSavingChange);
const form = useForm<RateLimitValues>({
resolver: zodResolver(rateLimitSchema),
defaultValues: {
limit_conn_per_server: route.limit_conn_per_server
? String(route.limit_conn_per_server)
: '',
limit_conn_per_ip: route.limit_conn_per_ip ? String(route.limit_conn_per_ip) : '',
limit_rate: route.limit_rate || '',
},
});
useEffect(() => {
form.reset({
limit_conn_per_server: route.limit_conn_per_server
? String(route.limit_conn_per_server)
: '',
limit_conn_per_ip: route.limit_conn_per_ip ? String(route.limit_conn_per_ip) : '',
limit_rate: route.limit_rate || '',
});
}, [form, route]);
return (
<SectionShell
title="流量限制"
description="站点限流,空值或 0 表示关闭。"
formId={proxyRouteFormIds.limits}
saving={saving}
>
<Form {...form}>
<form
id={proxyRouteFormIds.limits}
className="grid gap-5 md:grid-cols-2"
onSubmit={form.handleSubmit(async (values) => {
await save(
{
limit_conn_per_server: Number(values.limit_conn_per_server.trim() || '0'),
limit_conn_per_ip: Number(values.limit_conn_per_ip.trim() || '0'),
limit_rate: normalizeLimitRate(values.limit_rate),
},
'流量限制已保存',
);
})}
>
<FormField
control={form.control}
name="limit_conn_per_server"
render={({ field }) => (
<FormItem>
<FormLabel>并发限制</FormLabel>
<FormControl>
<Input placeholder="120" {...field} />
</FormControl>
<FormDescription>限制当前站点最大并发连接数。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="limit_conn_per_ip"
render={({ field }) => (
<FormItem>
<FormLabel>单 IP 限制</FormLabel>
<FormControl>
<Input placeholder="12" {...field} />
</FormControl>
<FormDescription>限制单个 IP 的最大并发数。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="limit_rate"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormLabel>限速</FormLabel>
<FormControl>
<Input placeholder="512k/1m" {...field} />
</FormControl>
<FormDescription>限制单请求带宽,例如 512k 或 1m。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SectionShell>
);
}
@@ -0,0 +1,438 @@
'use client';
import {useEffect} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {useQuery} from '@tanstack/react-query';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@/components/ui/form';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
import {Textarea} from '@/components/ui/textarea';
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {NodeService, PagesService} from '@/lib/services/openflare';
import {
customHeadersToText,
parseCustomHeadersText,
parseOriginUrl,
parseOriginUrls,
validateOriginHost,
} from '../../components/helpers';
import {proxyRouteFormIds} from '../helpers';
import {useRouteSectionSave} from '../hooks/use-route-section-save';
import {SectionShell} from './section-shell';
const reverseProxySchema = z
.object({
upstream_type: z.enum(['direct', 'tunnel', 'pages']),
origin_urls_text: z.string().trim(),
origin_host: z.string(),
tunnel_id: z.string().optional(),
tunnel_target_addr: z.string().trim().optional(),
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
pages_project_id: z.string().optional(),
custom_headers_text: z.string(),
remark: z.string().max(255, '备注不能超过 255 个字符'),
})
.superRefine((value, context) => {
if (value.upstream_type === 'direct') {
if (!value.origin_urls_text.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_urls_text'],
message: '请至少填写一个上游地址',
});
} else {
const { error } = parseOriginUrls(value.origin_urls_text);
if (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_urls_text'],
message: error,
});
}
}
} else if (value.upstream_type === 'tunnel') {
if (!value.tunnel_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['tunnel_id'],
message: '请选择内网穿透隧道',
});
}
if (!value.tunnel_target_addr) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['tunnel_target_addr'],
message: '请填写内网服务地址 (如 127.0.0.1:8080)',
});
}
} else if (!value.pages_project_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['pages_project_id'],
message: '请选择 Pages 项目',
});
}
const originHostError = validateOriginHost(value.origin_host);
if (originHostError) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_host'],
message: originHostError,
});
}
const { error: headerError } = parseCustomHeadersText(value.custom_headers_text);
if (headerError) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['custom_headers_text'],
message: headerError,
});
}
});
type ReverseProxyValues = z.infer<typeof reverseProxySchema>;
interface ProxySectionProps {
route: ProxyRouteItem;
onRouteUpdate: (route: ProxyRouteItem) => void;
onSavingChange?: (saving: boolean) => void;
}
export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySectionProps) {
const { saving, save } = useRouteSectionSave(route, onRouteUpdate, onSavingChange);
const tunnelsQuery = useQuery({
queryKey: ['openflare', 'nodes'],
queryFn: () => NodeService.listNodes(),
});
const pagesProjectsQuery = useQuery({
queryKey: ['openflare', 'pages-projects'],
queryFn: () => PagesService.listProjects(),
});
const tunnelClients = (tunnelsQuery.data ?? []).filter(
(node) => node.node_type === 'tunnel_client',
);
const pagesProjects = (pagesProjectsQuery.data ?? []).filter(
(project) => project.enabled && project.active_deployment_id,
);
const form = useForm<ReverseProxyValues>({
resolver: zodResolver(reverseProxySchema),
defaultValues: {
upstream_type: route.upstream_type || 'direct',
origin_urls_text: route.upstream_list.join('\n'),
origin_host: route.origin_host || '',
tunnel_id: route.tunnel_node_id ? String(route.tunnel_node_id) : '',
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol:
(route.tunnel_target_protocol as 'http' | 'https') || 'http',
pages_project_id: route.pages_project_id ? String(route.pages_project_id) : '',
custom_headers_text: customHeadersToText(route.custom_header_list),
remark: route.remark || '',
},
});
useEffect(() => {
form.reset({
upstream_type: route.upstream_type || 'direct',
origin_urls_text: route.upstream_list.join('\n'),
origin_host: route.origin_host || '',
tunnel_id: route.tunnel_node_id ? String(route.tunnel_node_id) : '',
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol:
(route.tunnel_target_protocol as 'http' | 'https') || 'http',
pages_project_id: route.pages_project_id ? String(route.pages_project_id) : '',
custom_headers_text: customHeadersToText(route.custom_header_list),
remark: route.remark || '',
});
}, [form, route]);
const upstreamType = form.watch('upstream_type');
return (
<SectionShell
title="反向代理"
description="配置请求回源上游的策略与地址。"
formId={proxyRouteFormIds.proxy}
saving={saving}
>
<Form {...form}>
<form
id={proxyRouteFormIds.proxy}
className="space-y-5"
onSubmit={form.handleSubmit(async (values) => {
let originUrl = '';
let originScheme: 'http' | 'https' = 'http';
let originAddress = '';
let originPort = '';
let originUri = '';
let upstreams: string[] = [];
if (values.upstream_type === 'direct') {
const { urls } = parseOriginUrls(values.origin_urls_text);
const primaryOrigin = parseOriginUrl(urls[0]);
originUrl = urls[0];
originScheme = primaryOrigin.scheme;
originAddress = primaryOrigin.address;
originPort = primaryOrigin.port;
originUri = primaryOrigin.uri;
upstreams = urls.slice(1);
} else if (values.upstream_type === 'tunnel') {
originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`;
originScheme = values.tunnel_target_protocol as 'http' | 'https';
originAddress = values.tunnel_target_addr || '';
} else {
originUrl = 'http://127.0.0.1';
originScheme = 'http';
originAddress = '127.0.0.1';
originPort = '80';
}
const { headers } = parseCustomHeadersText(values.custom_headers_text);
await save(
{
origin_id: null,
origin_url: originUrl,
origin_scheme: originScheme,
origin_address: originAddress,
origin_port: originPort,
origin_uri: originUri,
origin_host: values.origin_host.trim(),
upstreams,
custom_headers: headers,
remark: values.remark.trim(),
upstream_type: values.upstream_type,
tunnel_node_id:
values.upstream_type === 'tunnel' && values.tunnel_id
? Number(values.tunnel_id)
: null,
tunnel_target_addr:
values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
tunnel_target_protocol:
values.upstream_type === 'tunnel' ? values.tunnel_target_protocol : '',
pages_project_id:
values.upstream_type === 'pages' && values.pages_project_id
? Number(values.pages_project_id)
: null,
},
'反向代理设置已保存',
);
})}
>
<FormField
control={form.control}
name="upstream_type"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>回源方式</FormLabel>
<div className="flex flex-wrap gap-4">
{(
[
['direct', '直连上游'],
['tunnel', '内网穿透 (Tunnel)'],
['pages', 'Pages 静态站点'],
] as const
).map(([value, label]) => (
<label key={value} className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="radio"
value={value}
checked={field.value === value}
onChange={() => field.onChange(value)}
className="size-4 accent-primary"
/>
<Label className="font-normal">{label}</Label>
</label>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
{upstreamType === 'direct' ? (
<FormField
control={form.control}
name="origin_urls_text"
render={({ field }) => (
<FormItem>
<FormLabel>上游地址</FormLabel>
<FormControl>
<Textarea
className="min-h-40 font-mono text-xs"
placeholder={
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
}
{...field}
/>
</FormControl>
<FormDescription>
每行一个完整 URL。第一行作为主回源,多上游模式请保持相同协议且不要包含 path 或 query。
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : null}
{upstreamType === 'tunnel' ? (
<div className="space-y-4 rounded-lg border border-dashed bg-muted/30 p-4">
<FormField
control={form.control}
name="tunnel_id"
render={({ field }) => (
<FormItem>
<FormLabel>选择内网穿透隧道</FormLabel>
<Select value={field.value || 'none'} onValueChange={(value) => field.onChange(value === 'none' ? '' : value)}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="请选择..." />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">请选择...</SelectItem>
{tunnelClients.map((tunnel) => (
<SelectItem key={tunnel.id} value={String(tunnel.id)}>
{tunnel.name} ({tunnel.status === 'online' ? '在线' : '离线'})
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>将请求转发到该隧道连接的客户端节点。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tunnel_target_protocol"
render={({ field }) => (
<FormItem>
<FormLabel>内网服务协议</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="https">HTTPS</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tunnel_target_addr"
render={({ field }) => (
<FormItem>
<FormLabel>内网服务地址</FormLabel>
<FormControl>
<Input placeholder="127.0.0.1:8080" {...field} />
</FormControl>
<FormDescription>例如: 127.0.0.1:8080 或 192.168.1.10:80</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
) : null}
{upstreamType === 'pages' ? (
<div className="rounded-lg border border-dashed bg-muted/30 p-4">
<FormField
control={form.control}
name="pages_project_id"
render={({ field }) => (
<FormItem>
<FormLabel>选择 Pages 项目</FormLabel>
<Select value={field.value || 'none'} onValueChange={(value) => field.onChange(value === 'none' ? '' : value)}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="请选择..." />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">请选择...</SelectItem>
{pagesProjects.map((project) => (
<SelectItem key={project.id} value={String(project.id)}>
{project.name} ({project.slug})
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>仅显示已启用且已有激活部署的 Pages 项目。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
) : null}
<FormField
control={form.control}
name="origin_host"
render={({ field }) => (
<FormItem>
<FormLabel>Origin Host Header</FormLabel>
<FormControl>
<Input placeholder="origin.example.internal" {...field} />
</FormControl>
<FormDescription>留空时默认透传访问域名 $host。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="custom_headers_text"
render={({ field }) => (
<FormItem>
<FormLabel>自定义请求头</FormLabel>
<FormControl>
<Textarea
className="min-h-32 font-mono text-xs"
placeholder={'X-Trace-Id: $request_id\nX-Site: marketing'}
{...field}
/>
</FormControl>
<FormDescription>每行一条,格式为 Key: Value。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="remark"
render={({ field }) => (
<FormItem>
<FormLabel>备注</FormLabel>
<FormControl>
<Textarea placeholder="例如:多活回源,优先使用上海入口" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SectionShell>
);
}
@@ -0,0 +1,183 @@
'use client';
import Link from 'next/link';
import {useCallback, useState} from 'react';
import {ArrowLeft, Loader2, Route, Save, Upload} from 'lucide-react';
import {toast} from 'sonner';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {DiffDialog} from '@/app/(main)/config-versions/components/diff-dialog';
import type {ConfigDiffResult, ProxyRouteConfigSection, ProxyRouteItem} from '@/lib/services/openflare';
import {ConfigVersionService} from '@/lib/services/openflare';
import {getErrorMessage} from '../../components/helpers';
import {submitProxyRouteSectionForm} from '../helpers';
function hasConfigDiff(diff: ConfigDiffResult) {
return (
diff.added_domains.length > 0 ||
diff.removed_domains.length > 0 ||
diff.modified_domains.length > 0 ||
diff.main_config_changed ||
diff.waf_config_changed ||
diff.changed_option_keys.length > 0 ||
!diff.active_version
);
}
interface RouteHeaderProps {
route: ProxyRouteItem;
activeSection: ProxyRouteConfigSection;
saving?: boolean;
}
export function RouteHeader({ route, activeSection, saving = false }: RouteHeaderProps) {
const [diffOpen, setDiffOpen] = useState(false);
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false);
const [diff, setDiff] = useState<ConfigDiffResult | null>(null);
const [diffLoading, setDiffLoading] = useState(false);
const [diffError, setDiffError] = useState<string | null>(null);
const [publishing, setPublishing] = useState(false);
const handleSave = useCallback(() => {
const submitted = submitProxyRouteSectionForm(activeSection);
if (!submitted) {
toast.error('当前分区无法保存');
}
}, [activeSection]);
const loadDiff = useCallback(async () => {
setDiffLoading(true);
setDiffError(null);
try {
const diffData = await ConfigVersionService.diff();
setDiff(diffData);
return diffData;
} catch (error) {
const message = getErrorMessage(error);
setDiffError(message);
return null;
} finally {
setDiffLoading(false);
}
}, []);
const handlePublishClick = useCallback(async () => {
setDiffOpen(true);
const diffData = diff ?? (await loadDiff());
if (!diffData) {
return;
}
if (!hasConfigDiff(diffData)) {
toast.info('当前配置与激活版本一致,无需发布');
return;
}
setPublishConfirmOpen(true);
}, [diff, loadDiff]);
const handlePublish = useCallback(async () => {
setPublishing(true);
try {
const version = await ConfigVersionService.publish();
toast.success('发布成功', { description: `版本 ${version.version}` });
setPublishConfirmOpen(false);
setDiffOpen(false);
setDiff(null);
} catch (error) {
toast.error('发布失败', { description: getErrorMessage(error) });
} finally {
setPublishing(false);
}
}, []);
return (
<>
<div className="space-y-4">
<Button variant="ghost" size="sm" className="h-8 gap-1.5 px-0 text-xs" asChild>
<Link href="/proxy-routes">
<ArrowLeft className="size-3.5" />
返回规则列表
</Link>
</Button>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<Route className="size-5 text-primary" />
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{route.site_name}</h1>
<Badge variant={route.enabled ? 'default' : 'secondary'}>
{route.enabled ? '已启用' : '已停用'}
</Badge>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="secondary"
className="h-8 gap-1.5 text-xs"
disabled={saving}
onClick={handleSave}
>
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save className="size-3.5" />}
{saving ? '保存中...' : '保存当前分区'}
</Button>
<Button
size="sm"
className="h-8 gap-1.5 text-xs"
disabled={publishing || diffLoading}
onClick={() => void handlePublishClick()}
>
{publishing || diffLoading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Upload className="size-3.5" />
)}
发布配置
</Button>
</div>
</div>
</div>
<DiffDialog
open={diffOpen}
onOpenChange={(open) => {
setDiffOpen(open);
if (!open) {
setPublishConfirmOpen(false);
}
}}
diff={diff}
loading={diffLoading}
error={diffError}
/>
<AlertDialog open={publishConfirmOpen} onOpenChange={setPublishConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认发布配置</AlertDialogTitle>
<AlertDialogDescription>
将把当前待发布配置生成新版本并设为激活版本,节点将随后拉取更新。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={publishing}>取消</AlertDialogCancel>
<Button onClick={() => void handlePublish()} disabled={publishing}>
{publishing ? <Loader2 className="size-4 animate-spin" /> : '确认发布'}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,37 @@
'use client';
import type {ReactNode} from 'react';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
interface SectionShellProps {
title: string;
description: string;
formId: string;
saving?: boolean;
children: ReactNode;
}
export function SectionShell({
title,
description,
formId,
saving = false,
children,
}: SectionShellProps) {
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
<div className="space-y-1">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
<Button type="submit" form={formId} size="sm" className="h-8 shrink-0 text-xs" disabled={saving}>
{saving ? '保存中...' : '保存'}
</Button>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
@@ -0,0 +1,148 @@
'use client';
import {useEffect, useMemo, useState} from 'react';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
import {LoadingStateWithBorder} from '@/components/layout/loading';
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {WafService} from '@/lib/services/openflare';
import {cn} from '@/lib/utils';
import {getErrorMessage} from '../../components/helpers';
import {proxyRouteFormIds} from '../helpers';
import {SectionShell} from './section-shell';
interface WafSectionProps {
route: ProxyRouteItem;
onSavingChange?: (saving: boolean) => void;
}
export function WafSection({ route, onSavingChange }: WafSectionProps) {
const queryClient = useQueryClient();
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
const wafQuery = useQuery({
queryKey: ['openflare', 'waf', 'site-rule-groups', route.id],
queryFn: () => WafService.listSiteRuleGroups(route.id),
});
const wafMutation = useMutation({
mutationFn: (ids: number[]) => WafService.updateSiteRuleGroups(route.id, ids),
onMutate: () => {
onSavingChange?.(true);
},
onSettled: () => {
onSavingChange?.(false);
},
onSuccess: async (result) => {
setSelectedIDs(result.applied_ids);
toast.success('WAF 规则组已更新');
await Promise.all([
queryClient.invalidateQueries({
queryKey: ['openflare', 'waf', 'site-rule-groups', route.id],
}),
queryClient.invalidateQueries({ queryKey: ['openflare', 'waf', 'rule-groups'] }),
queryClient.invalidateQueries({ queryKey: ['openflare', 'config-versions', 'diff'] }),
]);
},
onError: (error) => {
toast.error('保存失败', { description: getErrorMessage(error) });
},
});
useEffect(() => {
if (wafQuery.data) {
setSelectedIDs(wafQuery.data.applied_ids);
}
}, [wafQuery.data]);
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]);
return (
<SectionShell
title="WAF"
description="全局规则组始终生效;这里可以为当前网站叠加自定义规则组。"
formId={proxyRouteFormIds.waf}
saving={wafMutation.isPending}
>
{wafQuery.isLoading ? (
<LoadingStateWithBorder description="加载 WAF 规则组..." />
) : wafQuery.isError ? (
<ErrorInline
message={getErrorMessage(wafQuery.error)}
onRetry={() => void wafQuery.refetch()}
/>
) : (
<form
id={proxyRouteFormIds.waf}
className="space-y-5"
onSubmit={(event) => {
event.preventDefault();
wafMutation.mutate(selectedIDs);
}}
>
{wafQuery.data?.global_rule_group ? (
<div className="rounded-lg border bg-muted/30 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Global Rule Group
</p>
<p className="mt-1 text-sm font-semibold">
{wafQuery.data.global_rule_group.name}
</p>
</div>
<Badge variant="outline">始终生效</Badge>
</div>
</div>
) : null}
<div className="grid gap-3 md:grid-cols-2">
{(wafQuery.data?.rule_groups ?? []).map((group) => (
<label
key={group.id}
className={cn(
'flex cursor-pointer items-start gap-3 rounded-lg border p-4 transition',
selectedSet.has(group.id) && 'border-primary bg-muted/40',
)}
>
<input
type="checkbox"
checked={selectedSet.has(group.id)}
onChange={(event) => {
const checked = event.target.checked;
setSelectedIDs((current) =>
checked
? [...current, group.id].sort((left, right) => left - right)
: current.filter((id) => id !== group.id),
);
}}
className="mt-1 size-4 rounded border accent-primary"
/>
<span className="min-w-0">
<span className="block text-sm font-semibold">{group.name}</span>
<span className="mt-1 block text-xs text-muted-foreground">
{group.enabled ? '启用中' : '已停用'} ·{' '}
{group.ip_whitelist.length +
group.ip_blacklist.length +
group.country_whitelist.length +
group.country_blacklist.length}{' '}
条规则
</span>
</span>
</label>
))}
</div>
{(wafQuery.data?.rule_groups ?? []).length === 0 ? (
<EmptyStateWithBorder description="暂无自定义 WAF 规则组" />
) : null}
</form>
)}
</SectionShell>
);
}