mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix: avoid static previews for frontend index HTML
Project entry HTML often only mounts a dev-server app, while built output can be served statically. The desktop open-with policy now only offers the in-app browser for static HTML candidates, and preview-fs rewrites built HTML root-relative assets under the workspace preview path. Constraint: Frontend project index.html needs a dev server instead of preview-fs static serving Rejected: Treat every .html file as browser-previewable | project entry HTML can render blank or with missing assets Confidence: high Scope-risk: moderate Tested: cd desktop && bun run check:desktop Tested: bun run check:server Tested: git diff --check Not-tested: bun run verify was intentionally stopped at user request
This commit is contained in:
parent
d222b47cc4
commit
ebc78befbe
@ -280,7 +280,7 @@ describe('CurrentTurnChangeCard – open-with buttons', () => {
|
||||
expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'README.md', 'file')
|
||||
})
|
||||
|
||||
it('clicking index.html open-with opens menu with in-app browser item', async () => {
|
||||
it('clicking project index.html open-with opens menu with workspace preview item', async () => {
|
||||
renderCard(['/w/proj/index.html'])
|
||||
const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' })
|
||||
|
||||
@ -288,6 +288,18 @@ describe('CurrentTurnChangeCard – open-with buttons', () => {
|
||||
fireEvent.click(openWithBtn!)
|
||||
})
|
||||
|
||||
expect(await screen.findByText('openWith.workspacePreview')).toBeInTheDocument()
|
||||
expect(screen.queryByText('openWith.inAppBrowser')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clicking built dist index.html open-with opens menu with in-app browser item', async () => {
|
||||
renderCard(['/w/proj/dist/index.html'])
|
||||
const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(openWithBtn!)
|
||||
})
|
||||
|
||||
expect(await screen.findByText('openWith.inAppBrowser')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@ -52,7 +52,7 @@ describe('handlePreviewLink', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('routes a relative .html file to openBrowser with the preview-fs (workspace) url', () => {
|
||||
it('routes a built 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)
|
||||
@ -64,6 +64,14 @@ describe('handlePreviewLink', () => {
|
||||
expect(deps.openFilePreview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes a frontend project index.html to workspace preview instead of static browser preview', () => {
|
||||
const deps = makeDeps()
|
||||
const handled = handlePreviewLink('todo-app/index.html', deps)
|
||||
expect(handled).toBe(true)
|
||||
expect(deps.openFilePreview).toHaveBeenCalledWith('s1', 'todo-app/index.html')
|
||||
expect(deps.openBrowser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes relative previewable docs to openFilePreview with the relative path', () => {
|
||||
const deps = makeDeps()
|
||||
const handled = handlePreviewLink('docs/report.md', deps)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { classifyPreviewLink } from './previewLinkRouter'
|
||||
import { shouldOfferStaticHtmlPreview } from './htmlPreviewPolicy'
|
||||
|
||||
export type PreviewLinkDeps = {
|
||||
sessionId: string
|
||||
@ -61,6 +62,10 @@ export function handlePreviewLink(href: string, deps: PreviewLinkDeps): boolean
|
||||
// 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.
|
||||
if (!isAbsoluteLocalPath(filePath) && !shouldOfferStaticHtmlPreview(filePath)) {
|
||||
deps.openFilePreview(deps.sessionId, filePath)
|
||||
return true
|
||||
}
|
||||
const url = isAbsoluteLocalPath(filePath)
|
||||
? localFileUrl(deps.serverBaseUrl, filePath)
|
||||
: previewFsUrl(deps.serverBaseUrl, deps.sessionId, filePath)
|
||||
|
||||
36
desktop/src/lib/htmlPreviewPolicy.ts
Normal file
36
desktop/src/lib/htmlPreviewPolicy.ts
Normal file
@ -0,0 +1,36 @@
|
||||
const HTML_EXT = /\.(html?|xhtml)$/i
|
||||
const INDEX_HTML_RE = /(^|\/)index\.html?$/i
|
||||
|
||||
const STATIC_OUTPUT_DIRS = new Set([
|
||||
'build',
|
||||
'coverage',
|
||||
'dist',
|
||||
'docs',
|
||||
'lcov-report',
|
||||
'out',
|
||||
'public',
|
||||
'site',
|
||||
'storybook-static',
|
||||
])
|
||||
|
||||
function normalizePathForPolicy(filePath: string): string {
|
||||
return filePath
|
||||
.split(/[?#]/, 1)[0]!
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\.\//, '')
|
||||
}
|
||||
|
||||
export function isHtmlFilePath(filePath: string): boolean {
|
||||
return HTML_EXT.test(normalizePathForPolicy(filePath))
|
||||
}
|
||||
|
||||
export function shouldOfferStaticHtmlPreview(filePath: string): boolean {
|
||||
const normalized = normalizePathForPolicy(filePath)
|
||||
if (!HTML_EXT.test(normalized)) return false
|
||||
if (!INDEX_HTML_RE.test(normalized)) return true
|
||||
|
||||
return normalized
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.some((segment) => STATIC_OUTPUT_DIRS.has(segment.toLowerCase()))
|
||||
}
|
||||
@ -58,14 +58,24 @@ describe('openWithContextForWorkspaceFile', () => {
|
||||
expect(result).toEqual({ kind: 'file', absolutePath: '/w/proj/README.md', relPath: 'README.md', previewable: true })
|
||||
})
|
||||
|
||||
it('index.html rel path → also has inAppBrowserUrl equal to previewFsUrl', () => {
|
||||
const result = openWithContextForWorkspaceFile('index.html', '/w/proj/index.html', { sessionId: SESSION, serverBaseUrl: BASE })
|
||||
it('frontend project index.html rel path → no inAppBrowserUrl because it needs a dev server', () => {
|
||||
const result = openWithContextForWorkspaceFile('todo-app/index.html', '/w/proj/todo-app/index.html', { sessionId: SESSION, serverBaseUrl: BASE })
|
||||
expect(result).toEqual({
|
||||
kind: 'file',
|
||||
absolutePath: '/w/proj/index.html',
|
||||
relPath: 'index.html',
|
||||
absolutePath: '/w/proj/todo-app/index.html',
|
||||
relPath: 'todo-app/index.html',
|
||||
previewable: true,
|
||||
inAppBrowserUrl: previewFsUrl(BASE, SESSION, 'index.html'),
|
||||
})
|
||||
})
|
||||
|
||||
it('built dist index.html rel path → has inAppBrowserUrl equal to previewFsUrl', () => {
|
||||
const result = openWithContextForWorkspaceFile('todo-app/dist/index.html', '/w/proj/todo-app/dist/index.html', { sessionId: SESSION, serverBaseUrl: BASE })
|
||||
expect(result).toEqual({
|
||||
kind: 'file',
|
||||
absolutePath: '/w/proj/todo-app/dist/index.html',
|
||||
relPath: 'todo-app/dist/index.html',
|
||||
previewable: true,
|
||||
inAppBrowserUrl: previewFsUrl(BASE, SESSION, 'todo-app/dist/index.html'),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import { classifyPreviewLink } from './previewLinkRouter'
|
||||
import { shouldOfferStaticHtmlPreview } from './htmlPreviewPolicy'
|
||||
import { isAbsoluteLocalPath, localFileUrl, previewFsUrl } from './handlePreviewLink'
|
||||
import type { OpenWithContext } from './openWithItems'
|
||||
|
||||
const HTML_EXT = /\.(html?|xhtml)$/i
|
||||
|
||||
/** Build an open-with context for a workspace file (we have both its relative + absolute path). */
|
||||
export function openWithContextForWorkspaceFile(
|
||||
relPath: string,
|
||||
@ -15,7 +14,7 @@ export function openWithContextForWorkspaceFile(
|
||||
absolutePath,
|
||||
relPath,
|
||||
previewable: true,
|
||||
inAppBrowserUrl: HTML_EXT.test(relPath) ? previewFsUrl(opts.serverBaseUrl, opts.sessionId, relPath) : undefined,
|
||||
inAppBrowserUrl: shouldOfferStaticHtmlPreview(relPath) ? previewFsUrl(opts.serverBaseUrl, opts.sessionId, relPath) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,10 +37,14 @@ export function openWithContextForHref(
|
||||
if (c.kind === 'browser-file' && 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 }
|
||||
const absolutePath = resolveAbsolute(opts.workDir, c.path)
|
||||
if (isAbsoluteLocalPath(c.path)) {
|
||||
return { kind: 'file', absolutePath, inAppBrowserUrl: localFileUrl(opts.serverBaseUrl, c.path) }
|
||||
}
|
||||
if (shouldOfferStaticHtmlPreview(c.path)) {
|
||||
return { kind: 'file', absolutePath, inAppBrowserUrl: previewFsUrl(opts.serverBaseUrl, opts.sessionId, c.path) }
|
||||
}
|
||||
return { kind: 'file', absolutePath, relPath: c.path, previewable: true }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@ -35,6 +35,13 @@ function setupWorkspace() {
|
||||
writeFileSync(path.join(root, 'index.html'), '<h1>ok</h1>')
|
||||
mkdirSync(path.join(root, 'assets'))
|
||||
writeFileSync(path.join(root, 'assets', 'a.css'), 'body{}')
|
||||
mkdirSync(path.join(root, 'dist', 'assets'), { recursive: true })
|
||||
writeFileSync(
|
||||
path.join(root, 'dist', 'index.html'),
|
||||
'<!doctype html><html><head><link rel="stylesheet" href="/assets/app.css"></head><body><script type="module" src="/assets/app.js"></script></body></html>',
|
||||
)
|
||||
writeFileSync(path.join(root, 'dist', 'assets', 'app.css'), 'body{color:red}')
|
||||
writeFileSync(path.join(root, 'dist', 'assets', 'app.js'), 'console.log("ok")')
|
||||
writeFileSync(path.join(root, 'clip.mp4'), VIDEO_BYTES)
|
||||
return root
|
||||
}
|
||||
@ -87,6 +94,17 @@ describe('handlePreviewFs', () => {
|
||||
expect(res.headers.get('content-type')).toBe('text/css; charset=utf-8')
|
||||
})
|
||||
|
||||
it('rewrites root-relative HTML assets to stay under the preview-fs file directory', async () => {
|
||||
const root = setupWorkspace()
|
||||
const resolve = async () => root
|
||||
const res = await handlePreviewFs(new URL('http://127.0.0.1/preview-fs/s1/dist/index.html'), resolve)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.text()
|
||||
expect(body).toContain('<base href="/preview-fs/s1/dist/">')
|
||||
expect(body).toContain('href="/preview-fs/s1/dist/assets/app.css"')
|
||||
expect(body).toContain('src="/preview-fs/s1/dist/assets/app.js"')
|
||||
})
|
||||
|
||||
it('blocks path traversal with 403', async () => {
|
||||
const root = setupWorkspace()
|
||||
const resolve = async () => root
|
||||
|
||||
@ -52,6 +52,7 @@ const PREFIX = '/preview-fs/'
|
||||
* against pathological / runaway files — not as a memory limit.
|
||||
*/
|
||||
const MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024
|
||||
const ROOT_RELATIVE_HTML_ATTR_RE = /\b(src|href)=(["'])\/(?!\/)([^"']*)\2/gi
|
||||
|
||||
export interface ParsedRange {
|
||||
start: number
|
||||
@ -149,7 +150,71 @@ export async function handlePreviewFs(
|
||||
return new Response('forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
return serveFileWithRange(target, reqHeaders)
|
||||
return servePreviewFsFile(target, url.pathname, reqHeaders)
|
||||
}
|
||||
|
||||
function previewHtmlBasePath(pathname: string): string {
|
||||
const slash = pathname.lastIndexOf('/')
|
||||
if (slash < 0) return '/'
|
||||
return pathname.slice(0, slash + 1)
|
||||
}
|
||||
|
||||
function escapeHtmlAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
export function rewritePreviewHtml(content: string, basePath: string): string {
|
||||
const normalizedBase = basePath.endsWith('/') ? basePath : `${basePath}/`
|
||||
const rewrittenRootAssets = content.replace(
|
||||
ROOT_RELATIVE_HTML_ATTR_RE,
|
||||
(_match, attr: string, quote: string, value: string) =>
|
||||
`${attr}=${quote}${normalizedBase}${value}${quote}`,
|
||||
)
|
||||
|
||||
if (/<base\b/i.test(rewrittenRootAssets)) {
|
||||
return rewrittenRootAssets
|
||||
}
|
||||
|
||||
return rewrittenRootAssets.replace(
|
||||
/<head\b[^>]*>/i,
|
||||
(head) => `${head}<base href="${escapeHtmlAttribute(normalizedBase)}">`,
|
||||
)
|
||||
}
|
||||
|
||||
async function servePreviewFsFile(
|
||||
target: string,
|
||||
requestPathname: string,
|
||||
reqHeaders?: Headers,
|
||||
): Promise<Response> {
|
||||
const ext = path.extname(target).toLowerCase()
|
||||
if ((ext !== '.html' && ext !== '.htm') || reqHeaders?.has('range')) {
|
||||
return serveFileWithRange(target, reqHeaders)
|
||||
}
|
||||
|
||||
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 content = fs.readFileSync(target, 'utf8')
|
||||
const transformed = rewritePreviewHtml(content, previewHtmlBasePath(requestPathname))
|
||||
|
||||
return new Response(transformed, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentTypeForPath(target),
|
||||
'Content-Length': String(Buffer.byteLength(transformed)),
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user