graphql-upload
Version:
Middleware and an Upload scalar to add support for GraphQL multipart requests (file uploads via queries and mutations) to various Node.js GraphQL servers.
358 lines (308 loc) • 10.1 kB
JavaScript
const Busboy = require('busboy');
const { WriteStream } = require('fs-capacitor');
const createError = require('http-errors');
const isObject = require('isobject');
const objectPath = require('object-path');
const { SPEC_URL } = require('../private/constants');
const ignoreStream = require('../private/ignoreStream');
const Upload = require('./Upload');
/**
* Processes a [GraphQL multipart request](https://github.com/jaydenseric/graphql-multipart-request-spec).
* It parses the `operations` and `map` fields to create an
* [`Upload`]{@link Upload} instance for each expected file upload, placing
* references wherever the file is expected in the
* [GraphQL operation]{@link GraphQLOperation} for the
* [`Upload` scalar]{@link GraphQLUpload} to derive it’s value. Errors are
* created with [`http-errors`](https://npm.im/http-errors) to assist in
* sending responses with appropriate HTTP status codes. Used in
* [`graphqlUploadExpress`]{@link graphqlUploadExpress} and
* [`graphqlUploadKoa`]{@link graphqlUploadKoa} and can be used to create
* custom middleware.
* @kind function
* @name processRequest
* @type {ProcessRequestFunction}
* @example <caption>Ways to `import`.</caption>
* ```js
* import { processRequest } from 'graphql-upload';
* ```
*
* ```js
* import processRequest from 'graphql-upload/public/processRequest.js';
* ```
* @example <caption>Ways to `require`.</caption>
* ```js
* const { processRequest } = require('graphql-upload');
* ```
*
* ```js
* const processRequest = require('graphql-upload/public/processRequest');
* ```
*/
module.exports = function processRequest(
request,
response,
{
maxFieldSize = 1000000, // 1 MB
maxFileSize = Infinity,
maxFiles = Infinity,
} = {}
) {
return new Promise((resolve, reject) => {
let released;
let exitError;
let currentStream;
let operations;
let operationsPath;
let map;
const parser = new Busboy({
headers: request.headers,
limits: {
fieldSize: maxFieldSize,
fields: 2, // Only operations and map.
fileSize: maxFileSize,
files: maxFiles,
},
});
/**
* Exits request processing with an error. Successive calls have no effect.
* @kind function
* @name processRequest~exit
* @param {object} error Error instance.
* @ignore
*/
const exit = (error) => {
if (exitError) return;
exitError = error;
reject(exitError);
parser.destroy();
if (currentStream) currentStream.destroy(exitError);
if (map)
for (const upload of map.values())
if (!upload.file) upload.reject(exitError);
request.unpipe(parser);
// With a sufficiently large request body, subsequent events in the same
// event frame cause the stream to pause after the parser is destroyed. To
// ensure that the request resumes, the call to .resume() is scheduled for
// later in the event loop.
setImmediate(() => {
request.resume();
});
};
/**
* Releases resources and cleans up Capacitor temporary files. Successive
* calls have no effect.
* @kind function
* @name processRequest~release
* @ignore
*/
const release = () => {
if (released) return;
released = true;
if (map)
for (const upload of map.values())
if (upload.file) upload.file.capacitor.release();
};
/**
* Handles when the request is closed before it properly ended.
* @kind function
* @name processRequest~abort
* @ignore
*/
const abort = () => {
exit(
createError(
499,
'Request disconnected during file upload stream parsing.'
)
);
};
parser.on(
'field',
(fieldName, value, fieldNameTruncated, valueTruncated) => {
if (exitError) return;
if (valueTruncated)
return exit(
createError(
413,
`The ‘${fieldName}’ multipart field value exceeds the ${maxFieldSize} byte size limit.`
)
);
switch (fieldName) {
case 'operations':
try {
operations = JSON.parse(value);
} catch (error) {
return exit(
createError(
400,
`Invalid JSON in the ‘operations’ multipart field (${SPEC_URL}).`
)
);
}
if (!isObject(operations) && !Array.isArray(operations))
return exit(
createError(
400,
`Invalid type for the ‘operations’ multipart field (${SPEC_URL}).`
)
);
operationsPath = objectPath(operations);
break;
case 'map': {
if (!operations)
return exit(
createError(
400,
`Misordered multipart fields; ‘map’ should follow ‘operations’ (${SPEC_URL}).`
)
);
let parsedMap;
try {
parsedMap = JSON.parse(value);
} catch (error) {
return exit(
createError(
400,
`Invalid JSON in the ‘map’ multipart field (${SPEC_URL}).`
)
);
}
if (!isObject(parsedMap))
return exit(
createError(
400,
`Invalid type for the ‘map’ multipart field (${SPEC_URL}).`
)
);
const mapEntries = Object.entries(parsedMap);
// Check max files is not exceeded, even though the number of files to
// parse might not match th(e map provided by the client.
if (mapEntries.length > maxFiles)
return exit(
createError(413, `${maxFiles} max file uploads exceeded.`)
);
map = new Map();
for (const [fieldName, paths] of mapEntries) {
if (!Array.isArray(paths))
return exit(
createError(
400,
`Invalid type for the ‘map’ multipart field entry key ‘${fieldName}’ array (${SPEC_URL}).`
)
);
map.set(fieldName, new Upload());
for (const [index, path] of paths.entries()) {
if (typeof path !== 'string')
return exit(
createError(
400,
`Invalid type for the ‘map’ multipart field entry key ‘${fieldName}’ array index ‘${index}’ value (${SPEC_URL}).`
)
);
try {
operationsPath.set(path, map.get(fieldName));
} catch (error) {
return exit(
createError(
400,
`Invalid object path for the ‘map’ multipart field entry key ‘${fieldName}’ array index ‘${index}’ value ‘${path}’ (${SPEC_URL}).`
)
);
}
}
}
resolve(operations);
}
}
}
);
parser.on('file', (fieldName, stream, filename, encoding, mimetype) => {
if (exitError) {
ignoreStream(stream);
return;
}
if (!map) {
ignoreStream(stream);
return exit(
createError(
400,
`Misordered multipart fields; files should follow ‘map’ (${SPEC_URL}).`
)
);
}
currentStream = stream;
stream.on('end', () => {
currentStream = null;
});
const upload = map.get(fieldName);
if (!upload) {
// The file is extraneous. As the rest can still be processed, just
// ignore it and don’t exit with an error.
ignoreStream(stream);
return;
}
let fileError;
const capacitor = new WriteStream();
capacitor.on('error', () => {
stream.unpipe();
stream.resume();
});
stream.on('limit', () => {
fileError = createError(
413,
`File truncated as it exceeds the ${maxFileSize} byte size limit.`
);
stream.unpipe();
capacitor.destroy(fileError);
});
stream.on('error', (error) => {
fileError = error;
stream.unpipe();
capacitor.destroy(exitError);
});
const file = {
filename,
mimetype,
encoding,
createReadStream(options) {
const error = fileError || (released ? exitError : null);
if (error) throw error;
return capacitor.createReadStream(options);
},
};
Object.defineProperty(file, 'capacitor', { value: capacitor });
stream.pipe(capacitor);
upload.resolve(file);
});
parser.once('filesLimit', () =>
exit(createError(413, `${maxFiles} max file uploads exceeded.`))
);
parser.once('finish', () => {
request.unpipe(parser);
request.resume();
if (!operations)
return exit(
createError(
400,
`Missing multipart field ‘operations’ (${SPEC_URL}).`
)
);
if (!map)
return exit(
createError(400, `Missing multipart field ‘map’ (${SPEC_URL}).`)
);
for (const upload of map.values())
if (!upload.file)
upload.reject(createError(400, 'File missing in the request.'));
});
parser.once('error', exit);
response.once('finish', release);
response.once('close', release);
request.once('close', abort);
request.once('end', () => {
request.removeListener('close', abort);
});
request.pipe(parser);
});
};
;