// Orginal forked from: Buzut/huge-uploader-nodejs // Copyright (c) 2018, Quentin Busuttil All rights reserved. import fs from "node:fs" import path from "node:path" import { promisify } from "node:util" import mimetypes from "mime-types" import crypto from "node:crypto" import Busboy from "busboy" export function getFileHash(file) { return new Promise((resolve, reject) => { const hash = crypto.createHash("sha256") file.on("data", (chunk) => hash.update(chunk)) file.on("end", () => resolve(hash.digest("hex"))) file.on("error", reject) }) } export function checkHeaders(headers) { if ( !headers["uploader-chunk-number"] || !headers["uploader-chunks-total"] || !headers["uploader-original-name"] || !headers["uploader-file-id"] || !headers["uploader-chunks-total"].match(/^[0-9]+$/) || !headers["uploader-chunk-number"].match(/^[0-9]+$/) ) { return false } return true } export function checkTotalSize(maxFileSize, maxChunkSize, totalChunks) { if (maxChunkSize * totalChunks > maxFileSize) { return false } return true } export function cleanChunks(dirPath) { fs.readdir(dirPath, (err, files) => { let filesLength = files.length files.forEach((file) => { fs.unlink(path.join(dirPath, file), () => { if (--filesLength === 0) fs.rmdir(dirPath, () => { }) // cb does nothing but required }) }) }) } export function createAssembleChunksPromise({ tmpDir, headers, useDate, }) { const asyncReadFile = promisify(fs.readFile) const asyncAppendFile = promisify(fs.appendFile) const originalMimeType = mimetypes.lookup(headers["uploader-original-name"]) const originalExtension = mimetypes.extension(originalMimeType) const totalChunks = +headers["uploader-chunks-total"] const fileId = headers["uploader-file-id"] const workPath = path.join(tmpDir, fileId) const chunksPath = path.resolve(workPath, "chunks") const assembledFilepath = path.join(workPath, `assembled.${originalExtension}`) let chunkCount = 0 let finalFilepath = null return () => { return new Promise((resolve, reject) => { const onEnd = async () => { try { const hash = await getFileHash(fs.createReadStream(assembledFilepath)) if (useDate) { finalFilepath = path.resolve(workPath, `${hash}_${Date.now()}.${originalExtension}`) } else { finalFilepath = path.resolve(workPath, `${hash}.${originalExtension}`) } fs.renameSync(assembledFilepath, finalFilepath) cleanChunks(chunksPath) return resolve({ filename: headers["uploader-original-name"], filepath: finalFilepath, cachePath: workPath, hash, mimetype: originalMimeType, extension: originalExtension, }) } catch (error) { return reject(error) } } const pipeChunk = () => { asyncReadFile(path.join(chunksPath, chunkCount.toString())) .then((chunk) => asyncAppendFile(assembledFilepath, chunk)) .then(() => { // 0 indexed files = length - 1, so increment before comparison if (totalChunks > ++chunkCount) { return pipeChunk(chunkCount) } return onEnd() }) .catch(reject) } pipeChunk() }) } } export function mkdirIfDoesntExist(dirPath, callback) { if (!fs.existsSync(dirPath)) { fs.mkdir(dirPath, { recursive: true }, callback) } } export function handleFile(tmpDir, headers, fileStream) { const dirPath = path.join(tmpDir, headers["uploader-file-id"]) const chunksPath = path.join(dirPath, "chunks") const chunkPath = path.join(chunksPath, headers["uploader-chunk-number"]) const useDate = headers["uploader-use-date"] === "true" const chunkCount = +headers["uploader-chunk-number"] const totalChunks = +headers["uploader-chunks-total"] let error let assembleChunksPromise let finished = false let writeStream const writeFile = () => { writeStream = fs.createWriteStream(chunkPath) writeStream.on("error", (err) => { error = err fileStream.resume() }) writeStream.on("close", () => { finished = true // if all is uploaded if (chunkCount === totalChunks - 1) { assembleChunksPromise = createAssembleChunksPromise({ tmpDir, headers, useDate, }) } }) fileStream.pipe(writeStream) } // make sure chunk is in range if (chunkCount < 0 || chunkCount >= totalChunks) { error = new Error("Chunk is out of range") fileStream.resume() } else if (chunkCount === 0) { // create file upload dir if it's first chunk mkdirIfDoesntExist(chunksPath, (err) => { if (err) { error = err fileStream.resume() } else writeFile() }) } else { // make sure dir exists if it's not first chunk fs.stat(dirPath, (err) => { if (err) { error = new Error("Upload has expired") fileStream.resume() } else writeFile() }) } return (callback) => { if (finished && !error) callback(null, assembleChunksPromise) else if (error) callback(error) else { writeStream.on("error", callback) writeStream.on("close", () => callback(null, assembleChunksPromise)) } } } export function uploadFile(req, tmpDir, maxFileSize, maxChunkSize) { return new Promise((resolve, reject) => { if (!checkHeaders(req.headers)) { reject(new Error("Missing header(s)")) return } if (!checkTotalSize(maxFileSize, req.headers["uploader-chunks-total"])) { reject(new Error("File is above size limit")) return } try { let limitReached = false let getFileStatus const busboy = Busboy({ headers: req.headers, limits: { files: 1, fileSize: maxChunkSize * 1000 * 1000 } }) busboy.on("file", (fieldname, fileStream) => { fileStream.on("limit", () => { limitReached = true fileStream.resume() }) getFileStatus = handleFile(tmpDir, req.headers, fileStream) }) busboy.on("close", () => { if (limitReached) { reject(new Error("Chunk is above size limit")) return } getFileStatus((fileErr, assembleChunksF) => { if (fileErr) reject(fileErr) else resolve(assembleChunksF) }) }) req.pipe(busboy) } catch (err) { reject(err) } }) } export default uploadFile