enhancement(extollo/extollo#2): add kernel module for parsing request body contents, uploading files
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
93
src/http/kernel/module/ParseIncomingBodyHTTPModule.ts
Normal file
93
src/http/kernel/module/ParseIncomingBodyHTTPModule.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import {HTTPKernelModule} from "../HTTPKernelModule"
|
||||
import {HTTPKernel} from "../HTTPKernel"
|
||||
import * as Busboy from "busboy"
|
||||
import {Request} from "../../lifecycle/Request"
|
||||
import {infer, uuid_v4} from "@extollo/util"
|
||||
import {Files} from "../../../service/Files"
|
||||
import {Config} from "../../../service/Config"
|
||||
import {Logging} from "../../../service/Logging"
|
||||
import {Injectable, Inject, Container} from "@extollo/di"
|
||||
|
||||
@Injectable()
|
||||
export class ParseIncomingBodyHTTPModule extends HTTPKernelModule {
|
||||
static register(kernel: HTTPKernel) {
|
||||
const files = <Files> Container.getContainer().make(Files)
|
||||
const logging = <Logging> Container.getContainer().make(Logging)
|
||||
if ( !files.hasFilesystem() ) {
|
||||
logging.warn(`No default filesystem is configured. This means request files will not be uploaded.`)
|
||||
}
|
||||
|
||||
kernel.register(this).first()
|
||||
}
|
||||
|
||||
@Inject()
|
||||
protected readonly files!: Files
|
||||
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
public async apply(request: Request): Promise<Request> {
|
||||
if ( !request.getHeader('content-type') ) return request
|
||||
|
||||
const config = this.config.get('server.uploads', {})
|
||||
|
||||
await new Promise<void>((res, rej) => {
|
||||
const busboy = new Busboy({
|
||||
headers: request.toNative().headers,
|
||||
})
|
||||
|
||||
busboy.on('field', (field, val) => {
|
||||
request.parsedInput[field] = infer(val)
|
||||
})
|
||||
|
||||
busboy.on('file', async (field, file, filename, encoding, mimetype) => {
|
||||
if ( !this.files.hasFilesystem() ) return
|
||||
|
||||
if ( !config?.enable ) {
|
||||
this.logging.warn(`Skipping uploaded file '${filename}' because uploading is disabled. Set the server.uploads.enable config to allow uploads.`)
|
||||
file.resume()
|
||||
return
|
||||
}
|
||||
|
||||
if ( config?.filter?.mimetype ) {
|
||||
const rex = new RegExp(config.filter.mimetype)
|
||||
if ( !rex.test(mimetype) ) {
|
||||
this.logging.debug(`Skipping uploaded file '${filename}' because the mimetype does not match the configured filter: (${config.filter.mimetype} -> ${mimetype})`)
|
||||
file.resume()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if ( config?.filter?.filename ) {
|
||||
const rex = new RegExp(config.filter.filename)
|
||||
if ( !rex.test(filename) ) {
|
||||
this.logging.debug(`Skipping uploaded file '${filename}' because the file name does not match the configured filter: (${config.filter.filename} -> ${filename})`)
|
||||
file.resume()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const fs = this.files.getFilesystem()
|
||||
const storePath = `${config.filesystemPrefix ? config.filesystemPrefix : ''}${(config.filesystemPrefix && !config.filesystemPrefix.endsWith('/')) ? '/' : ''}${field}-${uuid_v4()}`
|
||||
this.logging.verbose(`Uploading file in field ${field} to ${fs.getPrefix()}${storePath}`)
|
||||
file.pipe(await fs.putStoreFileAsStream({ storePath })) // FIXME might need to revisit this to ensure we don't res() before pipe finishes
|
||||
|
||||
request.uploadedFiles[field] = request.parsedInput[field] = fs.getPath(storePath)
|
||||
})
|
||||
|
||||
busboy.on('finish', () => {
|
||||
this.logging.debug(`Parsed body input: ${JSON.stringify(request.parsedInput)}`)
|
||||
res()
|
||||
})
|
||||
|
||||
busboy.on('error', rej)
|
||||
|
||||
request.toNative().pipe(busboy)
|
||||
})
|
||||
|
||||
return request
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Injectable, ScopedContainer, Container} from "@extollo/di"
|
||||
import {infer} from "@extollo/util"
|
||||
import {infer, UniversalPath} from "@extollo/util"
|
||||
import {IncomingMessage, ServerResponse} from "http"
|
||||
import {HTTPCookieJar} from "../kernel/HTTPCookieJar";
|
||||
import {TLSSocket} from "tls";
|
||||
@@ -84,6 +84,12 @@ export class Request extends ScopedContainer {
|
||||
/** The media types accepted by the client. */
|
||||
public readonly mediaTypes: string[];
|
||||
|
||||
/** Input parsed from the request */
|
||||
public readonly parsedInput: {[key: string]: any} = {}
|
||||
|
||||
/** Files parsed from the request. */
|
||||
public readonly uploadedFiles: {[key: string]: UniversalPath} = {}
|
||||
|
||||
constructor(
|
||||
/** The native Node.js request. */
|
||||
protected clientRequest: IncomingMessage,
|
||||
@@ -162,11 +168,22 @@ export class Request extends ScopedContainer {
|
||||
* @param key
|
||||
*/
|
||||
public input(key: string) {
|
||||
if ( key in this.parsedInput ) {
|
||||
return this.parsedInput[key]
|
||||
}
|
||||
|
||||
if ( key in this.query ) {
|
||||
return this.query[key]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the UniversalPath instance for a file uploaded in the given field on the request.
|
||||
*/
|
||||
public file(key: string): UniversalPath | undefined {
|
||||
return this.uploadedFiles[key]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the request accepts the given media type.
|
||||
* @param type - a mimetype, or the short forms json, xml, or html
|
||||
|
||||
@@ -89,6 +89,23 @@ export class Files extends Unit {
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
// Once they have initialized, register the DI token for the default filesystem
|
||||
this.app().registerProducer(Filesystem, () => {
|
||||
// This will throw FilesystemDoesNotExistError if no default filesystem was created
|
||||
// Such behavior is desired as it is clearer than an invalid injection error, e.g.
|
||||
return this.getFilesystem()
|
||||
})
|
||||
|
||||
// If file uploads are enabled, ensure that the default upload prefix exists
|
||||
if ( this.defaultFilesystem ) {
|
||||
const upload = this.config.get('server.uploads', {})
|
||||
if ( upload?.enable && upload?.filesystemPrefix ) {
|
||||
await this.defaultFilesystem.mkdir({
|
||||
storePath: upload.filesystemPrefix,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async down() {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {ExecuteResolvedRouteHandlerHTTPModule} from "../http/kernel/module/Execu
|
||||
import {error} from "../http/response/ErrorResponseFactory";
|
||||
import {ExecuteResolvedRoutePreflightHTTPModule} from "../http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule";
|
||||
import {ExecuteResolvedRoutePostflightHTTPModule} from "../http/kernel/module/ExecuteResolvedRoutePostflightHTTPModule";
|
||||
import {ParseIncomingBodyHTTPModule} from "../http/kernel/module/ParseIncomingBodyHTTPModule";
|
||||
|
||||
/**
|
||||
* Application unit that starts the HTTP/S server, creates Request and Response objects
|
||||
@@ -42,6 +43,7 @@ export class HTTPServer extends Unit {
|
||||
ExecuteResolvedRouteHandlerHTTPModule.register(this.kernel)
|
||||
ExecuteResolvedRoutePreflightHTTPModule.register(this.kernel)
|
||||
ExecuteResolvedRoutePostflightHTTPModule.register(this.kernel)
|
||||
ParseIncomingBodyHTTPModule.register(this.kernel)
|
||||
|
||||
await new Promise<void>((res, rej) => {
|
||||
this.server = createServer(this.handler)
|
||||
|
||||
Reference in New Issue
Block a user