From 0e163e02edca6f256e5ad1ec51098e72973e5b05 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Thu, 17 Sep 2026 11:00:22 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E7=9A=84=E6=A8=A1=E5=9D=97=E6=95=B0=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1=E3=80=81=E5=90=8D=E7=A7=B0=E8=A2=AB=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E4=B8=8E=20URL=20=E6=8A=93=E5=8F=96=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 项目详情接口 _count 漏 select modules,重新导入弹框因此显示「undefined 个模块」 - 重新导入不再用文档中的 name/description 覆盖用户自行设置的项目名称与描述 - URL 抓取失败原因此前被完全吞掉,只剩无信息量的 502:前端改为预判混合内容、 跳过必然超时的内网服务端代理、直连加超时,并抛出带错误码的 SpecFetchError - 服务端代理加 15s 超时并始终返回 JSON,不再挂到 nginx 超时返回 HTML 错误页 - 新增 SpecFetchErrorNotice 组件,跨域类失败时可展开具体的 CORS 配置引导 - nginx /api/ 补 client_max_body_size 12m,避免大文档在 nginx 层被 413 挡掉 Co-Authored-By: Claude Opus 5 --- packages/server/src/routes/fetch-spec.ts | 33 ++++- packages/server/src/routes/import.ts | 3 +- packages/server/src/routes/projects.ts | 2 +- packages/web/nginx.conf | 3 + .../src/components/SpecFetchErrorNotice.tsx | 68 ++++++++++ packages/web/src/lib/fetch-spec.ts | 127 +++++++++++++++--- packages/web/src/lib/i18n/en.ts | 14 ++ packages/web/src/lib/i18n/zh.ts | 14 ++ packages/web/src/pages/ImportDialog.tsx | 14 +- packages/web/src/pages/ReimportDialog.tsx | 13 +- .../web/src/pages/tabs/ProjectSettings.tsx | 2 +- 11 files changed, 254 insertions(+), 39 deletions(-) create mode 100644 packages/web/src/components/SpecFetchErrorNotice.tsx diff --git a/packages/server/src/routes/fetch-spec.ts b/packages/server/src/routes/fetch-spec.ts index 7e3e320..1c24674 100644 --- a/packages/server/src/routes/fetch-spec.ts +++ b/packages/server/src/routes/fetch-spec.ts @@ -4,26 +4,47 @@ import { requireAuth } from '../middleware/auth.js'; const router: RouterType = Router(); router.use(requireAuth); -// CORS proxy: frontend calls this when direct fetch is blocked by CORS +// 服务端兜底抓取:只有当浏览器直连被 CORS / 混合内容拦截时前端才会调用这里。 +// 目标地址若是内网 / VPN 资源,服务器通常访问不到,必须快速失败并返回 JSON, +// 否则请求会挂到 nginx 超时,前端只能看到一个没有信息量的 502/504 HTML 页面。 +const FETCH_TIMEOUT_MS = 15_000; + router.get('/', async (req, res) => { const specUrl = req.query.url as string; - if (!specUrl || !specUrl.startsWith('http')) { - res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide a valid URL' } }); + let target: URL; + try { + target = new URL(specUrl); + if (target.protocol !== 'http:' && target.protocol !== 'https:') throw new Error('bad protocol'); + } catch { + res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide a valid http(s) URL' } }); return; } try { - const response = await fetch(specUrl, { + const response = await fetch(target.toString(), { headers: { Accept: 'application/json, application/yaml, text/yaml, text/plain, */*' }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!response.ok) { - res.status(502).json({ success: false, error: { code: 'FETCH_FAILED', message: `Remote server returned ${response.status}` } }); + res.status(502).json({ + success: false, + error: { code: 'FETCH_FAILED', message: `Remote server returned ${response.status}` }, + }); return; } const text = await response.text(); res.json({ success: true, data: { content: text, contentType: response.headers.get('content-type') || '' } }); } catch (err) { - res.status(502).json({ success: false, error: { code: 'FETCH_FAILED', message: err instanceof Error ? err.message : 'Failed to fetch URL' } }); + const timedOut = err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError'); + res.status(502).json({ + success: false, + error: { + code: timedOut ? 'FETCH_TIMEOUT' : 'FETCH_FAILED', + message: timedOut + ? `Timed out after ${FETCH_TIMEOUT_MS / 1000}s — the server cannot reach ${target.host}` + : `${err instanceof Error ? err.message : 'Failed to fetch URL'} (${target.host})`, + }, + }); } }); diff --git a/packages/server/src/routes/import.ts b/packages/server/src/routes/import.ts index 9d11148..3462b1a 100644 --- a/packages/server/src/routes/import.ts +++ b/packages/server/src/routes/import.ts @@ -29,10 +29,11 @@ router.post('/:id/reimport', async (req, res) => { await tx.endpoint.deleteMany({ where: { projectId: project.id } }); await tx.module.deleteMany({ where: { projectId: project.id } }); + // 只更新文档本体,项目名称与描述由用户自行维护,重新导入不覆盖 await tx.project.update({ where: { id: project.id }, data: { - name: parsed.name, description: parsed.description, baseUrl: parsed.baseUrl, + baseUrl: parsed.baseUrl, openApiSpec: parsed.spec as any, openApiVersion: parsed.openApiVersion, }, }); diff --git a/packages/server/src/routes/projects.ts b/packages/server/src/routes/projects.ts index 2cc9ed1..78545c2 100644 --- a/packages/server/src/routes/projects.ts +++ b/packages/server/src/routes/projects.ts @@ -88,7 +88,7 @@ router.get('/:id', async (req, res) => { include: { _count: { select: { endpoints: true } } }, orderBy: { sortOrder: 'asc' }, }, - _count: { select: { endpoints: true } }, + _count: { select: { endpoints: true, modules: true } }, }, }); if (!project) { diff --git a/packages/web/nginx.conf b/packages/web/nginx.conf index 1758628..a2e89cf 100644 --- a/packages/web/nginx.conf +++ b/packages/web/nginx.conf @@ -4,6 +4,9 @@ server { index index.html; location /api/ { + # OpenAPI 文档整份提交,默认 1m 上限会被大文档打爆(server 侧允许 10mb) + client_max_body_size 12m; + proxy_read_timeout 120s; proxy_pass http://server:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/packages/web/src/components/SpecFetchErrorNotice.tsx b/packages/web/src/components/SpecFetchErrorNotice.tsx new file mode 100644 index 0000000..4371c70 --- /dev/null +++ b/packages/web/src/components/SpecFetchErrorNotice.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react'; +import { SpecFetchError, describeSpecFetchError } from '../lib/fetch-spec'; +import { useI18n } from '../lib/i18n'; + +/** 这些失败都卡在浏览器的跨域策略上,光看报错用户无从下手,需要给出具体配置 */ +const FIXABLE_BY_CONFIG: ReadonlySet = new Set(['blocked', 'mixedContent', 'privateNetwork']); + +function corsSnippet(origin: string): string { + return [ + '# Response headers', + `Access-Control-Allow-Origin: ${origin}`, + 'Access-Control-Allow-Private-Network: true', + '', + '# Nginx', + `add_header Access-Control-Allow-Origin "${origin}" always;`, + 'add_header Access-Control-Allow-Private-Network "true" always;', + ].join('\n'); +} + +/** 导入失败提示:错误原因 + 可展开的跨域配置引导 */ +export default function SpecFetchErrorNotice({ error }: { error: unknown }) { + const [showHelp, setShowHelp] = useState(false); + const { t } = useI18n(); + + if (!error) return null; + + const code = error instanceof SpecFetchError ? error.code : ''; + const helpful = FIXABLE_BY_CONFIG.has(code); + + return ( +
+
+ + + + + {describeSpecFetchError(error, t)} +
+ + {helpful && ( + <> + + + {showHelp && ( +
+

{t('common.specFetch.helpIntro')}

+
    +
  1. +

    {t('common.specFetch.helpCors')}

    +
    +                    {corsSnippet(window.location.origin)}
    +                  
    +
  2. +
  3. {t('common.specFetch.helpMixed')}
  4. +
  5. {t('common.specFetch.helpFallback')}
  6. +
+
+ )} + + )} +
+ ); +} diff --git a/packages/web/src/lib/fetch-spec.ts b/packages/web/src/lib/fetch-spec.ts index 580588a..50cfe58 100644 --- a/packages/web/src/lib/fetch-spec.ts +++ b/packages/web/src/lib/fetch-spec.ts @@ -1,32 +1,129 @@ import yaml from 'js-yaml'; import { apiFetch } from './api'; +import type { TFunction } from './i18n'; + +const DIRECT_TIMEOUT_MS = 30_000; + +export type SpecFetchCode = 'invalidUrl' | 'mixedContent' | 'privateNetwork' | 'blocked' | 'httpStatus' | 'parse'; + +/** 带错误码的抓取失败,供界面翻译成可操作的提示 */ +export class SpecFetchError extends Error { + code: SpecFetchCode; + detail: string; + proxyDetail: string; + + constructor(code: SpecFetchCode, detail = '', proxyDetail = '') { + super(`spec fetch failed: ${code}${detail ? ` (${detail})` : ''}`); + this.name = 'SpecFetchError'; + this.code = code; + this.detail = detail; + this.proxyDetail = proxyDetail; + } +} function parseSpecText(text: string): object { + const trimmed = text.trim(); + if (!trimmed) throw new SpecFetchError('parse', 'empty response'); try { - return JSON.parse(text); + return JSON.parse(trimmed); } catch { - return yaml.load(text) as object; + // 不是 JSON,继续按 YAML 解析 } + let loaded: unknown; + try { + loaded = yaml.load(trimmed); + } catch (err) { + throw new SpecFetchError('parse', err instanceof Error ? err.message.split('\n')[0] : 'invalid YAML'); + } + if (!loaded || typeof loaded !== 'object') { + throw new SpecFetchError('parse', trimmed.slice(0, 60)); + } + return loaded as object; +} + +function isLoopback(hostname: string): boolean { + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1'; +} + +/** 私有网段 / 本机 / 无域名后缀的主机名——只有用户的浏览器所在网络能访问到,服务端代理跑一趟必然超时 */ +function isPrivateHost(hostname: string): boolean { + const host = hostname.replace(/^\[|\]$/g, '').toLowerCase(); + if (isLoopback(host)) return true; + if (host.endsWith('.local') || host.endsWith('.internal') || host.endsWith('.lan')) return true; + if (host.startsWith('fc') || host.startsWith('fd') || host.startsWith('fe80:')) return true; // IPv6 ULA / link-local + const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (v4) { + const [a, b] = [Number(v4[1]), Number(v4[2])]; + return a === 10 || a === 127 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31) || (a === 169 && b === 254); + } + // 单段主机名(如 http://gateway/openapi.json)只在内网 DNS 里能解析 + return !host.includes('.'); } /** - * Fetch an OpenAPI spec from a URL and parse it. - * 1. Try direct fetch from browser (works for localhost/intranet) - * 2. If CORS blocks it, fall back to server-side proxy - * Returns a parsed spec object (JSON or YAML). + * 从 URL 抓取 OpenAPI 文档。 + * + * 1. 优先由浏览器直连抓取——用户的文档常位于内网或需要 VPN,只有浏览器所在的网络环境能访问到。 + * 2. 浏览器被拦截(CORS / 混合内容)时才退回服务端代理,服务端通常访问不到内网地址。 + * 3. 两条路都失败时抛出带错误码的 SpecFetchError,界面据此告诉用户到底卡在哪一步。 */ export async function fetchSpecFromUrl(url: string): Promise { - // Try direct fetch first (handles localhost, intranet, CORS-friendly APIs) + let target: URL; try { - const res = await fetch(url, { - headers: { Accept: 'application/json, application/yaml, text/yaml, */*' }, - }); - if (res.ok) return parseSpecText(await res.text()); + target = new URL(url.trim()); } catch { - // CORS or network error — fall through to server proxy + throw new SpecFetchError('invalidUrl'); + } + if (target.protocol !== 'http:' && target.protocol !== 'https:') { + throw new SpecFetchError('invalidUrl'); } - // Fall back to server-side proxy for CORS-restricted URLs - const data = await apiFetch<{ content: string }>(`/fetch-spec?url=${encodeURIComponent(url)}`); - return parseSpecText(data.content); + // HTTPS 页面读取 http:// 资源会被浏览器的混合内容策略直接拦截,连请求都发不出去 + const mixedContent = + window.location.protocol === 'https:' && target.protocol === 'http:' && !isLoopback(target.hostname); + + let directDetail = ''; + if (mixedContent) { + directDetail = 'mixed content blocked by the browser'; + } else { + try { + const res = await fetch(target.toString(), { + headers: { Accept: 'application/json, application/yaml, text/yaml, */*' }, + signal: AbortSignal.timeout(DIRECT_TIMEOUT_MS), + }); + // 拿到了真实状态码说明 CORS 已通过,结果是确定的,无需再走服务端代理 + if (!res.ok) throw new SpecFetchError('httpStatus', `${res.status} ${res.statusText}`.trim()); + return parseSpecText(await res.text()); + } catch (err) { + if (err instanceof SpecFetchError) throw err; + directDetail = err instanceof Error ? err.message : String(err); + } + } + + // 内网地址服务端必然访问不到,不必白等一次超时 + if (isPrivateHost(target.hostname)) { + throw new SpecFetchError('privateNetwork', directDetail); + } + + // 浏览器直连失败,退回服务端代理(对公网可达但未开 CORS 的地址有效) + try { + const data = await apiFetch<{ content: string }>(`/fetch-spec?url=${encodeURIComponent(target.toString())}`); + return parseSpecText(data.content); + } catch (err) { + if (err instanceof SpecFetchError) throw err; + const proxyDetail = err instanceof Error ? err.message : String(err); + throw new SpecFetchError(mixedContent ? 'mixedContent' : 'blocked', directDetail, proxyDetail); + } +} + +/** 把抓取错误翻译成用户可读、可操作的提示 */ +export function describeSpecFetchError(err: unknown, t: TFunction): string { + if (err instanceof SpecFetchError) { + return t(`common.specFetch.${err.code}` as 'common.specFetch.blocked', { + detail: err.detail, + proxyDetail: err.proxyDetail, + }); + } + if (err instanceof Error) return err.message; + return t('common.specFetch.blocked', { detail: '', proxyDetail: '' }); } diff --git a/packages/web/src/lib/i18n/en.ts b/packages/web/src/lib/i18n/en.ts index 75d5d3f..d661e9d 100644 --- a/packages/web/src/lib/i18n/en.ts +++ b/packages/web/src/lib/i18n/en.ts @@ -166,6 +166,20 @@ const en = { 'common.dropFile': 'Drop your OpenAPI file here', 'common.jsonOrYaml': 'JSON or YAML', + // ===== Spec fetching ===== + 'common.specFetch.invalidUrl': 'Invalid URL. Enter a full address starting with http:// or https://.', + 'common.specFetch.mixedContent': 'This page is served over HTTPS, so the browser blocks reading an http:// address (mixed content). The server-side fallback failed too ({proxyDetail}).', + 'common.specFetch.privateNetwork': 'This address lives on a private network, so only your browser can reach it — but the browser was blocked from reading it ({detail}).', + 'common.specFetch.blocked': 'The browser could not read this URL directly — the target service most likely does not allow cross-origin requests ({detail}). The server-side fallback failed too ({proxyDetail}).', + 'common.specFetch.httpStatus': 'The document server returned {detail}.', + 'common.specFetch.parse': 'The fetched content is not a valid JSON or YAML document ({detail}).', + 'common.specFetch.helpShow': 'How do I make URL import work?', + 'common.specFetch.helpHide': 'Hide', + 'common.specFetch.helpIntro': 'Browsers only read cross-origin URLs that explicitly allow it. URL import works once all of the following are true:', + 'common.specFetch.helpCors': 'The document service returns CORS headers allowing this site. Private network addresses need the Private Network header too:', + 'common.specFetch.helpMixed': 'If the document URL is http:// while this site is HTTPS, also set "Insecure content" to "Allow" under the lock icon → Site settings in your browser.', + 'common.specFetch.helpFallback': 'If changing the document service is not an option, open the URL in your browser, save it as a .json or .yaml file, and import it via "Upload File" — that path is not subject to cross-origin rules and gives the same result.', + // ===== Theme ===== 'theme.light': 'Light', 'theme.dark': 'Dark', diff --git a/packages/web/src/lib/i18n/zh.ts b/packages/web/src/lib/i18n/zh.ts index 123e396..ed83d86 100644 --- a/packages/web/src/lib/i18n/zh.ts +++ b/packages/web/src/lib/i18n/zh.ts @@ -168,6 +168,20 @@ const zh: Record = { 'common.dropFile': '将 OpenAPI 文件拖放到这里', 'common.jsonOrYaml': 'JSON 或 YAML', + // ===== Spec fetching ===== + 'common.specFetch.invalidUrl': 'URL 无效,请填写以 http:// 或 https:// 开头的完整地址。', + 'common.specFetch.mixedContent': '当前页面是 HTTPS,浏览器禁止读取 http:// 地址(混合内容拦截);服务端代抓取同样失败({proxyDetail})。', + 'common.specFetch.privateNetwork': '该地址位于内网或私有网段,只有你的浏览器能访问到,但浏览器直连被拦截({detail})。', + 'common.specFetch.blocked': '浏览器无法直接读取该地址,通常是目标服务未允许跨域访问({detail});服务端代抓取同样失败({proxyDetail})。', + 'common.specFetch.httpStatus': '文档服务返回 {detail}。', + 'common.specFetch.parse': '抓取到的内容不是有效的 JSON 或 YAML 文档({detail})。', + 'common.specFetch.helpShow': '如何让 URL 导入可用?', + 'common.specFetch.helpHide': '收起', + 'common.specFetch.helpIntro': '浏览器只允许读取明确放行的跨域地址。要让 URL 导入生效,需要同时满足下面几点:', + 'common.specFetch.helpCors': '文档服务在响应中带上跨域头,放行本站;内网地址还需要 Private Network 头:', + 'common.specFetch.helpMixed': '文档地址是 http:// 而本站是 HTTPS 时,还需在浏览器地址栏的锁图标 →「网站设置」→「不安全内容」中选择「允许」。', + 'common.specFetch.helpFallback': '不方便改动文档服务时,在浏览器里打开该地址、另存为 .json 或 .yaml 文件,再用「上传文件」导入——这条路不受跨域限制,效果完全相同。', + // ===== Theme ===== 'theme.light': '浅色', 'theme.dark': '深色', diff --git a/packages/web/src/pages/ImportDialog.tsx b/packages/web/src/pages/ImportDialog.tsx index ae0a477..980b681 100644 --- a/packages/web/src/pages/ImportDialog.tsx +++ b/packages/web/src/pages/ImportDialog.tsx @@ -5,6 +5,7 @@ import { apiFetch } from '../lib/api'; import { fetchSpecFromUrl } from '../lib/fetch-spec'; import { useI18n } from '../lib/i18n'; import Modal from '../components/Modal'; +import SpecFetchErrorNotice from '../components/SpecFetchErrorNotice'; type ImportResult = { project: { id: string; name: string }; @@ -17,7 +18,7 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) { const [fileContent, setFileContent] = useState(''); const [fileName, setFileName] = useState(''); const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); + const [error, setError] = useState(null); const [result, setResult] = useState(null); const [dragging, setDragging] = useState(false); const fileInputRef = useRef(null); @@ -41,7 +42,7 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) { const handleImport = async () => { setLoading(true); - setError(''); + setError(null); try { let body: Record; if (mode === 'url') { @@ -56,7 +57,7 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) { setResult(data); queryClient.invalidateQueries({ queryKey: ['projects'] }); } catch (err) { - setError(err instanceof Error ? err.message : 'Import failed'); + setError(err); } finally { setLoading(false); } @@ -104,12 +105,7 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) { )} - {error && ( -
- - {error} -
- )} +
diff --git a/packages/web/src/pages/ReimportDialog.tsx b/packages/web/src/pages/ReimportDialog.tsx index 14024a5..fc8e041 100644 --- a/packages/web/src/pages/ReimportDialog.tsx +++ b/packages/web/src/pages/ReimportDialog.tsx @@ -3,6 +3,7 @@ import { apiFetch } from '../lib/api'; import { fetchSpecFromUrl } from '../lib/fetch-spec'; import { useI18n } from '../lib/i18n'; import Modal from '../components/Modal'; +import SpecFetchErrorNotice from '../components/SpecFetchErrorNotice'; type ReimportResult = { stats: { modules: number; endpoints: number }; @@ -24,7 +25,7 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc const [fileContent, setFileContent] = useState(''); const [fileName, setFileName] = useState(''); const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); + const [error, setError] = useState(null); const [result, setResult] = useState(null); const [dragging, setDragging] = useState(false); const fileInputRef = useRef(null); @@ -46,7 +47,7 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc const handleReimport = async () => { setLoading(true); - setError(''); + setError(null); try { let body: Record; if (mode === 'url') { @@ -61,7 +62,7 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc setResult(data); setStep('success'); } catch (err) { - setError(err instanceof Error ? err.message : 'Re-import failed'); + setError(err); } finally { setLoading(false); } @@ -84,8 +85,8 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc

{t('dashboard.reimport.warningTitle')}

    -
  • {t('dashboard.reimport.warningModules', { count: currentStats.modules })}
  • -
  • {t('dashboard.reimport.warningEndpoints', { count: currentStats.endpoints })}
  • +
  • {t('dashboard.reimport.warningModules', { count: currentStats.modules ?? 0 })}
  • +
  • {t('dashboard.reimport.warningEndpoints', { count: currentStats.endpoints ?? 0 })}

{t('dashboard.reimport.warningNote')}

@@ -138,7 +139,7 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc
)} - {error &&
{error}
} +
diff --git a/packages/web/src/pages/tabs/ProjectSettings.tsx b/packages/web/src/pages/tabs/ProjectSettings.tsx index d2ffd90..cdafff1 100644 --- a/packages/web/src/pages/tabs/ProjectSettings.tsx +++ b/packages/web/src/pages/tabs/ProjectSettings.tsx @@ -66,7 +66,7 @@ export default function ProjectSettings({ project }: { project: Project }) {

{t('dashboard.projectSettings.reimportTitle')}

- {t('dashboard.projectSettings.reimportDesc', { modules: project._count.modules, endpoints: project._count.endpoints })} + {t('dashboard.projectSettings.reimportDesc', { modules: project._count.modules ?? 0, endpoints: project._count.endpoints ?? 0 })}