@nasriya/hypercloud
Version:
Nasriya HyperCloud is a lightweight Node.js HTTP2 framework.
287 lines (286 loc) • 11.9 kB
JavaScript
import UploadedMemoryFile from "./UploadMemoryFile.js";
import UploadedStorageFile from "./UploadedStorageFile.js";
import RequestBody from "../../handler/assets/requestBody.js";
import fs from "fs";
import helpers from "../../../utils/helpers.js";
import path from "path";
const mimes = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, '../../../data/mimes.json'), { encoding: 'utf8' }));
class UploadHandler {
#_currentFile;
#_request;
#_initReq;
#_response;
#_configs = {
/**The maximum file size allowed */
maxFileSize: 0,
// Initialize storage and limits
limits: {
/**The maximum size allowed to store files in memory while uploading */
fileStream: 0,
images: 0,
videos: 0,
mime: null
}
};
#_files = [];
#_fields = {};
#_data = {
contentLength: 0,
boundary: '',
endBoundary: '',
chunks: 0,
};
#_flags = {
useFileStream: false,
finished: false
};
#_promiseResponse = {
resolve: undefined,
reject: undefined,
};
constructor(req, initReq, res) {
this.#_request = req;
this.#_initReq = initReq;
this.#_response = res;
this.#_configs = {
maxFileSize: this.#_request.server.uploads.maxFileSize,
limits: {
fileStream: this.#_request.server.uploads.limits.fileStream,
images: this.#_request.server.uploads.limits.images,
videos: this.#_request.server.uploads.limits.videos,
mime: this.#_request.server.uploads.limits.mime
}
};
}
async #validate() {
if (this.#_request.method !== 'POST') {
throw { code: 500, message: 'Cannot handle form data for a non-POST request' };
}
this.#_data.contentLength = parseInt(this.#_request.headers['content-length'] || '0', 10);
const contentType = this.#_request.headers['content-type'];
if (!contentType || !contentType.includes('multipart/form-data')) {
this.#_response.status(400).json({ message: 'Content-Type must be multipart/form-data' });
throw { code: 400, message: 'Content-Type must be multipart/form-data' };
}
const boundaryMatch = contentType.match(/boundary=(.*)$/);
if (!boundaryMatch?.[1]) {
this.#_response.status(400).json({ message: 'Boundary not found in multipart/form-data' });
throw { code: 400, message: 'Boundary not found in multipart/form-data' };
}
this.#_data.boundary = boundaryMatch[1];
this.#_data.endBoundary = `\r\n--${this.#_data.boundary}--`;
this.#_flags.useFileStream = this.#_data.contentLength > this.#_configs.limits.fileStream;
}
#_helpers = {
analyze: async (data) => {
try {
// Regular expressions to match Content-Disposition and Content-Type headers
const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"(?:;\s*filename="[^"]+")?\s*(?:\r?\n|\r?\n|\n)?(?:\r?\n|\r?)([^\r\n]*)/;
const fileRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)";\s*filename="([^"]+)"\s*(?:\r?\n|\r?\n|\n)?Content-Type:\s*([^;\r\n]*)\s*(?:\r?\n|\r?\n|\n)?(?:\r?\n|\r?)(.*)/;
const matchField = data.match(fieldRegex);
const matchFile = data.match(fileRegex);
if (matchFile) {
// Extract the field name, file name, and content type
const details = {
fieldName: matchFile[1],
fileName: matchFile[2],
mime: matchFile[3],
tempPath: this.#_request.server.uploads.directory
};
if (!details.fieldName || !details.fileName || !details.mime) {
throw new Error(`The header is invalid`);
}
if (!mimes.includes(details.mime)) {
throw new Error(`The request mime type is not supported: ${details.mime}`);
}
this.#_currentFile = (() => {
if (this.#_flags.useFileStream) {
return new UploadedStorageFile(details);
}
else {
return new UploadedMemoryFile(details);
}
})();
this.#_files.push(this.#_currentFile);
// Remove the matched lines from the data
let cleanedData = data.replace(matchFile[0], '').replace(details.mime, '');
// Remove any leading or trailing newlines caused by the removal
cleanedData = cleanedData.trim();
await this.#_helpers.process(cleanedData);
}
else {
if (matchField) {
this.#_fields[matchField[1]] = matchField[2].trim();
}
}
}
catch (error) {
await this.#_helpers.handleError(error);
}
},
process: async (chunk) => {
if (this.#_flags.useFileStream) {
try {
await this.#_currentFile.write(chunk);
}
catch (error) {
await this.#_helpers.handleError(error);
}
}
else {
this.#_currentFile.push(chunk);
}
},
processNew: async (chunk) => {
await this.#_helpers.finalize();
await this.#_helpers.analyze(chunk);
},
finalize: async () => {
try {
if (this.#_currentFile) {
if (this.#_currentFile instanceof UploadedStorageFile) {
await this.#_currentFile.finish();
}
this.#_currentFile = undefined;
}
}
catch (error) {
await this.#_helpers.handleError(error);
}
},
cleanUp: async () => {
const promises = this.#_files.filter(file => file instanceof UploadedStorageFile).map(file => {
return new Promise((resolve, reject) => {
fs.promises.unlink(file.path).then(() => {
helpers.printConsole(`Temporary Uploaded File "${file.fileName}" has been deleted from "${file.path}"`);
resolve();
}).catch(err => {
helpers.printConsole(`Unable to delete Temporary Uploaded File "${file.fileName}" from "${file.path}". Reason: ${err?.message || 'Unknown'}`);
helpers.printConsole(err);
reject();
});
});
});
const result = await Promise.allSettled(promises);
const rejected = result.filter(i => i.status === 'rejected');
if (rejected.length > 0) {
throw new Error(`Unable to clean uploaded files from "${this.#_request.server.uploads.directory}". Make sure to delete them manually.`);
}
},
handleError: async (error) => {
await this.#_helpers.cleanUp();
this.#_promiseResponse.reject(error);
}
};
#_queue = {
storage: [],
processing: false,
add: (chunk) => {
this.#_data.chunks++;
this.#_queue.storage.push(chunk);
this.#_queue.process();
},
processChunk: async (chunk) => {
try {
const firstChunk = this.#_data.chunks === 1;
const isLastChunk = chunk.includes(this.#_data.endBoundary);
if (isLastChunk) {
chunk = chunk.replace(this.#_data.endBoundary, '');
}
if (chunk.length === 0) {
return;
}
// Normalize the first boundary
if (firstChunk) {
chunk = chunk.replace(`--${this.#_data.boundary}`, this.#_data.boundary);
}
const hasBoundary = chunk.includes(this.#_data.boundary);
if (hasBoundary) {
for (const part of chunk.split(`${this.#_data.boundary}\r\n`).filter(i => i.length > 0)) {
await this.#_helpers.processNew(part);
}
}
else {
await this.#_helpers.process(chunk);
}
if (isLastChunk && this.#_currentFile instanceof UploadedStorageFile && !this.#_currentFile.closed) {
await this.#_currentFile.finish();
}
}
catch (error) {
throw error;
}
},
process: async () => {
if (this.#_queue.processing) {
return;
}
this.#_queue.processing = true;
try {
while (this.#_queue.storage.length > 0) {
const chunk = this.#_queue.storage.shift();
if (chunk) {
await this.#_queue.processChunk(chunk);
}
}
}
catch (error) {
this.#_queue.processing = false;
await this.#_helpers.handleError(error);
}
this.#_queue.processing = false;
if (this.#_flags.finished)
this.#_queue.onFinished();
},
onFinished: () => {
const storageF = this.#_files.filter(i => i instanceof UploadedStorageFile).map(file => {
return {
fieldName: file.fieldName,
fileName: file.fileName,
mime: file.mime,
size: file.size,
path: file.path
};
});
const memoryF = this.#_files.filter(i => i instanceof UploadedMemoryFile).map(file => {
return {
fieldName: file.fieldName,
fileName: file.fileName,
mime: file.mime,
size: file.size,
content: file.content
};
});
this.#_initReq.body = new RequestBody().from({
files: [...storageF, ...memoryF],
fields: this.#_fields,
cleanup: this.#_helpers.cleanUp
});
this.#_initReq.bodyType = 'formData';
this.#_helpers.finalize().then(() => this.#_promiseResponse.resolve());
}
};
async handle() {
return new Promise((resolve, reject) => {
this.#_promiseResponse.resolve = resolve;
this.#_promiseResponse.reject = reject;
this.#validate().catch(async (error) => {
await this.#_helpers.handleError(error);
});
this.#_request.httpRequest.on('data', async (chunk) => {
this.#_queue.add(chunk);
});
this.#_request.httpRequest.on('end', async () => {
this.#_flags.finished = true;
if (!this.#_queue.processing) {
this.#_queue.onFinished();
}
});
this.#_request.httpRequest.on('error', async (error) => {
await this.#_helpers.handleError(error);
});
});
}
get contentLength() { return this.#_data.contentLength; }
}
export default UploadHandler;