diff --git a/core/server/adapters/storage/LocalMediaStorage.js b/core/server/adapters/storage/LocalMediaStorage.js new file mode 100644 index 0000000000..6d5ebfed56 --- /dev/null +++ b/core/server/adapters/storage/LocalMediaStorage.js @@ -0,0 +1,177 @@ +// # Local File System Video Storage module +// The (default) module for storing media, using the local file system +const serveStatic = require('../../../shared/express').static; + +const fs = require('fs-extra'); +const path = require('path'); +const Promise = require('bluebird'); +const moment = require('moment'); +const config = require('../../../shared/config'); +const tpl = require('@tryghost/tpl'); +const logging = require('@tryghost/logging'); +const errors = require('@tryghost/errors'); +const constants = require('@tryghost/constants'); +const urlUtils = require('../../../shared/url-utils'); +const StorageBase = require('ghost-storage-base'); + +const messages = { + videoNotFound: 'Video not found', + videoNotFoundWithRef: 'Video not found: {video}', + cannotReadVideo: 'Could not read video: {video}' +}; + +class LocalMediaStore extends StorageBase { + constructor() { + super(); + + this.storagePath = config.getContentPath('media'); + } + + /** + * Saves the video to storage (the file system) + * + * @param {Object} video + * @param {String} video.name + * @param {String} video.type + * @param {String} video.path + * @param {String} targetDir + * @returns {Promise} + */ + async save(video, targetDir) { + let targetFilename; + + // NOTE: the base implementation of `getTargetDir` returns the format this.storagePath/YYYY/MM + targetDir = targetDir || this.getTargetDir(this.storagePath); + + const filename = await this.getUniqueFileName(video, targetDir); + + targetFilename = filename; + await fs.mkdirs(targetDir); + + await fs.copy(video.path, targetFilename); + + // The src for the video must be in URI format, not a file system path, which in Windows uses \ + // For local file system storage can use relative path so add a slash + const fullUrl = ( + urlUtils.urlJoin('/', + urlUtils.getSubdir(), + constants.STATIC_MEDIA_URL_PREFIX, + path.relative(this.storagePath, targetFilename)) + ).replace(new RegExp(`\\${path.sep}`, 'g'), '/'); + + return fullUrl; + } + + exists(fileName, targetDir) { + const filePath = path.join(targetDir || this.storagePath, fileName); + + return fs.stat(filePath) + .then(() => { + return true; + }) + .catch(() => { + return false; + }); + } + + /** + * For some reason send divides the max age number by 1000 + * Fallthrough: false ensures that if an video isn't found, it automatically 404s + * Wrap server static errors + * + * @returns {serveStaticContent} + */ + serve() { + const {storagePath} = this; + + return function serveStaticContent(req, res, next) { + const startedAtMoment = moment(); + + return serveStatic( + storagePath, + { + maxAge: constants.ONE_YEAR_MS, + fallthrough: false, + onEnd: () => { + logging.info('LocalMediaStorage.serve', req.path, moment().diff(startedAtMoment, 'ms') + 'ms'); + } + } + )(req, res, (err) => { + if (err) { + if (err.statusCode === 404) { + return next(new errors.NotFoundError({ + message: tpl(messages.videoNotFound), + code: 'STATIC_FILE_NOT_FOUND', + property: err.path + })); + } + + if (err.statusCode === 400) { + return next(new errors.BadRequestError({err: err})); + } + + if (err.statusCode === 403) { + return next(new errors.NoPermissionError({err: err})); + } + + return next(new errors.GhostError({err: err})); + } + + next(); + }); + }; + } + + /** + * Not implemented. + * @returns {Promise.<*>} + */ + delete() { + return Promise.reject('not implemented'); + } + + /** + * Reads bytes from disk for a target video + * - path of target video (without content path!) + * + * @param options + */ + read(options) { + options = options || {}; + + // remove trailing slashes + options.path = (options.path || '').replace(/\/$|\\$/, ''); + + const targetPath = path.join(this.storagePath, options.path); + + return new Promise((resolve, reject) => { + fs.readFile(targetPath, (err, bytes) => { + if (err) { + if (err.code === 'ENOENT' || err.code === 'ENOTDIR') { + return reject(new errors.NotFoundError({ + err: err, + message: tpl(messages.videoNotFoundWithRef, {video: options.path}) + })); + } + + if (err.code === 'ENAMETOOLONG') { + return reject(new errors.BadRequestError({err: err})); + } + + if (err.code === 'EACCES') { + return reject(new errors.NoPermissionError({err: err})); + } + + return reject(new errors.GhostError({ + err: err, + message: tpl(messages.cannotReadVideo, {video: options.path}) + })); + } + + resolve(bytes); + }); + }); + } +} + +module.exports = LocalMediaStore; diff --git a/core/server/adapters/storage/index.js b/core/server/adapters/storage/index.js index 769db73c2d..4faa690d3a 100644 --- a/core/server/adapters/storage/index.js +++ b/core/server/adapters/storage/index.js @@ -1,7 +1,7 @@ const adapterManager = require('../../services/adapter-manager'); /** - * @param {'images'|'videos'|'audios'} [feature] - name for the "feature" to enable through adapter, e.g.: images or videos storage + * @param {'images'|'media'|'files'} [feature] - name for the "feature" to enable through adapter, e.g.: images or media storage * @returns {Object} adapter instance */ function getStorage(feature) { diff --git a/core/shared/config/defaults.json b/core/shared/config/defaults.json index 881ec57bc8..b9e7a20876 100644 --- a/core/shared/config/defaults.json +++ b/core/shared/config/defaults.json @@ -23,7 +23,9 @@ } }, "storage": { - "active": "LocalFileStorage" + "active": "LocalFileStorage", + "media": "LocalMediaStorage", + "LocalMediaStorage": {} }, "scheduling": { "active": "SchedulingDefault" diff --git a/core/shared/config/helpers.js b/core/shared/config/helpers.js index 1d64d43470..a1dd04817b 100644 --- a/core/shared/config/helpers.js +++ b/core/shared/config/helpers.js @@ -32,6 +32,8 @@ const getContentPath = function getContentPath(type) { switch (type) { case 'images': return path.join(this.get('paths:contentPath'), 'images/'); + case 'media': + return path.join(this.get('paths:contentPath'), 'media/'); case 'themes': return path.join(this.get('paths:contentPath'), 'themes/'); case 'adapters': diff --git a/test/unit/server/services/adapter-manager/options-resolver.test.js b/test/unit/server/services/adapter-manager/options-resolver.test.js index ac881547d8..d338e15794 100644 --- a/test/unit/server/services/adapter-manager/options-resolver.test.js +++ b/test/unit/server/services/adapter-manager/options-resolver.test.js @@ -24,11 +24,11 @@ describe('Adapter Manager: options resolver', function () { }); it('returns adapter configuration based on specified feature', function () { - const name = 'storage:videos'; + const name = 'storage:media'; const adapterServiceConfig = { storage: { active: 'cloud-storage', - videos: 'local-storage', + media: 'local-storage', 'cloud-storage': { custom: 'configValue' }, @@ -48,11 +48,11 @@ describe('Adapter Manager: options resolver', function () { }); it('returns active configuration if piece of feature adapter is missing', function () { - const name = 'storage:videos'; + const name = 'storage:media'; const adapterServiceConfig = { storage: { active: 'cloud-storage', - videos: 'local-storage', + media: 'local-storage', 'cloud-storage': { custom: 'configValue' }