Skip to content

Commit a50daec

Browse files
committed
feat(headers): improve inline detection with query params and media support
- Replace isExpectedDocument with isDocumentNavigation using sec-fetch-dest/mode - Add isPreviewableInline for media/PDF detection - Add isInlineDisposition with ?download and ?inline query param support - Prefer inline for media types that browsers can render directly - Consolidate file/folder header logic into single function
1 parent b86c680 commit a50daec

File tree

1 file changed

+73
-68
lines changed

1 file changed

+73
-68
lines changed

packages/utility/file-server/src/http/headers.ts

Lines changed: 73 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -32,90 +32,95 @@ const rfc5987Encode = (str: string) =>
3232
.replace(/['()]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())
3333
.replace(/\*/g, '%2A')
3434

35-
const buildDisposition = (type: 'inline' | 'attachment', filename: string) => {
36-
const fallbackName = toAsciiFallback(filename || 'download')
37-
const encoded = rfc5987Encode(filename || 'download')
38-
return `${type}; filename="${fallbackName}"; filename*=UTF-8''${encoded}`
35+
// Check if this is actually a document navigation (based on headers only)
36+
// Used to determine if browser will auto-decompress Content-Encoding
37+
const isDocumentNavigation = (req: Request) => {
38+
const destHeader = req.headers['sec-fetch-dest']
39+
const dest = (Array.isArray(destHeader) ? destHeader[0] : (destHeader ?? '')).toLowerCase()
40+
if (dest && dest !== 'document') return false // e.g. <img>, <video>, fetch(), etc.
41+
42+
const modeHeader = req.headers['sec-fetch-mode']
43+
const mode = (Array.isArray(modeHeader) ? modeHeader[0] : (modeHeader ?? '')).toLowerCase()
44+
if (mode && mode !== 'navigate') return false // programmatic fetch / subresource
45+
46+
return true
3947
}
4048

41-
const isExpectedDocument = (req: Request) => {
49+
// Decide if this file type is something browsers can usually render inline via URL
50+
const isPreviewableInline = (metadata: DownloadMetadata) => {
51+
if (metadata.isEncrypted) return false
52+
53+
const mimeType = getMimeType(metadata).toLowerCase()
54+
const directDisplayTypes = ['image/', 'video/', 'audio/']
55+
4256
return (
43-
req.headers['sec-fetch-site'] === 'none' ||
44-
(req.headers['sec-fetch-site'] === 'same-site' && req.headers['sec-fetch-mode'] === 'navigate')
57+
directDisplayTypes.some((type) => mimeType.startsWith(type)) || mimeType === 'application/pdf'
4558
)
4659
}
4760

48-
export const handleDownloadResponseHeaders = (
49-
req: Request,
50-
res: Response,
51-
metadata: DownloadMetadata,
52-
options: DownloadOptions,
53-
) => {
54-
const fileName = metadata.name || 'download'
55-
const documentExpected = isExpectedDocument(req)
56-
const shouldHandleEncoding = req.query.ignoreEncoding
57-
? req.query.ignoreEncoding !== 'true'
58-
: documentExpected
61+
const isInlineDisposition = (req: Request, metadata: DownloadMetadata) => {
62+
// Explicit query overrides - treat presence as boolean flag
63+
// ?download or ?download=true triggers attachment, ?download=false is ignored
64+
if (req.query.download === 'true' || req.query.download === '') return false
65+
// ?inline or ?inline=true triggers inline, ?inline=false is ignored
66+
if (req.query.inline === 'true' || req.query.inline === '') return true
5967

60-
const isEncrypted = metadata.isEncrypted
61-
if (metadata.type === 'file') {
62-
setFileResponseHeaders(
63-
res,
64-
metadata,
65-
isEncrypted,
66-
documentExpected,
67-
shouldHandleEncoding,
68-
fileName,
69-
options,
70-
)
71-
} else {
72-
setFolderResponseHeaders(res, isEncrypted, documentExpected, fileName)
73-
}
68+
// Folders (served as zip) should default to attachment
69+
if (metadata.type !== 'file') return false
70+
71+
// For media / PDFs that browsers can render directly, prefer inline even for subresources
72+
if (isPreviewableInline(metadata)) return true
73+
74+
// Fallback to header-based detection: top-level document navigations are inline
75+
return isDocumentNavigation(req)
7476
}
7577

76-
const setFileResponseHeaders = (
78+
const buildDisposition = (req: Request, metadata: DownloadMetadata, filename: string) => {
79+
const fallbackName = toAsciiFallback(filename || 'download')
80+
const encoded = rfc5987Encode(filename || 'download')
81+
const type = isInlineDisposition(req, metadata) ? 'inline' : 'attachment'
82+
return `${type}; filename="${fallbackName}"; filename*=UTF-8''${encoded}`
83+
}
84+
85+
export const handleDownloadResponseHeaders = (
86+
req: Request,
7787
res: Response,
7888
metadata: DownloadMetadata,
79-
isEncrypted: boolean,
80-
isExpectedDocument: boolean,
81-
shouldHandleEncoding: boolean,
82-
fileName: string,
8389
{ byteRange = undefined, rawMode = false }: DownloadOptions,
8490
) => {
85-
const contentType = !isEncrypted && !rawMode ? getMimeType(metadata) : 'application/octet-stream'
86-
res.set('Content-Type', contentType)
87-
res.set(
88-
'Content-Disposition',
89-
buildDisposition(isExpectedDocument ? 'inline' : 'attachment', fileName),
90-
)
91-
const compressedButNoEncrypted = metadata.isCompressed && !isEncrypted
91+
const baseName = metadata.name || 'download'
92+
const fileName = metadata.type === 'file' ? baseName : `${baseName}.zip`
9293

93-
if (compressedButNoEncrypted && shouldHandleEncoding && !rawMode && !byteRange) {
94-
res.set('Content-Encoding', 'deflate')
95-
}
96-
97-
if (byteRange) {
98-
res.status(206)
99-
res.set('Content-Range', `bytes ${byteRange[0]}-${byteRange[1]}/${metadata.size}`)
100-
const upperBound = byteRange[1] ?? Number(metadata.size) - 1
101-
res.set('Content-Length', (upperBound - byteRange[0] + 1).toString())
102-
} else if (metadata.size) {
103-
res.set('Content-Length', metadata.size.toString())
94+
if (metadata.type === 'file') {
95+
const contentType =
96+
!metadata.isEncrypted && !rawMode ? getMimeType(metadata) : 'application/octet-stream'
97+
res.set('Content-Type', contentType)
98+
99+
const compressedButNotEncrypted = metadata.isCompressed && !metadata.isEncrypted
100+
101+
// Only set Content-Encoding for document navigations where browsers auto-decompress
102+
const shouldHandleEncoding = req.query.ignoreEncoding
103+
? req.query.ignoreEncoding !== 'true'
104+
: isDocumentNavigation(req)
105+
106+
if (compressedButNotEncrypted && shouldHandleEncoding && !rawMode && !byteRange) {
107+
res.set('Content-Encoding', 'deflate')
108+
}
109+
110+
if (byteRange) {
111+
res.status(206)
112+
res.set('Content-Range', `bytes ${byteRange[0]}-${byteRange[1]}/${metadata.size}`)
113+
const upperBound = byteRange[1] ?? Number(metadata.size) - 1
114+
res.set('Content-Length', (upperBound - byteRange[0] + 1).toString())
115+
} else if (metadata.size) {
116+
res.set('Content-Length', metadata.size.toString())
117+
}
118+
} else {
119+
const contentType = metadata.isEncrypted ? 'application/octet-stream' : 'application/zip'
120+
res.set('Content-Type', contentType)
104121
}
105-
}
106122

107-
const setFolderResponseHeaders = (
108-
res: Response,
109-
isEncrypted: boolean,
110-
isExpectedDocument: boolean,
111-
fileName: string,
112-
) => {
113-
const contentType = isEncrypted ? 'application/octet-stream' : 'application/zip'
114-
res.set('Content-Type', contentType)
115-
res.set(
116-
'Content-Disposition',
117-
buildDisposition(isExpectedDocument ? 'inline' : 'attachment', `${fileName}.zip`),
118-
)
123+
res.set('Content-Disposition', buildDisposition(req, metadata, fileName))
119124
}
120125

121126
export const handleS3DownloadResponseHeaders = (

0 commit comments

Comments
 (0)