Files
2026-07-31 15:11:57 +02:00

131 lines
3.7 KiB
JavaScript

const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const multer = require("multer");
const uploadDirectory = path.resolve(
process.env.UPLOAD_DIR || path.join(process.cwd(), "uploads"),
);
const maxFileSize = Number.parseInt(process.env.MAX_UPLOAD_SIZE_BYTES, 10) || 5 * 1024 * 1024;
const extensionByMimeType = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
};
// File signatures (magic bytes) that must match the declared Content-Type.
const signatureByMimeType = {
"image/jpeg": [Buffer.from([0xff, 0xd8, 0xff])],
"image/png": [Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
"image/gif": [Buffer.from("GIF87a"), Buffer.from("GIF89a")],
};
function hasValidImageSignature(file) {
const signatures = signatureByMimeType[file.mimetype];
if (!signatures) {
return isWebP(file.path);
}
const header = Buffer.alloc(12);
const fd = fs.openSync(file.path, "r");
fs.readSync(fd, header, 0, 12, 0);
fs.closeSync(fd);
return signatures.some((signature) =>
header.subarray(0, signature.length).equals(signature),
);
}
function isWebP(filePath) {
const header = Buffer.alloc(12);
const fd = fs.openSync(filePath, "r");
fs.readSync(fd, header, 0, 12, 0);
fs.closeSync(fd);
return (
header.subarray(0, 4).equals(Buffer.from("RIFF")) &&
header.subarray(8, 12).equals(Buffer.from("WEBP"))
);
}
fs.mkdirSync(uploadDirectory, { recursive: true });
const storage = multer.diskStorage({
destination: uploadDirectory,
filename: (req, file, callback) => {
callback(null, `${crypto.randomUUID()}${extensionByMimeType[file.mimetype]}`);
},
});
const uploadImage = multer({
storage,
limits: { fileSize: maxFileSize, files: 1 },
fileFilter: (req, file, callback) => {
if (!extensionByMimeType[file.mimetype]) {
return callback(new Error("Only JPEG, PNG, GIF, and WebP images are allowed"));
}
callback(null, true);
},
}).single("image");
const ALLOWED_IMAGE_PREFIXES = ["/posts/image/", "/profiles/image/"];
function handleImageUpload(req, res, next) {
uploadImage(req, res, (err) => {
if (!err) {
if (req.file && !hasValidImageSignature(req.file)) {
fs.promises.unlink(req.file.path).catch(() => {});
return res.status(400).send("Uploaded file is not a valid image");
}
return next();
}
if (err.code === "LIMIT_FILE_SIZE") {
return res.status(413).send("Image exceeds the maximum allowed size");
}
return res.status(400).send(err.message);
});
}
function handlePostUpload(req, res, next) {
return handleImageUpload(req, res, next);
}
function handleProfileUpload(req, res, next) {
return handleImageUpload(req, res, next);
}
function imageLinkForFile(file, prefix = "/posts/image/") {
return file ? `${prefix}${file.filename}` : null;
}
function profileImageLinkForFile(file) {
return imageLinkForFile(file, "/profiles/image/");
}
async function removeImageLink(imageLink) {
if (!imageLink || !ALLOWED_IMAGE_PREFIXES.some((prefix) => imageLink.startsWith(prefix))) {
return;
}
const filename = path.basename(imageLink);
await fs.promises.unlink(path.join(uploadDirectory, filename)).catch((err) => {
if (err.code !== "ENOENT") throw err;
});
}
async function removeUploadedProfileFile(file) {
if (file) await removeImageLink(profileImageLinkForFile(file));
}
async function removeUploadedFile(file) {
if (file) await removeImageLink(imageLinkForFile(file));
}
module.exports = {
handlePostUpload,
handleProfileUpload,
imageLinkForFile,
profileImageLinkForFile,
removeImageLink,
removeUploadedFile,
removeUploadedProfileFile,
uploadDirectory,
};