From 8ddac90c11b3bbc18b8698d4ddd394a543325e88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 30 May 2026 23:46:34 +0800 Subject: [PATCH] feat(server): serve absolute local files via /local-file so file:// links open in the in-app browser Co-Authored-By: Claude Opus 4.8 (1M context) --- desktop/src/lib/handlePreviewLink.test.ts | 84 ++++++++++- desktop/src/lib/handlePreviewLink.ts | 40 +++++- .../src/lib/openWithContextForHref.test.ts | 6 +- desktop/src/lib/openWithContextForHref.ts | 9 +- src/server/api/__tests__/localFile.test.ts | 132 ++++++++++++++++++ src/server/api/filesystem.ts | 2 +- src/server/api/localFile.ts | 92 ++++++++++++ src/server/api/previewFs.ts | 17 +++ src/server/index.ts | 25 ++++ 9 files changed, 393 insertions(+), 14 deletions(-) create mode 100644 src/server/api/__tests__/localFile.test.ts create mode 100644 src/server/api/localFile.ts diff --git a/desktop/src/lib/handlePreviewLink.test.ts b/desktop/src/lib/handlePreviewLink.test.ts index a1ed84a6..39fb2c84 100644 --- a/desktop/src/lib/handlePreviewLink.test.ts +++ b/desktop/src/lib/handlePreviewLink.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from 'vitest' -import { handlePreviewLink, type PreviewLinkDeps } from './handlePreviewLink' +import { + handlePreviewLink, + isAbsoluteLocalPath, + localFileUrl, + previewFsUrl, + type PreviewLinkDeps, +} from './handlePreviewLink' function makeDeps(overrides?: Partial): PreviewLinkDeps { return { @@ -22,21 +28,42 @@ describe('handlePreviewLink', () => { expect(deps.openExternal).not.toHaveBeenCalled() }) - it('routes absolute html file paths to openBrowser with the preview-fs url', () => { + it('routes absolute html file paths to openBrowser with the local-file url', () => { const deps = makeDeps() const handled = handlePreviewLink('/Users/x/index.html', deps) expect(handled).toBe(true) - // previewFsUrl preserves the leading slash, yielding an intentional `//` - // between and the absolute path so the server resolves it as - // an absolute-within-workspace path. + // Absolute paths may live outside the session workspace, so they go through + // the $HOME-sandboxed /local-file route, NOT /preview-fs. expect(deps.openBrowser).toHaveBeenCalledWith( 's1', - 'http://127.0.0.1:8787/preview-fs/s1//Users/x/index.html', + 'http://127.0.0.1:8787/local-file/Users/x/index.html', ) expect(deps.openFilePreview).not.toHaveBeenCalled() expect(deps.openExternal).not.toHaveBeenCalled() }) + it('routes file:// urls to openBrowser with the local-file url', () => { + const deps = makeDeps() + const handled = handlePreviewLink('file:///Users/x/page.html', deps) + expect(handled).toBe(true) + expect(deps.openBrowser).toHaveBeenCalledWith( + 's1', + 'http://127.0.0.1:8787/local-file/Users/x/page.html', + ) + }) + + it('routes a relative .html file to openBrowser with the preview-fs (workspace) url', () => { + const deps = makeDeps() + const handled = handlePreviewLink('out/index.html', deps) + expect(handled).toBe(true) + // Relative → stays workspace-scoped via /preview-fs. + expect(deps.openBrowser).toHaveBeenCalledWith( + 's1', + 'http://127.0.0.1:8787/preview-fs/s1/out/index.html', + ) + expect(deps.openFilePreview).not.toHaveBeenCalled() + }) + it('routes relative previewable docs to openFilePreview with the relative path', () => { const deps = makeDeps() const handled = handlePreviewLink('docs/report.md', deps) @@ -64,3 +91,48 @@ describe('handlePreviewLink', () => { expect(deps.openExternal).not.toHaveBeenCalled() }) }) + +describe('isAbsoluteLocalPath', () => { + it('treats POSIX leading-slash paths as absolute', () => { + expect(isAbsoluteLocalPath('/Users/x/page.html')).toBe(true) + }) + it('treats Windows drive paths as absolute (both slash styles)', () => { + expect(isAbsoluteLocalPath('C:\\Users\\x\\page.html')).toBe(true) + expect(isAbsoluteLocalPath('C:/Users/x/page.html')).toBe(true) + }) + it('treats relative paths as not absolute', () => { + expect(isAbsoluteLocalPath('out/index.html')).toBe(false) + expect(isAbsoluteLocalPath('./page.html')).toBe(false) + }) +}) + +describe('localFileUrl', () => { + it('appends a POSIX absolute path after /local-file, preserving slashes', () => { + expect(localFileUrl('http://127.0.0.1:8787', '/Users/x/page.html')).toBe( + 'http://127.0.0.1:8787/local-file/Users/x/page.html', + ) + }) + it('encodes spaces/unicode per segment but keeps separators', () => { + expect(localFileUrl('http://127.0.0.1:8787', '/Users/x/with space/p.html')).toBe( + 'http://127.0.0.1:8787/local-file/Users/x/with%20space/p.html', + ) + }) + it('normalizes Windows backslashes and keeps a leading slash', () => { + expect(localFileUrl('http://127.0.0.1:8787', 'C:\\proj\\page.html')).toBe( + 'http://127.0.0.1:8787/local-file/C%3A/proj/page.html', + ) + }) + it('trims a trailing slash on the base', () => { + expect(localFileUrl('http://127.0.0.1:8787/', '/a/b.html')).toBe( + 'http://127.0.0.1:8787/local-file/a/b.html', + ) + }) +}) + +describe('previewFsUrl (unchanged, regression guard)', () => { + it('still builds a workspace-scoped preview-fs url for relative paths', () => { + expect(previewFsUrl('http://127.0.0.1:8787', 's1', 'out/index.html')).toBe( + 'http://127.0.0.1:8787/preview-fs/s1/out/index.html', + ) + }) +}) diff --git a/desktop/src/lib/handlePreviewLink.ts b/desktop/src/lib/handlePreviewLink.ts index 465be78f..5e863227 100644 --- a/desktop/src/lib/handlePreviewLink.ts +++ b/desktop/src/lib/handlePreviewLink.ts @@ -21,6 +21,34 @@ export function previewFsUrl(base: string, sessionId: string, filePath: string): return `${base.replace(/\/$/, '')}/preview-fs/${encodeURIComponent(sessionId)}/${filePath.replace(/^\/+/, '/')}` } +/** True for POSIX absolute (`/...`) or Windows drive (`X:\` / `X:/`) paths. */ +export function isAbsoluteLocalPath(p: string): boolean { + return p.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(p) +} + +/** + * Build a `/local-file/` URL for the local server so an absolute + * file outside the session workspace can open in the in-app browser. + * + * The path is appended PATH-style (not as a query param) so relative asset URLs + * inside served HTML resolve against the same directory. Each path segment is + * `encodeURIComponent`-escaped (so spaces / unicode survive) while the `/` + * separators are preserved. A Windows drive path (`C:\proj\page.html`) has its + * backslashes normalized to `/`; the leading separator is always present so the + * server can re-root the path. + */ +export function localFileUrl(base: string, absPath: string): string { + const withForwardSlashes = absPath.replace(/\\/g, '/') + const withLeadingSlash = withForwardSlashes.startsWith('/') + ? withForwardSlashes + : `/${withForwardSlashes}` + const encoded = withLeadingSlash + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/') + return `${base.replace(/\/$/, '')}/local-file${encoded}` +} + /** Returns true if handled (caller should preventDefault). */ export function handlePreviewLink(href: string, deps: PreviewLinkDeps): boolean { const cls = classifyPreviewLink(href) @@ -28,9 +56,17 @@ export function handlePreviewLink(href: string, deps: PreviewLinkDeps): boolean case 'browser-localhost': deps.openBrowser(deps.sessionId, cls.url!) return true - case 'browser-file': - deps.openBrowser(deps.sessionId, previewFsUrl(deps.serverBaseUrl, deps.sessionId, cls.path!)) + case 'browser-file': { + const filePath = cls.path! + // Absolute paths (incl. file:// → absolute) may live OUTSIDE the session + // workspace, so serve them via the $HOME-sandboxed /local-file route. + // Relative paths stay workspace-scoped via /preview-fs. + const url = isAbsoluteLocalPath(filePath) + ? localFileUrl(deps.serverBaseUrl, filePath) + : previewFsUrl(deps.serverBaseUrl, deps.sessionId, filePath) + deps.openBrowser(deps.sessionId, url) return true + } case 'file-preview': deps.openFilePreview(deps.sessionId, cls.path!) return true diff --git a/desktop/src/lib/openWithContextForHref.test.ts b/desktop/src/lib/openWithContextForHref.test.ts index 7b0ccd7b..5787648b 100644 --- a/desktop/src/lib/openWithContextForHref.test.ts +++ b/desktop/src/lib/openWithContextForHref.test.ts @@ -6,7 +6,7 @@ vi.mock('./desktopRuntime', () => ({ })) import { openWithContextForHref, openWithContextForWorkspaceFile } from './openWithContextForHref' -import { previewFsUrl } from './handlePreviewLink' +import { localFileUrl, previewFsUrl } from './handlePreviewLink' const BASE = 'http://127.0.0.1:4321' const SESSION = 's1' @@ -27,12 +27,12 @@ describe('openWithContextForHref', () => { expect(result).toEqual({ kind: 'file', absolutePath: '/w/docs/a.md', relPath: 'docs/a.md', previewable: true }) }) - it('absolute path in browser-file → inAppBrowserUrl via previewFsUrl', () => { + it('absolute path in browser-file → inAppBrowserUrl via localFileUrl ($HOME-sandboxed route)', () => { const result = openWithContextForHref('/x/p.html', { sessionId: SESSION, serverBaseUrl: BASE }) expect(result).toEqual({ kind: 'file', absolutePath: '/x/p.html', - inAppBrowserUrl: previewFsUrl(BASE, SESSION, '/x/p.html'), + inAppBrowserUrl: localFileUrl(BASE, '/x/p.html'), }) }) diff --git a/desktop/src/lib/openWithContextForHref.ts b/desktop/src/lib/openWithContextForHref.ts index f5431623..123e02a0 100644 --- a/desktop/src/lib/openWithContextForHref.ts +++ b/desktop/src/lib/openWithContextForHref.ts @@ -1,5 +1,5 @@ import { classifyPreviewLink } from './previewLinkRouter' -import { previewFsUrl } from './handlePreviewLink' +import { isAbsoluteLocalPath, localFileUrl, previewFsUrl } from './handlePreviewLink' import type { OpenWithContext } from './openWithItems' const HTML_EXT = /\.(html?|xhtml)$/i @@ -36,7 +36,12 @@ export function openWithContextForHref( return { kind: 'file', absolutePath: resolveAbsolute(opts.workDir, c.path), relPath: c.path, previewable: true } } if (c.kind === 'browser-file' && c.path) { - return { kind: 'file', absolutePath: resolveAbsolute(opts.workDir, c.path), inAppBrowserUrl: previewFsUrl(opts.serverBaseUrl, opts.sessionId, c.path) } + // Absolute paths may be outside the session workspace → serve via the + // $HOME-sandboxed /local-file route; relative paths stay workspace-scoped. + const inAppBrowserUrl = isAbsoluteLocalPath(c.path) + ? localFileUrl(opts.serverBaseUrl, c.path) + : previewFsUrl(opts.serverBaseUrl, opts.sessionId, c.path) + return { kind: 'file', absolutePath: resolveAbsolute(opts.workDir, c.path), inAppBrowserUrl } } return null } diff --git a/src/server/api/__tests__/localFile.test.ts b/src/server/api/__tests__/localFile.test.ts new file mode 100644 index 00000000..644982f2 --- /dev/null +++ b/src/server/api/__tests__/localFile.test.ts @@ -0,0 +1,132 @@ +import { afterAll, describe, expect, it } from 'bun:test' +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs' +import { tmpdir, homedir } from 'node:os' +import * as path from 'node:path' +import { handleLocalFile, reconstructAbsolutePath } from '../localFile' + +// Deterministic 256-byte payload (bytes 0..255) so range slices are checkable. +const VIDEO_BYTES = Uint8Array.from({ length: 256 }, (_, i) => i) + +// Build the work area under $HOME so it is inside isAllowedFilesystemPath's +// allow-list (HOME / tmp / registered roots). tmpdir() on macOS resolves to +// /private/tmp via realpath which is also allowed, but $HOME is the most +// portable choice across platforms for an "inside the sandbox" fixture. +const SANDBOX_ROOTS = mkdtempSync(path.join(homedir(), '.lf-test-')) + +afterAll(() => { + rmSync(SANDBOX_ROOTS, { recursive: true, force: true }) +}) + +function setupFiles() { + const root = mkdtempSync(path.join(SANDBOX_ROOTS, 'proj-')) + writeFileSync(path.join(root, 'page.html'), '

ok

') + mkdirSync(path.join(root, 'assets')) + writeFileSync(path.join(root, 'assets', 'a.css'), 'body{}') + writeFileSync(path.join(root, 'clip.mp4'), VIDEO_BYTES) + writeFileSync(path.join(root, 'with space.html'), '

spaced

') + return root +} + +/** Build a /local-file/ URL exactly the way the desktop helper does. */ +function localFileRequestUrl(absPath: string): URL { + const withForwardSlashes = absPath.replace(/\\/g, '/') + const withLeading = withForwardSlashes.startsWith('/') + ? withForwardSlashes + : `/${withForwardSlashes}` + const encoded = withLeading + .split('/') + .map((s) => encodeURIComponent(s)) + .join('/') + return new URL(`http://127.0.0.1/local-file${encoded}`) +} + +describe('reconstructAbsolutePath', () => { + it('re-roots a POSIX path (leading slash consumed by the prefix)', () => { + expect(reconstructAbsolutePath('Users/me/page.html')).toBe('/Users/me/page.html') + }) + it('decodes percent-encoded segments', () => { + expect(reconstructAbsolutePath('Users/me/with%20space.html')).toBe('/Users/me/with space.html') + }) + it('keeps a Windows drive path absolute', () => { + expect(reconstructAbsolutePath('C:/Users/me/page.html')).toBe('C:/Users/me/page.html') + }) + it('returns null for an empty remainder', () => { + expect(reconstructAbsolutePath('')).toBeNull() + }) +}) + +describe('handleLocalFile', () => { + it('serves an in-sandbox .html with text/html + Accept-Ranges', async () => { + const root = setupFiles() + const res = await handleLocalFile(localFileRequestUrl(path.join(root, 'page.html'))) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8') + expect(res.headers.get('accept-ranges')).toBe('bytes') + expect(await res.text()).toBe('

ok

') + }) + + it('serves nested assets with the right content-type', async () => { + const root = setupFiles() + const res = await handleLocalFile(localFileRequestUrl(path.join(root, 'assets', 'a.css'))) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('text/css; charset=utf-8') + }) + + it('serves a file whose name contains a space', async () => { + const root = setupFiles() + const res = await handleLocalFile(localFileRequestUrl(path.join(root, 'with space.html'))) + expect(res.status).toBe(200) + expect(await res.text()).toBe('

spaced

') + }) + + it('honours a closed byte-range with 206 + Content-Range', async () => { + const root = setupFiles() + const res = await handleLocalFile( + localFileRequestUrl(path.join(root, 'clip.mp4')), + new Headers({ Range: 'bytes=0-9' }), + ) + expect(res.status).toBe(206) + expect(res.headers.get('content-range')).toBe('bytes 0-9/256') + expect(res.headers.get('content-length')).toBe('10') + expect(res.headers.get('accept-ranges')).toBe('bytes') + const body = new Uint8Array(await res.arrayBuffer()) + expect(body.length).toBe(10) + expect(body).toEqual(VIDEO_BYTES.slice(0, 10)) + }) + + it('serves video content-type + Accept-Ranges on a full 200', async () => { + const root = setupFiles() + const res = await handleLocalFile(localFileRequestUrl(path.join(root, 'clip.mp4'))) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('video/mp4') + expect(res.headers.get('accept-ranges')).toBe('bytes') + expect(res.headers.get('content-length')).toBe('256') + }) + + it('rejects a path OUTSIDE the sandbox with 403', async () => { + const res = await handleLocalFile(localFileRequestUrl('/etc/hosts')) + expect(res.status).toBe(403) + }) + + it('rejects /etc/passwd with 403 (sandbox escape)', async () => { + const res = await handleLocalFile(localFileRequestUrl('/etc/passwd')) + expect(res.status).toBe(403) + }) + + it('404s a missing in-sandbox file', async () => { + const root = setupFiles() + const res = await handleLocalFile(localFileRequestUrl(path.join(root, 'does-not-exist.html'))) + expect(res.status).toBe(404) + }) + + it('403s when the prefix was stripped by URL normalization (traversal)', async () => { + // `..` collapsing removes the /local-file/ prefix → treated as escape. + const res = await handleLocalFile(new URL('http://127.0.0.1/local-file/../../etc/passwd')) + expect(res.status).toBe(403) + }) + + it('400s when no path follows the prefix', async () => { + const res = await handleLocalFile(new URL('http://127.0.0.1/local-file/')) + expect(res.status).toBe(400) + }) +}) diff --git a/src/server/api/filesystem.ts b/src/server/api/filesystem.ts index ab8ef011..1056374e 100644 --- a/src/server/api/filesystem.ts +++ b/src/server/api/filesystem.ts @@ -52,7 +52,7 @@ function isVcsMetadataDirectoryName(name: string): boolean { return VCS_METADATA_DIRECTORY_NAMES.has(name.toLowerCase()) } -function isAllowedFilesystemPath(targetPath: string): boolean { +export function isAllowedFilesystemPath(targetPath: string): boolean { const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(targetPath)) const homeDir = path.resolve(os.homedir()) diff --git a/src/server/api/localFile.ts b/src/server/api/localFile.ts new file mode 100644 index 00000000..9a78b867 --- /dev/null +++ b/src/server/api/localFile.ts @@ -0,0 +1,92 @@ +import * as path from 'node:path' +import { isAllowedFilesystemPath } from './filesystem.js' +import { serveFileWithRange } from './previewFs.js' +import { normalizeDriveRootPathForPlatform } from '../services/windowsDrivePath.js' + +const PREFIX = '/local-file/' + +/** + * Reconstruct the absolute filesystem path encoded in a `/local-file/...` + * request pathname. + * + * The request path *after* the `/local-file/` prefix IS the target's absolute + * path with its leading separator dropped by the prefix slice. We URL-decode + * each segment (so spaces / unicode names survive) and re-add the root: + * + * - POSIX: `Users/me/page.html` → `/Users/me/page.html` + * - Windows drive: `C:/me/page.html` → `C:/me/page.html` (already rooted) + * - Windows drive (with leading `/`, e.g. from `file:///C:/...`): + * `C:/me/page.html` arrives the same way once the prefix is sliced. + * + * The WHATWG URL parser collapses `..` segments before this handler runs, so a + * traversal such as `/local-file/../../etc/passwd` arrives with its pathname + * normalized to `/etc/passwd` — i.e. the `/local-file/` prefix is gone. We + * treat any request that lost the prefix as a sandbox escape (handled by the + * caller returning 403); here we only reconstruct, the sandbox check is the + * `isAllowedFilesystemPath` gate in {@link handleLocalFile}. + * + * Returns `null` when the remainder is empty. + */ +export function reconstructAbsolutePath(rest: string): string | null { + if (!rest) return null + + const decoded = rest + .split('/') + .map((segment) => { + try { + return decodeURIComponent(segment) + } catch { + return segment + } + }) + .join('/') + + if (!decoded) return null + + // Windows drive form: `C:/...` or `C:\...` is already absolute. + if (/^[a-zA-Z]:[\\/]/.test(decoded) || /^[a-zA-Z]:$/.test(decoded)) { + return decoded + } + + // POSIX: the leading `/` was consumed by the `/local-file/` prefix slice. + return `/${decoded}` +} + +/** + * Serve a single ABSOLUTE local file by path so `file://` links and AI-emitted + * absolute paths can open in the in-app browser. + * + * URL shape: `/local-file/` where the path after the prefix is + * the on-disk absolute path (its leading separator dropped by the prefix, or a + * `C:/...` drive form on Windows). This is PATH-based, not query-param based, + * so relative asset URLs (`./app.css`, `img/logo.png`) inside served HTML + * resolve against the same `/local-file/...` directory. + * + * Security: the resolved path is gated by {@link isAllowedFilesystemPath} — the + * same `$HOME` / `/tmp` / `/private/tmp` / registered-roots allow-list used by + * the filesystem image endpoint. Anything outside is rejected with 403, so + * `/etc/passwd` and friends stay denied. Streaming + byte-range handling is + * shared with `/preview-fs` via {@link serveFileWithRange}, so HTML, CSS, JS, + * images, fonts, video, and text all serve with the correct `Content-Type`, + * `Accept-Ranges`, and 206 partial responses. + */ +export async function handleLocalFile( + url: URL, + reqHeaders?: Headers, +): Promise { + if (!url.pathname.startsWith(PREFIX)) { + return new Response('forbidden', { status: 403 }) + } + + const rest = url.pathname.slice(PREFIX.length) + const absPath = reconstructAbsolutePath(rest) + if (!absPath) return new Response('bad request', { status: 400 }) + + const resolved = path.resolve(normalizeDriveRootPathForPlatform(absPath)) + + if (!isAllowedFilesystemPath(resolved)) { + return new Response('forbidden', { status: 403 }) + } + + return serveFileWithRange(resolved, reqHeaders) +} diff --git a/src/server/api/previewFs.ts b/src/server/api/previewFs.ts index b00ee60d..347a54ab 100644 --- a/src/server/api/previewFs.ts +++ b/src/server/api/previewFs.ts @@ -149,6 +149,23 @@ export async function handlePreviewFs( return new Response('forbidden', { status: 403 }) } + return serveFileWithRange(target, reqHeaders) +} + +/** + * Stream a single resolved absolute file as an HTTP response, honouring a + * `Range` header (206 partial / 416 unsatisfiable) and falling back to a full + * 200 otherwise. The body is streamed straight from disk via `Bun.file(...)`, + * never buffered into memory, so this is safe for large media. + * + * Callers are responsible for any path-sandboxing BEFORE invoking this — it + * trusts `target` to already be authorised. It returns 404 when the path is + * missing or not a regular file, and 413 above {@link MAX_FILE_BYTES}. + */ +export async function serveFileWithRange( + target: string, + reqHeaders?: Headers, +): Promise { let stat: fs.Stats try { stat = fs.statSync(target) diff --git a/src/server/index.ts b/src/server/index.ts index 90d6866e..87efd24f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -16,6 +16,7 @@ import { ProviderService } from './services/providerService.js' import { handleHahaOAuthCallback } from './api/haha-oauth.js' import { handleHahaOpenAIOAuthCallback } from './api/haha-openai-oauth.js' import { handlePreviewFs } from './api/previewFs.js' +import { handleLocalFile } from './api/localFile.js' import { sessionService } from './services/sessionService.js' import { conversationService } from './services/conversationService.js' import { OPENAI_CODEX_REDIRECT_PATH } from '../services/openaiAuth/client.js' @@ -307,6 +308,30 @@ export function startServer(port = PORT, host = HOST) { return withCors(response, cors) } + // Local filesystem — serve an ABSOLUTE local file ($HOME/tmp/registered + // roots sandbox) so `file://` links / AI-emitted absolute paths open in + // the in-app browser. Gated identically to /preview-fs above. + if (url.pathname.startsWith('/local-file/')) { + if (cors.rejected) { + return corsRejectedResponse(cors) + } + + if (authRequired) { + const authError = await requireH5Token(req) + if (authError) { + return withCors(authError, cors) + } + } else if (forceAuth) { + const authError = await requireAuth(req) + if (authError) { + return withCors(authError, cors) + } + } + + const response = await handleLocalFile(url, req.headers) + return withCors(response, cors) + } + // REST API if (url.pathname.startsWith('/api/')) { if (cors.rejected) {