UNPKG

ghost

Version:

The professional publishing platform

372 lines (321 loc) 11.6 kB
const path = require('path'); const os = require('os'); const multer = require('multer'); const fs = require('fs-extra'); const zlib = require('zlib'); const util = require('util'); const errors = require('@tryghost/errors'); const config = require('../../../../shared/config'); const tpl = require('@tryghost/tpl'); const logging = require('@tryghost/logging'); const gunzip = util.promisify(zlib.gunzip); const gzip = util.promisify(zlib.gzip); const messages = { db: { missingFile: 'Please select a database file to import.', invalidFile: 'Unsupported file. Please try any of the following formats: {extensions}' }, redirects: { missingFile: 'Please select a JSON file.', invalidFile: 'Please select a valid JSON file to import.' }, routes: { missingFile: 'Please select a YAML file.', invalidFile: 'Please select a valid YAML file to import.' }, themes: { missingFile: 'Please select a theme.', invalidFile: 'Please select a valid zip file.' }, members: { missingFile: 'Please select a members CSV file.', invalidFile: 'Please select a valid CSV file.' }, images: { missingFile: 'Please select an image.', invalidFile: 'Please select a valid image.' }, svg: { missingFile: 'Please select a SVG image.', invalidFile: 'Please select a valid SVG image' }, icons: { missingFile: 'Please select an icon.', invalidFile: 'Icon must be a square .ico or .png file between 60px – 1,000px, under 100kb.' }, media: { missingFile: 'Please select a media file.', invalidFile: 'Please select a valid media file.' }, thumbnail: { missingFile: 'Please select a thumbnail.', invalidFile: 'Please select a valid thumbnail.' } }; const enabledClear = config.get('uploadClear') || true; const upload = multer({dest: os.tmpdir()}); const deleteSingleFile = (file) => { if (!file.path) { return; } fs.unlink(file.path).catch(err => logging.error(err)); }; const single = name => function singleUploadFunction(req, res, next) { const singleUpload = upload.single(name); singleUpload(req, res, (err) => { if (err) { // Busboy, Multer or Dicer errors are usually caused by invalid file uploads if (err instanceof multer.MulterError || err.stack?.includes('dicer') || err.stack?.includes('busboy')) { return next(new errors.BadRequestError({ err })); } return next(err); } if (enabledClear) { const deleteFiles = () => { res.removeListener('finish', deleteFiles); res.removeListener('close', deleteFiles); if (!req.disableUploadClear) { if (req.files) { return req.files.forEach(deleteSingleFile); } if (req.file) { return deleteSingleFile(req.file); } } }; if (!req.disableUploadClear) { res.on('finish', deleteFiles); res.on('close', deleteFiles); } } next(); }); }; const media = (fileName, thumbName) => function mediaUploadFunction(req, res, next) { const mediaUpload = upload.fields([{ name: fileName, maxCount: 1 }, { name: thumbName, maxCount: 1 }]); mediaUpload(req, res, (err) => { if (err) { // Busboy, Multer or Dicer errors are usually caused by invalid file uploads if (err instanceof multer.MulterError || err.stack?.includes('dicer') || err.stack?.includes('busboy')) { return next(new errors.BadRequestError({ err })); } return next(err); } if (enabledClear) { const deleteFiles = () => { res.removeListener('finish', deleteFiles); res.removeListener('close', deleteFiles); if (!req.disableUploadClear) { if (req.files.file) { return req.files.file.forEach(deleteSingleFile); } if (req.files.thumbnail) { return req.files.thumbnail.forEach(deleteSingleFile); } } }; if (!req.disableUploadClear) { res.on('finish', deleteFiles); res.on('close', deleteFiles); } } next(); }); }; const checkFileExists = (fileData) => { return !!(fileData.mimetype && fileData.path); }; const checkFileIsValid = (fileData, types, extensions) => { const type = fileData.mimetype; if (types.includes(type) && extensions.includes(fileData.ext)) { return true; } return false; }; /** * * @param {String} filepath * @returns {Promise<String | null>} * * Reads the SVG file, sanitizes it, and writes the sanitized content back to the file. * Returns the sanitized content or null if the SVG could not be sanitized. */ const sanitizeSvg = async (filepath, isZipped = false) => { try { const original = await readSvg(filepath, isZipped); const sanitized = sanitizeSvgContent(original); if (!sanitized) { return null; } await writeSvg(filepath, sanitized, isZipped); return sanitized; } catch (error) { logging.error('Error sanitizing SVG:', error); return null; } }; /** * * @param {String} content * @returns {String | null} * * Returns sanitized SVG content, or null if the content is invalid. * */ const sanitizeSvgContent = (content) => { const {JSDOM} = require('jsdom'); const createDOMPurify = require('dompurify'); const window = new JSDOM('').window; const DOMPurify = createDOMPurify(window); const sanitized = DOMPurify.sanitize(content, {USE_PROFILES: {svg: true, svgFilters: true}}); // Check whether the sanitized content still contains a non-empty <svg> tag const validSvgTag = sanitized?.match(/<svg[^>]*>\s*[\S]+[\S\s]*<\/svg>/); if (!sanitized || sanitized.trim() === '' || !validSvgTag) { return null; } return sanitized; }; /** * * @param {String} filepath * @param {Boolean} isZipped * @returns {Promise<String | null>} * * Reads .svg or .svgz files and returns the content as a string. * */ const readSvg = async (filepath, isZipped = false) => { if (isZipped) { const compressed = await fs.readFile(filepath); return (await gunzip(compressed)).toString(); } return await fs.readFile(filepath, 'utf8'); }; /** * * @param {String} filepath * @param {String} content * @param {Boolean} isZipped * * Writes SVG content to a .svg or .svgz file. */ const writeSvg = async (filepath, content, isZipped = false) => { if (isZipped) { const compressed = await gzip(content); return await fs.writeFile(filepath, compressed); } return await fs.writeFile(filepath, content); }; /** * * @param {Object} options * @param {String} options.type - type of the file * @returns {import('express').RequestHandler} */ const validation = function ({type}) { // if we finish the data/importer logic, we forward the request to the specified importer /** * @param {import('express').Request} req * @param {import('express').Response} res * @param {import('express').NextFunction} next */ return async function uploadValidation(req, res, next) { const extensions = (config.get('uploads')[type] && config.get('uploads')[type].extensions) || []; const contentTypes = (config.get('uploads')[type] && config.get('uploads')[type].contentTypes) || []; req.file = req.file || {}; req.file.name = req.file.originalname; req.file.type = req.file.mimetype; // Check if a file was provided if (!checkFileExists(req.file)) { return next(new errors.ValidationError({ message: tpl(messages[type].missingFile) })); } req.file.ext = path.extname(req.file.name).toLowerCase(); // Check if the file is valid if (!checkFileIsValid(req.file, contentTypes, extensions)) { return next(new errors.UnsupportedMediaTypeError({ message: tpl(messages[type].invalidFile, {extensions: extensions}) })); } // Sanitize SVG files if (req.file.ext === '.svg' || req.file.ext === '.svgz') { const sanitized = await sanitizeSvg(req.file.path, req.file.ext === '.svgz'); if (!sanitized) { return next(new errors.UnsupportedMediaTypeError({ message: tpl(messages.svg.invalidFile) })); } } next(); }; }; /** * * @param {Object} options * @param {String} options.type - type of the file * @returns {import('express').RequestHandler} */ const mediaValidation = function ({type}) { return function mediaUploadValidation(req, res, next) { const extensions = (config.get('uploads')[type] && config.get('uploads')[type].extensions) || []; const contentTypes = (config.get('uploads')[type] && config.get('uploads')[type].contentTypes) || []; const thumbnailExtensions = (config.get('uploads').thumbnails && config.get('uploads').thumbnails.extensions) || []; const thumbnailContentTypes = (config.get('uploads').thumbnails && config.get('uploads').thumbnails.contentTypes) || []; const {file: [file] = []} = req.files; if (!file || !checkFileExists(file)) { return next(new errors.ValidationError({ message: tpl(messages[type].missingFile) })); } req.file = file; req.file.name = req.file.originalname; req.file.type = req.file.mimetype; req.file.ext = path.extname(req.file.name).toLowerCase(); if (!checkFileIsValid(req.file, contentTypes, extensions)) { return next(new errors.UnsupportedMediaTypeError({ message: tpl(messages[type].invalidFile, {extensions: extensions}) })); } const {thumbnail: [thumbnailFile] = []} = req.files; if (thumbnailFile) { if (!checkFileExists(thumbnailFile)) { return next(new errors.ValidationError({ message: tpl(messages.thumbnail.missingFile) })); } req.thumbnail = thumbnailFile; req.thumbnail.ext = path.extname(thumbnailFile.originalname).toLowerCase(); req.thumbnail.name = `${path.basename(req.file.name, path.extname(req.file.name))}_thumb${req.thumbnail.ext}`; req.thumbnail.type = req.thumbnail.mimetype; if (!checkFileIsValid(req.thumbnail, thumbnailContentTypes, thumbnailExtensions)) { return next(new errors.UnsupportedMediaTypeError({ message: tpl(messages.thumbnail.invalidFile, {extensions: thumbnailExtensions}) })); } } next(); }; }; module.exports = { single, media, validation, mediaValidation }; // Exports for testing only module.exports._test = { checkFileExists, checkFileIsValid, sanitizeSvgContent };