mirror of
https://github.com/thelounge/thelounge.git
synced 2024-05-03 15:03:41 +02:00
3a6ac4e5ec
We need to remove the metadata without breaking the animation. For that we use sharp which incooperates libvips (binaries for most common distros included). This also decreases client side upload complexity as we remove the metadata on the serverside. Sharp: https://sharp.pixelplumbing.com/ libvips: https://libvips.github.io/libvips/
326 lines
9 KiB
JavaScript
326 lines
9 KiB
JavaScript
"use strict";
|
|
|
|
const Helper = require("../helper");
|
|
const busboy = require("busboy");
|
|
const {v4: uuidv4} = require("uuid");
|
|
const path = require("path");
|
|
const fs = require("fs");
|
|
const fileType = require("file-type");
|
|
const readChunk = require("read-chunk");
|
|
const crypto = require("crypto");
|
|
const isUtf8 = require("is-utf8");
|
|
const log = require("../log");
|
|
const contentDisposition = require("content-disposition");
|
|
const sharp = require("sharp");
|
|
|
|
// 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/flac": "audio.flac",
|
|
"image/bmp": "image.bmp",
|
|
"image/gif": "image.gif",
|
|
"image/jpeg": "image.jpg",
|
|
"image/png": "image.png",
|
|
"image/webp": "image.webp",
|
|
"image/avif": "image.avif",
|
|
"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 = 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(token) {
|
|
return setTimeout(() => uploadTokens.delete(token), 60 * 1000);
|
|
}
|
|
|
|
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 = Helper.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,
|
|
});
|
|
}
|
|
|
|
if (detectedMimeType === "audio/vnd.wave") {
|
|
// Send a more common mime type for wave audio files
|
|
// so that browsers can play them correctly
|
|
detectedMimeType = "audio/wav";
|
|
}
|
|
|
|
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.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"] < 1) {
|
|
return abortWithError(Error("Length Required"));
|
|
}
|
|
|
|
// Only allow multipart, as busboy can throw an error on unsupported types
|
|
if (!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,
|
|
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(Helper.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) {
|
|
log.err(`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, fileStream, filename, encoding, contentType) => {
|
|
uploadUrl = `${randomName}/${encodeURIComponent(filename)}`;
|
|
|
|
if (Helper.config.fileUpload.baseUrl) {
|
|
uploadUrl = new URL(uploadUrl, Helper.config.fileUpload.baseUrl).toString();
|
|
} else {
|
|
uploadUrl = `uploads/${uploadUrl}`;
|
|
}
|
|
|
|
// Sharps prebuilt libvips does not include gif support, but that is not a problem,
|
|
// as GIFs don't support EXIF metadata or anything alike
|
|
const isImage = contentType.startsWith("image/") && !contentType.endsWith("gif");
|
|
|
|
// if the busboy data stream errors out or goes over the file size limit
|
|
// abort the processing with an error
|
|
fileStream.on("error", abortWithError);
|
|
fileStream.on("limit", () => {
|
|
if (!isImage) {
|
|
fileStream.unpipe(streamWriter);
|
|
}
|
|
|
|
fileStream.on("readable", fileStream.read.bind(fileStream));
|
|
|
|
abortWithError(Error("File size limit reached"));
|
|
});
|
|
|
|
if (isImage) {
|
|
const chunks = [];
|
|
fileStream
|
|
.on("data", (chunk) => {
|
|
chunks.push(chunk);
|
|
})
|
|
.on("end", () => {
|
|
const buffer = Buffer.concat(chunks);
|
|
sharp(buffer, {animated: true, pages: -1, sequentialRead: true})
|
|
.toFile(destPath) // Removes metadata by default https://sharp.pixelplumbing.com/api-output#tofile
|
|
.catch(abortWithError);
|
|
});
|
|
} else {
|
|
// 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 = Helper.config.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 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) {
|
|
if (e.code !== "ENOENT") {
|
|
log.warn(`Failed to read ${filePath}: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
module.exports = Uploader;
|