mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
1256 lines
38 KiB
TypeScript
1256 lines
38 KiB
TypeScript
'use client';
|
||
|
||
import Link from 'next/link';
|
||
import { zodResolver } from '@hookform/resolvers/zod';
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { useEffect, useMemo, useState } from 'react';
|
||
import type { ReactNode } from 'react';
|
||
import { Controller, useForm } from 'react-hook-form';
|
||
import { z } from 'zod';
|
||
|
||
import { EmptyState } from '@/components/feedback/empty-state';
|
||
import { ErrorState } from '@/components/feedback/error-state';
|
||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||
import { LoadingState } from '@/components/feedback/loading-state';
|
||
import { PageHeader } from '@/components/layout/page-header';
|
||
import { AppCard } from '@/components/ui/app-card';
|
||
import { getManagedDomains } from '@/features/managed-domains/api/managed-domains';
|
||
import {
|
||
getProxyRoute,
|
||
updateProxyRoute,
|
||
} from '@/features/proxy-routes/api/proxy-routes';
|
||
import {
|
||
buildDomainRowsFromRoute,
|
||
DomainListInput,
|
||
type DomainListRow,
|
||
} from '@/features/proxy-routes/components/domain-list-input';
|
||
import {
|
||
buildPayloadFromRoute,
|
||
customHeadersToText,
|
||
getErrorMessage,
|
||
getWebsiteConfigSection,
|
||
linesFromTextarea,
|
||
normalizeLimitRate,
|
||
parseCustomHeadersText,
|
||
parseOriginUrl,
|
||
parseOriginUrls,
|
||
validateCacheRules,
|
||
validateDomains,
|
||
validateLimitRate,
|
||
validateOriginHost,
|
||
websiteConfigSections,
|
||
} from '@/features/proxy-routes/helpers';
|
||
import type {
|
||
ProxyRouteItem,
|
||
ProxyRouteMutationPayload,
|
||
} from '@/features/proxy-routes/types';
|
||
import { getTlsCertificates } from '@/features/tls-certificates/api/tls-certificates';
|
||
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
|
||
import {
|
||
PrimaryButton,
|
||
ResourceField,
|
||
ResourceInput,
|
||
ResourceSelect,
|
||
ResourceTextarea,
|
||
SecondaryButton,
|
||
ToggleField,
|
||
} from '@/features/shared/components/resource-primitives';
|
||
import { cn } from '@/lib/utils/cn';
|
||
|
||
type FeedbackState = {
|
||
tone: 'success' | 'danger';
|
||
message: string;
|
||
};
|
||
|
||
type SaveContext = {
|
||
message: string;
|
||
};
|
||
|
||
type SaveHandler = (
|
||
payload: ProxyRouteMutationPayload,
|
||
context: SaveContext,
|
||
) => void;
|
||
|
||
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 跳转前,请先为域名选择证书',
|
||
});
|
||
}
|
||
});
|
||
|
||
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,
|
||
});
|
||
}
|
||
});
|
||
|
||
const reverseProxySchema = z
|
||
.object({
|
||
origin_urls_text: z.string().trim().min(1, '请至少填写一个上游地址'),
|
||
origin_host: z.string(),
|
||
custom_headers_text: z.string(),
|
||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||
})
|
||
.superRefine((value, context) => {
|
||
const { error } = parseOriginUrls(value.origin_urls_text);
|
||
if (error) {
|
||
context.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
path: ['origin_urls_text'],
|
||
message: error,
|
||
});
|
||
}
|
||
|
||
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,
|
||
});
|
||
}
|
||
});
|
||
|
||
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 DomainSettingsValues = z.infer<typeof domainSettingsSchema>;
|
||
type RateLimitValues = z.infer<typeof rateLimitSchema>;
|
||
type ReverseProxyValues = z.infer<typeof reverseProxySchema>;
|
||
type CacheValues = z.infer<typeof cacheSchema>;
|
||
|
||
function normalizeSelectedCertificateIDs(rows: DomainListRow[]) {
|
||
return Array.from(
|
||
new Set(
|
||
rows
|
||
.filter((item) => item.domain.trim() !== '')
|
||
.map((item) => Number(item.certificateId))
|
||
.filter((item) => Number.isFinite(item) && item > 0),
|
||
),
|
||
);
|
||
}
|
||
|
||
function buildDomainCertificateIDs(rows: DomainListRow[]) {
|
||
return rows
|
||
.filter((item) => item.domain.trim() !== '')
|
||
.map((item) => {
|
||
const certificateID = Number(item.certificateId);
|
||
return Number.isFinite(certificateID) && certificateID > 0
|
||
? certificateID
|
||
: 0;
|
||
});
|
||
}
|
||
|
||
function buildDomainRows(route: ProxyRouteItem) {
|
||
const selectedCertIDs =
|
||
route.cert_ids.length > 0
|
||
? route.cert_ids
|
||
: route.cert_id
|
||
? [route.cert_id]
|
||
: [];
|
||
|
||
return buildDomainRowsFromRoute(
|
||
route.domains,
|
||
route.domain_cert_ids,
|
||
selectedCertIDs,
|
||
);
|
||
}
|
||
|
||
function ConfigSectionShell({
|
||
title,
|
||
description,
|
||
formId,
|
||
saving,
|
||
children,
|
||
}: {
|
||
title: string;
|
||
description: string;
|
||
formId: string;
|
||
saving: boolean;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<AppCard
|
||
title={title}
|
||
description={description}
|
||
action={
|
||
<PrimaryButton type="submit" form={formId} disabled={saving}>
|
||
{saving ? '保存中...' : '保存'}
|
||
</PrimaryButton>
|
||
}
|
||
>
|
||
{children}
|
||
</AppCard>
|
||
);
|
||
}
|
||
|
||
function DomainSettingsSection({
|
||
route,
|
||
certificates,
|
||
saving,
|
||
onSave,
|
||
suggestionSources,
|
||
}: {
|
||
route: ProxyRouteItem;
|
||
certificates: TlsCertificateItem[];
|
||
saving: boolean;
|
||
onSave: SaveHandler;
|
||
suggestionSources: string[];
|
||
}) {
|
||
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 selectedCertificateIDs = normalizeSelectedCertificateIDs(
|
||
form.watch('domain_rows'),
|
||
);
|
||
|
||
return (
|
||
<ConfigSectionShell
|
||
title="域名设置"
|
||
description="在一个列表里同时维护域名、证书和 HTTPS 跳转。保存时会自动汇总站点证书集合。"
|
||
formId="proxy-route-domains-form"
|
||
saving={saving}
|
||
>
|
||
<form
|
||
id="proxy-route-domains-form"
|
||
className="space-y-5"
|
||
onSubmit={form.handleSubmit((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);
|
||
|
||
onSave(
|
||
buildPayloadFromRoute(route, {
|
||
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,
|
||
}),
|
||
{ message: '域名设置已保存。' },
|
||
);
|
||
})}
|
||
>
|
||
<ToggleField
|
||
label="启用站点"
|
||
description="关闭后会保留配置,但不会参与发布。"
|
||
checked={form.watch('enabled')}
|
||
onChange={(checked) =>
|
||
form.setValue('enabled', checked, { shouldDirty: true })
|
||
}
|
||
/>
|
||
|
||
<ResourceField
|
||
label="站点标识"
|
||
hint="建议使用稳定、可读的业务标识,不必与域名完全一致。"
|
||
error={form.formState.errors.site_name?.message}
|
||
>
|
||
<ResourceInput
|
||
placeholder="marketing-site"
|
||
{...form.register('site_name')}
|
||
/>
|
||
</ResourceField>
|
||
|
||
<ResourceField
|
||
label="域名列表"
|
||
hint="每行配置一个域名。可为不同域名选择不同证书,相同证书也可以重复选择。"
|
||
error={form.formState.errors.domain_rows?.message as string | undefined}
|
||
container="div"
|
||
>
|
||
<Controller
|
||
control={form.control}
|
||
name="domain_rows"
|
||
render={({ field }) => (
|
||
<DomainListInput
|
||
rows={field.value}
|
||
onChange={field.onChange}
|
||
onBlur={field.onBlur}
|
||
suggestionSources={suggestionSources}
|
||
certificates={certificates}
|
||
/>
|
||
)}
|
||
/>
|
||
</ResourceField>
|
||
|
||
<ToggleField
|
||
label="HTTP 自动跳转到 HTTPS"
|
||
description={
|
||
selectedCertificateIDs.length > 0
|
||
? '开启后会额外生成 80 端口重定向规则。'
|
||
: '至少为一个域名选择证书后才能启用。'
|
||
}
|
||
checked={form.watch('redirect_http')}
|
||
disabled={selectedCertificateIDs.length === 0}
|
||
onChange={(checked) =>
|
||
form.setValue('redirect_http', checked, { shouldDirty: true })
|
||
}
|
||
/>
|
||
</form>
|
||
</ConfigSectionShell>
|
||
);
|
||
}
|
||
|
||
function RateLimitSection({
|
||
route,
|
||
saving,
|
||
onSave,
|
||
}: {
|
||
route: ProxyRouteItem;
|
||
saving: boolean;
|
||
onSave: SaveHandler;
|
||
}) {
|
||
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 (
|
||
<ConfigSectionShell
|
||
title="流量限制"
|
||
description="站点限流,空值或 0 表示关闭。"
|
||
formId="proxy-route-limits-form"
|
||
saving={saving}
|
||
>
|
||
<form
|
||
id="proxy-route-limits-form"
|
||
className="grid gap-5 md:grid-cols-2"
|
||
onSubmit={form.handleSubmit((values) => {
|
||
onSave(
|
||
buildPayloadFromRoute(route, {
|
||
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),
|
||
}),
|
||
{ message: '流量限制已保存。' },
|
||
);
|
||
})}
|
||
>
|
||
<ResourceField
|
||
label="并发限制"
|
||
hint="限制当前站点最大并发连接数。"
|
||
error={form.formState.errors.limit_conn_per_server?.message}
|
||
>
|
||
<ResourceInput
|
||
placeholder="120"
|
||
{...form.register('limit_conn_per_server')}
|
||
/>
|
||
</ResourceField>
|
||
|
||
<ResourceField
|
||
label="单 IP 限制"
|
||
hint="限制单个 IP 的最大并发数。"
|
||
error={form.formState.errors.limit_conn_per_ip?.message}
|
||
>
|
||
<ResourceInput
|
||
placeholder="12"
|
||
{...form.register('limit_conn_per_ip')}
|
||
/>
|
||
</ResourceField>
|
||
|
||
<ResourceField
|
||
label="限速"
|
||
hint="限制单请求带宽,例如 512k 或 1m。"
|
||
error={form.formState.errors.limit_rate?.message}
|
||
className="md:col-span-2"
|
||
>
|
||
<ResourceInput placeholder="512k/1m" {...form.register('limit_rate')} />
|
||
</ResourceField>
|
||
</form>
|
||
</ConfigSectionShell>
|
||
);
|
||
}
|
||
|
||
function ReverseProxySection({
|
||
route,
|
||
saving,
|
||
onSave,
|
||
}: {
|
||
route: ProxyRouteItem;
|
||
saving: boolean;
|
||
onSave: SaveHandler;
|
||
}) {
|
||
const form = useForm<ReverseProxyValues>({
|
||
resolver: zodResolver(reverseProxySchema),
|
||
defaultValues: {
|
||
origin_urls_text: route.upstream_list.join('\n'),
|
||
origin_host: route.origin_host || '',
|
||
custom_headers_text: customHeadersToText(route.custom_header_list),
|
||
remark: route.remark || '',
|
||
},
|
||
});
|
||
|
||
useEffect(() => {
|
||
form.reset({
|
||
origin_urls_text: route.upstream_list.join('\n'),
|
||
origin_host: route.origin_host || '',
|
||
custom_headers_text: customHeadersToText(route.custom_header_list),
|
||
remark: route.remark || '',
|
||
});
|
||
}, [form, route]);
|
||
|
||
return (
|
||
<ConfigSectionShell
|
||
title="反向代理"
|
||
description="第一行作为主回源;填写多行时会自动进入多上游负载均衡模式。"
|
||
formId="proxy-route-proxy-form"
|
||
saving={saving}
|
||
>
|
||
<form
|
||
id="proxy-route-proxy-form"
|
||
className="space-y-5"
|
||
onSubmit={form.handleSubmit((values) => {
|
||
const { urls } = parseOriginUrls(values.origin_urls_text);
|
||
const primaryOrigin = parseOriginUrl(urls[0]);
|
||
const { headers } = parseCustomHeadersText(values.custom_headers_text);
|
||
|
||
onSave(
|
||
buildPayloadFromRoute(route, {
|
||
origin_id: null,
|
||
origin_url: urls[0],
|
||
origin_scheme: primaryOrigin.scheme,
|
||
origin_address: primaryOrigin.address,
|
||
origin_port: primaryOrigin.port,
|
||
origin_uri: primaryOrigin.uri,
|
||
origin_host: values.origin_host.trim(),
|
||
upstreams: urls.slice(1),
|
||
custom_headers: headers,
|
||
remark: values.remark.trim(),
|
||
}),
|
||
{ message: '反向代理设置已保存。' },
|
||
);
|
||
})}
|
||
>
|
||
<ResourceField
|
||
label="上游地址"
|
||
hint="每行一个完整 URL。多上游模式下不要带 path 或 query。"
|
||
error={form.formState.errors.origin_urls_text?.message}
|
||
>
|
||
<ResourceTextarea
|
||
aria-label="上游地址"
|
||
className="min-h-40"
|
||
placeholder={'https://origin-a.internal:443\nhttps://origin-b.internal:443'}
|
||
{...form.register('origin_urls_text')}
|
||
/>
|
||
</ResourceField>
|
||
|
||
<ResourceField
|
||
label="Origin Host Header"
|
||
hint="留空时默认透传访问域名 $host。"
|
||
error={form.formState.errors.origin_host?.message}
|
||
>
|
||
<ResourceInput
|
||
placeholder="origin.example.internal"
|
||
{...form.register('origin_host')}
|
||
/>
|
||
</ResourceField>
|
||
|
||
<ResourceField
|
||
label="自定义请求头"
|
||
hint="每行一条,格式为 Key: Value。"
|
||
error={form.formState.errors.custom_headers_text?.message}
|
||
>
|
||
<ResourceTextarea
|
||
className="min-h-32"
|
||
placeholder={'X-Trace-Id: $request_id\nX-Site: marketing'}
|
||
{...form.register('custom_headers_text')}
|
||
/>
|
||
</ResourceField>
|
||
|
||
<ResourceField label="备注" error={form.formState.errors.remark?.message}>
|
||
<ResourceTextarea
|
||
placeholder="例如:多活回源,优先使用上海入口"
|
||
{...form.register('remark')}
|
||
/>
|
||
</ResourceField>
|
||
</form>
|
||
</ConfigSectionShell>
|
||
);
|
||
}
|
||
|
||
function CacheSection({
|
||
route,
|
||
saving,
|
||
onSave,
|
||
}: {
|
||
route: ProxyRouteItem;
|
||
saving: boolean;
|
||
onSave: SaveHandler;
|
||
}) {
|
||
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');
|
||
|
||
return (
|
||
<ConfigSectionShell
|
||
title="缓存"
|
||
description="保留现有安全绕过逻辑,只对当前站点生效。"
|
||
formId="proxy-route-cache-form"
|
||
saving={saving}
|
||
>
|
||
<form
|
||
id="proxy-route-cache-form"
|
||
className="space-y-5"
|
||
onSubmit={form.handleSubmit((values) => {
|
||
const rules = linesFromTextarea(values.cache_rules_text);
|
||
onSave(
|
||
buildPayloadFromRoute(route, {
|
||
cache_enabled: values.cache_enabled,
|
||
cache_policy: values.cache_enabled ? values.cache_policy : 'url',
|
||
cache_rules:
|
||
values.cache_enabled && values.cache_policy !== 'url' ? rules : [],
|
||
}),
|
||
{ message: '缓存设置已保存。' },
|
||
);
|
||
})}
|
||
>
|
||
<ToggleField
|
||
label="启用站点缓存"
|
||
description="系统仍会自动绕过非 GET、带 Authorization 或常见登录态 Cookie 的请求。"
|
||
checked={watchedEnabled}
|
||
onChange={(checked) =>
|
||
form.setValue('cache_enabled', checked, { shouldDirty: true })
|
||
}
|
||
/>
|
||
|
||
<ResourceField label="缓存策略">
|
||
<ResourceSelect
|
||
disabled={!watchedEnabled}
|
||
{...form.register('cache_policy')}
|
||
>
|
||
<option value="url">按 URL 缓存</option>
|
||
<option value="suffix">按后缀缓存</option>
|
||
<option value="path_prefix">按路径前缀缓存</option>
|
||
<option value="path_exact">按精确路径缓存</option>
|
||
</ResourceSelect>
|
||
</ResourceField>
|
||
|
||
<ResourceField
|
||
label="缓存规则"
|
||
error={form.formState.errors.cache_rules_text?.message}
|
||
hint={
|
||
watchedPolicy === 'suffix'
|
||
? '每行一个后缀,例如 jpg、css、js。'
|
||
: watchedPolicy === 'path_prefix'
|
||
? '每行一个路径前缀,例如 /assets、/static。'
|
||
: watchedPolicy === 'path_exact'
|
||
? '每行一个精确路径,例如 /robots.txt。'
|
||
: '按 URL 缓存时无需额外规则。'
|
||
}
|
||
>
|
||
<ResourceTextarea
|
||
disabled={!watchedEnabled || watchedPolicy === 'url'}
|
||
className="min-h-32"
|
||
placeholder={
|
||
watchedPolicy === 'suffix'
|
||
? 'jpg\ncss\njs'
|
||
: watchedPolicy === 'path_prefix'
|
||
? '/assets\n/static'
|
||
: watchedPolicy === 'path_exact'
|
||
? '/robots.txt\n/manifest.json'
|
||
: '按 URL 缓存时无需额外规则'
|
||
}
|
||
{...form.register('cache_rules_text')}
|
||
/>
|
||
</ResourceField>
|
||
</form>
|
||
</ConfigSectionShell>
|
||
);
|
||
}
|
||
|
||
type PowListValues = {
|
||
ips: string;
|
||
ip_cidrs: string;
|
||
paths: string;
|
||
path_regexes: string;
|
||
user_agents: string;
|
||
};
|
||
|
||
const defaultPowList: PowListValues = {
|
||
ips: '',
|
||
ip_cidrs: '',
|
||
paths: '',
|
||
path_regexes: '',
|
||
user_agents: '',
|
||
};
|
||
|
||
const powSchema = z
|
||
.object({
|
||
pow_enabled: z.boolean(),
|
||
difficulty: z.coerce.number().int().min(1).max(16),
|
||
algorithm: z.enum(['fast', 'slow']),
|
||
session_ttl: z.coerce.number().int().min(60),
|
||
challenge_ttl: z.coerce.number().int().min(30),
|
||
whitelist: z.object({
|
||
ips: z.string(),
|
||
ip_cidrs: z.string(),
|
||
paths: z.string(),
|
||
path_regexes: z.string(),
|
||
user_agents: z.string(),
|
||
}),
|
||
blacklist: z.object({
|
||
ips: z.string(),
|
||
ip_cidrs: z.string(),
|
||
paths: z.string(),
|
||
path_regexes: z.string(),
|
||
user_agents: z.string(),
|
||
}),
|
||
})
|
||
.superRefine((value, context) => {
|
||
if (!value.pow_enabled) return;
|
||
const dimensions: { key: string; label: string }[] = [
|
||
{ key: 'ips', label: 'IP' },
|
||
{ key: 'ip_cidrs', label: 'IP CIDR' },
|
||
{ key: 'paths', label: '路径' },
|
||
{ key: 'path_regexes', label: '路径正则' },
|
||
{ key: 'user_agents', label: 'User-Agent' },
|
||
];
|
||
for (const dim of dimensions) {
|
||
const wl = linesFromTextarea(
|
||
(value.whitelist as Record<string, string>)[dim.key] || '',
|
||
);
|
||
const bl = linesFromTextarea(
|
||
(value.blacklist as Record<string, string>)[dim.key] || '',
|
||
);
|
||
if (wl.length > 0 && bl.length > 0) {
|
||
context.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
message: `${dim.label} 不能同时配置白名单和黑名单`,
|
||
path: ['blacklist', dim.key],
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
type PowValues = z.infer<typeof powSchema>;
|
||
|
||
function buildPowListFromConfig(
|
||
list:
|
||
| { ips?: string[]; ip_cidrs?: string[]; paths?: string[]; path_regexes?: string[]; user_agents?: string[] }
|
||
| undefined,
|
||
): PowListValues {
|
||
return {
|
||
ips: (list?.ips ?? []).join('\n'),
|
||
ip_cidrs: (list?.ip_cidrs ?? []).join('\n'),
|
||
paths: (list?.paths ?? []).join('\n'),
|
||
path_regexes: (list?.path_regexes ?? []).join('\n'),
|
||
user_agents: (list?.user_agents ?? []).join('\n'),
|
||
};
|
||
}
|
||
|
||
function PowSection({
|
||
route,
|
||
saving,
|
||
onSave,
|
||
}: {
|
||
route: ProxyRouteItem;
|
||
saving: boolean;
|
||
onSave: SaveHandler;
|
||
}) {
|
||
const powConfig = route.pow_config;
|
||
const form = useForm<PowValues>({
|
||
resolver: zodResolver(powSchema),
|
||
defaultValues: {
|
||
pow_enabled: route.pow_enabled,
|
||
difficulty: powConfig?.difficulty ?? 4,
|
||
algorithm: powConfig?.algorithm ?? 'fast',
|
||
session_ttl: powConfig?.session_ttl ?? 86400,
|
||
challenge_ttl: powConfig?.challenge_ttl ?? 300,
|
||
whitelist: buildPowListFromConfig(powConfig?.whitelist),
|
||
blacklist: buildPowListFromConfig(powConfig?.blacklist),
|
||
},
|
||
});
|
||
|
||
useEffect(() => {
|
||
form.reset({
|
||
pow_enabled: route.pow_enabled,
|
||
difficulty: powConfig?.difficulty ?? 4,
|
||
algorithm: powConfig?.algorithm ?? 'fast',
|
||
session_ttl: powConfig?.session_ttl ?? 86400,
|
||
challenge_ttl: powConfig?.challenge_ttl ?? 300,
|
||
whitelist: buildPowListFromConfig(powConfig?.whitelist),
|
||
blacklist: buildPowListFromConfig(powConfig?.blacklist),
|
||
});
|
||
}, [form, route, powConfig]);
|
||
|
||
const watchedEnabled = form.watch('pow_enabled');
|
||
|
||
const parseList = (text: string): string[] =>
|
||
linesFromTextarea(text).filter(Boolean);
|
||
|
||
return (
|
||
<ConfigSectionShell
|
||
title="PoW 防护"
|
||
description="启用 Proof-of-Work 反爬虫验证。首次访问的浏览器需要完成计算挑战才能继续。"
|
||
formId="proxy-route-pow-form"
|
||
saving={saving}
|
||
>
|
||
<form
|
||
id="proxy-route-pow-form"
|
||
className="space-y-5"
|
||
onSubmit={form.handleSubmit((values) => {
|
||
const powConfigPayload = JSON.stringify({
|
||
difficulty: values.difficulty,
|
||
algorithm: values.algorithm,
|
||
session_ttl: values.session_ttl,
|
||
challenge_ttl: values.challenge_ttl,
|
||
whitelist: {
|
||
ips: parseList(values.whitelist.ips),
|
||
ip_cidrs: parseList(values.whitelist.ip_cidrs),
|
||
paths: parseList(values.whitelist.paths),
|
||
path_regexes: parseList(values.whitelist.path_regexes),
|
||
user_agents: parseList(values.whitelist.user_agents),
|
||
},
|
||
blacklist: {
|
||
ips: parseList(values.blacklist.ips),
|
||
ip_cidrs: parseList(values.blacklist.ip_cidrs),
|
||
paths: parseList(values.blacklist.paths),
|
||
path_regexes: parseList(values.blacklist.path_regexes),
|
||
user_agents: parseList(values.blacklist.user_agents),
|
||
},
|
||
});
|
||
onSave(
|
||
buildPayloadFromRoute(route, {
|
||
pow_enabled: values.pow_enabled,
|
||
pow_config: powConfigPayload,
|
||
}),
|
||
{ message: 'PoW 防护设置已保存。' },
|
||
);
|
||
})}
|
||
>
|
||
<ToggleField
|
||
label="启用 PoW 防护"
|
||
description="对访问此站点的请求进行 Proof-of-Work 验证,阻止自动化爬虫。"
|
||
checked={watchedEnabled}
|
||
onChange={(checked) =>
|
||
form.setValue('pow_enabled', checked, { shouldDirty: true })
|
||
}
|
||
/>
|
||
|
||
<ResourceField label="验证算法">
|
||
<ResourceSelect
|
||
disabled={!watchedEnabled}
|
||
{...form.register('algorithm')}
|
||
>
|
||
<option value="fast">Fast(WebCrypto SHA-256)</option>
|
||
<option value="slow">Slow(兼容模式)</option>
|
||
</ResourceSelect>
|
||
</ResourceField>
|
||
|
||
<ResourceField
|
||
label="难度"
|
||
hint="数值越高验证越慢,1-16。推荐 3-5。"
|
||
error={form.formState.errors.difficulty?.message}
|
||
>
|
||
<ResourceInput
|
||
type="number"
|
||
min={1}
|
||
max={16}
|
||
disabled={!watchedEnabled}
|
||
{...form.register('difficulty')}
|
||
/>
|
||
</ResourceField>
|
||
|
||
<ResourceField
|
||
label="会话有效期(秒)"
|
||
hint="通过验证后 Cookie 的有效期。"
|
||
error={form.formState.errors.session_ttl?.message}
|
||
>
|
||
<ResourceInput
|
||
type="number"
|
||
min={60}
|
||
disabled={!watchedEnabled}
|
||
{...form.register('session_ttl')}
|
||
/>
|
||
</ResourceField>
|
||
|
||
<ResourceField
|
||
label="挑战有效期(秒)"
|
||
hint="挑战令牌的有效期。"
|
||
error={form.formState.errors.challenge_ttl?.message}
|
||
>
|
||
<ResourceInput
|
||
type="number"
|
||
min={30}
|
||
disabled={!watchedEnabled}
|
||
{...form.register('challenge_ttl')}
|
||
/>
|
||
</ResourceField>
|
||
|
||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||
<fieldset disabled={!watchedEnabled} className="space-y-4">
|
||
<legend className="text-sm font-medium text-[var(--foreground-primary)] mb-2">
|
||
白名单(匹配的请求跳过 PoW)
|
||
</legend>
|
||
<ResourceField label="IP" hint="每行一个 IP 地址">
|
||
<ResourceTextarea
|
||
className="min-h-20"
|
||
placeholder="1.2.3.4 5.6.7.8"
|
||
{...form.register('whitelist.ips')}
|
||
/>
|
||
</ResourceField>
|
||
<ResourceField label="IP CIDR" hint="每行一个 CIDR 范围">
|
||
<ResourceTextarea
|
||
className="min-h-20"
|
||
placeholder="10.0.0.0/8 192.168.0.0/16"
|
||
{...form.register('whitelist.ip_cidrs')}
|
||
/>
|
||
</ResourceField>
|
||
<ResourceField label="路径" hint="每行一个路径通配符">
|
||
<ResourceTextarea
|
||
className="min-h-20"
|
||
placeholder="/.well-known/* /favicon.ico"
|
||
{...form.register('whitelist.paths')}
|
||
/>
|
||
</ResourceField>
|
||
<ResourceField label="路径正则" hint="每行一个正则表达式">
|
||
<ResourceTextarea
|
||
className="min-h-20"
|
||
placeholder="^/api/public/"
|
||
{...form.register('whitelist.path_regexes')}
|
||
/>
|
||
</ResourceField>
|
||
<ResourceField label="User-Agent" hint="每行一个关键字">
|
||
<ResourceTextarea
|
||
className="min-h-20"
|
||
placeholder="Googlebot bingbot"
|
||
{...form.register('whitelist.user_agents')}
|
||
/>
|
||
</ResourceField>
|
||
</fieldset>
|
||
|
||
<fieldset disabled={!watchedEnabled} className="space-y-4">
|
||
<legend className="text-sm font-medium text-[var(--foreground-primary)] mb-2">
|
||
黑名单(匹配的请求必须 PoW)
|
||
</legend>
|
||
<ResourceField label="IP" hint="每行一个 IP 地址">
|
||
<ResourceTextarea
|
||
className="min-h-20"
|
||
placeholder="1.2.3.4"
|
||
{...form.register('blacklist.ips')}
|
||
/>
|
||
</ResourceField>
|
||
<ResourceField label="IP CIDR" hint="每行一个 CIDR 范围">
|
||
<ResourceTextarea
|
||
className="min-h-20"
|
||
placeholder="10.0.0.0/8"
|
||
{...form.register('blacklist.ip_cidrs')}
|
||
/>
|
||
</ResourceField>
|
||
<ResourceField label="路径" hint="每行一个路径通配符">
|
||
<ResourceTextarea
|
||
className="min-h-20"
|
||
placeholder="/admin/*"
|
||
{...form.register('blacklist.paths')}
|
||
/>
|
||
</ResourceField>
|
||
<ResourceField label="路径正则" hint="每行一个正则表达式">
|
||
<ResourceTextarea
|
||
className="min-h-20"
|
||
placeholder="^/private/"
|
||
{...form.register('blacklist.path_regexes')}
|
||
/>
|
||
</ResourceField>
|
||
<ResourceField label="User-Agent" hint="每行一个关键字">
|
||
<ResourceTextarea
|
||
className="min-h-20"
|
||
placeholder="bot crawler"
|
||
{...form.register('blacklist.user_agents')}
|
||
/>
|
||
</ResourceField>
|
||
</fieldset>
|
||
</div>
|
||
{form.formState.errors.blacklist && (
|
||
<p className="text-sm text-[var(--color-danger)]">
|
||
{Object.values(form.formState.errors.blacklist)
|
||
.flatMap((e) =>
|
||
e && typeof e === 'object' && 'message' in e
|
||
? [e.message as string]
|
||
: [],
|
||
)
|
||
.join('; ')}
|
||
</p>
|
||
)}
|
||
</form>
|
||
</ConfigSectionShell>
|
||
);
|
||
}
|
||
|
||
export function ProxyRouteConfigPage({
|
||
routeId,
|
||
initialSection,
|
||
}: {
|
||
routeId: string;
|
||
initialSection?: string;
|
||
}) {
|
||
const queryClient = useQueryClient();
|
||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||
|
||
const numericRouteID = Number(routeId);
|
||
const currentSection = getWebsiteConfigSection(initialSection);
|
||
|
||
const routeQuery = useQuery({
|
||
queryKey: ['proxy-routes', 'detail', numericRouteID],
|
||
queryFn: () => getProxyRoute(numericRouteID),
|
||
enabled: Number.isFinite(numericRouteID) && numericRouteID > 0,
|
||
});
|
||
const certificatesQuery = useQuery({
|
||
queryKey: ['tls-certificates', 'list'],
|
||
queryFn: getTlsCertificates,
|
||
});
|
||
const managedDomainsQuery = useQuery({
|
||
queryKey: ['managed-domains'],
|
||
queryFn: getManagedDomains,
|
||
});
|
||
|
||
const saveMutation = useMutation({
|
||
mutationFn: async ({
|
||
payload,
|
||
context,
|
||
}: {
|
||
payload: Parameters<typeof updateProxyRoute>[1];
|
||
context: SaveContext;
|
||
}) => {
|
||
const updatedRoute = await updateProxyRoute(numericRouteID, payload);
|
||
return { updatedRoute, context };
|
||
},
|
||
onSuccess: async ({ updatedRoute, context }) => {
|
||
queryClient.setQueryData(
|
||
['proxy-routes', 'detail', numericRouteID],
|
||
updatedRoute,
|
||
);
|
||
setFeedback({ tone: 'success', message: context.message });
|
||
await Promise.all([
|
||
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
|
||
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
|
||
]);
|
||
},
|
||
onError: (error) => {
|
||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||
},
|
||
});
|
||
|
||
const route = routeQuery.data;
|
||
const certificates = useMemo(
|
||
() => certificatesQuery.data ?? [],
|
||
[certificatesQuery.data],
|
||
);
|
||
const domainSuggestionSources = useMemo(
|
||
() => [
|
||
...(route?.domains ?? []),
|
||
...(managedDomainsQuery.data?.map((item) => item.domain) ?? []),
|
||
],
|
||
[managedDomainsQuery.data, route?.domains],
|
||
);
|
||
|
||
if (!Number.isFinite(numericRouteID) || numericRouteID <= 0) {
|
||
return (
|
||
<EmptyState
|
||
title="缺少站点 ID"
|
||
description="请从站点列表进入配置页面。"
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (routeQuery.isLoading || certificatesQuery.isLoading) {
|
||
return <LoadingState />;
|
||
}
|
||
|
||
if (routeQuery.isError) {
|
||
return (
|
||
<ErrorState
|
||
title="站点详情加载失败"
|
||
description={getErrorMessage(routeQuery.error)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (certificatesQuery.isError) {
|
||
return (
|
||
<ErrorState
|
||
title="证书列表加载失败"
|
||
description={getErrorMessage(certificatesQuery.error)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (!route) {
|
||
return (
|
||
<EmptyState
|
||
title="站点不存在"
|
||
description="该站点可能已被删除,或当前 ID 无法匹配到记录。"
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<PageHeader
|
||
title={route.site_name}
|
||
description={`主域名 ${route.primary_domain},共 ${route.domain_count} 个域名`}
|
||
action={
|
||
<div className="flex flex-wrap gap-3">
|
||
<Link
|
||
href="/proxy-route"
|
||
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||
>
|
||
返回列表
|
||
</Link>
|
||
<SecondaryButton
|
||
type="button"
|
||
onClick={() =>
|
||
queryClient.invalidateQueries({
|
||
queryKey: ['proxy-routes', 'detail', numericRouteID],
|
||
})
|
||
}
|
||
>
|
||
刷新详情
|
||
</SecondaryButton>
|
||
</div>
|
||
}
|
||
/>
|
||
|
||
{feedback ? (
|
||
<InlineMessage tone={feedback.tone} message={feedback.message} />
|
||
) : null}
|
||
|
||
<div className="grid gap-6 xl:grid-cols-[280px_minmax(0,1fr)]">
|
||
<aside className="space-y-4">
|
||
<AppCard title="配置分区">
|
||
<div className="space-y-2">
|
||
{websiteConfigSections.map((section) => {
|
||
const active = section.key === currentSection;
|
||
return (
|
||
<Link
|
||
key={section.key}
|
||
href={`/proxy-route/detail?id=${route.id}§ion=${section.key}`}
|
||
className={cn(
|
||
'block rounded-2xl border px-4 py-3 transition',
|
||
active
|
||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
|
||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:border-[var(--border-strong)]',
|
||
)}
|
||
>
|
||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||
{section.label}
|
||
</p>
|
||
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
|
||
{section.description}
|
||
</p>
|
||
</Link>
|
||
);
|
||
})}
|
||
</div>
|
||
</AppCard>
|
||
</aside>
|
||
|
||
<div className="min-w-0 space-y-6">
|
||
{currentSection === 'domains' ? (
|
||
<DomainSettingsSection
|
||
route={route}
|
||
certificates={certificates}
|
||
saving={saveMutation.isPending}
|
||
suggestionSources={domainSuggestionSources}
|
||
onSave={(payload, context) =>
|
||
saveMutation.mutate({ payload, context })
|
||
}
|
||
/>
|
||
) : null}
|
||
|
||
{currentSection === 'limits' ? (
|
||
<RateLimitSection
|
||
route={route}
|
||
saving={saveMutation.isPending}
|
||
onSave={(payload, context) =>
|
||
saveMutation.mutate({ payload, context })
|
||
}
|
||
/>
|
||
) : null}
|
||
|
||
{currentSection === 'proxy' ? (
|
||
<ReverseProxySection
|
||
route={route}
|
||
saving={saveMutation.isPending}
|
||
onSave={(payload, context) =>
|
||
saveMutation.mutate({ payload, context })
|
||
}
|
||
/>
|
||
) : null}
|
||
|
||
{currentSection === 'cache' ? (
|
||
<CacheSection
|
||
route={route}
|
||
saving={saveMutation.isPending}
|
||
onSave={(payload, context) =>
|
||
saveMutation.mutate({ payload, context })
|
||
}
|
||
/>
|
||
) : null}
|
||
|
||
{currentSection === 'pow' ? (
|
||
<PowSection
|
||
route={route}
|
||
saving={saveMutation.isPending}
|
||
onSave={(payload, context) =>
|
||
saveMutation.mutate({ payload, context })
|
||
}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|