thelounge
Version:
The self-hosted Web IRC client
270 lines (269 loc) • 11.6 kB
JavaScript
;
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;