thelounge/src/plugins/uploader.js
Nachtalb 3a6ac4e5ec
Support animated webp images
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/
2021-04-13 18:24:32 +02:00

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;