| 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 | import { getNodeName, hasPermission, nodeIsFolder, nodeIsLink, urlToNode, VfsNode, walkNode, statusCodeForMissingPerm } from './vfs' |
| 4 | import Koa from 'koa' |
| 5 | import { filterMapGenerator, isWindowsDrive, pathDecodeSegments, safeDecodeURIComponent, statWithTimeout, wantArray } from './misc' |
| 6 | import { QuickZipStream } from './QuickZipStream' |
| 7 | import { createReadStream } from 'fs' |
| 8 | import { defineConfig } from './config' |
| 9 | import { basename, dirname } from 'path' |
| 10 | 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) { |
| 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' |
| 22 | // ctx.query.list is undefined | string | string[] |
| 23 | const name = list?.length === 1 ? safeDecodeURIComponent(basename(list[0]!), '') : getNodeName(node) |
| 24 | try { |
| 25 | forceDownload(ctx, (isWindowsDrive(name) ? name[0] : (name || 'archive')) + '.zip') |
| 26 | } |
| 27 | catch { return ctx.status = 400 } |
| 28 | const { filterName, filterComment } = paramsToFilter(ctx.query) |
| 29 | const walker = !list ? walkNode(node, { ctx, requiredPerm: 'can_archive' }) |
| 30 | : (async function*(): AsyncIterableIterator<VfsNode> { |
| 31 | for await (const uri of list) { |
| 32 | if (ctx.isAborted()) return |
| 33 | const subNode = await urlToNode(uri, ctx, node) |
| 34 | if (!subNode) |
| 35 | continue |
| 36 | if (nodeIsFolder(subNode)) { // a directory needs to be walked |
| 37 | if (hasPermission(subNode, 'can_list', ctx) && hasPermission(subNode, 'can_archive', ctx)) { |
| 38 | const archivePath = pathDecodeSegments(uri) |
| 39 | yield { ...subNode, name: archivePath } // keep empty selected folders at the same path used for their contents |
| 40 | yield* walkNode(subNode, { ctx, prefixPath: archivePath + '/', requiredPerm: 'can_archive' }) |
| 41 | } |
| 42 | continue |
| 43 | } |
| 44 | let folder = dirname(decodeURIComponent(uri)) // decodeURI() won't account for %23=# . uri is safe because otherwise urlToNode above would have already returned undefined |
| 45 | folder = folder === '.' ? '' : folder + '/' |
| 46 | yield { ...subNode, name: folder + getNodeName(subNode) } // reflect relative path in archive, otherwise way may have name-clashes |
| 47 | } |
| 48 | })() |
| 49 | const mappedWalker = filterMapGenerator(walker, async (el:VfsNode) => { |
| 50 | if (nodeIsLink(el)) return |
| 51 | if (!hasPermission(el, 'can_archive', ctx)) return // the fact you see it doesn't mean you can get it |
| 52 | const { source } = el |
| 53 | let name = getNodeName(el) |
| 54 | if (!IS_WINDOWS) // posix supports \ in file names, but zip tools don't |
| 55 | name = name.replaceAll('\\', '_') |
| 56 | if (filterName && !filterName(name) |
| 57 | || filterComment && !filterComment(await getCommentFor(source) || '')) |
| 58 | return |
| 59 | try { |
| 60 | if (nodeIsFolder(el)) |
| 61 | return { path: name + '/' } |
| 62 | if (!source) return |
| 63 | const st = await (el.stats || statWithTimeout(source)) |
| 64 | if (!st || !st.isFile()) |
| 65 | return |
| 66 | return { |
| 67 | path: name, |
| 68 | size: st.size, |
| 69 | ts: st.mtime, |
| 70 | mode: st.mode, |
| 71 | sourcePath: source, |
| 72 | getData: () => createReadStream(source, { start: 0 , end: Math.max(0, st.size-1) }) |
| 73 | } |
| 74 | } |
| 75 | catch {} |
| 76 | }) |
| 77 | const zip = new QuickZipStream(mappedWalker) |
| 78 | const time = 1000 * zipSeconds.get() |
| 79 | const size = await zip.calculateSize(time) |
| 80 | const range = applyRange(ctx, size) // keep var size as ctx.response.length won't preserve a NaN |
| 81 | if (ctx.status >= 400) |
| 82 | return |
| 83 | if (range) |
| 84 | zip.applyRange(range.start, range.end) |
| 85 | ctx.body = zip |
| 86 | ctx.req.on('close', ()=> zip.destroy()) |
| 87 | ctx.state.archive = 'zip' |
| 88 | monitorAsDownload(ctx, size, range?.start) |
| 89 | } |
| 90 | |
| 91 | const zipSeconds = defineConfig('zip_calculate_size_for_seconds', 5) |
| 92 | |
| 93 | declare module "koa" { |
| 94 | interface DefaultState { |
| 95 | archive?: string |
| 96 | } |
| 97 | } |