UNPKG

thelounge

Version:

The self-hosted Web IRC client

270 lines (269 loc) 11.6 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const config_1 = __importDefault(require("../config")); const busboy_1 = __importDefault(require("@fastify/busboy")); const uuid_1 = require("uuid"); const path_1 = __importDefault(require("path")); const fs_1 = __importDefault(require("fs")); const file_type_1 = __importDefault(require("file-type")); const read_chunk_1 = __importDefault(require("read-chunk")); const crypto_1 = __importDefault(require("crypto")); const is_utf8_1 = __importDefault(require("is-utf8")); const log_1 = __importDefault(require("../log")); const content_disposition_1 = __importDefault(require("content-disposition")); // 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.on("upload:auth", () => { const token = (0, uuid_1.v4)(); 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(token) { return setTimeout(() => uploadTokens.delete(token), 60 * 1000); } // TODO: type static router(express) { express.get("/uploads/:name/:slug*?", Uploader.routeGetFile); express.post("/uploads/new/:token", Uploader.routeUploadFile); } static async routeGetFile(req, res) { 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_1.default.getFileUploadPath(); const filePath = path_1.default.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 = (0, content_disposition_1.default)(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(req, res) { let busboyInstance; let uploadUrl; let randomName; let destDir; let destPath; let streamWriter; 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) => { doneCallback(); // if we ended up erroring out, delete the output file from disk if (destPath && fs_1.default.existsSync(destPath)) { fs_1.default.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_1.default({ headers: req.headers, limits: { files: 1, 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_1.default.randomBytes(8).toString("hex"); destDir = path_1.default.join(config_1.default.getFileUploadPath(), randomName.substring(0, 2)); destPath = path_1.default.join(destDir, randomName); } while (fs_1.default.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_1.default.mkdirSync(destDir, { recursive: true }); } catch (err) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions log_1.default.error(`Error ensuring ${destDir} exists for uploads: ${err.message}`); return abortWithError(err); } // Open a file stream for writing streamWriter = fs_1.default.createWriteStream(destPath); streamWriter.on("error", abortWithError); busboyInstance.on("file", (fieldname, fileStream, filename) => { uploadUrl = `${randomName}/${encodeURIComponent(filename)}`; if (config_1.default.values.fileUpload.baseUrl) { uploadUrl = new URL(uploadUrl, config_1.default.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<any, Record<string, any>>' is not assignable to parameter of type '{ (err: any): Response<any, Record<string, any>>; (): 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_1.default.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) { try { const buffer = await (0, read_chunk_1.default)(filePath, 0, 5120); // returns {ext, mime} if found, null if not. const file = await file_type_1.default.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 ((0, is_utf8_1.default)(buffer)) { return "text/plain"; } // otherwise assume it's random binary data return "application/octet-stream"; } catch (e) { if (e.code !== "ENOENT") { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions log_1.default.warn(`Failed to read ${filePath}: ${e.message}`); } } return null; } } exports.default = Uploader;