2018-09-03 07:30:05 +00:00
|
|
|
"use strict";
|
|
|
|
|
|
|
|
const Helper = require("../helper");
|
2019-02-19 15:12:08 +00:00
|
|
|
const busboy = require("busboy");
|
2020-02-24 13:35:15 +00:00
|
|
|
const {v4: uuidv4} = require("uuid");
|
2018-09-03 07:30:05 +00:00
|
|
|
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");
|
2021-04-03 10:32:49 +00:00
|
|
|
const contentDisposition = require("content-disposition");
|
2021-04-01 14:46:45 +00:00
|
|
|
const sharp = require("sharp");
|
2021-04-03 10:32:49 +00:00
|
|
|
|
|
|
|
// 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",
|
2021-04-11 13:43:42 +00:00
|
|
|
"audio/flac": "audio.flac",
|
2021-04-03 10:32:49 +00:00
|
|
|
"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",
|
|
|
|
};
|
2018-09-03 07:30:05 +00:00
|
|
|
|
2019-02-19 15:12:08 +00:00
|
|
|
const uploadTokens = new Map();
|
|
|
|
|
2018-09-03 07:30:05 +00:00
|
|
|
class Uploader {
|
2018-09-04 09:08:30 +00:00
|
|
|
constructor(socket) {
|
2019-02-19 15:12:08 +00:00
|
|
|
socket.on("upload:auth", () => {
|
|
|
|
const token = uuidv4();
|
2018-09-03 07:30:05 +00:00
|
|
|
|
2019-02-19 15:12:08 +00:00
|
|
|
socket.emit("upload:auth", token);
|
2018-09-03 07:30:05 +00:00
|
|
|
|
2019-02-19 15:12:08 +00:00
|
|
|
// Invalidate the token in one minute
|
2020-07-15 09:29:02 +00:00
|
|
|
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);
|
2019-02-19 15:12:08 +00:00
|
|
|
});
|
2018-09-03 07:30:05 +00:00
|
|
|
}
|
|
|
|
|
2020-07-15 09:29:02 +00:00
|
|
|
static createTokenTimeout(token) {
|
|
|
|
return setTimeout(() => uploadTokens.delete(token), 60 * 1000);
|
2018-09-03 07:30:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
static router(express) {
|
2019-02-19 15:12:08 +00:00
|
|
|
express.get("/uploads/:name/:slug*?", Uploader.routeGetFile);
|
|
|
|
express.post("/uploads/new/:token", Uploader.routeUploadFile);
|
|
|
|
}
|
|
|
|
|
2020-01-08 14:11:01 +00:00
|
|
|
static async routeGetFile(req, res) {
|
2019-02-19 15:12:08 +00:00
|
|
|
const name = req.params.name;
|
|
|
|
|
|
|
|
const nameRegex = /^[0-9a-f]{16}$/;
|
2018-09-03 07:30:05 +00:00
|
|
|
|
2019-02-19 15:12:08 +00:00
|
|
|
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);
|
2020-02-26 08:07:40 +00:00
|
|
|
let detectedMimeType = await Uploader.getFileType(filePath);
|
2019-02-19 15:12:08 +00:00
|
|
|
|
|
|
|
// doesn't exist
|
|
|
|
if (detectedMimeType === null) {
|
|
|
|
return res.status(404).send("Not found");
|
|
|
|
}
|
|
|
|
|
2020-07-15 09:29:02 +00:00
|
|
|
// Force a download in the browser if it's not an allowed type (binary or otherwise unknown)
|
2021-04-03 10:32:49 +00:00
|
|
|
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,
|
|
|
|
});
|
|
|
|
}
|
2019-02-19 15:12:08 +00:00
|
|
|
|
2020-02-26 08:07:40 +00:00
|
|
|
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";
|
|
|
|
}
|
|
|
|
|
2021-04-03 10:32:49 +00:00
|
|
|
res.setHeader("Content-Disposition", disposition);
|
2019-02-19 15:12:08 +00:00
|
|
|
res.setHeader("Cache-Control", "max-age=86400");
|
|
|
|
res.contentType(detectedMimeType);
|
2018-09-03 07:30:05 +00:00
|
|
|
|
2019-02-19 15:12:08 +00:00
|
|
|
return res.sendFile(filePath);
|
|
|
|
}
|
|
|
|
|
|
|
|
static routeUploadFile(req, res) {
|
|
|
|
let busboyInstance;
|
|
|
|
let uploadUrl;
|
|
|
|
let randomName;
|
|
|
|
let destDir;
|
|
|
|
let destPath;
|
|
|
|
let streamWriter;
|
2021-04-13 18:45:16 +00:00
|
|
|
let removeMetadata;
|
2019-02-19 15:12:08 +00:00
|
|
|
|
|
|
|
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;
|
2018-09-03 07:30:05 +00:00
|
|
|
}
|
|
|
|
|
2019-02-19 15:12:08 +00:00
|
|
|
// close the output file stream
|
|
|
|
if (streamWriter) {
|
|
|
|
streamWriter.end();
|
|
|
|
streamWriter = null;
|
|
|
|
}
|
|
|
|
};
|
2018-09-03 07:30:05 +00:00
|
|
|
|
2021-04-13 18:39:45 +00:00
|
|
|
const successfullCompletion = () => {
|
|
|
|
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,
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
2019-02-19 15:12:08 +00:00
|
|
|
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;
|
2018-09-03 07:30:05 +00:00
|
|
|
}
|
|
|
|
|
2019-02-19 15:12:08 +00:00
|
|
|
return res.status(400).json({error: err.message});
|
|
|
|
};
|
|
|
|
|
|
|
|
// if the authentication token is incorrect, bail out
|
|
|
|
if (uploadTokens.delete(req.params.token) !== true) {
|
2019-08-25 17:14:34 +00:00
|
|
|
return abortWithError(Error("Invalid upload token"));
|
2019-02-19 15:12:08 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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")));
|
2021-04-13 18:45:16 +00:00
|
|
|
busboyInstance.on("field", (fieldname, val) => {
|
|
|
|
if (fieldname === "removeMetadata") {
|
|
|
|
removeMetadata = val === "true";
|
|
|
|
}
|
|
|
|
});
|
2019-02-19 15:12:08 +00:00
|
|
|
|
|
|
|
// 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 {
|
2020-03-16 11:53:48 +00:00
|
|
|
fs.mkdirSync(destDir, {recursive: true});
|
2019-02-19 15:12:08 +00:00
|
|
|
} catch (err) {
|
|
|
|
log.err(`Error ensuring ${destDir} exists for uploads: ${err.message}`);
|
|
|
|
return abortWithError(err);
|
|
|
|
}
|
|
|
|
|
2021-04-01 14:46:45 +00:00
|
|
|
busboyInstance.on("file", (fieldname, fileStream, filename, encoding, contentType) => {
|
2019-10-31 11:21:22 +00:00
|
|
|
uploadUrl = `${randomName}/${encodeURIComponent(filename)}`;
|
|
|
|
|
|
|
|
if (Helper.config.fileUpload.baseUrl) {
|
|
|
|
uploadUrl = new URL(uploadUrl, Helper.config.fileUpload.baseUrl).toString();
|
|
|
|
} else {
|
|
|
|
uploadUrl = `uploads/${uploadUrl}`;
|
|
|
|
}
|
2019-01-30 15:36:37 +00:00
|
|
|
|
2021-04-01 14:46:45 +00:00
|
|
|
// 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");
|
|
|
|
|
2019-02-19 15:12:08 +00:00
|
|
|
// 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", () => {
|
2021-04-01 14:46:45 +00:00
|
|
|
if (!isImage) {
|
|
|
|
fileStream.unpipe(streamWriter);
|
|
|
|
}
|
|
|
|
|
2019-02-19 15:12:08 +00:00
|
|
|
fileStream.on("readable", fileStream.read.bind(fileStream));
|
2018-09-03 07:30:05 +00:00
|
|
|
|
2019-02-19 15:12:08 +00:00
|
|
|
abortWithError(Error("File size limit reached"));
|
|
|
|
});
|
|
|
|
|
2021-04-01 14:46:45 +00:00
|
|
|
if (isImage) {
|
2021-04-13 18:39:45 +00:00
|
|
|
let sharpInstance = sharp({
|
|
|
|
animated: true,
|
|
|
|
pages: -1,
|
|
|
|
sequentialRead: true,
|
|
|
|
});
|
|
|
|
|
2021-04-13 18:45:16 +00:00
|
|
|
if (!removeMetadata) {
|
|
|
|
sharpInstance = sharpInstance.withMetadata();
|
|
|
|
}
|
|
|
|
|
2021-04-13 18:39:45 +00:00
|
|
|
sharpInstance
|
|
|
|
.rotate() // auto-orient based on the EXIF Orientation tag
|
|
|
|
.toFile(destPath, (err) => {
|
|
|
|
// Removes metadata by default https://sharp.pixelplumbing.com/api-output#tofile if no `withMetadata` is present
|
|
|
|
if (err) {
|
|
|
|
abortWithError(err);
|
|
|
|
} else {
|
|
|
|
successfullCompletion();
|
|
|
|
}
|
2021-04-01 14:46:45 +00:00
|
|
|
});
|
2021-04-13 18:39:45 +00:00
|
|
|
|
|
|
|
fileStream.pipe(sharpInstance);
|
2021-04-01 14:46:45 +00:00
|
|
|
} else {
|
2021-04-13 18:39:45 +00:00
|
|
|
// Open a file stream for writing
|
|
|
|
streamWriter = fs.createWriteStream(destPath);
|
|
|
|
streamWriter.on("error", abortWithError);
|
|
|
|
streamWriter.on("finish", successfullCompletion);
|
|
|
|
|
2021-04-01 14:46:45 +00:00
|
|
|
// Attempt to write the stream to file
|
|
|
|
fileStream.pipe(streamWriter);
|
|
|
|
}
|
2018-09-03 07:30:05 +00:00
|
|
|
});
|
2019-02-19 15:12:08 +00:00
|
|
|
|
|
|
|
// pipe request body to busboy for processing
|
|
|
|
return req.pipe(busboyInstance);
|
2018-09-03 07:30:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
static getMaxFileSize() {
|
|
|
|
const configOption = Helper.config.fileUpload.maxFileSize;
|
|
|
|
|
2019-02-19 15:12:08 +00:00
|
|
|
// Busboy uses Infinity to allow unlimited file size
|
|
|
|
if (configOption < 1) {
|
|
|
|
return Infinity;
|
2018-09-03 07:30:05 +00:00
|
|
|
}
|
|
|
|
|
2019-02-19 15:12:08 +00:00
|
|
|
// maxFileSize is in bytes, but config option is passed in as KB
|
2018-09-03 07:30:05 +00:00
|
|
|
return configOption * 1024;
|
|
|
|
}
|
|
|
|
|
2019-01-30 15:36:37 +00:00
|
|
|
// Returns null if an error occurred (e.g. file not found)
|
|
|
|
// Returns a string with the type otherwise
|
2020-01-08 14:11:01 +00:00
|
|
|
static async getFileType(filePath) {
|
2018-09-03 07:30:05 +00:00
|
|
|
try {
|
2020-02-06 10:41:43 +00:00
|
|
|
const buffer = await readChunk(filePath, 0, 5120);
|
2018-09-03 07:30:05 +00:00
|
|
|
|
2019-01-30 15:36:37 +00:00
|
|
|
// returns {ext, mime} if found, null if not.
|
2020-01-08 14:11:01 +00:00
|
|
|
const file = await fileType.fromBuffer(buffer);
|
2018-09-03 07:30:05 +00:00
|
|
|
|
2019-02-19 15:12:08 +00:00
|
|
|
// if a file type was detected correctly, return it
|
2019-01-30 15:36:37 +00:00
|
|
|
if (file) {
|
|
|
|
return file.mime;
|
|
|
|
}
|
|
|
|
|
2019-02-19 15:12:08 +00:00
|
|
|
// if the buffer is a valid UTF-8 buffer, use text/plain
|
2019-01-30 15:36:37 +00:00
|
|
|
if (isUtf8(buffer)) {
|
|
|
|
return "text/plain";
|
|
|
|
}
|
2018-09-03 07:30:05 +00:00
|
|
|
|
2019-02-19 15:12:08 +00:00
|
|
|
// otherwise assume it's random binary data
|
2019-01-30 15:36:37 +00:00
|
|
|
return "application/octet-stream";
|
|
|
|
} catch (e) {
|
|
|
|
if (e.code !== "ENOENT") {
|
|
|
|
log.warn(`Failed to read ${filePath}: ${e.message}`);
|
|
|
|
}
|
2018-09-03 07:30:05 +00:00
|
|
|
}
|
|
|
|
|
2019-01-30 15:36:37 +00:00
|
|
|
return null;
|
2018-09-03 07:30:05 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = Uploader;
|