cc-haha/src/server/api/previewFs.ts
程序员阿江(Relakkes) 8ddac90c11 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>
2026-05-31 17:40:46 +08:00

224 lines
7.2 KiB
TypeScript

import * as fs from 'node:fs'
import * as path from 'node:path'
import {
isSameOrInsidePathForPlatform,
normalizeDriveRootPathForPlatform,
} from '../services/windowsDrivePath.js'
const CONTENT_TYPES: Record<string, string> = {
html: 'text/html; charset=utf-8',
htm: 'text/html; charset=utf-8',
css: 'text/css; charset=utf-8',
js: 'text/javascript; charset=utf-8',
mjs: 'text/javascript; charset=utf-8',
json: 'application/json; charset=utf-8',
svg: 'image/svg+xml',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
ico: 'image/x-icon',
woff: 'font/woff',
woff2: 'font/woff2',
txt: 'text/plain; charset=utf-8',
md: 'text/plain; charset=utf-8',
// Video — served inline via <video> with HTTP byte-range streaming.
mp4: 'video/mp4',
webm: 'video/webm',
mov: 'video/quicktime',
m4v: 'video/x-m4v',
// Audio.
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
}
export function contentTypeForPath(filePath: string): string {
const ext = path.extname(filePath).slice(1).toLowerCase()
return CONTENT_TYPES[ext] ?? 'application/octet-stream'
}
export type ResolveWorkDir = (sessionId: string) => Promise<string | null>
const PREFIX = '/preview-fs/'
/**
* Upper bound on what we'll serve. The old 50 MB cap existed because the file
* was buffered into memory via `readFileSync`; that would 413 real dubbed
* videos. We now STREAM every response through `Bun.file(...)` (including
* byte-ranges), so the in-memory pressure is gone and we can raise this a lot.
* We keep a generous-but-finite ceiling (2 GiB) purely as a sanity guard
* against pathological / runaway files — not as a memory limit.
*/
const MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024
export interface ParsedRange {
start: number
end: number
}
/**
* Parse a single HTTP `Range` header against a known file `size`.
*
* Supports the common single-range forms:
* - `bytes=start-end` (explicit closed range)
* - `bytes=start-` (open-ended → to EOF)
* - `bytes=-N` (suffix → last N bytes)
*
* Returns inclusive `{ start, end }` byte offsets clamped to `[0, size-1]`,
* `null` when the header is absent/unparseable (caller should fall back to a
* full 200 response), or `'unsatisfiable'` when the range cannot be satisfied
* (caller should reply 416). Multi-range requests (comma-separated) are not
* supported and fall back to a full response.
*/
export function parseRange(
rangeHeader: string | null | undefined,
size: number,
): ParsedRange | null | 'unsatisfiable' {
if (!rangeHeader) return null
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim())
if (!match) return null
const startRaw = match[1]
const endRaw = match[2]
// `bytes=-N` suffix form: the last N bytes.
if (startRaw === '') {
if (endRaw === '') return null // `bytes=-` is malformed → ignore.
const suffixLen = Number(endRaw)
if (!Number.isFinite(suffixLen) || suffixLen <= 0) return 'unsatisfiable'
if (size === 0) return 'unsatisfiable'
const start = Math.max(0, size - suffixLen)
return { start, end: size - 1 }
}
const start = Number(startRaw)
if (!Number.isFinite(start)) return null
// start beyond EOF is unsatisfiable.
if (start >= size) return 'unsatisfiable'
let end: number
if (endRaw === '') {
end = size - 1 // open-ended → EOF
} else {
end = Number(endRaw)
if (!Number.isFinite(end)) return null
if (end < start) return 'unsatisfiable'
end = Math.min(end, size - 1) // clamp to EOF
}
return { start, end }
}
/**
* Serve a single file from a session's sandboxed workspace directory.
*
* URL shape: `/preview-fs/<sessionId>/<relPath>` where `<relPath>` may itself
* contain `/` separators. The WHATWG URL parser collapses `..` segments before
* this handler runs, so a traversal attempt such as
* `/preview-fs/s1/../../etc/passwd` arrives with its pathname normalized to
* `/etc/passwd` — i.e. the `/preview-fs/` prefix is gone. We treat any request
* that lost the prefix as a sandbox escape and return 403. Requests that keep
* the prefix are additionally re-validated against the resolved work-dir root.
*/
export async function handlePreviewFs(
url: URL,
resolveWorkDir: ResolveWorkDir,
reqHeaders?: Headers,
): Promise<Response> {
if (!url.pathname.startsWith(PREFIX)) {
return new Response('forbidden', { status: 403 })
}
const rest = url.pathname.slice(PREFIX.length)
const slash = rest.indexOf('/')
if (slash <= 0) return new Response('bad request', { status: 400 })
const sessionId = decodeURIComponent(rest.slice(0, slash))
const relRaw = decodeURIComponent(rest.slice(slash + 1))
const workDir = await resolveWorkDir(sessionId)
if (!workDir) return new Response('no workdir', { status: 404 })
const root = path.resolve(normalizeDriveRootPathForPlatform(workDir))
const target = path.resolve(root, relRaw)
if (!isSameOrInsidePathForPlatform(target, root)) {
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)
} catch {
return new Response('not found', { status: 404 })
}
if (!stat.isFile()) return new Response('not a file', { status: 404 })
if (stat.size > MAX_FILE_BYTES) return new Response('too large', { status: 413 })
const size = stat.size
const contentType = contentTypeForPath(target)
// Stream straight from disk via Bun.file — never buffer whole media into
// memory. Bun's file blob is an acceptable Response body.
const file = Bun.file(target)
const range = parseRange(reqHeaders?.get('range'), size)
if (range === 'unsatisfiable') {
return new Response('range not satisfiable', {
status: 416,
headers: {
'Content-Range': `bytes */${size}`,
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-cache',
},
})
}
if (range) {
const { start, end } = range
// `slice(start, end + 1)` — Bun's slice end is exclusive, range end is
// inclusive — streams just the requested window.
return new Response(file.slice(start, end + 1), {
status: 206,
headers: {
'Content-Type': contentType,
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': String(end - start + 1),
'Cache-Control': 'no-cache',
},
})
}
// No (or unparseable) Range header → stream the whole file as 200.
return new Response(file, {
status: 200,
headers: {
'Content-Type': contentType,
'Content-Length': String(size),
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-cache',
},
})
}