UNPKG

@nasriya/hypercloud

Version:

Nasriya HyperCloud is a lightweight Node.js HTTP2 framework.

292 lines (291 loc) 12.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const UploadMemoryFile_1 = __importDefault(require("./UploadMemoryFile")); const UploadedStorageFile_1 = __importDefault(require("./UploadedStorageFile")); const requestBody_1 = __importDefault(require("../../handler/assets/requestBody")); const fs_1 = __importDefault(require("fs")); const helpers_1 = __importDefault(require("../../../utils/helpers")); const path_1 = __importDefault(require("path")); const mimes = JSON.parse(fs_1.default.readFileSync(path_1.default.join(__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_1.default(details); } else { return new UploadMemoryFile_1.default(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_1.default) { 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_1.default).map(file => { return new Promise((resolve, reject) => { fs_1.default.promises.unlink(file.path).then(() => { helpers_1.default.printConsole(`Temporary Uploaded File "${file.fileName}" has been deleted from "${file.path}"`); resolve(); }).catch(err => { helpers_1.default.printConsole(`Unable to delete Temporary Uploaded File "${file.fileName}" from "${file.path}". Reason: ${err?.message || 'Unknown'}`); helpers_1.default.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_1.default && !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_1.default).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 UploadMemoryFile_1.default).map(file => { return { fieldName: file.fieldName, fileName: file.fileName, mime: file.mime, size: file.size, content: file.content }; }); this.#_initReq.body = new requestBody_1.default().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; } } exports.default = UploadHandler;