385 lines
9.3 KiB
JavaScript
385 lines
9.3 KiB
JavaScript
import crypto from 'node:crypto'
|
|
import fs from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { EventEmitter } from 'node:events'
|
|
import { Stream } from 'node:stream'
|
|
import { StringDecoder } from 'node:string_decoder'
|
|
|
|
import File from './file.js'
|
|
import { MultipartParser } from './multipart_parser.js'
|
|
import { UrlencodedParser } from './urlencoded_parser.js'
|
|
import { OctetParser, EmptyParser } from './octet_parser.js'
|
|
import { JSONParser } from './json_parser.js'
|
|
|
|
function randomPath(uploadDir) {
|
|
var name = 'upload_' + crypto.randomBytes(16).toString('hex')
|
|
return join(uploadDir, name)
|
|
}
|
|
|
|
/* ------------------------------------- */
|
|
|
|
export default class IncomingForm extends EventEmitter {
|
|
#req = null
|
|
|
|
#error = false
|
|
#ended = false
|
|
|
|
ended = false
|
|
headers = null
|
|
|
|
bytesReceived = null
|
|
bytesExpected = null
|
|
|
|
#parser = null
|
|
#pending = true
|
|
|
|
#openedFiles = []
|
|
|
|
constructor(req, opts = {}) {
|
|
super()
|
|
|
|
this.#req = req
|
|
|
|
this.uploadDir = opts.uploadDir
|
|
this.encoding = opts.encoding || 'utf-8'
|
|
this.multiples = opts.multiples || false
|
|
|
|
// Parse headers and setup the parser, ready to start listening for data.
|
|
this.writeHeaders(req.headers)
|
|
|
|
req
|
|
.on('error', err => {
|
|
this.#handleError(err)
|
|
this.#clearUploads()
|
|
})
|
|
.on('aborted', () => {
|
|
this.emit('aborted')
|
|
this.#clearUploads()
|
|
})
|
|
.on('data', buffer => this.write(buffer))
|
|
.on('end', () => {
|
|
if (this.#error) {
|
|
return
|
|
}
|
|
let err = this.#parser.end()
|
|
if (err) {
|
|
this.#handleError(err)
|
|
}
|
|
})
|
|
}
|
|
|
|
writeHeaders(headers) {
|
|
this.headers = headers
|
|
this.#parseContentLength()
|
|
this.#parseContentType()
|
|
}
|
|
|
|
write(buffer) {
|
|
if (this.#error) {
|
|
return
|
|
}
|
|
if (!this.#parser) {
|
|
return this.#handleError(new Error('uninitialized parser'))
|
|
}
|
|
this.bytesReceived += buffer.length
|
|
this.emit('progress', this.bytesReceived, this.bytesExpected)
|
|
|
|
this.#parser.write(buffer)
|
|
}
|
|
|
|
pause() {
|
|
try {
|
|
this.#req.pause()
|
|
} catch (err) {
|
|
if (!this.#ended) {
|
|
this.#handleError(err)
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
resume() {
|
|
try {
|
|
this.#req.resume()
|
|
} catch (err) {
|
|
if (!this.#ended) {
|
|
this.#handleError(err)
|
|
}
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
#handlePart(part) {
|
|
if (part.filename === undefined) {
|
|
let value = ''
|
|
let decoder = new StringDecoder(this.encoding)
|
|
|
|
part
|
|
.on('data', buffer => {
|
|
value += decoder.write(buffer)
|
|
})
|
|
.on('end', () => {
|
|
this.emit('field', part.name, value)
|
|
})
|
|
} else {
|
|
let file = new File({
|
|
path: randomPath(this.uploadDir),
|
|
name: part.filename,
|
|
type: part.mime
|
|
})
|
|
|
|
file.open()
|
|
|
|
this.#openedFiles.push(file)
|
|
|
|
this.#pending = true
|
|
|
|
part
|
|
.on('data', buffer => {
|
|
if (buffer.length == 0) {
|
|
return
|
|
}
|
|
file.write(buffer)
|
|
})
|
|
.on('end', () => {
|
|
console.log('file part end...')
|
|
file.end(() => {
|
|
console.log('<><><><>', part.name, file)
|
|
this.emit('file', part.name, file)
|
|
this.#pending = false
|
|
// this.#handleEnd()
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
#parseContentType() {
|
|
let contentType = this.headers['content-type']
|
|
let lower = contentType.toLowerCase()
|
|
|
|
if (this.bytesExpected === 0) {
|
|
return (this.#parser = new EmptyParser())
|
|
}
|
|
|
|
if (lower.includes('octet-stream')) {
|
|
return this.#createStreamParser()
|
|
}
|
|
|
|
if (lower.includes('urlencoded')) {
|
|
return this.#createUrlencodedParser()
|
|
}
|
|
|
|
if (lower.includes('multipart')) {
|
|
let matches = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/)
|
|
if (matches) {
|
|
this.#createMultipartParser(matches[1] || matches[2])
|
|
} else {
|
|
this.#handleError(new TypeError('unknow multipart boundary'))
|
|
}
|
|
return
|
|
}
|
|
|
|
if (lower.match(/json|appliation|plain|text/)) {
|
|
return this.#createJsonParser()
|
|
}
|
|
|
|
this.#handleError(new TypeError('unknown content-type: ' + contentType))
|
|
}
|
|
|
|
#parseContentLength() {
|
|
this.bytesReceived = 0
|
|
if (this.headers['content-length']) {
|
|
this.bytesExpected = +this.headers['content-length']
|
|
} else if (this.headers['transfer-encoding'] === undefined) {
|
|
this.bytesExpected = 0
|
|
}
|
|
}
|
|
|
|
#createMultipartParser(boundary) {
|
|
let parser = new MultipartParser(boundary)
|
|
let headerField, headerValue, part
|
|
|
|
parser
|
|
.on('partBegin', function () {
|
|
part = new Stream()
|
|
part.readable = true
|
|
part.headers = {}
|
|
part.name = null
|
|
part.filename = null
|
|
part.mime = null
|
|
|
|
part.transferEncoding = 'binary'
|
|
part.transferBuffer = ''
|
|
|
|
headerField = ''
|
|
headerValue = ''
|
|
})
|
|
.on('headerField', (b, start, end) => {
|
|
headerField += b.toString(this.encoding, start, end)
|
|
})
|
|
.on('headerValue', (b, start, end) => {
|
|
headerValue += b.toString(this.encoding, start, end)
|
|
})
|
|
.on('headerEnd', () => {
|
|
headerField = headerField.toLowerCase()
|
|
part.headers[headerField] = headerValue
|
|
|
|
var m = headerValue.match(/\bname="([^"]+)"/i)
|
|
if (headerField == 'content-disposition') {
|
|
if (m) {
|
|
part.name = m[1]
|
|
}
|
|
|
|
part.filename = this._fileName(headerValue)
|
|
} else if (headerField == 'content-type') {
|
|
part.mime = headerValue
|
|
} else if (headerField == 'content-transfer-encoding') {
|
|
part.transferEncoding = headerValue.toLowerCase()
|
|
}
|
|
|
|
headerField = ''
|
|
headerValue = ''
|
|
})
|
|
.on('headersEnd', () => {
|
|
switch (part.transferEncoding) {
|
|
case 'binary':
|
|
case '7bit':
|
|
case '8bit':
|
|
parser
|
|
.on('partData', function (b, start, end) {
|
|
part.emit('data', b.slice(start, end))
|
|
})
|
|
.on('partEnd', function () {
|
|
part.emit('end')
|
|
})
|
|
break
|
|
|
|
case 'base64':
|
|
parser
|
|
.on('partData', function (b, start, end) {
|
|
part.transferBuffer += b.slice(start, end).toString('ascii')
|
|
|
|
/*
|
|
four bytes (chars) in base64 converts to three bytes in binary
|
|
encoding. So we should always work with a number of bytes that
|
|
can be divided by 4, it will result in a number of buytes that
|
|
can be divided vy 3.
|
|
*/
|
|
var offset = parseInt(part.transferBuffer.length / 4, 10) * 4
|
|
part.emit(
|
|
'data',
|
|
Buffer.from(
|
|
part.transferBuffer.substring(0, offset),
|
|
'base64'
|
|
)
|
|
)
|
|
part.transferBuffer = part.transferBuffer.substring(offset)
|
|
})
|
|
.on('partEnd', function () {
|
|
part.emit('data', Buffer.from(part.transferBuffer, 'base64'))
|
|
part.emit('end')
|
|
})
|
|
break
|
|
|
|
default:
|
|
return this.#handleError(new Error('unknown transfer-encoding'))
|
|
}
|
|
|
|
this.#handlePart(part)
|
|
})
|
|
.on('end', () => {
|
|
if (this.#pending) {
|
|
setTimeout(_ => parser.emit('end'))
|
|
} else {
|
|
this.#handleEnd()
|
|
}
|
|
})
|
|
|
|
this.#parser = parser
|
|
}
|
|
|
|
_fileName(headerValue) {
|
|
var m = headerValue.match(/\bfilename="(.*?)"($|; )/i)
|
|
if (!m) return
|
|
|
|
var filename = m[1].substr(m[1].lastIndexOf('\\') + 1)
|
|
filename = filename.replace(/%22/g, '"')
|
|
filename = filename.replace(/&#([\d]{4});/g, function (m, code) {
|
|
return String.fromCharCode(code)
|
|
})
|
|
return filename
|
|
}
|
|
|
|
#createUrlencodedParser() {
|
|
this.#parser = new UrlencodedParser()
|
|
|
|
this.#parser
|
|
.on('field', fields => this.emit('field', false, fields))
|
|
.on('end', () => this.#handleEnd())
|
|
}
|
|
|
|
#createStreamParser() {
|
|
let filename = this.headers['x-file-name']
|
|
let mime = this.headers['x-file-type']
|
|
|
|
this.#parser = new OctetParser(filename, mime, randomPath(this.uploadDir))
|
|
|
|
if (this.bytesExpected) {
|
|
this.#parser.initLength(this.bytesExpected)
|
|
}
|
|
|
|
this.#parser
|
|
.on('file', file => {
|
|
this.emit('file', false, file)
|
|
})
|
|
.on('end', () => this.#handleEnd())
|
|
.on('error', err => this.#handleError(err))
|
|
}
|
|
|
|
#createJsonParser() {
|
|
this.#parser = new JSONParser()
|
|
|
|
if (this.bytesExpected) {
|
|
this.#parser.initLength(this.bytesExpected)
|
|
}
|
|
|
|
this.#parser
|
|
.on('field', (key, val) => {
|
|
this.emit('field', key, val)
|
|
})
|
|
.on('end', () => this.#handleEnd())
|
|
.on('error', err => this.#handleError(err))
|
|
}
|
|
|
|
#clearUploads() {
|
|
while (this.#openedFiles.length) {
|
|
let file = this.#openedFiles.pop()
|
|
file._writeStream.destroy()
|
|
setTimeout(_ => {
|
|
try {
|
|
fs.unlink(file.path)
|
|
} catch (e) {}
|
|
})
|
|
}
|
|
}
|
|
|
|
#handleError(err) {
|
|
if (this.#error || this.#ended) {
|
|
return
|
|
}
|
|
this.error = true
|
|
this.emit('error', err)
|
|
}
|
|
|
|
#handleEnd() {
|
|
if (this.#ended || this.#error) {
|
|
return
|
|
}
|
|
this.#ended = true
|
|
this.emit('end')
|
|
}
|
|
}
|