mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +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')
|
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'])
|
renderCard(['/w/proj/index.html'])
|
||||||
const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' })
|
const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' })
|
||||||
|
|
||||||
@ -288,6 +288,18 @@ describe('CurrentTurnChangeCard – open-with buttons', () => {
|
|||||||
fireEvent.click(openWithBtn!)
|
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()
|
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 deps = makeDeps()
|
||||||
const handled = handlePreviewLink('out/index.html', deps)
|
const handled = handlePreviewLink('out/index.html', deps)
|
||||||
expect(handled).toBe(true)
|
expect(handled).toBe(true)
|
||||||
@ -64,6 +64,14 @@ describe('handlePreviewLink', () => {
|
|||||||
expect(deps.openFilePreview).not.toHaveBeenCalled()
|
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', () => {
|
it('routes relative previewable docs to openFilePreview with the relative path', () => {
|
||||||
const deps = makeDeps()
|
const deps = makeDeps()
|
||||||
const handled = handlePreviewLink('docs/report.md', deps)
|
const handled = handlePreviewLink('docs/report.md', deps)
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { classifyPreviewLink } from './previewLinkRouter'
|
import { classifyPreviewLink } from './previewLinkRouter'
|
||||||
|
import { shouldOfferStaticHtmlPreview } from './htmlPreviewPolicy'
|
||||||
|
|
||||||
export type PreviewLinkDeps = {
|
export type PreviewLinkDeps = {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
@ -61,6 +62,10 @@ export function handlePreviewLink(href: string, deps: PreviewLinkDeps): boolean
|
|||||||
// Absolute paths (incl. file:// → absolute) may live OUTSIDE the session
|
// Absolute paths (incl. file:// → absolute) may live OUTSIDE the session
|
||||||
// workspace, so serve them via the $HOME-sandboxed /local-file route.
|
// workspace, so serve them via the $HOME-sandboxed /local-file route.
|
||||||
// Relative paths stay workspace-scoped via /preview-fs.
|
// Relative paths stay workspace-scoped via /preview-fs.
|
||||||
|
if (!isAbsoluteLocalPath(filePath) && !shouldOfferStaticHtmlPreview(filePath)) {
|
||||||
|
deps.openFilePreview(deps.sessionId, filePath)
|
||||||
|
return true
|
||||||
|
}
|
||||||
const url = isAbsoluteLocalPath(filePath)
|
const url = isAbsoluteLocalPath(filePath)
|
||||||
? localFileUrl(deps.serverBaseUrl, filePath)
|
? localFileUrl(deps.serverBaseUrl, filePath)
|
||||||
: previewFsUrl(deps.serverBaseUrl, deps.sessionId, 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 })
|
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', () => {
|
it('frontend project index.html rel path → no inAppBrowserUrl because it needs a dev server', () => {
|
||||||
const result = openWithContextForWorkspaceFile('index.html', '/w/proj/index.html', { sessionId: SESSION, serverBaseUrl: BASE })
|
const result = openWithContextForWorkspaceFile('todo-app/index.html', '/w/proj/todo-app/index.html', { sessionId: SESSION, serverBaseUrl: BASE })
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
kind: 'file',
|
kind: 'file',
|
||||||
absolutePath: '/w/proj/index.html',
|
absolutePath: '/w/proj/todo-app/index.html',
|
||||||
relPath: 'index.html',
|
relPath: 'todo-app/index.html',
|
||||||
previewable: true,
|
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 { classifyPreviewLink } from './previewLinkRouter'
|
||||||
|
import { shouldOfferStaticHtmlPreview } from './htmlPreviewPolicy'
|
||||||
import { isAbsoluteLocalPath, localFileUrl, previewFsUrl } from './handlePreviewLink'
|
import { isAbsoluteLocalPath, localFileUrl, previewFsUrl } from './handlePreviewLink'
|
||||||
import type { OpenWithContext } from './openWithItems'
|
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). */
|
/** Build an open-with context for a workspace file (we have both its relative + absolute path). */
|
||||||
export function openWithContextForWorkspaceFile(
|
export function openWithContextForWorkspaceFile(
|
||||||
relPath: string,
|
relPath: string,
|
||||||
@ -15,7 +14,7 @@ export function openWithContextForWorkspaceFile(
|
|||||||
absolutePath,
|
absolutePath,
|
||||||
relPath,
|
relPath,
|
||||||
previewable: true,
|
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) {
|
if (c.kind === 'browser-file' && c.path) {
|
||||||
// Absolute paths may be outside the session workspace → serve via the
|
// Absolute paths may be outside the session workspace → serve via the
|
||||||
// $HOME-sandboxed /local-file route; relative paths stay workspace-scoped.
|
// $HOME-sandboxed /local-file route; relative paths stay workspace-scoped.
|
||||||
const inAppBrowserUrl = isAbsoluteLocalPath(c.path)
|
const absolutePath = resolveAbsolute(opts.workDir, c.path)
|
||||||
? localFileUrl(opts.serverBaseUrl, c.path)
|
if (isAbsoluteLocalPath(c.path)) {
|
||||||
: previewFsUrl(opts.serverBaseUrl, opts.sessionId, c.path)
|
return { kind: 'file', absolutePath, inAppBrowserUrl: localFileUrl(opts.serverBaseUrl, c.path) }
|
||||||
return { kind: 'file', absolutePath: resolveAbsolute(opts.workDir, c.path), inAppBrowserUrl }
|
}
|
||||||
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,13 @@ function setupWorkspace() {
|
|||||||
writeFileSync(path.join(root, 'index.html'), '<h1>ok</h1>')
|
writeFileSync(path.join(root, 'index.html'), '<h1>ok</h1>')
|
||||||
mkdirSync(path.join(root, 'assets'))
|
mkdirSync(path.join(root, 'assets'))
|
||||||
writeFileSync(path.join(root, 'assets', 'a.css'), 'body{}')
|
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)
|
writeFileSync(path.join(root, 'clip.mp4'), VIDEO_BYTES)
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
@ -87,6 +94,17 @@ describe('handlePreviewFs', () => {
|
|||||||
expect(res.headers.get('content-type')).toBe('text/css; charset=utf-8')
|
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 () => {
|
it('blocks path traversal with 403', async () => {
|
||||||
const root = setupWorkspace()
|
const root = setupWorkspace()
|
||||||
const resolve = async () => root
|
const resolve = async () => root
|
||||||
|
|||||||
@ -52,6 +52,7 @@ const PREFIX = '/preview-fs/'
|
|||||||
* against pathological / runaway files — not as a memory limit.
|
* against pathological / runaway files — not as a memory limit.
|
||||||
*/
|
*/
|
||||||
const MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024
|
const MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024
|
||||||
|
const ROOT_RELATIVE_HTML_ATTR_RE = /\b(src|href)=(["'])\/(?!\/)([^"']*)\2/gi
|
||||||
|
|
||||||
export interface ParsedRange {
|
export interface ParsedRange {
|
||||||
start: number
|
start: number
|
||||||
@ -149,7 +150,71 @@ export async function handlePreviewFs(
|
|||||||
return new Response('forbidden', { status: 403 })
|
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