import Config from "../config"; import busboy, {BusboyHeaders} from "@fastify/busboy"; import {v4 as uuidv4} from "uuid"; import path from "path"; import fs from "fs"; import fileType from "file-type"; import readChunk from "read-chunk"; import crypto from "crypto"; import isUtf8 from "is-utf8"; import log from "../log"; import contentDisposition from "content-disposition"; import type {Socket} from "socket.io"; import {Request, Response} from "express"; // Map of allowed mime types to their respecive default filenames // that will be rendered in browser without forcing them to be downloaded const inlineContentDispositionTypes = { "application/ogg": "media.ogx", "audio/midi": "audio.midi", "audio/mpeg": "audio.mp3", "audio/ogg": "audio.ogg", "audio/vnd.wave": "audio.wav", "audio/x-flac": "audio.flac", "audio/x-m4a": "audio.m4a", "image/bmp": "image.bmp", "image/gif": "image.gif", "image/jpeg": "image.jpg", "image/png": "image.png", "image/webp": "image.webp", "image/avif": "image.avif", "image/jxl": "image.jxl", "text/plain": "text.txt", "video/mp4": "video.mp4", "video/ogg": "video.ogv", "video/webm": "video.webm", }; const uploadTokens = new Map(); class Uploader { constructor(socket: Socket) { socket.on("upload:auth", () => { const token = uuidv4(); socket.emit("upload:auth", token); // Invalidate the token in one minute const timeout = Uploader.createTokenTimeout(token); uploadTokens.set(token, timeout); }); socket.on("upload:ping", (token) => { if (typeof token !== "string") { return; } let timeout = uploadTokens.get(token); if (!timeout) { return; } clearTimeout(timeout); timeout = Uploader.createTokenTimeout(token); uploadTokens.set(token, timeout); }); } static createTokenTimeout(this: void, token: string) { return setTimeout(() => uploadTokens.delete(token), 60 * 1000); } // TODO: type static router(this: void, express: any) { express.get("/uploads/:name/:slug*?", Uploader.routeGetFile); express.post("/uploads/new/:token", Uploader.routeUploadFile); } static async routeGetFile(this: void, req: Request, res: Response) { const name = req.params.name; const nameRegex = /^[0-9a-f]{16}$/; if (!nameRegex.test(name)) { return res.status(404).send("Not found"); } const folder = name.substring(0, 2); const uploadPath = Config.getFileUploadPath(); const filePath = path.join(uploadPath, folder, name); let detectedMimeType = await Uploader.getFileType(filePath); // doesn't exist if (detectedMimeType === null) { return res.status(404).send("Not found"); } // Force a download in the browser if it's not an allowed type (binary or otherwise unknown) let slug = req.params.slug; const isInline = detectedMimeType in inlineContentDispositionTypes; let disposition = isInline ? "inline" : "attachment"; if (!slug && isInline) { slug = inlineContentDispositionTypes[detectedMimeType]; } if (slug) { disposition = contentDisposition(slug.trim(), { fallback: false, type: disposition, }); } // Send a more common mime type for audio files // so that browsers can play them correctly if (detectedMimeType === "audio/vnd.wave") { detectedMimeType = "audio/wav"; } else if (detectedMimeType === "audio/x-flac") { detectedMimeType = "audio/flac"; } else if (detectedMimeType === "audio/x-m4a") { detectedMimeType = "audio/mp4"; } else if (detectedMimeType === "video/quicktime") { detectedMimeType = "video/mp4"; } res.setHeader("Content-Disposition", disposition); res.setHeader("Cache-Control", "max-age=86400"); res.contentType(detectedMimeType); return res.sendFile(filePath); } static routeUploadFile(this: void, req: Request, res: Response) { let busboyInstance: NodeJS.WritableStream | busboy | null | undefined; let uploadUrl: string | URL; let randomName: string; let destDir: fs.PathLike; let destPath: fs.PathLike | null; let streamWriter: fs.WriteStream | null; const doneCallback = () => { // detach the stream and drain any remaining data if (busboyInstance) { req.unpipe(busboyInstance); req.on("readable", req.read.bind(req)); busboyInstance.removeAllListeners(); busboyInstance = null; } // close the output file stream if (streamWriter) { streamWriter.end(); streamWriter = null; } }; const abortWithError = (err: any) => { doneCallback(); // if we ended up erroring out, delete the output file from disk if (destPath && fs.existsSync(destPath)) { fs.unlinkSync(destPath); destPath = null; } return res.status(400).json({error: err.message}); }; // if the authentication token is incorrect, bail out if (uploadTokens.delete(req.params.token) !== true) { return abortWithError(Error("Invalid upload token")); } // if the request does not contain any body data, bail out if (req.headers["content-length"] && parseInt(req.headers["content-length"]) < 1) { return abortWithError(Error("Length Required")); } // Only allow multipart, as busboy can throw an error on unsupported types if ( !( req.headers["content-type"] && req.headers["content-type"].startsWith("multipart/form-data") ) ) { return abortWithError(Error("Unsupported Content Type")); } // create a new busboy processor, it is wrapped in try/catch // because it can throw on malformed headers try { busboyInstance = new busboy({ headers: req.headers as BusboyHeaders, limits: { files: 1, // only allow one file per upload fileSize: Uploader.getMaxFileSize(), }, }); } catch (err) { return abortWithError(err); } // Any error or limit from busboy will abort the upload with an error busboyInstance.on("error", abortWithError); busboyInstance.on("partsLimit", () => abortWithError(Error("Parts limit reached"))); busboyInstance.on("filesLimit", () => abortWithError(Error("Files limit reached"))); busboyInstance.on("fieldsLimit", () => abortWithError(Error("Fields limit reached"))); // generate a random output filename for the file // we use do/while loop to prevent the rare case of generating a file name // that already exists on disk do { randomName = crypto.randomBytes(8).toString("hex"); destDir = path.join(Config.getFileUploadPath(), randomName.substring(0, 2)); destPath = path.join(destDir, randomName); } while (fs.existsSync(destPath)); // we split the filename into subdirectories (by taking 2 letters from the beginning) // this helps avoid file system and certain tooling limitations when there are // too many files on one folder try { fs.mkdirSync(destDir, {recursive: true}); } catch (err: any) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions log.error(`Error ensuring ${destDir} exists for uploads: ${err.message}`); return abortWithError(err); } // Open a file stream for writing streamWriter = fs.createWriteStream(destPath); streamWriter.on("error", abortWithError); busboyInstance.on( "file", ( fieldname: any, fileStream: { on: ( arg0: string, arg1: {(err: any): Response>; (): void} ) => void; unpipe: (arg0: any) => void; read: {bind: (arg0: any) => any}; pipe: (arg0: any) => void; }, filename: string | number | boolean ) => { uploadUrl = `${randomName}/${encodeURIComponent(filename)}`; if (Config.values.fileUpload.baseUrl) { uploadUrl = new URL(uploadUrl, Config.values.fileUpload.baseUrl).toString(); } else { uploadUrl = `uploads/${uploadUrl}`; } // if the busboy data stream errors out or goes over the file size limit // abort the processing with an error // @ts-expect-error Argument of type '(err: any) => Response>' is not assignable to parameter of type '{ (err: any): Response>; (): void; }'.ts(2345) fileStream.on("error", abortWithError); fileStream.on("limit", () => { fileStream.unpipe(streamWriter); fileStream.on("readable", fileStream.read.bind(fileStream)); return abortWithError(Error("File size limit reached")); }); // Attempt to write the stream to file fileStream.pipe(streamWriter); } ); busboyInstance.on("finish", () => { doneCallback(); if (!uploadUrl) { return res.status(400).json({error: "Missing file"}); } // upload was done, send the generated file url to the client res.status(200).json({ url: uploadUrl, }); }); // pipe request body to busboy for processing return req.pipe(busboyInstance); } static getMaxFileSize() { const configOption = Config.values.fileUpload.maxFileSize; // Busboy uses Infinity to allow unlimited file size if (configOption < 1) { return Infinity; } // maxFileSize is in bytes, but config option is passed in as KB return configOption * 1024; } // Returns null if an error occurred (e.g. file not found) // Returns a string with the type otherwise static async getFileType(filePath: string) { try { const buffer = await readChunk(filePath, 0, 5120); // returns {ext, mime} if found, null if not. const file = await fileType.fromBuffer(buffer); // if a file type was detected correctly, return it if (file) { return file.mime; } // if the buffer is a valid UTF-8 buffer, use text/plain if (isUtf8(buffer)) { return "text/plain"; } // otherwise assume it's random binary data return "application/octet-stream"; } catch (e: any) { if (e.code !== "ENOENT") { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions log.warn(`Failed to read ${filePath}: ${e.message}`); } } return null; } } export default Uploader;