zip: larger selection is possible thanks to optimization

Massimo Melina committed Apr 29, 2026 at 00:40 UTC 0929c65129ba5dfdc381fc228c6912e7a16d09d0
4 files changed +40 -3
frontend/src/menu.ts
+5 -2
@@ -20,6 +20,7 @@ import { reloadList } from './useFetchList'
20 import { cut } from './clip'
21 import { Btn, BtnProps, Checkbox, CustomCode } from './components'
22 import i18n from './i18n'
23 +import { encodeUrlList } from '../../src/urlList'
24 const { t, useI18N } = i18n
25
26 export function MenuPanel() {
@@ -40,9 +41,11 @@ export function MenuPanel() {
41 setTimeout(() => setJustStarted(true), 1000)
42 }, [stopSearch, setJustStarted])
43
43 - // passing files as string in the url should allow 1-2000 items before hitting the url limit of 64KB. Shouldn't be a problem, right?
44 + // compact repeated folder names so search selections from the same folders are less likely to hit URL limits (64kb)
45 const ofs = location.pathname.length
45 - const list = useMemo(() => Object.keys(selected).map(s => s.slice(ofs, s.endsWith('/') ? -1 : Infinity)).join('//'), [selected])
46 + const list = useMemo(() => encodeUrlList(
47 + Object.keys(selected).map(s => s.slice(ofs, s.endsWith('/') ? -1 : Infinity))
48 + ), [selected])
49
50 // avoid useless dom changes while we are still waiting for necessary data
51 const [changingButton, setChangingButton] = useState<'' | 'upload' | 'delete'>('')
src/urlList.ts new
+32
@@ -0,0 +1,32 @@
1 +// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2 +
3 +// utilities to pass a list of files via url
4 +
5 +// not easy to find chars that are not allowed in file names both for windows and unix
6 +const URL_LIST_SEPARATOR = '//'
7 +const URL_LIST_SAME_FOLDER = '\0' // nul cannot be a valid file name char, and query encoding carries it as %00 without conflicting with path slashes
8 +
9 +export function encodeUrlList(entries: string[]) {
10 + let previousFolder = ''
11 + return entries.map(entry => {
12 + const slash = entry.lastIndexOf('/')
13 + const folder = slash < 0 ? '' : entry.slice(0, slash + 1)
14 + const name = slash < 0 ? entry : entry.slice(slash + 1)
15 + if (folder && folder === previousFolder)
16 + return URL_LIST_SAME_FOLDER + name
17 + previousFolder = folder
18 + return entry
19 + }).join(URL_LIST_SEPARATOR)
20 +}
21 +
22 +export function decodeUrlList(list?: string) {
23 + let previousFolder = ''
24 + return list?.split(URL_LIST_SEPARATOR).map(entry => {
25 + const sameFolder = entry.startsWith(URL_LIST_SAME_FOLDER)
26 + if (sameFolder && previousFolder)
27 + entry = previousFolder + entry.slice(URL_LIST_SAME_FOLDER.length)
28 + const slash = entry.lastIndexOf('/')
29 + previousFolder = slash < 0 ? '' : entry.slice(0, slash + 1)
30 + return entry
31 + })
32 +}
src/zip.ts
+2 -1
@@ -11,10 +11,11 @@ import { applyRange, forceDownload, monitorAsDownload } from './serveFile'
11 import { HTTP_OK, IS_WINDOWS } from './const'
12 import { paramsToFilter } from './api.get_file_list'
13 import { getCommentFor } from './comments'
14 +import { decodeUrlList } from './urlList'
15
16 // expects 'node' to have had permissions checked by caller
17 export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) {
17 - const list = wantArray(ctx.query.list)[0]?.split('//') // slash is the only char not allowed in file names both for windows and unix, but still we need to encode whole paths, so the only safe choice to separate the entries is the double slash
18 + const list = decodeUrlList(wantArray(ctx.query.list)[0])
19 if (!list && statusCodeForMissingPerm(node, 'can_archive', ctx)) return
20 ctx.status = HTTP_OK
21 ctx.mime = 'zip'
tests/test.ts
+1
@@ -234,6 +234,7 @@ describe('basics', () => {
234 test('zip.partial', req('/f1/?get=zip', { re:/^page$/, length: zipLength }, { headers: { Range: `bytes=${zipOfs}-${zipOfs+zipLength-1}` } }) )
235 test('zip.partial.resume', req('/f1/?get=zip', { re:/^page/, length:zipSize-zipOfs }, { headers: { Range: `bytes=${zipOfs}-` } }) )
236 test('zip.partial.end', req('/f1/f2/?get=zip', { re:/^6/, length:10 }, { headers: { Range: 'bytes=-10' } }) )
237 + test('zip.list.compacted folders', req('/f1/?get=zip&list=page%2Fgpl.png%2F%2F%00index.html', /page\/gpl.png.+page\/index.html/))
238 test('zip.list.bad encoding', req('/f1/?get=zip&list=%E0%A4%A//%00', { status: 200, length: 22 })) // basically empty
239 test('zip.list.null filename', req('/f1/?get=zip&list=%00', 400)) // tries to name the output with null-byte
240 test('zip.masked deep', req('/cantSearchForMasksDeep/?get=zip', {