From 446d75ce717dc8dcbdf101135104f100b57fa6ae Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Sun, 13 May 2018 22:40:34 +0200 Subject: [PATCH] Support for custom block thumbnails in Mosaico templates --- client/src/lib/mosaico.js | 40 ++++++++------ models/files.js | 30 +++++++---- routes/mosaico.js | 101 +++++++++++++++++++++--------------- shared/mosaico-templates.js | 38 +++++++------- shared/templates.js | 26 +++++++--- 5 files changed, 139 insertions(+), 96 deletions(-) diff --git a/client/src/lib/mosaico.js b/client/src/lib/mosaico.js index 6a8541e9..85362826 100644 --- a/client/src/lib/mosaico.js +++ b/client/src/lib/mosaico.js @@ -7,8 +7,14 @@ import styles from "./mosaico.scss"; import {UntrustedContentHost} from './untrusted'; import {Icon} from "./bootstrap-components"; -import {getUrl} from "./urls"; -import {base, unbase} from "../../../shared/templates"; +import { + getSandboxUrl, + getTrustedUrl +} from "./urls"; +import { + base, + unbase +} from "../../../shared/templates"; export const ResourceType = { @@ -62,7 +68,7 @@ export class MosaicoEditor extends Component { return (
- {this.state.fullscreen && } + {this.state.fullscreen && }
{this.props.title}
@@ -96,8 +102,6 @@ export class MosaicoSandbox extends Component { } componentDidMount() { - const publicPath = 'public/mosaico'; - if (!Mosaico.isCompatible()) { alert('Update your browser!'); return; @@ -123,23 +127,24 @@ export class MosaicoSandbox extends Component { plugins.unshift(vm => { // This is an override of the default paths in Mosaico - vm.logoPath = getUrl(publicPath + '/img/mosaico32.png'); + vm.logoPath = getTrustedUrl('public/mosaico/img/mosaico32.png'); vm.logoUrl = '#'; }); const config = { - imgProcessorBackend: getUrl(`mosaico/img/${this.props.entityTypeId}/${this.props.entityId}`), - emailProcessorBackend: getUrl('mosaico/dl/'), + imgProcessorBackend: getTrustedUrl(`mosaico/img/${this.props.entityTypeId}/${this.props.entityId}`), + emailProcessorBackend: getSandboxUrl('mosaico/dl/'), fileuploadConfig: { - url: getUrl(`mosaico/upload/${this.props.entityTypeId}/${this.props.entityId}`) + url: getSandboxUrl(`mosaico/upload/${this.props.entityTypeId}/${this.props.entityId}`) }, strings: window.mosaicoLanguageStrings }; - const urlBase = getUrl(); - const metadata = this.props.initialMetadata && JSON.parse(base(this.props.initialMetadata, urlBase)); - const model = this.props.initialModel && JSON.parse(base(this.props.initialModel, urlBase)); - const template = getUrl(`mosaico/templates/${this.props.templateId}/index.html`); + const sandboxUrlBase = getSandboxUrl(); + const trustedUrlBase = getTrustedUrl(); + const metadata = this.props.initialMetadata && JSON.parse(base(this.props.initialMetadata, trustedUrlBase, sandboxUrlBase)); + const model = this.props.initialModel && JSON.parse(base(this.props.initialModel, trustedUrlBase, sandboxUrlBase)); + const template = getSandboxUrl(`mosaico/templates/${this.props.templateId}/index.html`); const allPlugins = plugins.concat(window.mosaicoPlugins); @@ -148,11 +153,12 @@ export class MosaicoSandbox extends Component { async onMethodAsync(method, params) { if (method === 'exportState') { - const urlBase = getUrl(); + const sandboxUrlBase = getSandboxUrl(); + const trustedUrlBase = getTrustedUrl(); return { - html: unbase(this.viewModel.exportHTML(), urlBase), - model: unbase(this.viewModel.exportJSON(), urlBase), - metadata: unbase(this.viewModel.exportMetadata(), urlBase) + html: unbase(this.viewModel.exportHTML(), trustedUrlBase, sandboxUrlBase, true), + model: unbase(this.viewModel.exportJSON(), trustedUrlBase, sandboxUrlBase), + metadata: unbase(this.viewModel.exportMetadata(), trustedUrlBase, sandboxUrlBase) }; } } diff --git a/models/files.js b/models/files.js index 0f8f18f2..6e2a77f2 100644 --- a/models/files.js +++ b/models/files.js @@ -8,6 +8,7 @@ const fs = require('fs-extra-promise'); const path = require('path'); const interoperableErrors = require('../shared/interoperable-errors'); const permissions = require('../lib/permissions'); +const {getTrustedUrl} = require('../lib/urls'); const entityTypes = permissions.getEntityTypes(); @@ -21,8 +22,8 @@ function getFilePath(type, entityId, filename) { return path.join(path.join(filesDir, type, entityId.toString()), filename); } -function getFileUrl(context, type, entityId, filename, getUrl) { - return getUrl(`files/${type}/${entityId}/${filename}`, context) +function getFileUrl(context, type, entityId, filename) { + return getTrustedUrl(`files/${type}/${entityId}/${filename}`, context) } function getFilesTable(type) { @@ -65,11 +66,11 @@ async function getFileById(context, type, id) { }; } -async function getFileByFilename(context, type, entityId, name) { +async function _getFileBy(context, type, entityId, key, value) { enforceTypePermitted(type); const file = await knex.transaction(async tx => { - // XXX - Note that we don't check permissions here. This makes files generally public. However one has to know the generated name of the file. - const file = await tx(getFilesTable(type)).where({entity: entityId, filename: name}).first(); + await shares.enforceEntityPermissionTx(tx, context, type, entityId, 'view'); + const file = await tx(getFilesTable(type)).where({entity: entityId, [key]: value}).first(); return file; }); @@ -84,8 +85,16 @@ async function getFileByFilename(context, type, entityId, name) { }; } -async function getFileByUrl(context, type, entityId, url, getUrl) { - const urlPrefix = getUrl(`files/${type}/${entityId}/`, context); +async function getFileByOriginalName(context, type, entityId, name) { + return await _getFileBy(context, type, entityId, 'originalname', name) +} + +async function getFileByFilename(context, type, entityId, name) { + return await _getFileBy(context, type, entityId, 'filename', name) +} + +async function getFileByUrl(context, type, entityId, url) { + const urlPrefix = getTrustedUrl(`files/${type}/${entityId}/`, context); if (url.startsWith(urlPrefix)) { const name = url.substring(urlPrefix.length); return await getFileByFilename(context, type, entityId, name); @@ -154,10 +163,8 @@ async function createFiles(context, type, entityId, files, getUrl = null, dontRe type: file.mimetype, }; - if (getUrl) { - filesRetEntry.url = getFileUrl(context, type, entityId, file.filename, getUrl); - filesRetEntry.thumbnailUrl = getFileUrl(context, type, entityId, file.filename, getUrl); // TODO - use smaller thumbnails - } + filesRetEntry.url = getFileUrl(context, type, entityId, file.filename); + filesRetEntry.thumbnailUrl = getFileUrl(context, type, entityId, file.filename); // TODO - use smaller thumbnails filesRet.push(filesRetEntry); @@ -223,6 +230,7 @@ module.exports = { getFileById, getFileByFilename, getFileByUrl, + getFileByOriginalName, createFiles, removeFile, getFileUrl, diff --git a/routes/mosaico.js b/routes/mosaico.js index 481dece8..634d0b71 100644 --- a/routes/mosaico.js +++ b/routes/mosaico.js @@ -21,6 +21,7 @@ const templates = require('../models/templates'); const mosaicoTemplates = require('../models/mosaico-templates'); const contextHelpers = require('../lib/context-helpers'); +const interoperableErrors = require('../shared/interoperable-errors'); const { getTrustedUrl, getSandboxUrl } = require('../lib/urls'); const { base } = require('../shared/templates'); @@ -125,43 +126,35 @@ function sanitizeSize(val, min, max, defaultVal, allowNull) { function getRouter(trusted) { const router = routerFactory.create(); - const getUrl = trusted ? getTrustedUrl : getSandboxUrl; - - router.getAsync('/img/:type/:entityId', passport.loggedIn, async (req, res) => { - const method = req.query.method; - const params = req.query.params; - let [width, height] = params.split(','); - let image; - - if (method === 'placeholder') { - width = sanitizeSize(width, 1, 2048, 600, false); - height = sanitizeSize(height, 1, 2048, 300, false); - image = await placeholderImage(width, height); - - } else { - width = sanitizeSize(width, 1, 2048, 600, false); - height = sanitizeSize(height, 1, 2048, 300, true); - - const file = await files.getFileByUrl(req.context, req.params.type, req.params.entityId, req.query.src, getUrl); - image = await resizedImage(file.path, method, width, height); - } - - res.set('Content-Type', 'image/' + image.format); - image.stream.pipe(res); - }); - - router.getAsync('/templates/:mosaicoTemplateId/index.html', passport.loggedIn, async (req, res) => { - const tmpl = await mosaicoTemplates.getById(req.context, req.params.mosaicoTemplateId); - - res.set('Content-Type', 'text/html'); - res.send(base(tmpl.data.html, getUrl('', req.context))); - }); - - router.use('/templates/:mosaicoTemplateId', express.static(path.join(__dirname, '..', 'client', 'public', 'mosaico', 'templates', 'versafix-1'))); - - if (!trusted) { - fileHelpers.installUploadHandler(router, '/upload/:type/:entityId', getUrl, true); + router.getAsync('/templates/:mosaicoTemplateId/index.html', passport.loggedIn, async (req, res) => { + const tmpl = await mosaicoTemplates.getById(req.context, req.params.mosaicoTemplateId); + + res.set('Content-Type', 'text/html'); + res.send(base(tmpl.data.html, getTrustedUrl(), getSandboxUrl('', req.context))); + }); + + // Mosaico looks for block thumbnails in edres folder relative to index.html of the template. We respond to such requests here. + // We use the following naming convention in Mosaico templates for the block thumbnails: edres/xxx -> edres-xxx + router.getAsync('/templates/:mosaicoTemplateId/edres/:fileName', async (req, res, next) => { + try { + const file = await files.getFileByOriginalName(contextHelpers.getAdminContext(), 'mosaicoTemplate', req.params.mosaicoTemplateId, req.params.fileName); + res.type(file.mimetype); + return res.download(file.path, file.name); + } catch (err) { + if (err instanceof interoperableErrors.NotFoundError) { + next(); + } else { + throw err; + } + } + }); + + // This is a fallback to versafix-1 if the block thumbnail is not defined by the template + router.use('/templates/:mosaicoTemplateId/edres', express.static(path.join(__dirname, '..', 'client', 'public', 'mosaico', 'templates', 'versafix-1', 'edres'))); + + + fileHelpers.installUploadHandler(router, '/upload/:type/:entityId', getSandboxUrl, true); router.getAsync('/upload/:type/:entityId', passport.loggedIn, async (req, res) => { const entries = await files.list(req.context, req.params.type, req.params.entityId); @@ -170,9 +163,9 @@ function getRouter(trusted) { for (const entry of entries) { filesOut.push({ name: entry.originalname, - url: files.getFileUrl(req.context, req.params.type, req.params.entityId, entry.filename, getUrl), + url: files.getFileUrl(req.context, req.params.type, req.params.entityId, entry.filename), size: entry.size, - thumbnailUrl: files.getFileUrl(req.context, req.params.type, req.params.entityId, entry.filename, getUrl) // TODO - use smaller thumbnails + thumbnailUrl: files.getFileUrl(req.context, req.params.type, req.params.entityId, entry.filename) // TODO - use smaller thumbnails }) } @@ -204,12 +197,38 @@ function getRouter(trusted) { reactCsrfToken: req.csrfToken(), mailtrainConfig: JSON.stringify(mailtrainConfig), scriptFiles: [ - getUrl('mailtrain/common.js'), - getUrl('mailtrain/mosaico.js') + getSandboxUrl('mailtrain/common.js'), + getSandboxUrl('mailtrain/mosaico.js') ], - mosaicoPublicPath: getUrl('public/mosaico') + mosaicoPublicPath: getSandboxUrl('public/mosaico') }); }); + + } else { + router.getAsync('/img/:type/:entityId', async (req, res) => { + const method = req.query.method; + const params = req.query.params; + let [width, height] = params.split(','); + let image; + + + // FIXME - cache the generated files !!! + if (method === 'placeholder') { + width = sanitizeSize(width, 1, 2048, 600, false); + height = sanitizeSize(height, 1, 2048, 300, false); + image = await placeholderImage(width, height); + + } else { + width = sanitizeSize(width, 1, 2048, 600, false); + height = sanitizeSize(height, 1, 2048, 300, true); + + const file = await files.getFileByUrl(contextHelpers.getAdminContext(), req.params.type, req.params.entityId, req.query.src); + image = await resizedImage(file.path, method, width, height); + } + + res.set('Content-Type', 'image/' + image.format); + image.stream.pipe(res); + }); } return router; diff --git a/shared/mosaico-templates.js b/shared/mosaico-templates.js index 33420b2e..21aed124 100644 --- a/shared/mosaico-templates.js +++ b/shared/mosaico-templates.js @@ -1397,78 +1397,78 @@ const versafix = '\n' + '  \n' + ' \n' + - ' \n' + + ' \n' + ' \n' + '  \n' + ' \n' + - ' \n' + + ' \n' + ' \n' + '  \n' + ' \n' + - ' \n' + + ' \n' + ' \n' + '  \n' + ' \n' + - ' \n' + + ' \n' + ' \n' + '  \n' + ' \n' + - ' \n' + + ' \n' + ' \n' + '  \n' + ' \n' + - ' \n' + + ' \n' + ' \n' + '  \n' + ' \n' + - ' \n' + + ' \n' + ' \n' + '  \n' + ' \n' + - ' \n' + + ' \n' + ' \n' + '  \n' + ' \n' + - ' \n' + + ' \n' + ' \n' + ' \n' + ' \n' + '  \n' + ' \n' + - ' \n' + + ' \n' + ' \n' + '  \n' + ' \n' + - ' \n' + + ' \n' + ' \n' + '  \n' + ' \n' + - ' \n' + + ' \n' + ' \n' + '  \n' + ' \n' + - ' \n' + + ' \n' + ' \n' + '  \n' + ' \n' + - ' \n' + + ' \n' + ' \n' + '  \n' + ' \n' + - ' \n' + + ' \n' + ' \n' + '  \n' + ' \n' + - ' \n' + + ' \n' + ' \n' + '  \n' + ' \n' + - ' \n' + + ' \n' + ' \n' + '  \n' + ' \n' + - ' \n' + + ' \n' + ' \n' + ' \n' + ' \n' + @@ -1516,7 +1516,7 @@ const versafix = '\n' + ' \n' + - ' sponsorsponsor\n' + ' \n' + ' \n' + diff --git a/shared/templates.js b/shared/templates.js index ec62d233..488cb688 100644 --- a/shared/templates.js +++ b/shared/templates.js @@ -1,19 +1,29 @@ 'use strict'; -function base(text, baseUrl) { - if (baseUrl.endsWith('/')) { - baseUrl = baseUrl.substring(0, baseUrl.length - 1); +// FIXME - process also urlencoded strings - this is for the mosaico/img/template, which passes the file in src parameter + +function base(text, trustedBaseUrl, sandboxBaseUrl) { + if (trustedBaseUrl.endsWith('/')) { + trustedBaseUrl = trustedBaseUrl.substring(0, trustedBaseUrl.length - 1); } - return text.split('[URL_BASE]').join(baseUrl); + if (sandboxBaseUrl.endsWith('/')) { + sandboxBaseUrl = sandboxBaseUrl.substring(0, sandboxBaseUrl.length - 1); + } + + return text.split('[URL_BASE]').join(trustedBaseUrl).split('[SANDBOX_URL_BASE]').join(sandboxBaseUrl); } -function unbase(text, baseUrl) { - if (baseUrl.endsWith('/')) { - baseUrl = baseUrl.substring(0, baseUrl.length - 1); +function unbase(text, trustedBaseUrl, sandboxBaseUrl, treatSandboxAsTrusted = false) { + if (trustedBaseUrl.endsWith('/')) { + trustedBaseUrl = trustedBaseUrl.substring(0, trustedBaseUrl.length - 1); } - return text.split(baseUrl).join('[URL_BASE]'); + if (sandboxBaseUrl.endsWith('/')) { + sandboxBaseUrl = sandboxBaseUrl.substring(0, sandboxBaseUrl.length - 1); + } + + return text.split(trustedBaseUrl).join('[URL_BASE]').split(sandboxBaseUrl).join(treatSandboxAsTrusted ? '[URL_BASE]' : '[SANDBOX_URL_BASE]'); } module.exports = {