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}}
+
+
+