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', {