UNPKG

ghost-storage-base

Version:
180 lines (153 loc) 6.11 kB
const moment = require('moment'); const path = require('path'); const crypto = require('crypto'); // Most UNIX filesystems have a 255 bytes limit for the filename length // We keep 2 additional bytes, to make space for a file suffix (e.g. "_o" for original files after transformation) const MAX_FILENAME_BYTES = 253; class StorageBase { constructor() { Object.defineProperty(this, 'requiredFns', { value: ['exists', 'save', 'serve', 'delete', 'read'], writable: false }); } /** * Returns the target directory for a given base directory * * @param {String} baseDir -- the base directory to save the file in * @returns {string} -- Returns a directory path with the format baseDir/YYYY/MM, e.g. content/images/2025/01 */ getTargetDir(baseDir) { const date = moment(); const month = date.format('MM'); const year = date.format('YYYY'); if (baseDir) { return path.join(baseDir, year, month); } return path.join(year, month); } /** * Generates a unique and secure file path for a given file and target directory * * @param {Object} file * @param {String} file.name -- the name of the file * @param {String} targetDir -- the target directory to save the file in * @returns {string} */ getUniqueSecureFilePath(file, targetDir) { const originalFileName = path.basename(file.name); const sanitizedFileName = this.sanitizeFileName(originalFileName); const ext = this.getFileExtension(sanitizedFileName); // e.g. ".png" const name = path.basename(sanitizedFileName, ext); // e.g. my-file const hash = this.generateSecureHash(); // e.g. 1a2b3c4d5e6f7890 const newFileName = this.generateFileName({name, hash, ext}); // e.g. my-file-1a2b3c4d5e6f7890.png return path.join(targetDir, newFileName); } /** * Sanitizes the file name, to accept only ASCII characters, '@', and '.' * Replaces other characters with dashes. * Example: город.zip is replaced with ----.zip * * @param {String} fileName * @returns {String} */ sanitizeFileName(fileName) { return fileName.replace(/[^\w@.]/gi, '-'); } /** * Extracts the file extension from the filename, if it is a valid extension * * @param {string} fileName * @returns {string} */ getFileExtension(filename) { const ext = path.extname(filename); // If the extension is not a valid extension, return an empty string if (!ext.match(/(\.[a-z0-9]{2,10})+$/i)) { return ''; } return ext; } /** * Generates a secure hash for the filename, to make it very unlikely to be guessed and very likely to be unique * Uses 8 random bytes -> 8 * 8 = 64 bits of entropy -> 2^64 possible combinations (18 quintillion, i.e. 18 followed by 18 zeros) * * @returns {string} */ generateSecureHash() { return crypto.randomBytes(8).toString('hex'); } /** * Generates a filename with the following format: my-file-1a2b3c4d5e6f7890.png, with a filename length under MAX_FILENAME_LENGTH * * @param {String} name -- name of the file without extension, e.g. my-file. If needed, this will be truncated, so that the filename is under MAX_FILENAME_BYTES * @param {String} hash -- a secured hash to append the file, after the stem of the file, e.g. 1a2b3c4d5e6f * @param {String} ext -- the extension of the file, e.g. ".png" * @returns {String} */ generateFileName({name, hash, ext = ''}) { const encoder = new TextEncoder(); let filename = `${name}-${hash}${ext}`; const fileNameBytes = encoder.encode(filename); // If the filename has more bytes than the maximum allowed, truncate the name if (fileNameBytes.length > MAX_FILENAME_BYTES) { const nameBytes = encoder.encode(name); const bytesToRemove = fileNameBytes.length - MAX_FILENAME_BYTES; const truncatedNameBytes = nameBytes.slice(0, -bytesToRemove); const decoder = new TextDecoder(); filename = `${decoder.decode(truncatedNameBytes)}-${hash}${ext}`; } return filename; } /** * [Deprecated] Returns a unique file path for a given file and target directory * * @param {Object} file * @param {string} file.name * @param {string} targetDir * @returns {Promise<string>} * @deprecated use getUniqueSecureFilePath instead */ getUniqueFileName(file, targetDir) { var ext = path.extname(file.name), name; // poor extension validation // .1 or .342 is not a valid extension, .mp4 is though! if (!ext.match(/\.\d+$/)) { name = this.sanitizeFileName(path.basename(file.name, ext)); return this.generateUnique(targetDir, name, ext, 0); } else { name = this.sanitizeFileName(path.basename(file.name)); return this.generateUnique(targetDir, name, null, 0); } } /** * [Deprecated] Generates a unique file path using a numbering system, e.g. image.png, image-1.png, image-2.png, etc. * @param {String} dir * @param {String} name * @param {String} ext * @param {Number} i index * @returns {Promise<String>} * @deprecated use getUniqueSecureFilePath instead */ generateUnique(dir, name, ext, i) { let filename; let append = ''; if (i) { append = '-' + i; } if (ext) { filename = name + append + ext; } else { filename = name + append; } return this.exists(filename, dir).then((exists) => { if (exists) { i = i + 1; return this.generateUnique(dir, name, ext, i); } else { return path.join(dir, filename); } }); } } module.exports = StorageBase;