diff --git a/app-builder.js b/app-builder.js
new file mode 100644
index 00000000..6d34202b
--- /dev/null
+++ b/app-builder.js
@@ -0,0 +1,388 @@
+'use strict';
+
+const config = require('config');
+const log = require('npmlog');
+
+const _ = require('./lib/translate')._;
+
+const { nodeifyFunction } = require('./lib/nodeify');
+
+const express = require('express');
+const bodyParser = require('body-parser');
+const path = require('path');
+const favicon = require('serve-favicon');
+const logger = require('morgan');
+const cookieParser = require('cookie-parser');
+const session = require('express-session');
+const RedisStore = require('connect-redis')(session);
+const flash = require('connect-flash');
+const hbs = require('hbs');
+const handlebarsHelpers = require('./lib/handlebars-helpers');
+const compression = require('compression');
+const passport = require('./lib/passport');
+const tools = require('./lib/tools');
+const contextHelpers = require('./lib/context-helpers');
+
+const getSettings = nodeifyFunction(require('./models/settings').get);
+const api = require('./routes/api');
+
+// These are routes for the new React-based client
+const reports = require('./routes/reports');
+const subscription = require('./routes/subscription');
+const mosaico = require('./routes/mosaico');
+const files = require('./routes/files');
+
+const namespacesRest = require('./routes/rest/namespaces');
+const usersRest = require('./routes/rest/users');
+const accountRest = require('./routes/rest/account');
+const reportTemplatesRest = require('./routes/rest/report-templates');
+const reportsRest = require('./routes/rest/reports');
+const campaignsRest = require('./routes/rest/campaigns');
+const listsRest = require('./routes/rest/lists');
+const formsRest = require('./routes/rest/forms');
+const fieldsRest = require('./routes/rest/fields');
+const sharesRest = require('./routes/rest/shares');
+const segmentsRest = require('./routes/rest/segments');
+const subscriptionsRest = require('./routes/rest/subscriptions');
+const templatesRest = require('./routes/rest/templates');
+const blacklistRest = require('./routes/rest/blacklist');
+const editorsRest = require('./routes/rest/editors');
+const filesRest = require('./routes/rest/files');
+
+const root = require('./routes/root');
+
+const interoperableErrors = require('./shared/interoperable-errors');
+
+hbs.registerPartials(__dirname + '/views/partials');
+hbs.registerPartials(__dirname + '/views/subscription/partials/');
+
+/**
+ * We need this helper to make sure that we consume flash messages only
+ * when we are able to actually display these. Otherwise we might end up
+ * in a situation where we consume a flash messages but then comes a redirect
+ * and the message is never displayed
+ */
+hbs.registerHelper('flash_messages', function () { // eslint-disable-line prefer-arrow-callback
+ if (typeof this.flash !== 'function') { // eslint-disable-line no-invalid-this
+ return '';
+ }
+
+ let messages = this.flash(); // eslint-disable-line no-invalid-this
+ let response = [];
+
+ // group messages by type
+ Object.keys(messages).forEach(key => {
+ let el = '
';
+
+ if (key === 'danger') {
+ el += '
';
+ }
+
+ let rows = [];
+
+ messages[key].forEach(message => {
+ message = hbs.handlebars.escapeExpression(message);
+ message = message.replace(/(\r\n|\n|\r)/gm, '
');
+ rows.push(message);
+ });
+
+ if (rows.length > 1) {
+ el += '
' + rows.join('
\n
') + '
';
+ } else {
+ el += rows.join('');
+ }
+
+ el += '
';
+
+ response.push(el);
+ });
+
+ return new hbs.handlebars.SafeString(
+ response.join('\n')
+ );
+});
+
+handlebarsHelpers.registerHelpers(hbs.handlebars);
+
+
+function createApp(trusted) {
+ const app = express();
+
+ function install404Fallback(url) {
+ app.use(url, (req, res, next) => {
+ next(new interoperableErrors.NotFoundError());
+ });
+
+ app.use(url + '/*', (req, res, next) => {
+ next(new interoperableErrors.NotFoundError());
+ });
+ }
+
+ function useWith404Fallback(url, route) {
+ app.use(url, route);
+ install404Fallback(url);
+ }
+
+
+ // view engine setup
+ app.set('views', path.join(__dirname, 'views'));
+ app.set('view engine', 'hbs');
+
+ // Handle proxies. Needed to resolve client IP
+ if (config.www.proxy) {
+ app.set('trust proxy', config.www.proxy);
+ }
+
+ // Do not expose software used
+ app.disable('x-powered-by');
+
+ app.use(compression());
+ app.use(favicon(path.join(__dirname, 'client', 'public', 'favicon.ico')));
+
+ app.use(logger(config.www.log, {
+ stream: {
+ write: message => {
+ message = (message || '').toString();
+ if (message) {
+ log.info('HTTP', message.replace('\n', '').trim());
+ }
+ }
+ }
+ }));
+
+ app.use(cookieParser());
+ useWith404Fallback('/public', express.static(path.join(__dirname, 'client', 'public')));
+ useWith404Fallback('/mailtrain', express.static(path.join(__dirname, 'client', 'dist')));
+ useWith404Fallback('/locales', express.static(path.join(__dirname, 'client', 'locales')));
+
+ app.use(session({
+ store: config.redis.enabled ? new RedisStore(config.redis) : false,
+ secret: config.www.secret,
+ saveUninitialized: false,
+ resave: false
+ }));
+
+ app.use(flash());
+
+ app.use(bodyParser.urlencoded({
+ extended: true,
+ limit: config.www.postSize
+ }));
+
+ app.use(bodyParser.text({
+ limit: config.www.postSize
+ }));
+
+ app.use(bodyParser.json({
+ limit: config.www.postSize
+ }));
+
+ if (trusted) {
+ passport.setup(app);
+ } else {
+ app.use(passport.tryAuthByRestrictedAccessToken);
+ }
+
+ /* FIXME - can we remove this???
+
+ // make sure flash messages are available
+ app.use((req, res, next) => {
+ res.locals.flash = req.flash.bind(req);
+ res.locals.user = req.user;
+ res.locals.admin = req.user && req.user.id == 1; // FIXME, this should verify the admin privileges and set this accordingly
+ res.locals.ldap = {
+ enabled: config.ldap.enabled,
+ passwordresetlink: config.ldap.passwordresetlink
+ };
+
+ let menu = [{
+ title: _('Home'),
+ url: '/',
+ selected: true
+ }];
+
+ res.setSelectedMenu = key => {
+ menu.forEach(item => {
+ item.selected = (item.key === key);
+ });
+ };
+
+ res.locals.menu = menu;
+ tools.updateMenu(res);
+
+ res.locals.customStyles = config.customstyles || [];
+ res.locals.customScripts = config.customscripts || [];
+
+ let bodyClasses = [];
+ if (req.user) {
+ bodyClasses.push('logged-in user-' + req.user.username);
+ }
+ res.locals.bodyClass = bodyClasses.join(' ');
+
+ getSettings(['uaCode', 'shoutout'], (err, configItems) => {
+ if (err) {
+ return next(err);
+ }
+ Object.keys(configItems).forEach(key => {
+ res.locals[key] = configItems[key];
+ });
+ next();
+ });
+ });
+ */
+
+ // Endpoint under /api are authenticated by access token
+ app.all('/api/*', passport.authByAccessToken);
+
+ // Marks the following endpoint to return JSON object when error occurs
+ app.all('/api/*', (req, res, next) => {
+ req.needsAPIJSONResponse = true;
+ next();
+ });
+
+
+ app.all('/rest/*', (req, res, next) => {
+ req.needsRESTJSONResponse = true;
+ next();
+ });
+
+
+ // Initializes the request context to be used for authorization
+ app.use((req, res, next) => {
+ req.context = contextHelpers.getRequestContext(req);
+ next();
+ });
+
+
+ // Regular endpoints
+ useWith404Fallback('/subscription', subscription);
+ useWith404Fallback('/files', files);
+ useWith404Fallback('/mosaico', mosaico);
+
+ if (config.reports && config.reports.enabled === true) {
+ useWith404Fallback('/reports', reports);
+ }
+
+
+ // API endpoints
+ useWith404Fallback('/api', api);
+
+ // REST endpoints
+ app.use('/rest', namespacesRest);
+ app.use('/rest', usersRest);
+ app.use('/rest', accountRest);
+ app.use('/rest', campaignsRest);
+ app.use('/rest', listsRest);
+ app.use('/rest', formsRest);
+ app.use('/rest', fieldsRest);
+ app.use('/rest', sharesRest);
+ app.use('/rest', segmentsRest);
+ app.use('/rest', subscriptionsRest);
+ app.use('/rest', templatesRest);
+ app.use('/rest', blacklistRest);
+ app.use('/rest', editorsRest);
+ app.use('/rest', filesRest);
+
+ if (config.reports && config.reports.enabled === true) {
+ app.use('/rest', reportTemplatesRest);
+ app.use('/rest', reportsRest);
+ }
+ install404Fallback('/rest');
+
+ app.use('/', root);
+
+ // Error handlers
+ if (app.get('env') === 'development') {
+ // development error handler
+ // will print stacktrace
+ app.use((err, req, res, next) => {
+ if (!err) {
+ return next();
+ }
+
+ if (req.needsRESTJSONResponse) {
+ const resp = {
+ message: err.message,
+ error: err
+ };
+
+ if (err instanceof interoperableErrors.InteroperableError) {
+ resp.type = err.type;
+ resp.data = err.data;
+ }
+
+ res.status(err.status || 500).json(resp);
+
+ } else if (req.needsAPIJSONResponse) {
+ const resp = {
+ error: err.message || err,
+ data: []
+ };
+
+ return status(err.status || 500).json(resp);
+
+ } else {
+ if (err instanceof interoperableErrors.NotLoggedInError) {
+ return res.redirect('/account/login?next=' + encodeURIComponent(req.originalUrl));
+ } else {
+ res.status(err.status || 500);
+ res.render('error', {
+ message: err.message,
+ error: err
+ });
+ }
+ }
+
+ });
+ } else {
+ // production error handler
+ // no stacktraces leaked to user
+ app.use((err, req, res, next) => {
+ if (!err) {
+ return next();
+ }
+
+ console.log(err);
+ if (req.needsRESTJSONResponse) {
+ const resp = {
+ message: err.message,
+ error: {}
+ };
+
+ if (err instanceof interoperableErrors.InteroperableError) {
+ resp.type = err.type;
+ resp.data = err.data;
+ }
+
+ res.status(err.status || 500).json(resp);
+
+ } else if (req.needsAPIJSONResponse) {
+ const resp = {
+ error: err.message || err,
+ data: []
+ };
+
+ return status(err.status || 500).json(resp);
+
+ } else {
+ // TODO: Render interoperable errors using a special client that does internationalization of the error message
+
+ if (err instanceof interoperableErrors.NotLoggedInError) {
+ return res.redirect('/account/login?next=' + encodeURIComponent(req.originalUrl));
+ } else {
+ res.status(err.status || 500);
+ res.render('error', {
+ message: err.message,
+ error: {}
+ });
+ }
+ }
+ });
+ }
+
+ return app;
+}
+
+module.exports = {
+ createApp
+};
diff --git a/app.js b/app.js
deleted file mode 100644
index 7e327fe4..00000000
--- a/app.js
+++ /dev/null
@@ -1,384 +0,0 @@
-'use strict';
-
-const config = require('config');
-const log = require('npmlog');
-
-const _ = require('./lib/translate')._;
-
-const { nodeifyFunction } = require('./lib/nodeify');
-
-const express = require('express');
-const bodyParser = require('body-parser');
-const path = require('path');
-const favicon = require('serve-favicon');
-const logger = require('morgan');
-const cookieParser = require('cookie-parser');
-const session = require('express-session');
-const RedisStore = require('connect-redis')(session);
-const flash = require('connect-flash');
-const hbs = require('hbs');
-const handlebarsHelpers = require('./lib/handlebars-helpers');
-const compression = require('compression');
-const passport = require('./lib/passport');
-const tools = require('./lib/tools');
-const contextHelpers = require('./lib/context-helpers');
-
-const getSettings = nodeifyFunction(require('./models/settings').get);
-const api = require('./routes/api');
-
-// These are routes for the new React-based client
-const reports = require('./routes/reports');
-const subscription = require('./routes/subscription');
-const mosaico = require('./routes/mosaico');
-const files = require('./routes/files');
-
-const namespacesRest = require('./routes/rest/namespaces');
-const usersRest = require('./routes/rest/users');
-const accountRest = require('./routes/rest/account');
-const reportTemplatesRest = require('./routes/rest/report-templates');
-const reportsRest = require('./routes/rest/reports');
-const campaignsRest = require('./routes/rest/campaigns');
-const listsRest = require('./routes/rest/lists');
-const formsRest = require('./routes/rest/forms');
-const fieldsRest = require('./routes/rest/fields');
-const sharesRest = require('./routes/rest/shares');
-const segmentsRest = require('./routes/rest/segments');
-const subscriptionsRest = require('./routes/rest/subscriptions');
-const templatesRest = require('./routes/rest/templates');
-const blacklistRest = require('./routes/rest/blacklist');
-const editorsRest = require('./routes/rest/editors');
-const filesRest = require('./routes/rest/files');
-
-const root = require('./routes/root');
-
-const interoperableErrors = require('./shared/interoperable-errors');
-
-const app = express();
-
-function install404Fallback(url) {
- app.use(url, (req, res, next) => {
- next(new interoperableErrors.NotFoundError());
- });
-
- app.use(url + '/*', (req, res, next) => {
- next(new interoperableErrors.NotFoundError());
- });
-}
-
-function useWith404Fallback(url, route) {
- app.use(url, route);
- install404Fallback(url);
-}
-
-
-// view engine setup
-app.set('views', path.join(__dirname, 'views'));
-app.set('view engine', 'hbs');
-
-// Handle proxies. Needed to resolve client IP
-if (config.www.proxy) {
- app.set('trust proxy', config.www.proxy);
-}
-
-// Do not expose software used
-app.disable('x-powered-by');
-
-hbs.registerPartials(__dirname + '/views/partials');
-hbs.registerPartials(__dirname + '/views/subscription/partials/');
-
-/**
- * We need this helper to make sure that we consume flash messages only
- * when we are able to actually display these. Otherwise we might end up
- * in a situation where we consume a flash messages but then comes a redirect
- * and the message is never displayed
- */
-hbs.registerHelper('flash_messages', function () { // eslint-disable-line prefer-arrow-callback
- if (typeof this.flash !== 'function') { // eslint-disable-line no-invalid-this
- return '';
- }
-
- let messages = this.flash(); // eslint-disable-line no-invalid-this
- let response = [];
-
- // group messages by type
- Object.keys(messages).forEach(key => {
- let el = '';
-
- if (key === 'danger') {
- el += '
';
- }
-
- let rows = [];
-
- messages[key].forEach(message => {
- message = hbs.handlebars.escapeExpression(message);
- message = message.replace(/(\r\n|\n|\r)/gm, '
');
- rows.push(message);
- });
-
- if (rows.length > 1) {
- el += '
' + rows.join('
\n
') + '
';
- } else {
- el += rows.join('');
- }
-
- el += '
';
-
- response.push(el);
- });
-
- return new hbs.handlebars.SafeString(
- response.join('\n')
- );
-});
-
-handlebarsHelpers.registerHelpers(hbs.handlebars);
-
-
-app.use(compression());
-app.use(favicon(path.join(__dirname, 'client', 'public', 'favicon.ico')));
-
-app.use(logger(config.www.log, {
- stream: {
- write: message => {
- message = (message || '').toString();
- if (message) {
- log.info('HTTP', message.replace('\n', '').trim());
- }
- }
- }
-}));
-
-app.use(cookieParser());
-useWith404Fallback('/public', express.static(path.join(__dirname, 'client', 'public')));
-useWith404Fallback('/mailtrain', express.static(path.join(__dirname, 'client', 'dist')));
-useWith404Fallback('/locales', express.static(path.join(__dirname, 'client', 'locales')));
-
-app.use(session({
- store: config.redis.enabled ? new RedisStore(config.redis) : false,
- secret: config.www.secret,
- saveUninitialized: false,
- resave: false
-}));
-app.use(flash());
-
-app.use((req, res, next) => {
- req._ = str => _(str);
- next();
-});
-
-app.use(bodyParser.urlencoded({
- extended: true,
- limit: config.www.postsize
-}));
-
-app.use(bodyParser.text({
- limit: config.www.postsize
-}));
-
-app.use(bodyParser.json({
- limit: config.www.postsize
-}));
-
-passport.setup(app);
-
-// make sure flash messages are available
-app.use((req, res, next) => {
- res.locals.flash = req.flash.bind(req);
- res.locals.user = req.user;
- res.locals.admin = req.user && req.user.id == 1; // FIXME, this should verify the admin privileges and set this accordingly
- res.locals.ldap = {
- enabled: config.ldap.enabled,
- passwordresetlink: config.ldap.passwordresetlink
- };
-
- let menu = [{
- title: _('Home'),
- url: '/',
- selected: true
- }];
-
- res.setSelectedMenu = key => {
- menu.forEach(item => {
- item.selected = (item.key === key);
- });
- };
-
- res.locals.menu = menu;
- tools.updateMenu(res);
-
- res.locals.customStyles = config.customstyles || [];
- res.locals.customScripts = config.customscripts || [];
-
- let bodyClasses = [];
- if (req.user) {
- bodyClasses.push('logged-in user-' + req.user.username);
- }
- res.locals.bodyClass = bodyClasses.join(' ');
-
- getSettings(['uaCode', 'shoutout'], (err, configItems) => {
- if (err) {
- return next(err);
- }
- Object.keys(configItems).forEach(key => {
- res.locals[key] = configItems[key];
- });
- next();
- });
-});
-
-// Endpoint under /api are authenticated by access token
-app.all('/api/*', passport.authByAccessToken);
-
-
-// Marks the following endpoint to return JSON object when error occurs
-app.all('/api/*', (req, res, next) => {
- req.needsAPIJSONResponse = true;
- next();
-});
-
-app.all('/rest/*', (req, res, next) => {
- req.needsRESTJSONResponse = true;
- next();
-});
-
-
-// Initializes the request context to be used for authorization
-app.use((req, res, next) => {
- req.context = contextHelpers.getRequestContext(req);
- next();
-});
-
-
-// Regular endpoints
-useWith404Fallback('/subscription', subscription);
-useWith404Fallback('/files', files);
-useWith404Fallback('/mosaico', mosaico);
-
-if (config.reports && config.reports.enabled === true) {
- useWith404Fallback('/reports', reports);
-}
-
-
-// API endpoints
-useWith404Fallback('/api', api);
-
-
-// REST endpoints
-app.use('/rest', namespacesRest);
-app.use('/rest', usersRest);
-app.use('/rest', accountRest);
-app.use('/rest', campaignsRest);
-app.use('/rest', listsRest);
-app.use('/rest', formsRest);
-app.use('/rest', fieldsRest);
-app.use('/rest', sharesRest);
-app.use('/rest', segmentsRest);
-app.use('/rest', subscriptionsRest);
-app.use('/rest', templatesRest);
-app.use('/rest', blacklistRest);
-app.use('/rest', editorsRest);
-app.use('/rest', filesRest);
-
-if (config.reports && config.reports.enabled === true) {
- app.use('/rest', reportTemplatesRest);
- app.use('/rest', reportsRest);
-}
-install404Fallback('/rest');
-
-app.use('/', root);
-
-
-// Error handlers
-if (app.get('env') === 'development') {
- // development error handler
- // will print stacktrace
- app.use((err, req, res, next) => {
- if (!err) {
- return next();
- }
-
- if (req.needsRESTJSONResponse) {
- const resp = {
- message: err.message,
- error: err
- };
-
- if (err instanceof interoperableErrors.InteroperableError) {
- resp.type = err.type;
- resp.data = err.data;
- }
-
- res.status(err.status || 500).json(resp);
-
- } else if (req.needsAPIJSONResponse) {
- const resp = {
- error: err.message || err,
- data: []
- };
-
- return status(err.status || 500).json(resp);
-
- } else {
- if (err instanceof interoperableErrors.NotLoggedInError) {
- req.flash('danger', _('Need to be logged in to access restricted content'));
- return res.redirect('/account/login?next=' + encodeURIComponent(req.originalUrl));
- } else {
- res.status(err.status || 500);
- res.render('error', {
- message: err.message,
- error: err
- });
- }
- }
-
- });
-} else {
- // production error handler
- // no stacktraces leaked to user
- app.use((err, req, res, next) => {
- if (!err) {
- return next();
- }
-
- console.log(err);
- if (req.needsRESTJSONResponse) {
- const resp = {
- message: err.message,
- error: {}
- };
-
- if (err instanceof interoperableErrors.InteroperableError) {
- resp.type = err.type;
- resp.data = err.data;
- }
-
- res.status(err.status || 500).json(resp);
-
- } else if (req.needsAPIJSONResponse) {
- const resp = {
- error: err.message || err,
- data: []
- };
-
- return status(err.status || 500).json(resp);
-
- } else {
- // TODO: Render interoperable errors using a special client that does internationalization of the error message
-
- if (err instanceof interoperableErrors.NotLoggedInError) {
- req.flash('danger', _('Need to be logged in to access restricted content'));
- return res.redirect('/account/login?next=' + encodeURIComponent(req.originalUrl));
- } else {
- res.status(err.status || 500);
- res.render('error', {
- message: err.message,
- error: {}
- });
- }
- }
- });
-}
-
-
-module.exports = app;
diff --git a/client/public/mailtrain-notext.png b/client/public/mailtrain-notext.png
new file mode 100644
index 00000000..47978c0a
Binary files /dev/null and b/client/public/mailtrain-notext.png differ
diff --git a/client/src/account/Login.js b/client/src/account/Login.js
index 907d62ee..78fd9517 100644
--- a/client/src/account/Login.js
+++ b/client/src/account/Login.js
@@ -61,7 +61,7 @@ export default class Login extends Component {
const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.POST, '/rest/login');
if (submitSuccessful) {
- const nextUrl = qs.parse(this.props.location.search).next || '/';
+ const nextUrl = qs.parse(this.props.location.search).next || mailtrainConfig.urlBase;
/* This ensures we get config for the authenticated user */
window.location = nextUrl;
diff --git a/client/src/lib/form.js b/client/src/lib/form.js
index a6f38d72..7d1c6fd8 100644
--- a/client/src/lib/form.js
+++ b/client/src/lib/form.js
@@ -25,7 +25,6 @@ import { parseDate, parseBirthday, formatDate, formatBirthday, DateFormat, birth
import styles from "./styles.scss";
import moment from "moment";
-
const FormState = {
Loading: 0,
LoadingWithNotice: 1,
diff --git a/client/src/lib/mosaico-sandbox-root.js b/client/src/lib/mosaico-sandbox-root.js
new file mode 100644
index 00000000..d95b02a8
--- /dev/null
+++ b/client/src/lib/mosaico-sandbox-root.js
@@ -0,0 +1,22 @@
+'use strict';
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import {
+ I18nextProvider,
+} from 'react-i18next';
+import i18n from './i18n';
+import styles from "./mosaico.scss";
+import { MosaicoSandbox } from './mosaico';
+import { UntrustedContentRoot } from './untrusted';
+
+export default function() {
+ ReactDOM.render(
+
+ } />
+ ,
+ document.getElementById('root')
+ );
+};
+
+
diff --git a/client/src/lib/mosaico.js b/client/src/lib/mosaico.js
new file mode 100644
index 00000000..467468da
--- /dev/null
+++ b/client/src/lib/mosaico.js
@@ -0,0 +1,166 @@
+'use strict';
+
+import React, {Component} from 'react';
+import ReactDOM from 'react-dom';
+import {
+ I18nextProvider,
+ translate
+} from 'react-i18next';
+import i18n from './i18n';
+import PropTypes from "prop-types";
+import styles from "./mosaico.scss";
+import mailtrainConfig from 'mailtrainConfig';
+
+import { UntrustedContentHost } from './untrusted';
+import {
+ Button,
+ Icon
+} from "./bootstrap-components";
+
+export const ResourceType = {
+ TEMPLATE: 'template',
+ CAMPAIGN: 'campaign'
+}
+
+@translate(null, { withRef: true })
+export class MosaicoEditor extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ fullscreen: false
+ }
+ }
+
+ static propTypes = {
+ entityTypeId: PropTypes.string,
+ entity: PropTypes.object,
+ title: PropTypes.string,
+ onFullscreenAsync: PropTypes.func
+ }
+
+ async toggleFullscreenAsync() {
+ const fullscreen = !this.state.fullscreen;
+ this.setState({
+ fullscreen
+ });
+ await this.props.onFullscreenAsync(fullscreen);
+ }
+
+ async exportState() {
+ return await this.contentNode.ask('exportState');
+ }
+
+ render() {
+ const t = this.props.t;
+
+ const mosaicoData = {
+ entityTypeId: this.props.entityTypeId,
+ entityId: this.props.entity.id,
+ model: this.props.entity.data.model,
+ metadata: this.props.entity.data.metadata
+ };
+
+ return (
+
+
+ {this.state.fullscreen &&

}
+
{this.props.title}
+
+
+
this.contentNode = node} className={styles.host} contentProps={mosaicoData} contentSrc="mosaico/editor" tokenMethod="mosaico" tokenParams={mosaicoData}/>
+
+ );
+ }
+}
+
+MosaicoEditor.prototype.exportState = async function() {
+ return await this.getWrappedInstance().exportState();
+};
+
+
+
+@translate(null, { withRef: true })
+export class MosaicoSandbox extends Component {
+ constructor(props) {
+ super(props);
+ this.viewModel = null;
+ this.state = {
+ };
+ }
+
+ static propTypes = {
+ entityTypeId: PropTypes.string,
+ entityId: PropTypes.number,
+ model: PropTypes.object,
+ metadata: PropTypes.object
+ }
+
+ componentDidMount() {
+ const publicPath = '/public/mosaico';
+
+ if (!Mosaico.isCompatible()) {
+ alert('Update your browser!');
+ return;
+ }
+
+ const plugins = [...window.mosaicoPlugins];
+
+ plugins.push(viewModel => {
+ this.viewModel = viewModel;
+ });
+
+ // (Custom) HTML postRenderers
+ plugins.push(viewModel => {
+ viewModel.originalExportHTML = viewModel.exportHTML;
+ viewModel.exportHTML = () => {
+ let html = viewModel.originalExportHTML();
+ for (const portRender of window.mosaicoHTMLPostRenderers) {
+ html = postRender(html);
+ }
+ return html;
+ };
+ });
+
+ plugins.unshift(vm => {
+ // This is an override of the default paths in Mosaico
+ vm.logoPath = publicPath + '/img/mosaico32.png';
+ vm.logoUrl = '#';
+ });
+
+ const config = {
+ imgProcessorBackend: `/mosaico/img/${this.props.entityTypeId}/${this.props.entityId}`,
+ emailProcessorBackend: '/mosaico/dl/',
+ fileuploadConfig: {
+ url: `/mosaico/upload/${this.props.entityTypeId}/${this.props.entityId}`
+ },
+ strings: window.mosaicoLanguageStrings
+ };
+
+ const metadata = this.props.metadata;
+ const model = this.props.model;
+ const template = publicPath + '/templates/versafix-1/index.html';
+
+ const allPlugins = plugins.concat(window.mosaicoPlugins);
+
+ Mosaico.start(config, template, metadata, model, allPlugins);
+ }
+
+ async onMethodAsync(method, params) {
+ if (method === 'exportState') {
+ return {
+ html: this.viewModel.exportHTML(),
+ model: this.viewModel.exportJS(),
+ metadata: this.viewModel.metadata
+ };
+ }
+ }
+
+ render() {
+ return ;
+ }
+}
+
+MosaicoSandbox.prototype.onMethodAsync = async function(method, params) {
+ return await this.getWrappedInstance().onMethodAsync(method, params);
+};
diff --git a/client/src/lib/mosaico.scss b/client/src/lib/mosaico.scss
new file mode 100644
index 00000000..c289714d
--- /dev/null
+++ b/client/src/lib/mosaico.scss
@@ -0,0 +1,75 @@
+$navbarHeight: 34px;
+
+.editor {
+ .host {
+ height: 800px;
+ }
+}
+
+.editorFullscreen {
+ position: fixed;
+ top: 0px;
+ bottom: 0px;
+ left: 0px;
+ right: 0px;
+ z-index: 1000;
+ background: white;
+ margin-top: $navbarHeight;
+
+ .navbar {
+ margin-top: -$navbarHeight;
+ }
+
+ .host {
+ height: 100%;
+ }
+}
+
+:global .mo-standalone {
+ top: 0px;
+ bottom: 0px;
+ width: 100%;
+ position: absolute;
+}
+
+.navbar {
+ background: #DE4320;
+ width: 100%;
+ height: $navbarHeight;
+}
+
+.logo {
+ float: left;
+ height: $navbarHeight;
+ padding: 5px 0 5px 10px;
+ filter: brightness(0) invert(1);
+}
+
+.title {
+ padding: 5px 0 5px 10px;
+ font-size: 18px;
+ font-family: sans-serif;
+ font-family: "Ubuntu",Tahoma,"Helvetica Neue",Helvetica,Arial,sans-serif;
+ font-weight: bold;
+ float: left;
+ color: white;
+ height: $navbarHeight;
+}
+
+.btn {
+ display: block;
+ float: right;
+ padding: 0px 15px;
+ line-height: $navbarHeight;
+ text-align: center;
+ color: white;
+ font-size: 14px;
+ font-weight: bold;
+ font-family: sans-serif;
+ cursor: pointer;
+}
+
+.btn:hover {
+ background-color: #b1381e;
+ color: white;
+}
diff --git a/client/src/lib/page.js b/client/src/lib/page.js
index 7c885561..e4d9b656 100644
--- a/client/src/lib/page.js
+++ b/client/src/lib/page.js
@@ -319,7 +319,6 @@ class SectionContent extends Component {
ensureAuthenticated() {
if (!mailtrainConfig.isAuthenticated) {
- /* FIXME, once we turn Mailtrain to single-page application, this should become navigateTo */
this.navigateTo('/account/login?next=' + encodeURIComponent(window.location.pathname));
}
}
@@ -383,7 +382,7 @@ class Section extends Component {
render() {
return (
-
+
);
diff --git a/client/src/lib/styles.scss b/client/src/lib/styles.scss
index 910178ef..3a2c77d9 100644
--- a/client/src/lib/styles.scss
+++ b/client/src/lib/styles.scss
@@ -100,3 +100,15 @@
font-size: 20px;
color: #808080;
}
+
+
+.untrustedContent {
+ border: 0px none;
+ width: 100%;
+ overflow: hidden;
+}
+
+.withElementInFullscreen {
+ height: 0px;
+ overflow: hidden;
+}
\ No newline at end of file
diff --git a/client/src/lib/table.js b/client/src/lib/table.js
index 1ca77065..214acfc3 100644
--- a/client/src/lib/table.js
+++ b/client/src/lib/table.js
@@ -13,7 +13,7 @@ import 'datatables.net-bs/css/dataTables.bootstrap.css';
import axios from './axios';
-import { withPageHelpers } from '../lib/page'
+import { withPageHelpers } from './page'
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
import styles from "./styles.scss";
@@ -394,7 +394,7 @@ class Table extends Component {
The reference to the table can be obtained by ref.
*/
Table.prototype.refresh = function() {
- this.getWrappedInstance().refresh()
+ this.getWrappedInstance().refresh();
};
export {
diff --git a/client/src/lib/tree.js b/client/src/lib/tree.js
index 663587d4..8e39e651 100644
--- a/client/src/lib/tree.js
+++ b/client/src/lib/tree.js
@@ -12,7 +12,7 @@ import '../../vendor/fancytree/skin-bootstrap/ui.fancytree.min.css';
import './tree.css';
import axios from './axios';
-import { withPageHelpers } from '../lib/page'
+import { withPageHelpers } from './page'
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
import styles from "./styles.scss";
diff --git a/client/src/lib/untrusted.js b/client/src/lib/untrusted.js
new file mode 100644
index 00000000..043ff04b
--- /dev/null
+++ b/client/src/lib/untrusted.js
@@ -0,0 +1,227 @@
+'use strict';
+
+import React, {Component} from "react";
+import PropTypes from "prop-types";
+import {translate} from "react-i18next";
+import {requiresAuthenticatedUser, withPageHelpers} from "./page";
+import {withAsyncErrorHandler, withErrorHandling} from "./error-handling";
+import axios from "./axios";
+import styles from "./styles.scss";
+import {getTrustedUrl, getSandboxUrl} from "./urls";
+import {Table} from "./table";
+
+@translate(null, { withRef: true })
+@withPageHelpers
+@withErrorHandling
+@requiresAuthenticatedUser
+export class UntrustedContentHost extends Component {
+ constructor(props) {
+ super(props);
+
+ this.refreshAccessTokenTimeout = null;
+ this.accessToken = null;
+ this.contentNodeIsLoaded = false;
+
+ this.state = {
+ hasAccessToken: false,
+ };
+
+ this.receiveMessageHandler = ::this.receiveMessage;
+
+ this.rpcCounter = 0;
+ this.rpcResolves = new Map();
+ }
+
+ static propTypes = {
+ contentSrc: PropTypes.string,
+ contentProps: PropTypes.object,
+ tokenMethod: PropTypes.string,
+ tokenParams: PropTypes.object,
+ className: PropTypes.string
+ }
+
+ isInitialized() {
+ return !!this.accessToken;
+ }
+
+ receiveMessage(evt) {
+ const msg = evt.data;
+ console.log(msg);
+
+ if (msg.type === 'initNeeded') {
+ if (this.isInitialized()) {
+ this.sendMessage('init', {
+ accessToken: this.accessToken,
+ contentProps: this.props.contentProps
+ });
+ }
+ } else if (msg.type === 'rpcResponse') {
+ const resolve = this.rpcResolves.get(msg.data.msgId);
+ resolve(msg.data.ret);
+ }
+ }
+
+ sendMessage(type, data) {
+ if (this.contentNodeIsLoaded) { // This is to avoid errors "common.js:45744 Failed to execute 'postMessage' on 'DOMWindow': The target origin provided ('http://localhost:8081') does not match the recipient window's origin ('http://localhost:3000')"
+ this.contentNode.contentWindow.postMessage({type, data}, getSandboxUrl(''));
+ }
+ }
+
+ async ask(method, params) {
+ if (this.contentNodeIsLoaded) {
+ this.rpcCounter += 1;
+ const msgId = this.rpcCounter;
+
+ this.sendMessage('rpcRequest', {
+ method,
+ params,
+ msgId
+ });
+
+ return await (new Promise((resolve, reject) => {
+ this.rpcResolves.set(msgId, resolve);
+ }));
+ }
+ }
+
+ @withAsyncErrorHandler
+ async refreshAccessToken() {
+ const result = await axios.post(getTrustedUrl('rest/restricted-access-token'), {
+ method: this.props.tokenMethod,
+ params: this.props.tokenParams
+ });
+
+ this.accessToken = result.data;
+
+ if (!this.state.hasAccessToken) {
+ this.setState({
+ hasAccessToken: true
+ })
+ }
+
+ this.sendMessage('accessToken', this.accessToken);
+ }
+
+ scheduleRefreshAccessToken() {
+ this.refreshAccessTokenTimeout = setTimeout(() => {
+ this.refreshAccessToken();
+ this.scheduleRefreshAccessToken();
+ }, 60 * 1000);
+ }
+
+ handleUpdate() {
+ if (this.isInitialized()) {
+ this.sendMessage('initAvailable');
+ }
+
+ if (!this.state.hasAccessToken) {
+ this.refreshAccessToken();
+ }
+ }
+
+ componentDidMount() {
+ this.scheduleRefreshAccessToken();
+ window.addEventListener('message', this.receiveMessageHandler, false);
+
+ this.handleUpdate();
+ }
+
+ componentWillUnmount() {
+ clearTimeout(this.refreshAccessTokenTimeout);
+ window.removeEventListener('message', this.receiveMessageHandler, false);
+ }
+
+ contentNodeLoaded() {
+ this.contentNodeIsLoaded = true;
+ }
+
+ render() {
+ const t = this.props.t;
+
+ return (
+
+ );
+ }
+}
+
+UntrustedContentHost.prototype.ask = async function(method, params) {
+ return await this.getWrappedInstance().ask(method, params);
+};
+
+
+@translate()
+export class UntrustedContentRoot extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ initialized: false,
+ };
+
+ this.receiveMessageHandler = ::this.receiveMessage;
+ }
+
+ static propTypes = {
+ render: PropTypes.func
+ }
+
+
+ setAccessTokenCookie(token) {
+ document.cookie = 'restricted_access_token=' + token + '; expires=' + (new Date(Date.now()+60000)).toUTCString();
+ console.log(document.cookie);
+ }
+
+ async receiveMessage(evt) {
+ const msg = evt.data;
+ console.log(msg);
+
+ if (msg.type === 'initAvailable' && !this.state.initialized) {
+ this.sendMessage('initNeeded');
+
+ } else if (msg.type === 'init' && !this.state.initialized) {
+ this.setAccessTokenCookie(msg.data.accessToken);
+ this.setState({
+ initialized: true,
+ contentProps: msg.data.contentProps
+ });
+
+ } else if (msg.type === 'accessToken') {
+ this.setAccessTokenCookie(msg.data);
+ } else if (msg.type === 'rpcRequest') {
+ const ret = await this.contentNode.onMethodAsync(msg.data.method, msg.data.params);
+ this.sendMessage('rpcResponse', {msgId: msg.data.msgId, ret});
+ }
+ }
+
+ sendMessage(type, data) {
+ window.parent.postMessage({type, data}, getTrustedUrl(''));
+ }
+
+ componentDidMount() {
+ window.addEventListener('message', this.receiveMessageHandler, false);
+ this.sendMessage('initNeeded');
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener('message', this.receiveMessageHandler, false);
+ }
+
+ render() {
+ const t = this.props.t;
+
+ const props = {
+ ...this.state.contentProps,
+ ref: node => this.contentNode = node
+ };
+
+ if (this.state.initialized) {
+ return this.props.render(props);
+ } else {
+ return (
+
+ {t('Loading...')}
+
+ );
+ }
+ }
+}
\ No newline at end of file
diff --git a/client/src/lib/urls.js b/client/src/lib/urls.js
new file mode 100644
index 00000000..65adfff2
--- /dev/null
+++ b/client/src/lib/urls.js
@@ -0,0 +1,37 @@
+'use strict';
+
+import mailtrainConfig from "mailtrainConfig";
+
+let urlBase;
+let sandboxUrlBase;
+
+if (mailtrainConfig.urlBase.startsWith('/')) {
+ urlBase = window.location.protocol + '//' + window.location.hostname + ':' + mailtrainConfig.port + mailtrainConfig.urlBase;
+} else {
+ urlBase = mailtrainConfig.urlBase
+}
+
+if (mailtrainConfig.sandboxUrlBase) {
+ if (mailtrainConfig.urlBase.startsWith('/')) {
+ sandboxUrlBase = window.location.protocol + '//' + window.location.hostname + ':' + mailtrainConfig.sandboxPort + mailtrainConfig.sandboxUrlBase;
+ } else {
+ sandboxUrlBase = mailtrainConfig.sandboxUrlBase
+ }
+} else {
+ const loc = document.createElement("a");
+ loc.href = urlBase;
+ sandboxUrlBase = loc.protocol + '//' + loc.hostname + ':' + mailtrainConfig.sandboxPort + loc.pathname;
+}
+
+function getTrustedUrl(path) {
+ return urlBase + path;
+}
+
+function getSandboxUrl(path) {
+ return sandboxUrlBase + path;
+}
+
+export {
+ getTrustedUrl,
+ getSandboxUrl
+}
\ No newline at end of file
diff --git a/client/src/lists/root.js b/client/src/lists/root.js
index 8fa0bf6f..52bf7336 100644
--- a/client/src/lists/root.js
+++ b/client/src/lists/root.js
@@ -1,12 +1,7 @@
'use strict';
import React from 'react';
-import ReactDOM from 'react-dom';
-import { I18nextProvider } from 'react-i18next';
-import i18n from '../lib/i18n';
import qs from 'querystringify';
-
-import { Section } from '../lib/page';
import ListsList from './List';
import ListsCUD from './CUD';
import FormsList from './forms/List';
diff --git a/client/src/mosaico/root.js b/client/src/mosaico/root.js
deleted file mode 100644
index 0e4f0bac..00000000
--- a/client/src/mosaico/root.js
+++ /dev/null
@@ -1,118 +0,0 @@
-'use strict';
-
-import React, {Component} from 'react';
-import ReactDOM from 'react-dom';
-import {
- I18nextProvider,
- translate
-} from 'react-i18next';
-import i18n from '../lib/i18n';
-import PropTypes from "prop-types";
-import styles from "./styles.scss";
-
-const ResourceType = {
- TEMPLATE: 'template',
- CAMPAIGN: 'campaign'
-}
-
-@translate()
-class MosaicoEditor extends Component {
- constructor(props) {
- super(props);
- this.viewModel = null;
- this.state = {
- entityTypeId: ResourceType.TEMPLATE, // FIXME
- entityId: 13 // FIXME
- }
- }
-
- static propTypes = {
- //structure: PropTypes.oneOfType([PropTypes.object, PropTypes.func]).isRequired,
- }
-
- async onClose(evt) {
- const t = this.props.t;
-
- evt.preventDefault();
- evt.stopPropagation();
-
- if (confirm(t('Unsaved changes will be lost. Close now?'))) {
- window.location.href = `/${this.state.entityTypeId}s/${this.state.entityId}/edit`;
- }
- }
-
- componentDidMount() {
- const publicPath = '/public/mosaico';
-
- if (!Mosaico.isCompatible()) {
- alert('Update your browser!');
- return;
- }
-
- const plugins = window.mosaicoPlugins;
-
- plugins.push(viewModel => {
- this.viewModel = viewModel;
- });
-
- // (Custom) HTML postRenderers
- plugins.push(viewModel => {
- viewModel.originalExportHTML = viewModel.exportHTML;
- viewModel.exportHTML = () => {
- let html = viewModel.originalExportHTML();
- for (const portRender of window.mosaicoHTMLPostRenderers) {
- html = postRender(html);
- }
- return html;
- };
- });
-
- plugins.unshift(vm => {
- // This is a fix for the use of hardcoded path in Mosaico
- vm.logoPath = publicPath + '/img/mosaico32.png'
- });
-
- const config = {
- imgProcessorBackend: `/mosaico/img/${this.state.entityTypeId}/${this.state.entityId}`,
- emailProcessorBackend: '/mosaico/dl/',
- titleToken: "MOSAICO Responsive Email Designer",
- fileuploadConfig: {
- url: `/mosaico/upload/${this.state.entityTypeId}/${this.state.entityId}`
- },
- strings: window.mosaicoLanguageStrings
- };
-
- const metadata = undefined;
- const model = undefined;
- const template = publicPath + '/templates/versafix-1/index.html';
-
- const allPlugins = plugins.concat(window.mosaicoPlugins);
-
- Mosaico.start(config, template, metadata, model, allPlugins);
- }
-
- componentDidUpdate() {
- }
-
- render() {
- const t = this.props.t;
-
- return (
-
- );
- }
-}
-
-
-export default function() {
- ReactDOM.render(
- ,
- document.getElementById('root')
- );
-};
-
-
diff --git a/client/src/mosaico/styles.scss b/client/src/mosaico/styles.scss
deleted file mode 100644
index aaf8417c..00000000
--- a/client/src/mosaico/styles.scss
+++ /dev/null
@@ -1,35 +0,0 @@
-:global .mo-standalone {
- top: 34px;
- bottom: 0px;
- width: 100%;
- position: absolute;
-}
-
-.navbar {
- background: #DE4320;
- overflow: hidden;
- height: 34px;
- top: -34px;
- position: absolute;
- width: 100%;
-}
-
-.logo {
- height: 24px;
- padding: 5px 0 5px 10px;
- filter: brightness(0) invert(1);
-}
-
-.btn {
- display: block;
- float: right;
- width: 150px;
- line-height: 34px;
- text-align: center;
- color: white;
- font-size: 14px;
- font-weight: bold;
- font-family: sans-serif;
- cursor: pointer;
- border-left: 1px solid #972E15;
-}
\ No newline at end of file
diff --git a/client/src/namespaces/root.js b/client/src/namespaces/root.js
index b03b7e58..25969b6d 100644
--- a/client/src/namespaces/root.js
+++ b/client/src/namespaces/root.js
@@ -1,11 +1,6 @@
'use strict';
import React from 'react';
-import ReactDOM from 'react-dom';
-import { I18nextProvider } from 'react-i18next';
-import i18n from '../lib/i18n';
-
-import { Section } from '../lib/page';
import CUD from './CUD';
import List from './List';
import Share from '../shares/Share';
diff --git a/client/src/reports/root.js b/client/src/reports/root.js
index 429f609c..de6c33e4 100644
--- a/client/src/reports/root.js
+++ b/client/src/reports/root.js
@@ -1,11 +1,6 @@
'use strict';
import React from 'react';
-import ReactDOM from 'react-dom';
-import { I18nextProvider } from 'react-i18next';
-import i18n from '../lib/i18n';
-
-import { Section } from '../lib/page';
import ReportsCUD from './CUD';
import ReportsList from './List';
import ReportsView from './View';
@@ -13,7 +8,7 @@ import ReportsOutput from './Output';
import ReportTemplatesCUD from './templates/CUD';
import ReportTemplatesList from './templates/List';
import Share from '../shares/Share';
-import { ReportState } from '../../../shared/reports';
+import {ReportState} from '../../../shared/reports';
import mailtrainConfig from 'mailtrainConfig';
diff --git a/client/src/root.js b/client/src/root.js
index a89ccff7..c502bc70 100644
--- a/client/src/root.js
+++ b/client/src/root.js
@@ -139,7 +139,7 @@ class Root extends Component {
async logout() {
await axios.post('/rest/logout');
- window.location = '/';
+ window.location = mailtrainConfig.urlBase;
}
render() {
diff --git a/client/src/templates/CUD.js b/client/src/templates/CUD.js
index ae94babd..3f3f1a53 100644
--- a/client/src/templates/CUD.js
+++ b/client/src/templates/CUD.js
@@ -39,7 +39,8 @@ export default class CUD extends Component {
this.templateTypes = getTemplateTypes(props.t);
this.state = {
- showMergeTagReference: false
+ showMergeTagReference: false,
+ elementInFullscreen: false
};
this.initForm();
@@ -66,7 +67,8 @@ export default class CUD extends Component {
namespace: mailtrainConfig.user.namespace,
type: mailtrainConfig.editors[0],
text: '',
- html: ''
+ html: '',
+ data: {}
});
}
}
@@ -92,6 +94,11 @@ export default class CUD extends Component {
async submitHandler() {
const t = this.props.t;
+ if (this.props.entity) {
+ const typeKey = this.getFormValue('type');
+ await this.templateTypes[typeKey].htmlEditorBeforeSave(this);
+ }
+
let sendMethod, url;
if (this.props.entity) {
sendMethod = FormSendMethod.PUT;
@@ -120,6 +127,9 @@ export default class CUD extends Component {
}
async extractPlainText() {
+ const typeKey = this.getFormValue('type');
+ await this.templateTypes[typeKey].htmlEditorBeforeSave(this);
+
const html = this.getFormValue('html');
if (!html) {
alert('Missing HTML content');
@@ -145,6 +155,12 @@ export default class CUD extends Component {
});
}
+ async setElementInFullscreen(elementInFullscreen) {
+ this.setState({
+ elementInFullscreen
+ });
+ }
+
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
@@ -241,7 +257,7 @@ export default class CUD extends Component {
}
- {this.templateTypes[typeKey].form}
+ {this.templateTypes[typeKey].getHTMLEditor(this)}
To extract the text from HTML click here. Please note that your existing plaintext in the field above will be overwritten. This feature uses the Premailer API, a third party service. Their Terms of Service and Privacy Policy apply.}/>
@@ -249,7 +265,7 @@ export default class CUD extends Component {
return (
-
+
{canDelete &&
owner.editorNode = node} entity={owner.props.entity} entityTypeId={ResourceType.TEMPLATE} title={t('Mosaico Template Designer')} onFullscreenAsync={::owner.setElementInFullscreen}/>,
+ htmlEditorBeforeSave: async owner => {
+ const {html, metadata, model} = await owner.editorNode.exportState();
+ owner.updateFormValue('html', html);
+ owner.updateFormValue('data', {metadata, model});
+ }
};
templateTypes.grapejs = {
@@ -22,12 +32,14 @@ export function getTemplateTypes(t) {
templateTypes.ckeditor = {
typeName: t('CKEditor'),
- form:
+ getHTMLEditor: owner => ,
+ htmlEditorBeforeSave: async owner => {}
};
templateTypes.codeeditor = {
typeName: t('Code Editor'),
- form:
+ getHTMLEditor: owner => ,
+ htmlEditorBeforeSave: async owner => {}
};
templateTypes.mjml = {
diff --git a/client/src/templates/root.js b/client/src/templates/root.js
index 7fac049c..4554e50f 100644
--- a/client/src/templates/root.js
+++ b/client/src/templates/root.js
@@ -1,11 +1,7 @@
'use strict';
import React from 'react';
-import ReactDOM from 'react-dom';
-import { I18nextProvider } from 'react-i18next';
-import i18n from '../lib/i18n';
-import { Section } from '../lib/page';
import TemplatesCUD from './CUD';
import TemplatesList from './List';
import Share from '../shares/Share';
diff --git a/client/webpack.config.js b/client/webpack.config.js
index 5c35f6fa..49bfedb1 100644
--- a/client/webpack.config.js
+++ b/client/webpack.config.js
@@ -4,7 +4,7 @@ const path = require('path');
module.exports = {
entry: {
root: ['babel-polyfill', './src/root.js'],
- mosaico: ['babel-polyfill', './src/mosaico/root.js'],
+ mosaico: ['babel-polyfill', './src/lib/mosaico-sandbox-root.js'],
},
output: {
library: 'MailtrainReactBody',
diff --git a/config/default.toml b/config/default.toml
index b3029b1d..aeaf0181 100644
--- a/config/default.toml
+++ b/config/default.toml
@@ -22,19 +22,13 @@
title="mailtrain"
# Enabled HTML editors
-editors=["ckeditor", "codeeditor"]
+editors=["ckeditor", "codeeditor", "mosaico"]
# Default language to use
language="en"
-# Inject custom styles in layout.hbs
-# customstyles=["/custom/hello-world.css"]
-
-# Inject custom scripts in layout.hbs
-# customscripts=["/custom/hello-world.js"]
-
# Inject custom scripts in subscription/layout.mjml.hbs
-# customsubscriptionscripts=["/custom/hello-world.js"]
+# customSubscriptionScripts=["/custom/hello-world.js"]
# If you start out as a root user (eg. if you want to use ports lower than 1000)
# then you can downgrade the user once all services are up and running
@@ -43,9 +37,9 @@ language="en"
# If Mailtrain is started as root, "Reports" feature drops the privileges of script generating the report to disallow
# any modifications of Mailtrain code and even prohibits reading the production configuration (which contains the MySQL
-# password for read/write operations). The rouser/rogroup determines the user to be used
-#rouser="nobody"
-#rogroup="nogroup"
+# password for read/write operations). The roUser/roGroup determines the user to be used
+#roUser="nobody"
+#roGroup="nogroup"
[log]
# silly|verbose|info|http|warn|error|silent
@@ -54,8 +48,18 @@ level="verbose"
[www]
# HTTP port to listen on
port=3000
+# HTTP port to listen on for sandboxed requests
+sandboxPort=8081
# HTTP interface to listen on
host="0.0.0.0"
+# URL Base (must end with slash). It can be either relative (starting with slash) or absolute (starting with http:// or https://).
+# If it is relative, an absolute URL will be constructed automatically based on the domain where it is served and the "port" config parameter
+urlBase="/"
+# URL Base for sandbox urls (must end with slash). It can be either relative (starting with slash) or absolute (starting with http:// or https://)
+# If it is relative, an absolute URL will be constructed automatically based on the domain where it is served and the "port" config parameter
+# If not given at all, it is automatically constructed based on urlBase and sandboxPort.
+# sandboxUrlBase="/"
+
# Secret for signing the session ID cookie
secret="a cat"
# Session length in seconds when "remember me" is checked
@@ -66,10 +70,7 @@ log="dev"
# Set this to true if you are serving Mailtrain as a virtual domain through Nginx or Apache
proxy=false
# maximum POST body size
-postsize="2MB"
-# Uncomment to set uploads folder location for temporary data. Defaults to os.tmpdir()
-# If the service is started by `npm start` then os.tmpdir() points to CWD
-#tmpdir="/tmp"
+postSize="2MB"
[mysql]
host="localhost"
diff --git a/index.js b/index.js
index 3e6aa17f..58e07681 100644
--- a/index.js
+++ b/index.js
@@ -1,14 +1,9 @@
'use strict';
-/**
- * Module dependencies.
- */
-
const config = require('config');
const log = require('npmlog');
-const app = require('./app');
+const appBuilder = require('./app-builder');
const http = require('http');
-const fork = require('child_process').fork;
const triggers = require('./services/triggers');
const importer = require('./services/importer');
const verpServer = require('./services/verp-server');
@@ -24,100 +19,86 @@ const privilegeHelpers = require('./lib/privilege-helpers');
const knex = require('./lib/knex');
const shares = require('./models/shares');
-let port = config.www.port;
-let host = config.www.host;
+const trustedPort = config.www.port;
+const sandboxPort = config.www.sandboxPort;
+const host = config.www.host;
if (config.title) {
process.title = config.title;
}
log.level = config.log.level;
-app.set('port', port);
-/**
- * Create HTTP server.
- */
-let server = http.createServer(app);
+function startHTTPServer(trusted, port, callback) {
+ const app = appBuilder.createApp(trusted);
+ app.set('port', port);
-server.on('error', err => {
- if (err.syscall !== 'listen') {
- throw err;
- }
+ const server = http.createServer(app);
- let bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port;
-
- // handle specific listen errors with friendly messages
- switch (err.code) {
- case 'EACCES':
- log.error('Express', '%s requires elevated privileges', bind);
- return process.exit(1);
- case 'EADDRINUSE':
- log.error('Express', '%s is already in use', bind);
- return process.exit(1);
- default:
+ server.on('error', err => {
+ if (err.syscall !== 'listen') {
throw err;
- }
-});
-
-function spawnSenders(callback) {
- let processes = Math.max(Number(config.queue.processes) || 1, 1);
- let spawned = 0;
- let returned = false;
-
- if (processes > 1 && !config.redis.enabled) {
- log.error('Queue', '%s processes requested but Redis is not enabled, spawning 1 process', processes);
- processes = 1;
- }
-
- let spawnSender = function () {
- if (spawned >= processes) {
- if (!returned) {
- returned = true;
- return callback();
- }
- return false;
}
- let child = fork(__dirname + '/services/sender.js', []);
- let pid = child.pid;
- senders.workers.add(child);
+ const bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port;
- child.on('close', (code, signal) => {
- spawned--;
- senders.workers.delete(child);
- log.error('Child', 'Sender process %s exited with %s', pid, code || signal);
- // Respawn after 5 seconds
- setTimeout(() => spawnSender(), 5 * 1000).unref();
- });
+ // handle specific listen errors with friendly messages
+ switch (err.code) {
+ case 'EACCES':
+ log.error('Express', '%s requires elevated privileges', bind);
+ return process.exit(1);
+ case 'EADDRINUSE':
+ log.error('Express', '%s is already in use', bind);
+ return process.exit(1);
+ default:
+ throw err;
+ }
+ });
- spawned++;
- setImmediate(spawnSender);
- };
+ server.on('listening', () => {
+ const addr = server.address();
+ const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port;
+ log.info('Express', 'WWW server listening on %s', bind);
+ });
- spawnSender();
+ server.listen({port, host}, callback);
}
-server.on('listening', () => {
- let addr = server.address();
- let bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port;
- log.info('Express', 'WWW server listening on %s', bind);
- // start additional services
- function startNextServices() {
- testServer(() => {
- verpServer(() => {
+// ---------------------------------------------------------------------------------------
+// Start the whole circus here
+// ---------------------------------------------------------------------------------------
+dbcheck(err => { // Check if database needs upgrading before starting the server - legacy migration first
+ if (err) {
+ log.error('DB', err.message || err);
+ return process.exit(1);
+ }
- privilegeHelpers.dropRootPrivileges();
+ knex.migrate.latest() // And now the current migration with Knex
- tzupdate(() => {
- importer(() => {
- triggers(() => {
- spawnSenders(() => {
- feedcheck(() => {
- postfixBounceServer(async () => {
- await reportProcessor.init();
- log.info('Service', 'All services started');
+ .then(() => shares.regenerateRoleNamesTable())
+ .then(() => shares.rebuildPermissions())
+
+ .then(() =>
+ executor.spawn(() => {
+ testServer(() => {
+ verpServer(() => {
+ startHTTPServer(true, trustedPort, () => {
+ startHTTPServer(false, sandboxPort, () => {
+ privilegeHelpers.dropRootPrivileges();
+
+ tzupdate(() => {
+ importer(() => {
+ triggers(() => {
+ senders.spawn(() => {
+ feedcheck(() => {
+ postfixBounceServer(async () => {
+ await reportProcessor.init();
+ log.info('Service', 'All services started');
+ });
+ });
+ });
});
});
});
@@ -125,30 +106,8 @@ server.on('listening', () => {
});
});
});
- });
- }
-
- if (config.reports && config.reports.enabled === true) {
- executor.spawn(startNextServices);
- } else {
- startNextServices();
- }
-});
-
-
-// Check if database needs upgrading before starting the server
-// First, the legacy migration
-dbcheck(err => {
- if (err) {
- log.error('DB', err.message || err);
- return process.exit(1);
- }
-
- // And now the current migration with Knex
- knex.migrate.latest()
- .then(() => shares.regenerateRoleNamesTable())
- .then(() => shares.rebuildPermissions())
- .then(() => server.listen(port, host)); // Listen on provided port, on all network interfaces.
+ })
+ );
});
diff --git a/lib/client-helpers.js b/lib/client-helpers.js
index f4f57645..0e6aacf4 100644
--- a/lib/client-helpers.js
+++ b/lib/client-helpers.js
@@ -12,7 +12,11 @@ async function getAnonymousConfig(context) {
isAuthMethodLocal: passport.isAuthMethodLocal,
externalPasswordResetLink: config.ldap.passwordresetlink,
language: config.language || 'en',
- isAuthenticated: !!context.user
+ isAuthenticated: !!context.user,
+ urlBase: config.www.urlBase,
+ sandboxUrlBase: config.www.sandboxUrlBase,
+ port: config.www.port,
+ sandboxPort: config.www.sandboxPort
}
}
diff --git a/lib/passport.js b/lib/passport.js
index eea40c17..41b25653 100644
--- a/lib/passport.js
+++ b/lib/passport.js
@@ -75,7 +75,7 @@ module.exports.csrfProtection = csrf({
module.exports.parseForm = bodyParser.urlencoded({
extended: false,
- limit: config.www.postsize
+ limit: config.www.postSize
});
module.exports.loggedIn = (req, res, next) => {
@@ -87,35 +87,45 @@ module.exports.loggedIn = (req, res, next) => {
};
module.exports.authByAccessToken = (req, res, next) => {
- nodeifyPromise((async () => {
- if (!req.query.access_token) {
+ if (!req.query.access_token) {
+ res.status(403);
+ res.json({
+ error: 'Missing access_token',
+ data: []
+ });
+ }
+
+ users.getByAccessToken(req.query.access_token).then(user => {
+ req.user = user;
+ next();
+ }).catch(err => {
+ if (err instanceof interoperableErrors.PermissionDeniedError) {
res.status(403);
- return res.json({
- error: 'Missing access_token',
+ res.json({
+ error: 'Invalid or expired access_token',
+ data: []
+ });
+ } else {
+ res.status(500);
+ res.json({
+ error: err.message || err,
data: []
});
}
+ });
+};
- try {
- const user = await users.getByAccessToken(req.query.access_token);
+module.exports.tryAuthByRestrictedAccessToken = (req, res, next) => {
+ if (req.cookies.restricted_access_token) {
+ users.getByRestrictedAccessToken(req.cookies.restricted_access_token).then(user => {
req.user = user;
next();
- } catch (err) {
- if (err instanceof interoperableErrors.NotFoundError) {
- res.status(403);
- return res.json({
- error: 'Invalid or expired access_token',
- data: []
- });
- } else {
- res.status(500);
- return res.json({
- error: err.message || err,
- data: []
- });
- }
- }
- })(), next);
+ }).catch(err => {
+ next();
+ });
+ } else {
+ next();
+ }
};
module.exports.setup = app => {
diff --git a/lib/privilege-helpers.js b/lib/privilege-helpers.js
index 07d39844..31bb186b 100644
--- a/lib/privilege-helpers.js
+++ b/lib/privilege-helpers.js
@@ -8,25 +8,25 @@ const fs = require('fs');
const tryRequire = require('try-require');
const posix = tryRequire('posix');
-function _getConfigUidGid(prefix, defaultUid, defaultGid) {
+function _getConfigUidGid(userKey, groupKey, defaultUid, defaultGid) {
let uid = defaultUid;
let gid = defaultGid;
if (posix) {
try {
- if (config.user) {
- uid = posix.getpwnam(config[prefix + 'user']).uid;
+ if (config[userKey]) {
+ uid = posix.getpwnam(config[userKey]).uid;
}
} catch (err) {
- log.info('PrivilegeHelpers', 'Failed to resolve user id "%s"', config[prefix + 'user']);
+ log.info('PrivilegeHelpers', 'Failed to resolve user id "%s"', config[userKey]);
}
try {
- if (config.user) {
- gid = posix.getpwnam(config[prefix + 'group']).gid;
+ if (config[groupKey]) {
+ gid = posix.getpwnam(config[groupKey]).gid;
}
} catch (err) {
- log.info('PrivilegeHelpers', 'Failed to resolve group id "%s"', config[prefix + 'group']);
+ log.info('PrivilegeHelpers', 'Failed to resolve group id "%s"', config[groupKey]);
}
} else {
log.info('PrivilegeHelpers', 'Posix module not installed. Cannot resolve uid/gid');
@@ -36,12 +36,12 @@ function _getConfigUidGid(prefix, defaultUid, defaultGid) {
}
function getConfigUidGid() {
- return _getConfigUidGid('', process.getuid(), process.getgid());
+ return _getConfigUidGid('user', 'group', process.getuid(), process.getgid());
}
function getConfigROUidGid() {
- let rwIds = getConfigUidGid();
- return _getConfigUidGid('ro', rwIds.uid, rwIds.gid);
+ const rwIds = getConfigUidGid();
+ return _getConfigUidGid('roUser', 'roGroup', rwIds.uid, rwIds.gid);
}
function ensureMailtrainOwner(file, callback) {
diff --git a/lib/senders.js b/lib/senders.js
index c9086bf4..b9637882 100644
--- a/lib/senders.js
+++ b/lib/senders.js
@@ -1,5 +1,50 @@
'use strict';
+const fork = require('child_process').fork;
+
+const config = require('config');
+const log = require('npmlog');
+const workers = new Set();
+
+function spawn(callback) {
+ let processes = Math.max(Number(config.queue.processes) || 1, 1);
+ let spawned = 0;
+ let returned = false;
+
+ if (processes > 1 && !config.redis.enabled) {
+ log.error('Queue', '%s processes requested but Redis is not enabled, spawning 1 process', processes);
+ processes = 1;
+ }
+
+ let spawnSender = function () {
+ if (spawned >= processes) {
+ if (!returned) {
+ returned = true;
+ return callback();
+ }
+ return false;
+ }
+
+ let child = fork(__dirname + '/../services/sender.js', []);
+ let pid = child.pid;
+ workers.add(child);
+
+ child.on('close', (code, signal) => {
+ spawned--;
+ workers.delete(child);
+ log.error('Child', 'Sender process %s exited with %s', pid, code || signal);
+ // Respawn after 5 seconds
+ setTimeout(() => spawnSender(), 5 * 1000).unref();
+ });
+
+ spawned++;
+ setImmediate(spawnSender);
+ };
+
+ spawnSender();
+}
+
module.exports = {
- workers: new Set()
+ workers,
+ spawn
};
diff --git a/models/files.js b/models/files.js
index 0ec709a9..dc356c66 100644
--- a/models/files.js
+++ b/models/files.js
@@ -133,7 +133,8 @@ async function createFiles(context, type, entityId, files, dontReplace = false)
originalName: originalName,
size: file.size,
type: file.mimetype,
- url: `/files/${type}/${entityId}/${file.filename}`
+ url: `/files/${type}/${entityId}/${file.filename}`,
+ thumbnailUrl: `/files/${type}/${entityId}/${file.filename}` // TODO - use smaller thumbnails
});
if (existingNameMap.has(originalName)) {
@@ -145,10 +146,10 @@ async function createFiles(context, type, entityId, files, dontReplace = false)
}
const originalNameArray = Array.from(originalNameSet);
- await knex(getFilesTable(type)).where('entity', entityId).whereIn('originalname', originalNameArray).del();
+ await tx(getFilesTable(type)).where('entity', entityId).whereIn('originalname', originalNameArray).del();
if (fileEntities) {
- await knex(getFilesTable(type)).insert(fileEntities);
+ await tx(getFilesTable(type)).insert(fileEntities);
}
});
diff --git a/models/shares.js b/models/shares.js
index 0aacf5a2..cf108159 100644
--- a/models/shares.js
+++ b/models/shares.js
@@ -7,6 +7,7 @@ const { enforce } = require('../lib/helpers');
const dtHelpers = require('../lib/dt-helpers');
const permissions = require('../lib/permissions');
const interoperableErrors = require('../shared/interoperable-errors');
+const log = require('npmlog');
// TODO: This would really benefit from some permission cache connected to rebuildPermissions
// A bit of the problem is that the cache would have to expunged as the result of other processes modifying entites/permissions
@@ -418,14 +419,26 @@ function checkGlobalPermission(context, requiredOperations) {
return false;
}
- if (context.user.admin) { // This handles the getAdminContext() case
- return true;
- }
-
if (typeof requiredOperations === 'string') {
requiredOperations = [ requiredOperations ];
}
+ if (context.user.restrictedAccessHandler) {
+ log.verbose('check global permissions with restrictedAccessHandler -- requiredOperations: ' + requiredOperations);
+ const allowedPerms = context.user.restrictedAccessHandler.globalPermissions;
+ if (allowedPerms) {
+ requiredOperations = requiredOperations.filter(perm => allowedPerms.has(perm));
+ }
+ }
+
+ if (requiredOperations.length === 0) {
+ return false;
+ }
+
+ if (context.user.admin) { // This handles the getAdminContext() case
+ return true;
+ }
+
const roleSpec = config.roles.global[context.user.role];
let success = false;
if (roleSpec) {
@@ -453,6 +466,24 @@ async function _checkPermissionTx(tx, context, entityTypeId, entityId, requiredO
const entityType = permissions.getEntityType(entityTypeId);
+ if (typeof requiredOperations === 'string') {
+ requiredOperations = [ requiredOperations ];
+ }
+
+ if (context.user.restrictedAccessHandler) {
+ log.verbose('check permissions with restrictedAccessHandler -- entityTypeId: ' + entityTypeId + ' entityId: ' + entityId + ' requiredOperations: ' + requiredOperations);
+ if (context.user.restrictedAccessHandler.permissions && context.user.restrictedAccessHandler.permissions[entityTypeId]) {
+ const allowedPerms = context.user.restrictedAccessHandler.permissions[entityTypeId][entityId];
+ if (allowedPerms) {
+ requiredOperations = requiredOperations.filter(perm => allowedPerms.has(perm));
+ }
+ }
+ }
+
+ if (requiredOperations.length === 0) {
+ return false;
+ }
+
if (context.user.admin) { // This handles the getAdminContext() case. In this case we don't check the permission, but just the existence.
const existsQuery = tx(entityType.entitiesTable);
@@ -465,10 +496,6 @@ async function _checkPermissionTx(tx, context, entityTypeId, entityId, requiredO
return !!exists;
} else {
- if (typeof requiredOperations === 'string') {
- requiredOperations = [ requiredOperations ];
- }
-
const permsQuery = tx(entityType.permissionsTable)
.where('user', context.user.id)
.whereIn('operation', requiredOperations);
@@ -564,7 +591,6 @@ async function getPermissionsTx(tx, context, entityTypeId, entityId) {
return rows.map(x => x.operation);
}
-
module.exports = {
listByEntityDTAjax,
listByUserDTAjax,
diff --git a/models/templates.js b/models/templates.js
index 5a297ab6..016cc776 100644
--- a/models/templates.js
+++ b/models/templates.js
@@ -19,6 +19,8 @@ async function getById(context, id) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'template', id, 'view');
const entity = await tx('templates').where('id', id).first();
+ entity.data = JSON.parse(entity.data);
+
entity.permissions = await shares.getPermissionsTx(tx, context, 'template', id);
return entity;
});
@@ -34,9 +36,16 @@ async function listDTAjax(context, params) {
);
}
+async function _validateAndPreprocess(tx, entity, isCreate) {
+ entity.data = JSON.stringify(entity.data);
+}
+
async function create(context, entity) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createTemplate');
+
+ await _validateAndPreprocess(tx, entity, true);
+
await namespaceHelpers.validateEntity(tx, entity);
const ids = await tx('templates').insert(filterObject(entity, allowedKeys));
@@ -57,11 +66,15 @@ async function updateWithConsistencyCheck(context, entity) {
throw new interoperableErrors.NotFoundError();
}
+ existing.data = JSON.parse(existing.data);
+
const existingHash = hash(existing);
if (existingHash !== entity.originalHash) {
throw new interoperableErrors.ChangedError();
}
+ await _validateAndPreprocess(tx, entity, false);
+
await namespaceHelpers.validateEntity(tx, entity);
await namespaceHelpers.validateMove(context, entity, existing, 'template', 'createTemplate', 'delete');
diff --git a/models/users.js b/models/users.js
index cb339835..71a1c0ba 100644
--- a/models/users.js
+++ b/models/users.js
@@ -8,7 +8,7 @@ const interoperableErrors = require('../shared/interoperable-errors');
const passwordValidator = require('../shared/password-validator')();
const dtHelpers = require('../lib/dt-helpers');
const tools = require('../lib/tools-async');
-let crypto = require('crypto');
+const crypto = require('crypto');
const settings = require('./settings');
const urllib = require('url');
const _ = require('../lib/translate')._;
@@ -43,11 +43,7 @@ async function _getBy(context, key, value, extraColumns = []) {
const user = await knex('users').select(columns).where(key, value).first();
if (!user) {
- if (context) {
- shares.throwPermissionDenied();
- } else {
- throw new interoperableErrors.NotFoundError();
- }
+ shares.throwPermissionDenied();
}
await shares.enforceEntityPermission(context, 'namespace', user.namespace, 'manageUsers');
@@ -367,6 +363,50 @@ async function resetPassword(username, resetToken, password) {
}
+
+const restrictedAccessTokenMethods = {};
+const restrictedAccessTokens = new Map();
+
+function registerRestrictedAccessTokenMethod(method, getHandlerFromParams) {
+ restrictedAccessTokenMethods[method] = getHandlerFromParams;
+}
+
+function getRestrictedAccessToken(context, method, params) {
+ const token = crypto.randomBytes(24).toString('hex').toLowerCase();
+ const tokenEntry = {
+ token,
+ userId: context.user.id,
+ handler: restrictedAccessTokenMethods[method](params),
+ expires: Date.now() + 120 * 1000
+ };
+
+ restrictedAccessTokens.set(token, tokenEntry);
+
+ return token;
+}
+
+async function getByRestrictedAccessToken(token) {
+ const now = Date.now();
+ for (const entry of restrictedAccessTokens.values()) {
+ if (entry.expires < now) {
+ restrictedAccessTokens.delete(entry.token);
+ }
+ }
+
+ const tokenEntry = restrictedAccessTokens.get(token);
+
+ if (tokenEntry) {
+ const user = await getById(contextHelpers.getAdminContext(), tokenEntry.userId);
+ user.restrictedAccessHandler = tokenEntry.handler;
+
+ return user;
+
+ } else {
+ shares.throwPermissionDenied();
+ }
+}
+
+
module.exports = {
listDTAjax,
remove,
@@ -382,5 +422,8 @@ module.exports = {
resetAccessToken,
sendPasswordReset,
isPasswordResetTokenValid,
- resetPassword
+ resetPassword,
+ getByRestrictedAccessToken,
+ getRestrictedAccessToken,
+ registerRestrictedAccessTokenMethod
};
\ No newline at end of file
diff --git a/routes/mosaico.js b/routes/mosaico.js
index 4af6516d..820b74a7 100644
--- a/routes/mosaico.js
+++ b/routes/mosaico.js
@@ -7,6 +7,7 @@ const clientHelpers = require('../lib/client-helpers');
const gm = require('gm').subClass({
imageMagick: true
});
+const users = require('../models/users');
const bluebird = require('bluebird');
const fsReadFile = bluebird.promisify(require('fs').readFile);
@@ -16,6 +17,21 @@ const path = require('path');
const files = require('../models/files');
const fileHelpers = require('../lib/file-helpers');
+
+users.registerRestrictedAccessTokenMethod('mosaico', ({entityTypeId, entityId}) => {
+ if (entityTypeId === 'template' || entityTypeId === 'campaign') {
+ return {
+ permissions: {
+ [entityTypeId]: {
+ [entityId]: new Set(['manageFiles', 'view'])
+ }
+ }
+ };
+ }
+});
+
+
+
// FIXME - add authentication by sandboxToken
async function placeholderImage(width, height) {
@@ -136,7 +152,7 @@ router.getAsync('/upload/:type/:fileId', passport.loggedIn, async (req, res) =>
});
-router.getAsync('/editor', passport.csrfProtection, passport.loggedIn, async (req, res) => {
+router.getAsync('/editor', passport.csrfProtection, async (req, res) => {
const resourceType = req.query.type;
const resourceId = req.query.id;
diff --git a/routes/rest/account.js b/routes/rest/account.js
index fd8085d3..4b6a3eb2 100644
--- a/routes/rest/account.js
+++ b/routes/rest/account.js
@@ -52,12 +52,17 @@ router.postAsync('/password-reset-send', passport.csrfProtection, async (req, re
router.postAsync('/password-reset-validate', passport.csrfProtection, async (req, res) => {
const isValid = await users.isPasswordResetTokenValid(req.body.username, req.body.resetToken);
return res.json(isValid);
-})
+});
router.postAsync('/password-reset', passport.csrfProtection, async (req, res) => {
await users.resetPassword(req.body.username, req.body.resetToken, req.body.password);
return res.json();
-})
+});
+router.postAsync('/restricted-access-token', passport.loggedIn, async (req, res) => {
+ const restrictedAccessToken = await users.getRestrictedAccessToken(req.context, req.body.method, req.body.params);
+ return res.json(restrictedAccessToken);
+
+});
module.exports = router;
diff --git a/routes/subscription.js b/routes/subscription.js
index e97ec458..28a6f12d 100644
--- a/routes/subscription.js
+++ b/routes/subscription.js
@@ -100,7 +100,7 @@ async function injectCustomFormData(customFormId, viewKey, data) {
const configItems = await settings.get(['uaCode']);
data.uaCode = configItems.uaCode;
- data.customSubscriptionScripts = config.customsubscriptionscripts || [];
+ data.customSubscriptionScripts = config.customSubscriptionScripts || [];
}
async function getMjmlTemplate(template) {
diff --git a/setup/install-centos7.sh b/setup/install-centos7.sh
index ff38cd73..3c592857 100755
--- a/setup/install-centos7.sh
+++ b/setup/install-centos7.sh
@@ -76,8 +76,8 @@ useradd zone-mta || true
cat >> config/production.toml <