mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
1d9f4c9cb7
commit
8ddac90c11
@ -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>): 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 <sessionId> 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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/<absolute-path>` 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
|
||||
|
||||
@ -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'),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
132
src/server/api/__tests__/localFile.test.ts
Normal file
132
src/server/api/__tests__/localFile.test.ts
Normal file
@ -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'), '<h1>ok</h1>')
|
||||
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'), '<h1>spaced</h1>')
|
||||
return root
|
||||
}
|
||||
|
||||
/** Build a /local-file/<abs> 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('<h1>ok</h1>')
|
||||
})
|
||||
|
||||
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('<h1>spaced</h1>')
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@ -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())
|
||||
|
||||
|
||||
92
src/server/api/localFile.ts
Normal file
92
src/server/api/localFile.ts
Normal file
@ -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/<absolute-path>` 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<Response> {
|
||||
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)
|
||||
}
|
||||
@ -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<Response> {
|
||||
let stat: fs.Stats
|
||||
try {
|
||||
stat = fs.statSync(target)
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user