@@ -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
121126export const handleS3DownloadResponseHeaders = (
0 commit comments