Skip to content

Commit ba08f5c

Browse files
feat: add conditional caching and allow-same-origin to file preview, add PreviewHtmlButton
1 parent 7f9403c commit ba08f5c

10 files changed

Lines changed: 155 additions & 63 deletions

File tree

backend/src/routes/files.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,19 +57,50 @@ function isPreviewableMimeType(mimeType?: string): boolean {
5757
return mimeType !== undefined && PREVIEWABLE_MIME_TYPES.has(mimeType)
5858
}
5959

60-
function getPreviewHeaders(result: { name: string; size: number; mimeType?: string }): Record<string, string> {
60+
function buildPreviewValidators(stat: { size: number; lastModified: Date }): { etag: string; lastModified: string } {
61+
return {
62+
etag: `"${stat.size.toString(16)}-${stat.lastModified.getTime().toString(16)}"`,
63+
lastModified: stat.lastModified.toUTCString(),
64+
}
65+
}
66+
67+
function isPreviewFresh(c: Context, validators: { etag: string; lastModified: string }): boolean {
68+
const ifNoneMatch = c.req.header('if-none-match')
69+
if (ifNoneMatch !== undefined) {
70+
return ifNoneMatch.split(',').some((tag: string) => tag.trim() === validators.etag)
71+
}
72+
73+
const ifModifiedSince = c.req.header('if-modified-since')
74+
if (ifModifiedSince !== undefined) {
75+
const since = Date.parse(ifModifiedSince)
76+
return !Number.isNaN(since) && Date.parse(validators.lastModified) <= since
77+
}
78+
79+
return false
80+
}
81+
82+
function getPreviewHeaders(result: {
83+
name: string
84+
size: number
85+
mimeType?: string
86+
lastModified: Date
87+
}): Record<string, string> {
6188
const isHtmlSvg = result.mimeType === 'text/html' || result.mimeType === 'image/svg+xml'
89+
const validators = buildPreviewValidators(result)
6290
const headers: Record<string, string> = {
6391
'Content-Type': result.mimeType || 'application/octet-stream',
6492
'Content-Length': result.size.toString(),
6593
'Content-Disposition': `inline; filename="${result.name}"`,
6694
'X-Content-Type-Options': 'nosniff',
6795
'Referrer-Policy': 'no-referrer',
96+
'Cache-Control': 'private, must-revalidate, max-age=0',
97+
'ETag': validators.etag,
98+
'Last-Modified': validators.lastModified,
6899
}
69100

70101
if (isHtmlSvg) {
71102
headers['Content-Security-Policy'] = [
72-
'sandbox allow-scripts;',
103+
'sandbox allow-scripts allow-same-origin;',
73104
"default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval';",
74105
"script-src 'self' http: https: 'unsafe-inline' 'unsafe-eval';",
75106
"style-src 'self' http: https: 'unsafe-inline';",
@@ -100,18 +131,23 @@ export function createFileRoutes() {
100131
}
101132

102133
try {
103-
const result = await fileService.getFile(userPath)
134+
const stat = await fileService.getFilePreviewStat(userPath)
104135

105-
if (result.isDirectory) {
136+
if (stat.isDirectory) {
106137
return c.json({ error: 'Cannot preview directories' }, 400)
107138
}
108139

109-
if (!isPreviewableMimeType(result.mimeType)) {
140+
if (!isPreviewableMimeType(stat.mimeType)) {
110141
return c.json({ error: 'File type cannot be previewed' }, 415)
111142
}
112143

144+
const headers = getPreviewHeaders(stat)
145+
146+
if (isPreviewFresh(c, buildPreviewValidators(stat))) {
147+
return new Response(null, { status: 304, headers })
148+
}
149+
113150
const content = await fileService.getRawFileContent(userPath)
114-
const headers = getPreviewHeaders(result)
115151

116152
return new Response(content, { headers })
117153
} catch (error: unknown) {

backend/src/services/files.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,33 @@ export async function getRawFileContent(userPath: string): Promise<Buffer> {
6161
}
6262
}
6363

64+
export interface FilePreviewStat {
65+
isDirectory: boolean
66+
name: string
67+
size: number
68+
mimeType: string
69+
lastModified: Date
70+
}
71+
72+
export async function getFilePreviewStat(userPath: string): Promise<FilePreviewStat> {
73+
const validatedPath = validatePath(userPath)
74+
75+
const exists = await fileExists(validatedPath)
76+
if (!exists) {
77+
throw { message: 'File not found or cannot be read', statusCode: 404 }
78+
}
79+
80+
const stats = await getFileStats(validatedPath)
81+
82+
return {
83+
isDirectory: stats.isDirectory,
84+
name: path.basename(validatedPath),
85+
size: stats.size,
86+
mimeType: getMimeType(validatedPath),
87+
lastModified: stats.lastModified,
88+
}
89+
}
90+
6491
export async function getFile(userPath: string): Promise<FileInfo> {
6592
const validatedPath = validatePath(userPath)
6693
logger.info(`Getting file for path: ${userPath} -> ${validatedPath}`)

backend/test/routes/files.test.ts

Lines changed: 47 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ vi.mock('@opencode-manager/shared/config/env', () => ({
3030

3131
vi.mock('../../src/services/files', () => ({
3232
getFile: vi.fn(),
33+
getFilePreviewStat: vi.fn(),
3334
getRawFileContent: vi.fn(),
3435
getFileRange: vi.fn(),
3536
uploadFile: vi.fn(),
@@ -47,6 +48,7 @@ vi.mock('../../src/services/archive', () => ({
4748
}))
4849

4950
const getFile = fileService.getFile as MockedFunction<typeof fileService.getFile>
51+
const getFilePreviewStat = fileService.getFilePreviewStat as MockedFunction<typeof fileService.getFilePreviewStat>
5052
const getRawFileContent = fileService.getRawFileContent as MockedFunction<typeof fileService.getRawFileContent>
5153
const getFileRange = fileService.getFileRange as MockedFunction<typeof fileService.getFileRange>
5254
const uploadFile = fileService.uploadFile as MockedFunction<typeof fileService.uploadFile>
@@ -71,18 +73,18 @@ describe('File Routes', () => {
7173
})
7274

7375
describe('GET /preview/* - HTML Preview Assets', () => {
74-
const htmlFileInfo: FileInfo = {
76+
const lastModified = new Date('2024-01-01T00:00:00.000Z')
77+
78+
const htmlStat = {
7579
name: 'index.html',
76-
path: 'test-repo/index.html',
7780
isDirectory: false,
7881
size: 31,
79-
mimeType: 'text/html',
80-
content: '',
81-
lastModified: new Date(),
82+
mimeType: 'text/html' as const,
83+
lastModified,
8284
}
8385

84-
it('should serve HTML files with sandbox CSP headers', async () => {
85-
getFile.mockResolvedValue(htmlFileInfo)
86+
it('should serve HTML files with sandbox CSP and caching headers', async () => {
87+
getFilePreviewStat.mockResolvedValue(htmlStat)
8688
getRawFileContent.mockResolvedValue(Buffer.from('<html><body>Hello</body></html>'))
8789

8890
const response = await app.request('/api/files/preview/test-repo/index.html')
@@ -94,54 +96,70 @@ describe('File Routes', () => {
9496
expect(response.headers.get('Content-Disposition')).toContain('inline')
9597
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
9698
expect(response.headers.get('Referrer-Policy')).toBe('no-referrer')
97-
expect(response.headers.get('Content-Security-Policy')).toContain('sandbox allow-scripts')
99+
expect(response.headers.get('Content-Security-Policy')).toContain('sandbox allow-scripts allow-same-origin')
98100
expect(response.headers.get('Content-Security-Policy')).toContain('https:')
99-
expect(getFile).toHaveBeenCalledWith('test-repo/index.html')
101+
expect(response.headers.get('Cache-Control')).toBe('private, must-revalidate, max-age=0')
102+
expect(response.headers.get('ETag')).toBeTruthy()
103+
expect(response.headers.get('Last-Modified')).toBe(lastModified.toUTCString())
104+
expect(getFilePreviewStat).toHaveBeenCalledWith('test-repo/index.html')
100105
expect(getRawFileContent).toHaveBeenCalledWith('test-repo/index.html')
101106
})
102107

108+
it('should return 304 when ETag matches If-None-Match without reading the file', async () => {
109+
getFilePreviewStat.mockResolvedValue(htmlStat)
110+
getRawFileContent.mockResolvedValue(Buffer.from('<html><body>Hello</body></html>'))
111+
112+
const first = await app.request('/api/files/preview/test-repo/index.html')
113+
const etag = first.headers.get('ETag') as string
114+
await first.text()
115+
getRawFileContent.mockClear()
116+
117+
const response = await app.request('/api/files/preview/test-repo/index.html', {
118+
headers: { 'If-None-Match': etag },
119+
})
120+
121+
expect(response.status).toBe(304)
122+
expect(getRawFileContent).not.toHaveBeenCalled()
123+
})
124+
103125
it('should serve CSS files with text/css Content-Type', async () => {
104-
const cssFileInfo: FileInfo = {
126+
getFilePreviewStat.mockResolvedValue({
105127
name: 'styles.css',
106-
path: 'test-repo/styles/app.css',
107128
isDirectory: false,
108129
size: 50,
109130
mimeType: 'text/css',
110-
content: '',
111-
lastModified: new Date(),
112-
}
113-
getFile.mockResolvedValue(cssFileInfo)
131+
lastModified,
132+
})
114133
getRawFileContent.mockResolvedValue(Buffer.from('body { color: red }'))
115134

116135
const response = await app.request('/api/files/preview/test-repo/styles/app.css')
117136

118137
expect(response.status).toBe(200)
119138
expect(response.headers.get('Content-Type')).toContain('text/css')
120139
expect(response.headers.get('Content-Security-Policy')).toBeNull()
121-
expect(getFile).toHaveBeenCalledWith('test-repo/styles/app.css')
140+
expect(getFilePreviewStat).toHaveBeenCalledWith('test-repo/styles/app.css')
122141
})
123142

124143
it('should support query-based path parameter', async () => {
125-
getFile.mockResolvedValue(htmlFileInfo)
144+
getFilePreviewStat.mockResolvedValue(htmlStat)
126145
getRawFileContent.mockResolvedValue(Buffer.from('<html><body>Hello</body></html>'))
127146

128147
const response = await app.request('/api/files/preview?path=test-repo/index.html')
129148
const body = await response.text()
130149

131150
expect(response.status).toBe(200)
132151
expect(body).toBe('<html><body>Hello</body></html>')
133-
expect(getFile).toHaveBeenCalledWith('test-repo/index.html')
152+
expect(getFilePreviewStat).toHaveBeenCalledWith('test-repo/index.html')
134153
})
135154

136155
it('should return 400 for directory results', async () => {
137-
const dirInfo: FileInfo = {
156+
getFilePreviewStat.mockResolvedValue({
138157
name: 'test-repo',
139-
path: 'test-repo',
140158
isDirectory: true,
141159
size: 0,
142-
lastModified: new Date(),
143-
}
144-
getFile.mockResolvedValue(dirInfo)
160+
mimeType: 'text/plain',
161+
lastModified,
162+
})
145163

146164
const response = await app.request('/api/files/preview/test-repo')
147165

@@ -151,22 +169,20 @@ describe('File Routes', () => {
151169
})
152170

153171
it('should return 415 for non-previewable MIME types', async () => {
154-
const pdfFileInfo: FileInfo = {
172+
getFilePreviewStat.mockResolvedValue({
155173
name: 'doc.pdf',
156-
path: 'test-repo/doc.pdf',
157174
isDirectory: false,
158175
size: 100,
159-
mimeType: 'application/pdf',
160-
content: '',
161-
lastModified: new Date(),
162-
}
163-
getFile.mockResolvedValue(pdfFileInfo)
176+
mimeType: 'application/pdf' as never,
177+
lastModified,
178+
})
164179

165180
const response = await app.request('/api/files/preview/test-repo/doc.pdf')
166181

167182
expect(response.status).toBe(415)
168183
const body = await response.json() as { error: string }
169184
expect(body.error).toContain('File type cannot be previewed')
185+
expect(getRawFileContent).not.toHaveBeenCalled()
170186
})
171187

172188
it('should return 400 when no path is provided', async () => {
@@ -179,7 +195,7 @@ describe('File Routes', () => {
179195

180196
it('should return 403 for path traversal attempts', async () => {
181197
const error = { message: 'Path traversal detected', statusCode: 403 }
182-
getFile.mockRejectedValue(error)
198+
getFilePreviewStat.mockRejectedValue(error)
183199

184200
const response = await app.request('/api/files/preview/test-repo/../outside')
185201

frontend/src/components/file-browser/FilePreview.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ describe('FilePreview - HTML preview', () => {
4747

4848
const iframe = screen.getByTitle('HTML preview: dashboard.html')
4949
expect(iframe).toBeInTheDocument()
50-
expect(iframe).toHaveAttribute('sandbox', 'allow-scripts')
50+
expect(iframe).toHaveAttribute('sandbox', 'allow-scripts allow-same-origin')
5151
expect(iframe.getAttribute('src')).toContain('/api/files/preview/test-repo/dashboard.html')
5252
})
5353

frontend/src/components/file-browser/FilePreview.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,12 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
219219
['application/json', 'application/xml', 'text/javascript', 'text/typescript'].includes(file.mimeType || '')
220220

221221
const renderContent = () => {
222+
const htmlPreviewFrame = (
223+
<div className="h-[calc(100vh-12rem)] min-h-[480px] rounded border border-border overflow-hidden bg-white">
224+
<HtmlPreviewFrame title={`HTML preview: ${file.name}`} src={getFilePreviewUrl(file.path)} />
225+
</div>
226+
)
227+
222228
if (file.mimeType?.startsWith('image/')) {
223229
return (
224230
<div className="flex justify-center p-4">
@@ -236,11 +242,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
236242
const showMarkdownPreview = isMarkdownFile && markdownPreview && viewMode !== 'edit'
237243

238244
if (showHtmlPreview) {
239-
return (
240-
<div className="h-[calc(100vh-12rem)] min-h-[480px] rounded border border-border overflow-hidden bg-white">
241-
<HtmlPreviewFrame title={`HTML preview: ${file.name}`} src={getFilePreviewUrl(file.path)} />
242-
</div>
243-
)
245+
return htmlPreviewFrame
244246
}
245247

246248
return (
@@ -301,11 +303,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
301303
}
302304

303305
if (isHtmlFile && htmlPreview) {
304-
return (
305-
<div className="h-[calc(100vh-12rem)] min-h-[480px] rounded border border-border overflow-hidden bg-white">
306-
<HtmlPreviewFrame title={`HTML preview: ${file.name}`} src={getFilePreviewUrl(file.path)} />
307-
</div>
308-
)
306+
return htmlPreviewFrame
309307
}
310308

311309
try {

frontend/src/components/html-preview/HtmlArtifactPanel.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ describe('HtmlArtifactPanel', () => {
6262
expect(iframe).toBeInTheDocument()
6363
expect(iframe).toHaveAttribute('src')
6464
expect(iframe.getAttribute('src')).toContain('/api/files/preview/')
65-
expect(iframe).toHaveAttribute('sandbox', 'allow-scripts')
65+
expect(iframe).toHaveAttribute('sandbox', 'allow-scripts allow-same-origin')
6666
})
6767

6868
it('does not include allow-same-origin in sandbox', () => {

frontend/src/components/html-preview/HtmlPreviewFrame.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ interface HtmlPreviewFrameProps {
88
}
99

1010
export function HtmlPreviewFrame({ title, src, srcDoc, className }: HtmlPreviewFrameProps) {
11+
const sandbox = src ? 'allow-scripts allow-same-origin' : 'allow-scripts'
1112
return (
1213
<iframe
1314
title={title}
1415
src={src}
1516
srcDoc={srcDoc}
16-
sandbox="allow-scripts"
17+
sandbox={sandbox}
1718
referrerPolicy="no-referrer"
1819
className={cn('h-full w-full border-0 bg-white', className)}
1920
/>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { cn } from '@/lib/utils'
2+
3+
interface PreviewHtmlButtonProps {
4+
onClick: () => void
5+
className?: string
6+
}
7+
8+
export function PreviewHtmlButton({ onClick, className }: PreviewHtmlButtonProps) {
9+
return (
10+
<button
11+
onClick={onClick}
12+
className={cn(
13+
'rounded bg-card hover:bg-card-hover text-muted-foreground hover:text-foreground text-xs',
14+
className,
15+
)}
16+
title="Preview HTML artifact"
17+
>
18+
Preview HTML
19+
</button>
20+
)
21+
}

frontend/src/components/message/CodePreview.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useMobile } from '@/hooks/useMobile'
44
import { cn } from '@/lib/utils'
55
import { isHtmlPath } from '@/lib/htmlArtifacts'
66
import type { OpenHtmlArtifactInput } from '@/lib/htmlArtifacts'
7+
import { PreviewHtmlButton } from '@/components/html-preview/PreviewHtmlButton'
78

89
interface CodePreviewProps {
910
fileName: string
@@ -92,13 +93,7 @@ export function CodePreview({ fileName, content, onHtmlArtifactOpen }: CodePrevi
9293
<span className="font-medium truncate flex-1">{extractFileFromPath(fileName)}</span>
9394
<div className="flex items-center gap-1 flex-shrink-0">
9495
{isHtmlFile && onHtmlArtifactOpen && (
95-
<button
96-
onClick={handleHtmlArtifact}
97-
className="px-2 py-1 rounded bg-card hover:bg-card-hover text-muted-foreground hover:text-foreground text-xs"
98-
title="Preview HTML artifact"
99-
>
100-
Preview HTML
101-
</button>
96+
<PreviewHtmlButton onClick={handleHtmlArtifact} className="px-2 py-1" />
10297
)}
10398
<CopyButton content={content} title="Copy" iconSize="sm" variant="ghost" />
10499
</div>

0 commit comments

Comments
 (0)