diff --git a/lib/editor-helpers.js b/lib/editor-helpers.js new file mode 100644 index 00000000..c81eebad --- /dev/null +++ b/lib/editor-helpers.js @@ -0,0 +1,68 @@ +'use strict'; + +let _ = require('../lib/translate')._; +let helpers = require('../lib/helpers'); +let templates = require('../lib/models/templates'); +let campaigns = require('../lib/models/campaigns'); + +module.exports = { + getResource +}; + +function getResource(type, id, callback) { + if (type === 'template') { + templates.get(id, (err, template) => { + if (err || !template) { + return callback(err && err.message || err || _('Could not find template with specified ID')); + } + + getMergeTagsForResource(template, (err, mergeTags) => { + if (err) { + return callback(err.message || err); + } + + template.mergeTags = mergeTags; + return callback(null, template); + }); + }); + + } else if (type === 'campaign') { + campaigns.get(id, false, (err, campaign) => { + if (err || !campaign) { + return callback(err && err.message || err || _('Could not find campaign with specified ID')); + } + + getMergeTagsForResource(campaign, (err, mergeTags) => { + if (err) { + return callback(err.message || err); + } + + campaign.mergeTags = mergeTags; + return callback(null, campaign); + }); + }); + + } else { + return callback(_('Invalid resource type')); + } +} + +function getMergeTagsForResource(resource, callback) { + helpers.getDefaultMergeTags((err, defaultMergeTags) => { + if (err) { + return callback(err.message || err); + } + + if (!resource.list) { + return callback(null, defaultMergeTags); + } + + helpers.getListMergeTags(resource.list, (err, listMergeTags) => { + if (err) { + return callback(err.message || err); + } + + callback(null, defaultMergeTags.concat(listMergeTags)); + }); + }); +} diff --git a/routes/editorapi.js b/routes/editorapi.js new file mode 100644 index 00000000..2ae4a580 --- /dev/null +++ b/routes/editorapi.js @@ -0,0 +1,433 @@ +'use strict'; + +let config = require('config'); +let express = require('express'); +let router = new express.Router(); +let passport = require('../lib/passport'); +let os = require('os'); +let fs = require('fs'); +let path = require('path'); +let mkdirp = require('mkdirp'); +let cache = require('memory-cache'); +let crypto = require('crypto'); +let fetch = require('node-fetch'); +let events = require('events'); +let httpMocks = require('node-mocks-http'); +let multiparty = require('multiparty'); +let fileType = require('file-type'); +let escapeStringRegexp = require('escape-string-regexp'); +let jqueryFileUpload = require('jquery-file-upload-middleware'); +let gm = require('gm').subClass({ imageMagick: true }); +let url = require('url'); +let htmlToText = require('html-to-text'); +let premailerApi = require('premailer-api'); +let editorHelpers = require('../lib/editor-helpers'); +let _ = require('../lib/translate')._; +let mailer = require('../lib/mailer'); +let settings = require('../lib/models/settings'); +let templates = require('../lib/models/templates'); +let campaigns = require('../lib/models/campaigns'); + +router.all('/*', (req, res, next) => { + if (!req.user && !cache.get(req.get('If-Match'))) { + return res.status(403).send(_('Need to be logged in to access restricted content')); + } + if (req.originalUrl.startsWith('/editorapi/img?')) { + return next(); + } + if (!config.editors.map(e => e[0]).includes(req.query.editor)) { + return res.status(500).send(_('Invalid editor name')); + } + next(); +}); + +jqueryFileUpload.on('begin', fileInfo => { + fileInfo.name = fileInfo.name + .toLowerCase() + .replace(/ /g, '-') + .replace(/[^a-z0-9+-\.]+/g, ''); +}); + +let listImages = (dir, dirURL, callback) => { + fs.readdir(dir, (err, files = []) => { + if (err && err.code !== 'ENOENT') { + return callback(err.message || err); + } + files = files.filter(name => /\.(jpe?g|png|gif)$/i.test(name)); + files = files.map(name => { + return { + // mosaico + name, + url: dirURL + '/' + name, + thumbnailUrl: dirURL + '/thumbnail/' + name, + // grapejs + src: dirURL + '/' + name, + }; + }); + callback(null, files); + }); +}; + +let getStaticImageUrl = ({ dynamicUrl, staticDir, staticDirUrl }, callback) => { + mkdirp(staticDir, err => { + if (err) { + return callback(dynamicUrl); + } + + fs.readdir(staticDir, (err, files) => { + if (err) { + return callback(dynamicUrl); + } + + let hash = crypto.createHash('md5').update(dynamicUrl).digest('hex'); + let match = files.find(el => el.startsWith(hash)); + let headers = {}; + + if (match) { + return callback(staticDirUrl + '/' + match); + } + + if (dynamicUrl.includes('/editorapi/img?')) { + let token = crypto.randomBytes(16).toString('hex'); + cache.put(token, true, 1000); + headers['If-Match'] = token; + } + + fetch(dynamicUrl, { + headers + }) + .then(res => { + return res.buffer(); + }) + .then(buffer => { + let ft = fileType(buffer); + if (!ft) { + return callback(dynamicUrl); + } + if (['image/jpeg', 'image/png', 'image/gif'].includes(ft.mime)) { + fs.writeFile(path.join(staticDir, hash + '.' + ft.ext), buffer, err => { + if (err) { + return callback(dynamicUrl); + } + let staticUrl = staticDirUrl + '/' + hash + '.' + ft.ext; + callback(staticUrl); + }); + } else { + callback(dynamicUrl); + } + }); + }); + }); +}; + +let prepareHtml = ({ editorName, html }, callback) => { + settings.get('serviceUrl', (err, serviceUrl) => { + if (err) { + return callback(err.message || err); + } + + let jobs = 0; + let srcs = {}; + let re = /]+src="([^"]+)"/g; + let result; + while ((result = re.exec(html)) !== null) { + srcs[result[1]] = result[1]; + } + + let done = () => { + if (jobs === 0) { + Object.keys(srcs).forEach(src => { + // console.log(`replace dynamic - ${src} - with static - ${srcs[src]}`); + html = html.replace(new RegExp(escapeStringRegexp(src), 'g'), srcs[src]); + }); + callback(null, html); + } + }; + + Object.keys(srcs).forEach(src => { + jobs++; + let dynamicUrl = src.replace(/&/g, '&'); + dynamicUrl = /^https?:\/\/|^\/\//i.test(dynamicUrl) ? dynamicUrl : url.resolve(serviceUrl, dynamicUrl); + getStaticImageUrl({ + dynamicUrl, + staticDir: path.join(__dirname, '..', 'public', editorName, 'uploads', 'static'), + staticDirUrl: url.resolve(serviceUrl, editorName + '/uploads/static'), + }, staticUrl => { + srcs[src] = staticUrl; + jobs--; + done(); + }); + }); + + done(); + }); +}; + +let placeholderImage = (req, res, { width, height }) => { + let magick = gm(width, height, '#707070'); + let x = 0; + let y = 0; + let size = 40; + // stripes + while (y < height) { + magick = magick + .fill('#808080') + .drawPolygon([x, y], [x + size, y], [x + size * 2, y + size], [x + size * 2, y + size * 2]) + .drawPolygon([x, y + size], [x + size, y + size * 2], [x, y + size * 2]); + x = x + size * 2; + if (x > width) { + x = 0; + y = y + size * 2; + } + } + // text + magick = magick + .fill('#B0B0B0') + .fontSize(20) + .drawText(0, 0, width + ' x ' + height, 'center'); + + res.set('Content-Type', 'image/png'); + magick.stream('png').pipe(res); +}; + +let resizedImage = (req, res, { src, method, width, height }) => { + let magick = gm(src); + magick.format((err, format) => { + if (err) { + return res.status(500).send(err.message || err); + } + + switch (method) { + case 'resize': + res.set('Content-Type', 'image/' + format.toLowerCase()); + magick.autoOrient() + .resize(width, height) + .stream() + .pipe(res); + return; + + case 'cover': + res.set('Content-Type', 'image/' + format.toLowerCase()); + magick.autoOrient() + .resize(width, height + '^') + .gravity('Center') + .extent(width, height + '>') + .stream() + .pipe(res); + return; + + default: + res.status(501).send(_('Method not supported')); + } + }); +}; + +// /editorapi/img?src=" + encodeURIComponent(src) + "&method=" + encodeURIComponent(method) + "¶ms=" + encodeURIComponent(width + "," + height); +router.get('/img', passport.csrfProtection, (req, res) => { + settings.get('serviceUrl', (err, serviceUrl) => { + if (err) { + return res.status(500).send(err.message || err); + } + + let { src, method, params = '600,null' } = req.query; + let width = params.split(',')[0]; + let height = params.split(',')[1]; + width = (width === 'null') ? null : Number(width); + height = (height === 'null') ? null : Number(height); + + switch (method) { + case 'placeholder': + return placeholderImage(req, res, { width, height }); + case 'resize': + case 'cover': + src = /^https?:\/\/|^\/\//i.test(src) ? src : url.resolve(serviceUrl, src); + return resizedImage(req, res, { src, method, width, height }); + default: + return res.status(501).send(_('Method not supported')); + } + }); +}); + +router.post('/update', passport.parseForm, passport.csrfProtection, (req, res) => { + prepareHtml({ editorName: req.query.editor, html: req.body.html }, (err, html) => { + if (err) { + return res.status(500).send(err.message || err); + } + + req.body.html = html; + + if (req.query.type === 'template') { + templates.update(req.body.id, req.body, (err, updated) => { + if (err) { + return res.status(500).send(err.message || err); + } + res.send('ok'); + }); + + } else if (req.query.type === 'campaign') { + campaigns.update(req.body.id, req.body, (err, updated) => { + if (err) { + return res.status(500).send(err.message || err); + } + res.send('ok'); + }); + + } else { + res.status(500).send(_('Invalid resource type')); + } + }); +}); + +// https://github.com/artf/grapesjs/wiki/API-Asset-Manager +// https://github.com/aguidrevitch/jquery-file-upload-middleware + +router.get('/upload', passport.csrfProtection, (req, res) => { + settings.get('serviceUrl', (err, serviceUrl) => { + if (err) { + return res.status(500).send(err.message || err); + } + + let baseDir = path.join(__dirname, '..', 'public', req.query.editor, 'uploads'); + let baseDirUrl = serviceUrl + req.query.editor + '/uploads'; + + listImages(path.join(baseDir, '0'), baseDirUrl + '/0', (err, sharedImages) => { + if (err) { + return res.status(500).send(err.message || err); + } + + if (req.query.type === 'campaign' && Number(req.query.id) > 0) { + listImages(path.join(baseDir, req.query.id), baseDirUrl + '/' + req.query.id, (err, campaignImages) => { + err ? res.status(500).send(err.message || err) + : res.json({ files: sharedImages.concat(campaignImages) }); + }); + } else { + res.json({ files: sharedImages }); + } + }); + }); +}); + +router.post('/upload', passport.csrfProtection, (req, res) => { + let dirName = req.query.type === 'template' ? '0' + : req.query.type === 'campaign' && Number(req.query.id) > 0 ? req.query.id + : null; + + if (dirName === null) { + return res.status(500).send(_('Invalid resource type or ID')); + } + + let opts = { + tmpDir: config.www.tmpdir || os.tmpdir(), + imageVersions: req.query.editor === 'mosaico' ? { thumbnail: { width: 90, height: 90 } } : {}, + uploadDir: path.join(__dirname, '..', 'public', req.query.editor, 'uploads', dirName), + uploadUrl: '/' + req.query.editor + '/uploads/' + dirName, // must be root relative + acceptFileTypes:/(\.|\/)(gif|jpe?g|png)$/i, + }; + + let mockres = httpMocks.createResponse({ + eventEmitter: events.EventEmitter + }); + + mockres.on('end', () => { + if (req.query.editor === 'grapejs') { + let data = []; + JSON.parse(mockres._getData()).files.forEach(file => { + data.push({ src: file.url }); + }); + res.json({ data }); + } else { + res.send(mockres._getData()); + } + }); + + jqueryFileUpload.fileHandler(opts)(req, mockres); +}); + +router.post('/download', passport.csrfProtection, (req, res) => { + prepareHtml({ editorName: req.query.editor, html: req.body.html }, (err, html) => { + if (err) { + return res.status(500).send(err.message || err); + } + res.setHeader('Content-disposition', 'attachment; filename=' + req.body.filename); + res.setHeader('Content-type', 'text/html'); + res.send(html); + }); +}); + +let parseGrapejsMultipartTestForm = (req, res, next) => { + if (req.query.editor === 'grapejs') { + new multiparty.Form().parse(req, (err, fields, files) => { + req.body.email = fields.email[0]; + req.body.subject = fields.subject[0]; + req.body.html = fields.html[0]; + req.body._csrf = fields._csrf[0]; + next(); + }); + } else { + next(); + } +}; + +router.post('/test', parseGrapejsMultipartTestForm, passport.csrfProtection, (req, res) => { + prepareHtml({ editorName: req.query.editor, html: req.body.html }, (err, html) => { + if (err) { + req.query.editor === 'grapejs' + ? res.status(500).json({ errors: err.message || err }) + : res.status(500).send(err.message || err); + return; + } + + settings.list(['defaultAddress', 'defaultFrom'], (err, configItems) => { + if (err) { + req.query.editor === 'grapejs' + ? res.status(500).json({ errors: err.message || err }) + : res.status(500).send(err.message || err); + return; + } + + mailer.getMailer((err, transport) => { + if (err) { + req.query.editor === 'grapejs' + ? res.status(500).json({ errors: err.message || err }) + : res.status(500).send(err.message || err); + return; + } + + let opts = { + from: { + name: configItems.defaultFrom, + address: configItems.defaultAddress, + }, + to: req.body.email, + subject: req.body.subject, + text: htmlToText.fromString(html, { wordwrap: 100 }), + html, + }; + + transport.sendMail(opts, (err, info) => { + if (err) { + req.query.editor === 'grapejs' + ? res.status(500).json({ errors: err.message || err }) + : res.status(500).send(err.message || err); + return; + } + + req.query.editor === 'grapejs' + ? res.json({ data: 'ok' }) + : res.send('ok'); + }); + }); + }); + }); +}); + +router.post('/html-to-text', passport.parseForm, passport.csrfProtection, (req, res) => { + premailerApi.prepare({ html: req.body.html, fetchHTML: false }, (err, email) => { + if (err) { + return res.status(500).send(err.message || err); + } + res.send(email.text.replace(/%5B/g, '[').replace(/%5D/g, ']')); + }); +}); + +module.exports = router; diff --git a/routes/grapejs.js b/routes/grapejs.js new file mode 100644 index 00000000..239b721b --- /dev/null +++ b/routes/grapejs.js @@ -0,0 +1,50 @@ +'use strict'; + +let config = require('config'); +let express = require('express'); +let router = new express.Router(); +let passport = require('../lib/passport'); +let fs = require('fs'); +let path = require('path'); +let editorHelpers = require('../lib/editor-helpers.js') + +router.all('/*', (req, res, next) => { + if (!req.user) { + req.flash('danger', _('Need to be logged in to access restricted content')); + return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl)); + } + next(); +}); + +router.get('/editor', passport.csrfProtection, (req, res) => { + editorHelpers.getResource(req.query.type, req.query.id, (err, resource) => { + if (err) { + req.flash('danger', err.message || err); + return res.redirect('/'); + } + + resource.editorName = resource.editorName || 'grapejs'; + resource.editorData = !resource.editorData + ? { template: req.query.template || 'demo' } + : JSON.parse(resource.editorData); + + if (!resource.html && !resource.editorData.html) { + try { + let file = path.join(__dirname, '..', 'public', 'grapejs', 'templates', resource.editorData.template, 'index.html'); + resource.html = fs.readFileSync(file, 'utf8'); + } catch (err) { + resource.html = err.message || err; + } + } + + res.render('grapejs/editor', { + layout: 'grapejs/layout-editor', + type: req.query.type, + resource, + editorConfig: config.grapejs, + csrfToken: req.csrfToken(), + }); + }); +}); + +module.exports = router; diff --git a/routes/mosaico.js b/routes/mosaico.js new file mode 100644 index 00000000..4bc89700 --- /dev/null +++ b/routes/mosaico.js @@ -0,0 +1,56 @@ +'use strict'; + +let config = require('config'); +let express = require('express'); +let router = new express.Router(); +let passport = require('../lib/passport'); +let fs = require('fs'); +let path = require('path'); +let _ = require('../lib/translate')._; +let editorHelpers = require('../lib/editor-helpers'); + +router.all('/*', (req, res, next) => { + if (!req.user) { + req.flash('danger', _('Need to be logged in to access restricted content')); + return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl)); + } + next(); +}); + +router.get('/editor', passport.csrfProtection, (req, res) => { + editorHelpers.getResource(req.query.type, req.query.id, (err, resource) => { + if (err) { + req.flash('danger', err.message || err); + return res.redirect('/'); + } + + let getLanguageStrings = language => { + if (!language || language === 'en') { + return null; + } + language = language.split('_')[0]; + try { + let file = path.join(__dirname, '..', 'public', 'mosaico', 'dist', 'lang', 'mosaico-' + language + '.json'); + return fs.readFileSync(file, 'utf8'); + } catch (err) { + return null; + } + } + + resource.editorName = resource.editorName || 'mosaico'; + resource.editorData = !resource.editorData + ? { template: req.query.template || 'versafix-1' } + : JSON.parse(resource.editorData); + + res.render('mosaico/editor', { + layout: 'mosaico/layout-editor', + type: req.query.type, + resource, + editorConfig: config.mosaico, + languageStrings: getLanguageStrings(config.language), + csrfToken: req.csrfToken(), + }); + }); +}); + +module.exports = router; diff --git a/views/grapejs/editor.hbs b/views/grapejs/editor.hbs new file mode 100644 index 00000000..d9595ad8 --- /dev/null +++ b/views/grapejs/editor.hbs @@ -0,0 +1,371 @@ + + + +{{> editor_navbar}} + + +
+
+ {{#if resource.editorData.html}} + + {{{resource.editorData.html}}} + {{else}} + {{{resource.html}}} + {{/if}} +
+
+ + + + + + + + + +{{#if resource.mergeTags}} + +{{/if}} + + + diff --git a/views/grapejs/layout-editor.hbs b/views/grapejs/layout-editor.hbs new file mode 100644 index 00000000..59005bed --- /dev/null +++ b/views/grapejs/layout-editor.hbs @@ -0,0 +1,23 @@ + + + + + GrapesJS Newsletter Editor + + + + + + + + + + + + + + + {{{body}}} + + + diff --git a/views/mosaico/editor.hbs b/views/mosaico/editor.hbs new file mode 100644 index 00000000..1c882836 --- /dev/null +++ b/views/mosaico/editor.hbs @@ -0,0 +1,286 @@ + + + +{{> editor_navbar}} + + +{{#if resource.mergeTags}} + +{{/if}} + + + diff --git a/views/mosaico/layout-editor.hbs b/views/mosaico/layout-editor.hbs new file mode 100644 index 00000000..4787f670 --- /dev/null +++ b/views/mosaico/layout-editor.hbs @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + {{#if editorConfig.customscripts}} + {{#each editorConfig.customscripts}} + + {{/each}} + {{/if}} + + + + + + + {{{body}}} + + + diff --git a/views/partials/editor-bridge.hbs b/views/partials/editor-bridge.hbs index 3d65659f..065b389a 100644 --- a/views/partials/editor-bridge.hbs +++ b/views/partials/editor-bridge.hbs @@ -3,36 +3,45 @@ overflow: hidden; } #editor-frame, - #editor-frame-spinner { - border: none; + #editor-frame-loader { position: fixed; z-index: 10000; top: 0; left: 0; width: 100%; height: 100%; + border: none; } - #editor-frame-spinner { + #editor-frame-loader { z-index: 10001; - background: white; + background: #eaeced; } - #editor-frame-spinner div { - width: 120px; - height: 120px; - margin-top: -60px; - margin-left: -60px; + #editor-frame-loader div { position: absolute; top: 50%; left: 50%; + width: 62px; margin-left: -31px; + height: 72px; margin-top: -36px; + background: url('/mailtrain-header.png'); + -webkit-animation: pulsate 1.2s ease-out; + animation: pulsate 1.2s ease-out; + -webkit-animation-iteration-count: infinite; + animation-iteration-count: infinite; } - #editor-frame-spinner span:before { - font-size: 120px; - color: #efefef; + @-webkit-keyframes pulsate { + 0% { -webkit-transform: scale(0.1, 0.1); opacity: 0.5; } + 70% { opacity: 1.0; } + 100% { -webkit-transform: scale(1.2, 1.2); opacity: 0.0; } + } + @keyframes pulsate { + 0% { transform: scale(0.1, 0.1); opacity: 0.5; } + 70% { opacity: 1.0; } + 100% { transform: scale(1.2, 1.2); opacity: 0.0; } } -