Basic support for Mosaico-based email templates.
This commit is contained in:
parent
b5cdf57f72
commit
7b5642e911
38 changed files with 1271 additions and 751 deletions
388
app-builder.js
Normal file
388
app-builder.js
Normal file
|
@ -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 = '<div class="alert alert-' + key + ' alert-dismissible" role="alert"><button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">×</span></button>';
|
||||
|
||||
if (key === 'danger') {
|
||||
el += '<span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> ';
|
||||
}
|
||||
|
||||
let rows = [];
|
||||
|
||||
messages[key].forEach(message => {
|
||||
message = hbs.handlebars.escapeExpression(message);
|
||||
message = message.replace(/(\r\n|\n|\r)/gm, '<br>');
|
||||
rows.push(message);
|
||||
});
|
||||
|
||||
if (rows.length > 1) {
|
||||
el += '<p>' + rows.join('</p>\n<p>') + '</p>';
|
||||
} else {
|
||||
el += rows.join('');
|
||||
}
|
||||
|
||||
el += '</div>';
|
||||
|
||||
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
|
||||
};
|
384
app.js
384
app.js
|
@ -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 = '<div class="alert alert-' + key + ' alert-dismissible" role="alert"><button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">×</span></button>';
|
||||
|
||||
if (key === 'danger') {
|
||||
el += '<span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> ';
|
||||
}
|
||||
|
||||
let rows = [];
|
||||
|
||||
messages[key].forEach(message => {
|
||||
message = hbs.handlebars.escapeExpression(message);
|
||||
message = message.replace(/(\r\n|\n|\r)/gm, '<br>');
|
||||
rows.push(message);
|
||||
});
|
||||
|
||||
if (rows.length > 1) {
|
||||
el += '<p>' + rows.join('</p>\n<p>') + '</p>';
|
||||
} else {
|
||||
el += rows.join('');
|
||||
}
|
||||
|
||||
el += '</div>';
|
||||
|
||||
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;
|
BIN
client/public/mailtrain-notext.png
Normal file
BIN
client/public/mailtrain-notext.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.9 KiB |
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
22
client/src/lib/mosaico-sandbox-root.js
Normal file
22
client/src/lib/mosaico-sandbox-root.js
Normal file
|
@ -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(
|
||||
<I18nextProvider i18n={ i18n }>
|
||||
<UntrustedContentRoot render={props => <MosaicoSandbox {...props} />} />
|
||||
</I18nextProvider>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
};
|
||||
|
||||
|
166
client/src/lib/mosaico.js
Normal file
166
client/src/lib/mosaico.js
Normal file
|
@ -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 (
|
||||
<div className={this.state.fullscreen ? styles.editorFullscreen : styles.editor}>
|
||||
<div className={styles.navbar}>
|
||||
{this.state.fullscreen && <img className={styles.logo} src="/public/mailtrain-notext.png"/>}
|
||||
<div className={styles.title}>{this.props.title}</div>
|
||||
<a className={styles.btn} onClick={::this.toggleFullscreenAsync}><Icon icon="fullscreen"/></a>
|
||||
</div>
|
||||
<UntrustedContentHost ref={node => this.contentNode = node} className={styles.host} contentProps={mosaicoData} contentSrc="mosaico/editor" tokenMethod="mosaico" tokenParams={mosaicoData}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 <div/>;
|
||||
}
|
||||
}
|
||||
|
||||
MosaicoSandbox.prototype.onMethodAsync = async function(method, params) {
|
||||
return await this.getWrappedInstance().onMethodAsync(method, params);
|
||||
};
|
75
client/src/lib/mosaico.scss
Normal file
75
client/src/lib/mosaico.scss
Normal file
|
@ -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;
|
||||
}
|
|
@ -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 (
|
||||
<Router>
|
||||
<Router basename={mailtrainConfig.urlBase}>
|
||||
<SectionContent root={this.props.root} structure={this.structure} />
|
||||
</Router>
|
||||
);
|
||||
|
|
|
@ -100,3 +100,15 @@
|
|||
font-size: 20px;
|
||||
color: #808080;
|
||||
}
|
||||
|
||||
|
||||
.untrustedContent {
|
||||
border: 0px none;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.withElementInFullscreen {
|
||||
height: 0px;
|
||||
overflow: hidden;
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
227
client/src/lib/untrusted.js
Normal file
227
client/src/lib/untrusted.js
Normal file
|
@ -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 (
|
||||
<iframe className={styles.untrustedContent + ' ' + this.props.className} ref={node => this.contentNode = node} src={getSandboxUrl(this.props.contentSrc)} onLoad={::this.contentNodeLoaded}> </iframe>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
{t('Loading...')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
37
client/src/lib/urls.js
Normal file
37
client/src/lib/urls.js
Normal file
|
@ -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
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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 (
|
||||
<div className={styles.navbar}>
|
||||
<img className={styles.logo} src="/public/mailtrain-header.png"/>
|
||||
<a className={styles.btn} onClick={::this.onClose}>{t('CLOSE')}</a>
|
||||
<a className={styles.btn}><span></span></a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default function() {
|
||||
ReactDOM.render(
|
||||
<I18nextProvider i18n={ i18n }><MosaicoEditor /></I18nextProvider>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
||||
|
|
|
@ -139,7 +139,7 @@ class Root extends Component {
|
|||
|
||||
async logout() {
|
||||
await axios.post('/rest/logout');
|
||||
window.location = '/';
|
||||
window.location = mailtrainConfig.urlBase;
|
||||
}
|
||||
|
||||
render() {
|
||||
|
|
|
@ -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 {
|
|||
</div>}
|
||||
</AlignedRow>
|
||||
|
||||
{this.templateTypes[typeKey].form}
|
||||
{this.templateTypes[typeKey].getHTMLEditor(this)}
|
||||
|
||||
<ACEEditor id="text" height="400px" mode="text" label={t('Template content (plain text)')} help={<Trans>To extract the text from HTML click <ActionLink onClickAsync={::this.extractPlainText}>here</ActionLink>. Please note that your existing plaintext in the field above will be overwritten. This feature uses the <a href="http://premailer.dialect.ca/api">Premailer API</a>, a third party service. Their Terms of Service and Privacy Policy apply.</Trans>}/>
|
||||
</div>
|
||||
|
@ -249,7 +265,7 @@ export default class CUD extends Component {
|
|||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={this.state.elementInFullscreen ? styles.withElementInFullscreen : ''}>
|
||||
{canDelete &&
|
||||
<DeleteModalDialog
|
||||
stateOwner={this}
|
||||
|
|
|
@ -3,17 +3,27 @@
|
|||
import React from "react";
|
||||
import {
|
||||
ACEEditor,
|
||||
AlignedRow,
|
||||
CKEditor
|
||||
} from "../lib/form";
|
||||
import 'brace/mode/text';
|
||||
import 'brace/mode/html'
|
||||
|
||||
import {MosaicoEditor, ResourceType} from "../lib/mosaico";
|
||||
|
||||
|
||||
export function getTemplateTypes(t) {
|
||||
|
||||
const templateTypes = {};
|
||||
|
||||
templateTypes.mosaico = {
|
||||
typeName: t('Mosaico')
|
||||
typeName: t('Mosaico'),
|
||||
getHTMLEditor: owner => <AlignedRow label={t('Template content (HTML)')}><MosaicoEditor ref={node => owner.editorNode = node} entity={owner.props.entity} entityTypeId={ResourceType.TEMPLATE} title={t('Mosaico Template Designer')} onFullscreenAsync={::owner.setElementInFullscreen}/></AlignedRow>,
|
||||
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: <CKEditor id="html" height="600px" label={t('Template content (HTML)')}/>
|
||||
getHTMLEditor: owner => <CKEditor id="html" height="600px" label={t('Template content (HTML)')}/>,
|
||||
htmlEditorBeforeSave: async owner => {}
|
||||
};
|
||||
|
||||
templateTypes.codeeditor = {
|
||||
typeName: t('Code Editor'),
|
||||
form: <ACEEditor id="html" height="600px" mode="html" label={t('Template content (HTML)')}/>
|
||||
getHTMLEditor: owner => <ACEEditor id="html" height="600px" mode="html" label={t('Template content (HTML)')}/>,
|
||||
htmlEditorBeforeSave: async owner => {}
|
||||
};
|
||||
|
||||
templateTypes.mjml = {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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"
|
||||
|
|
121
index.js
121
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,28 +19,29 @@ 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 => {
|
||||
const server = http.createServer(app);
|
||||
|
||||
server.on('error', err => {
|
||||
if (err.syscall !== 'listen') {
|
||||
throw err;
|
||||
}
|
||||
|
||||
let bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port;
|
||||
const bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port;
|
||||
|
||||
// handle specific listen errors with friendly messages
|
||||
switch (err.code) {
|
||||
|
@ -58,62 +54,44 @@ server.on('error', err => {
|
|||
default:
|
||||
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);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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() {
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
knex.migrate.latest() // And now the current migration with Knex
|
||||
|
||||
.then(() => shares.regenerateRoleNamesTable())
|
||||
.then(() => shares.rebuildPermissions())
|
||||
|
||||
.then(() =>
|
||||
executor.spawn(() => {
|
||||
testServer(() => {
|
||||
verpServer(() => {
|
||||
|
||||
startHTTPServer(true, trustedPort, () => {
|
||||
startHTTPServer(false, sandboxPort, () => {
|
||||
privilegeHelpers.dropRootPrivileges();
|
||||
|
||||
tzupdate(() => {
|
||||
importer(() => {
|
||||
triggers(() => {
|
||||
spawnSenders(() => {
|
||||
senders.spawn(() => {
|
||||
feedcheck(() => {
|
||||
postfixBounceServer(async () => {
|
||||
await reportProcessor.init();
|
||||
|
@ -126,29 +104,10 @@ 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.
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
res.status(403);
|
||||
return res.json({
|
||||
res.json({
|
||||
error: 'Missing access_token',
|
||||
data: []
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await users.getByAccessToken(req.query.access_token);
|
||||
users.getByAccessToken(req.query.access_token).then(user => {
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err instanceof interoperableErrors.NotFoundError) {
|
||||
}).catch(err => {
|
||||
if (err instanceof interoperableErrors.PermissionDeniedError) {
|
||||
res.status(403);
|
||||
return res.json({
|
||||
res.json({
|
||||
error: 'Invalid or expired access_token',
|
||||
data: []
|
||||
});
|
||||
} else {
|
||||
res.status(500);
|
||||
return res.json({
|
||||
res.json({
|
||||
error: err.message || err,
|
||||
data: []
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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 => {
|
||||
next();
|
||||
});
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
})(), next);
|
||||
};
|
||||
|
||||
module.exports.setup = app => {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
};
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -76,8 +76,8 @@ useradd zone-mta || true
|
|||
cat >> config/production.toml <<EOT
|
||||
user="mailtrain"
|
||||
group="mailtrain"
|
||||
rouser="nobody"
|
||||
rogroup="nobody"
|
||||
roUser="nobody"
|
||||
roGroup="nobody"
|
||||
[log]
|
||||
level="error"
|
||||
[www]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue