@samitouri / QOSami-HFS / commits / 2926a603

fix: bad file name downloading non-ascii files with firefox on android #790

Massimo Melina committed Oct 30, 2024 at 16:03 UTC 2926a603bbf02be5ff958a794dcc5271d5c1daa7
4 files changed +18 -8
src/api.log.ts
+2 -2
@@ -5,7 +5,7 @@ import { HTTP_NOT_ACCEPTABLE, HTTP_NOT_FOUND, wait } from './cross'
5 import events from './events'
6 import { loggers } from './log'
7 import { SendListReadable } from './SendList'
8 -import { serveFile } from './serveFile'
8 +import { forceDownload, serveFile } from './serveFile'
9 import { ips } from './ips'
10
11 export default {
@@ -15,7 +15,7 @@ export default {
15 throw HTTP_NOT_FOUND
16 if (!log.path)
17 throw HTTP_NOT_ACCEPTABLE
18 - ctx.attachment(log.path)
18 + forceDownload(ctx, log.path)
19 if (range)
20 ctx.request.header.range = `bytes=${range}`
21 if (ctx.method === 'POST') // this would cause method_not_allowed
src/errorPages.ts
+1 -1
@@ -12,7 +12,7 @@ export function getErrorSections() {
12 // to be used with errors whose recipient is possibly human
13 export async function sendErrorPage(ctx: Koa.Context, code=ctx.status) {
14 ctx.type = 'text'
15 - ctx.set('content-disposition', '') // reset ctx.attachment
15 + ctx.set('content-disposition', '') // reset ctx.attachment (or forceDownload)
16 ctx.status = code
17 const msg = HTTP_MESSAGES[ctx.status]
18 if (!msg) return
src/serveFile.ts
+13 -3
@@ -16,12 +16,23 @@ import { getCurrentUsername } from './auth'
16 import { sendErrorPage } from './errorPages'
17 import { Readable } from 'stream'
18 import { createHash } from 'crypto'
19 +import iconv from 'iconv-lite'
20
21 const allowedReferer = defineConfig('allowed_referer', '')
22 const maxDownloads = downloadLimiter(defineConfig(CFG.max_downloads, 0), () => true)
23 const maxDownloadsPerIp = downloadLimiter(defineConfig(CFG.max_downloads_per_ip, 0), ctx => ctx.ip)
24 const maxDownloadsPerAccount = downloadLimiter(defineConfig(CFG.max_downloads_per_account, 0), ctx => getCurrentUsername(ctx) || undefined)
25
26 +function toAsciiEquivalent(s: string) {
27 + return iconv.encode(iconv.decode(Buffer.from(s), 'utf-8'), 'ascii').toString().replaceAll('?', '')
28 +}
29 +
30 +export function forceDownload(ctx: Koa.Context, name='') {
31 + // ctx.attachment is not working well on Windows. Eg: for file "èÖ.txt" it is producing `Content-Disposition: attachment; filename="??.txt"`. Koa uses module content-disposition, that actually produces a better result anyway: ``
32 + ctx.set('Content-Disposition', 'attachment'
33 + + (name && `; filename="${toAsciiEquivalent(name)}"; filename*=UTF-8''${encodeURI(name).replace(/#/g, '%23')}`))
34 +}
35 +
36 export async function serveFileNode(ctx: Koa.Context, node: VfsNode) {
37 const { source, mime } = node
38 const name = getNodeName(node)
@@ -37,7 +48,7 @@ export async function serveFileNode(ctx: Koa.Context, node: VfsNode) {
48 ctx.vfsNode = // legacy pre-0.51 (download-quota)
49 ctx.state.vfsNode = node // useful to tell service files from files shared by the user
50 if ('dl' in ctx.query) // please, download
40 - ctx.attachment(name)
51 + forceDownload(ctx, name)
52 else if (ctx.get('referer')?.endsWith('/') && with_(ctx.get('accept'), x => x && !x.includes('text')))
53 ctx.state.considerAsGui = true
54 await serveFile(ctx, source||'', mimeString)
@@ -63,8 +74,7 @@ const cacheControlDiskFiles = defineConfig('cache_control_disk_files', 5)
74 export async function serveFile(ctx: Koa.Context, source:string, mime?:string, content?: string | Buffer) {
75 if (!source)
76 return
66 - const fn = basename(source)
67 - mime = mime ?? mimeCfg.compiled()(fn)
77 + mime ??= mimeCfg.compiled()(basename(source))
78 if (mime === undefined || mime === MIME_AUTO)
79 mime = mimetypes.lookup(source) || ''
80 if (mime)
src/zip.ts
+2 -2
@@ -8,7 +8,7 @@ import { createReadStream } from 'fs'
8 import fs from 'fs/promises'
9 import { defineConfig } from './config'
10 import { basename, dirname } from 'path'
11 -import { applyRange, monitorAsDownload } from './serveFile'
11 +import { applyRange, forceDownload, monitorAsDownload } from './serveFile'
12 import { HTTP_OK } from './const'
13
14 // expects 'node' to have had permissions checked by caller
@@ -19,7 +19,7 @@ export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) {
19 ctx.mime = 'zip'
20 // ctx.query.list is undefined | string | string[]
21 const name = list?.length === 1 ? safeDecodeURIComponent(basename(list[0]!)) : getNodeName(node)
22 - ctx.attachment((isWindowsDrive(name) ? name[0] : (name || 'archive')) + '.zip')
22 + forceDownload(ctx, (isWindowsDrive(name) ? name[0] : (name || 'archive')) + '.zip')
23 const filter = pattern2filter(String(ctx.query.search||''))
24 const walker = !list ? walkNode(node, { ctx, requiredPerm: 'can_archive' })
25 : (async function*(): AsyncIterableIterator<VfsNode> {