| 1 | import Koa from 'koa' |
| 2 | import Busboy from 'busboy' |
| 3 | import { once } from 'events' |
| 4 | import { hasPermission, urlToNode, VfsNode } from './vfs' |
| 5 | import { dirname } from 'path' |
| 6 | import { uploadWriter } from './upload' |
| 7 | import { HTTP_BAD_REQUEST } from './cross-const' |
| 8 | import { onFirstEvent } from './first' |
| 9 | import { try_ } from './cross' |
| 10 | |
| 11 | export async function handleMultipartUpload(ctx: Koa.Context, node: VfsNode) { |
| 12 | if (ctx.request.type !== 'multipart/form-data') |
| 13 | return ctx.status = HTTP_BAD_REQUEST |
| 14 | ctx.state.uploads = [] |
| 15 | const locks: Promise<string>[] = [] |
| 16 | const fileJobs: Promise<any>[] = [] |
| 17 | const errors: string[] = [] |
| 18 | const bb = try_(() => Busboy({ headers: ctx.req.headers, preservePath: true }), e => { |
| 19 | ctx.body = String(e) // busboy validates multipart headers at construction time, so malformed requests must stop here as 400 |
| 20 | ctx.status = HTTP_BAD_REQUEST |
| 21 | }) |
| 22 | if (!bb) return |
| 23 | bb.on('field', (name: string) => { |
| 24 | if (name === 'upload') |
| 25 | errors.push('empty filename') |
| 26 | }) |
| 27 | bb.on('file', (_field, file, info) => { |
| 28 | const fn = info.filename || '' |
| 29 | if (!fn) { |
| 30 | errors.push('empty filename') |
| 31 | fileJobs.push(drainStream(file)) |
| 32 | return |
| 33 | } |
| 34 | ctx.state.uploadPath = decodeURI(ctx.path) + fn |
| 35 | ctx.state.uploads!.push(fn) |
| 36 | file.pause() |
| 37 | fileJobs.push(handleFile(file, fn)) |
| 38 | }) |
| 39 | bb.on('error', (err: Error) => { |
| 40 | console.warn("Couldn't parse POST requests:", String(err)) |
| 41 | ctx.status = HTTP_BAD_REQUEST |
| 42 | }) |
| 43 | ctx.req.pipe(bb) |
| 44 | await once(bb, 'finish') |
| 45 | await Promise.all(fileJobs) |
| 46 | if (!ctx.state.uploads?.length) { |
| 47 | if (!errors.length) |
| 48 | errors.push('no files') |
| 49 | ctx.status = HTTP_BAD_REQUEST |
| 50 | } |
| 51 | const uris = await Promise.all(locks) |
| 52 | ctx.body = errors.length ? { uris, errors } : { uris } |
| 53 | return |
| 54 | |
| 55 | async function handleFile(file: NodeJS.ReadableStream, fn: string) { |
| 56 | try { // it is still possible to allow upload in a folder and block in a subfolder, so check for it |
| 57 | const ret = !await subfolderBlocksUpload(fn) && uploadWriter(node, ctx.path, fn, ctx) |
| 58 | if (!ret) |
| 59 | return drainStream(file) |
| 60 | locks.push(ret.lockMiddleware) |
| 61 | file.pipe(ret) |
| 62 | file.resume() |
| 63 | } |
| 64 | catch (e) { |
| 65 | console.warn("Couldn't handle uploaded file:", String(e)) |
| 66 | file.resume() |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | function drainStream(stream: NodeJS.ReadableStream) { |
| 71 | stream.resume() |
| 72 | return new Promise(res => onFirstEvent(stream, ['end','close','error'], res)) |
| 73 | } |
| 74 | |
| 75 | async function subfolderBlocksUpload(fn: string) { |
| 76 | const prefix = dirname(fn.replaceAll('\\', '/')) |
| 77 | if (prefix === '.') // no subdir |
| 78 | return false |
| 79 | const subfolderNode = await urlToNode(prefix + '/', ctx, node, true) // final slash = explicitly a folder even if it doesn't exist on disk |
| 80 | return subfolderNode && !hasPermission(subfolderNode, 'can_upload', ctx) |
| 81 | } |
| 82 | } |