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');
|
const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.POST, '/rest/login');
|
||||||
|
|
||||||
if (submitSuccessful) {
|
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 */
|
/* This ensures we get config for the authenticated user */
|
||||||
window.location = nextUrl;
|
window.location = nextUrl;
|
||||||
|
|
|
@ -25,7 +25,6 @@ import { parseDate, parseBirthday, formatDate, formatBirthday, DateFormat, birth
|
||||||
import styles from "./styles.scss";
|
import styles from "./styles.scss";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
|
||||||
|
|
||||||
const FormState = {
|
const FormState = {
|
||||||
Loading: 0,
|
Loading: 0,
|
||||||
LoadingWithNotice: 1,
|
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() {
|
ensureAuthenticated() {
|
||||||
if (!mailtrainConfig.isAuthenticated) {
|
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));
|
this.navigateTo('/account/login?next=' + encodeURIComponent(window.location.pathname));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -383,7 +382,7 @@ class Section extends Component {
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router basename={mailtrainConfig.urlBase}>
|
||||||
<SectionContent root={this.props.root} structure={this.structure} />
|
<SectionContent root={this.props.root} structure={this.structure} />
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
|
|
|
@ -100,3 +100,15 @@
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
color: #808080;
|
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 axios from './axios';
|
||||||
|
|
||||||
import { withPageHelpers } from '../lib/page'
|
import { withPageHelpers } from './page'
|
||||||
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
|
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
|
||||||
import styles from "./styles.scss";
|
import styles from "./styles.scss";
|
||||||
|
|
||||||
|
@ -394,7 +394,7 @@ class Table extends Component {
|
||||||
The reference to the table can be obtained by ref.
|
The reference to the table can be obtained by ref.
|
||||||
*/
|
*/
|
||||||
Table.prototype.refresh = function() {
|
Table.prototype.refresh = function() {
|
||||||
this.getWrappedInstance().refresh()
|
this.getWrappedInstance().refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
|
@ -12,7 +12,7 @@ import '../../vendor/fancytree/skin-bootstrap/ui.fancytree.min.css';
|
||||||
import './tree.css';
|
import './tree.css';
|
||||||
import axios from './axios';
|
import axios from './axios';
|
||||||
|
|
||||||
import { withPageHelpers } from '../lib/page'
|
import { withPageHelpers } from './page'
|
||||||
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
|
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
|
||||||
import styles from "./styles.scss";
|
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';
|
'use strict';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
import { I18nextProvider } from 'react-i18next';
|
|
||||||
import i18n from '../lib/i18n';
|
|
||||||
import qs from 'querystringify';
|
import qs from 'querystringify';
|
||||||
|
|
||||||
import { Section } from '../lib/page';
|
|
||||||
import ListsList from './List';
|
import ListsList from './List';
|
||||||
import ListsCUD from './CUD';
|
import ListsCUD from './CUD';
|
||||||
import FormsList from './forms/List';
|
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';
|
'use strict';
|
||||||
|
|
||||||
import React from 'react';
|
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 CUD from './CUD';
|
||||||
import List from './List';
|
import List from './List';
|
||||||
import Share from '../shares/Share';
|
import Share from '../shares/Share';
|
||||||
|
|
|
@ -1,11 +1,6 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
import React from 'react';
|
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 ReportsCUD from './CUD';
|
||||||
import ReportsList from './List';
|
import ReportsList from './List';
|
||||||
import ReportsView from './View';
|
import ReportsView from './View';
|
||||||
|
@ -13,7 +8,7 @@ import ReportsOutput from './Output';
|
||||||
import ReportTemplatesCUD from './templates/CUD';
|
import ReportTemplatesCUD from './templates/CUD';
|
||||||
import ReportTemplatesList from './templates/List';
|
import ReportTemplatesList from './templates/List';
|
||||||
import Share from '../shares/Share';
|
import Share from '../shares/Share';
|
||||||
import { ReportState } from '../../../shared/reports';
|
import {ReportState} from '../../../shared/reports';
|
||||||
import mailtrainConfig from 'mailtrainConfig';
|
import mailtrainConfig from 'mailtrainConfig';
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -139,7 +139,7 @@ class Root extends Component {
|
||||||
|
|
||||||
async logout() {
|
async logout() {
|
||||||
await axios.post('/rest/logout');
|
await axios.post('/rest/logout');
|
||||||
window.location = '/';
|
window.location = mailtrainConfig.urlBase;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|
|
@ -39,7 +39,8 @@ export default class CUD extends Component {
|
||||||
this.templateTypes = getTemplateTypes(props.t);
|
this.templateTypes = getTemplateTypes(props.t);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
showMergeTagReference: false
|
showMergeTagReference: false,
|
||||||
|
elementInFullscreen: false
|
||||||
};
|
};
|
||||||
|
|
||||||
this.initForm();
|
this.initForm();
|
||||||
|
@ -66,7 +67,8 @@ export default class CUD extends Component {
|
||||||
namespace: mailtrainConfig.user.namespace,
|
namespace: mailtrainConfig.user.namespace,
|
||||||
type: mailtrainConfig.editors[0],
|
type: mailtrainConfig.editors[0],
|
||||||
text: '',
|
text: '',
|
||||||
html: ''
|
html: '',
|
||||||
|
data: {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -92,6 +94,11 @@ export default class CUD extends Component {
|
||||||
async submitHandler() {
|
async submitHandler() {
|
||||||
const t = this.props.t;
|
const t = this.props.t;
|
||||||
|
|
||||||
|
if (this.props.entity) {
|
||||||
|
const typeKey = this.getFormValue('type');
|
||||||
|
await this.templateTypes[typeKey].htmlEditorBeforeSave(this);
|
||||||
|
}
|
||||||
|
|
||||||
let sendMethod, url;
|
let sendMethod, url;
|
||||||
if (this.props.entity) {
|
if (this.props.entity) {
|
||||||
sendMethod = FormSendMethod.PUT;
|
sendMethod = FormSendMethod.PUT;
|
||||||
|
@ -120,6 +127,9 @@ export default class CUD extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
async extractPlainText() {
|
async extractPlainText() {
|
||||||
|
const typeKey = this.getFormValue('type');
|
||||||
|
await this.templateTypes[typeKey].htmlEditorBeforeSave(this);
|
||||||
|
|
||||||
const html = this.getFormValue('html');
|
const html = this.getFormValue('html');
|
||||||
if (!html) {
|
if (!html) {
|
||||||
alert('Missing HTML content');
|
alert('Missing HTML content');
|
||||||
|
@ -145,6 +155,12 @@ export default class CUD extends Component {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setElementInFullscreen(elementInFullscreen) {
|
||||||
|
this.setState({
|
||||||
|
elementInFullscreen
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const t = this.props.t;
|
const t = this.props.t;
|
||||||
const isEdit = !!this.props.entity;
|
const isEdit = !!this.props.entity;
|
||||||
|
@ -241,7 +257,7 @@ export default class CUD extends Component {
|
||||||
</div>}
|
</div>}
|
||||||
</AlignedRow>
|
</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>}/>
|
<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>
|
</div>
|
||||||
|
@ -249,7 +265,7 @@ export default class CUD extends Component {
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={this.state.elementInFullscreen ? styles.withElementInFullscreen : ''}>
|
||||||
{canDelete &&
|
{canDelete &&
|
||||||
<DeleteModalDialog
|
<DeleteModalDialog
|
||||||
stateOwner={this}
|
stateOwner={this}
|
||||||
|
|
|
@ -3,17 +3,27 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
ACEEditor,
|
ACEEditor,
|
||||||
|
AlignedRow,
|
||||||
CKEditor
|
CKEditor
|
||||||
} from "../lib/form";
|
} from "../lib/form";
|
||||||
import 'brace/mode/text';
|
import 'brace/mode/text';
|
||||||
import 'brace/mode/html'
|
import 'brace/mode/html'
|
||||||
|
|
||||||
|
import {MosaicoEditor, ResourceType} from "../lib/mosaico";
|
||||||
|
|
||||||
|
|
||||||
export function getTemplateTypes(t) {
|
export function getTemplateTypes(t) {
|
||||||
|
|
||||||
const templateTypes = {};
|
const templateTypes = {};
|
||||||
|
|
||||||
templateTypes.mosaico = {
|
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 = {
|
templateTypes.grapejs = {
|
||||||
|
@ -22,12 +32,14 @@ export function getTemplateTypes(t) {
|
||||||
|
|
||||||
templateTypes.ckeditor = {
|
templateTypes.ckeditor = {
|
||||||
typeName: t('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 = {
|
templateTypes.codeeditor = {
|
||||||
typeName: t('Code Editor'),
|
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 = {
|
templateTypes.mjml = {
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
import React from 'react';
|
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 TemplatesCUD from './CUD';
|
||||||
import TemplatesList from './List';
|
import TemplatesList from './List';
|
||||||
import Share from '../shares/Share';
|
import Share from '../shares/Share';
|
||||||
|
|
|
@ -4,7 +4,7 @@ const path = require('path');
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: {
|
entry: {
|
||||||
root: ['babel-polyfill', './src/root.js'],
|
root: ['babel-polyfill', './src/root.js'],
|
||||||
mosaico: ['babel-polyfill', './src/mosaico/root.js'],
|
mosaico: ['babel-polyfill', './src/lib/mosaico-sandbox-root.js'],
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
library: 'MailtrainReactBody',
|
library: 'MailtrainReactBody',
|
||||||
|
|
|
@ -22,19 +22,13 @@
|
||||||
title="mailtrain"
|
title="mailtrain"
|
||||||
|
|
||||||
# Enabled HTML editors
|
# Enabled HTML editors
|
||||||
editors=["ckeditor", "codeeditor"]
|
editors=["ckeditor", "codeeditor", "mosaico"]
|
||||||
|
|
||||||
# Default language to use
|
# Default language to use
|
||||||
language="en"
|
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
|
# 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)
|
# 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
|
# 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
|
# 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
|
# 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
|
# password for read/write operations). The roUser/roGroup determines the user to be used
|
||||||
#rouser="nobody"
|
#roUser="nobody"
|
||||||
#rogroup="nogroup"
|
#roGroup="nogroup"
|
||||||
|
|
||||||
[log]
|
[log]
|
||||||
# silly|verbose|info|http|warn|error|silent
|
# silly|verbose|info|http|warn|error|silent
|
||||||
|
@ -54,8 +48,18 @@ level="verbose"
|
||||||
[www]
|
[www]
|
||||||
# HTTP port to listen on
|
# HTTP port to listen on
|
||||||
port=3000
|
port=3000
|
||||||
|
# HTTP port to listen on for sandboxed requests
|
||||||
|
sandboxPort=8081
|
||||||
# HTTP interface to listen on
|
# HTTP interface to listen on
|
||||||
host="0.0.0.0"
|
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 for signing the session ID cookie
|
||||||
secret="a cat"
|
secret="a cat"
|
||||||
# Session length in seconds when "remember me" is checked
|
# 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
|
# Set this to true if you are serving Mailtrain as a virtual domain through Nginx or Apache
|
||||||
proxy=false
|
proxy=false
|
||||||
# maximum POST body size
|
# maximum POST body size
|
||||||
postsize="2MB"
|
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"
|
|
||||||
|
|
||||||
[mysql]
|
[mysql]
|
||||||
host="localhost"
|
host="localhost"
|
||||||
|
|
165
index.js
165
index.js
|
@ -1,14 +1,9 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/**
|
|
||||||
* Module dependencies.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const config = require('config');
|
const config = require('config');
|
||||||
const log = require('npmlog');
|
const log = require('npmlog');
|
||||||
const app = require('./app');
|
const appBuilder = require('./app-builder');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const fork = require('child_process').fork;
|
|
||||||
const triggers = require('./services/triggers');
|
const triggers = require('./services/triggers');
|
||||||
const importer = require('./services/importer');
|
const importer = require('./services/importer');
|
||||||
const verpServer = require('./services/verp-server');
|
const verpServer = require('./services/verp-server');
|
||||||
|
@ -24,100 +19,86 @@ const privilegeHelpers = require('./lib/privilege-helpers');
|
||||||
const knex = require('./lib/knex');
|
const knex = require('./lib/knex');
|
||||||
const shares = require('./models/shares');
|
const shares = require('./models/shares');
|
||||||
|
|
||||||
let port = config.www.port;
|
const trustedPort = config.www.port;
|
||||||
let host = config.www.host;
|
const sandboxPort = config.www.sandboxPort;
|
||||||
|
const host = config.www.host;
|
||||||
|
|
||||||
if (config.title) {
|
if (config.title) {
|
||||||
process.title = config.title;
|
process.title = config.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.level = config.log.level;
|
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);
|
||||||
if (err.syscall !== 'listen') {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
let bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port;
|
server.on('error', err => {
|
||||||
|
if (err.syscall !== 'listen') {
|
||||||
// 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;
|
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', []);
|
const bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port;
|
||||||
let pid = child.pid;
|
|
||||||
senders.workers.add(child);
|
|
||||||
|
|
||||||
child.on('close', (code, signal) => {
|
// handle specific listen errors with friendly messages
|
||||||
spawned--;
|
switch (err.code) {
|
||||||
senders.workers.delete(child);
|
case 'EACCES':
|
||||||
log.error('Child', 'Sender process %s exited with %s', pid, code || signal);
|
log.error('Express', '%s requires elevated privileges', bind);
|
||||||
// Respawn after 5 seconds
|
return process.exit(1);
|
||||||
setTimeout(() => spawnSender(), 5 * 1000).unref();
|
case 'EADDRINUSE':
|
||||||
});
|
log.error('Express', '%s is already in use', bind);
|
||||||
|
return process.exit(1);
|
||||||
|
default:
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
spawned++;
|
server.on('listening', () => {
|
||||||
setImmediate(spawnSender);
|
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
|
||||||
testServer(() => {
|
// ---------------------------------------------------------------------------------------
|
||||||
verpServer(() => {
|
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(() => {
|
.then(() => shares.regenerateRoleNamesTable())
|
||||||
importer(() => {
|
.then(() => shares.rebuildPermissions())
|
||||||
triggers(() => {
|
|
||||||
spawnSenders(() => {
|
.then(() =>
|
||||||
feedcheck(() => {
|
executor.spawn(() => {
|
||||||
postfixBounceServer(async () => {
|
testServer(() => {
|
||||||
await reportProcessor.init();
|
verpServer(() => {
|
||||||
log.info('Service', 'All services started');
|
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.
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,11 @@ async function getAnonymousConfig(context) {
|
||||||
isAuthMethodLocal: passport.isAuthMethodLocal,
|
isAuthMethodLocal: passport.isAuthMethodLocal,
|
||||||
externalPasswordResetLink: config.ldap.passwordresetlink,
|
externalPasswordResetLink: config.ldap.passwordresetlink,
|
||||||
language: config.language || 'en',
|
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({
|
module.exports.parseForm = bodyParser.urlencoded({
|
||||||
extended: false,
|
extended: false,
|
||||||
limit: config.www.postsize
|
limit: config.www.postSize
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports.loggedIn = (req, res, next) => {
|
module.exports.loggedIn = (req, res, next) => {
|
||||||
|
@ -87,35 +87,45 @@ module.exports.loggedIn = (req, res, next) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports.authByAccessToken = (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);
|
res.status(403);
|
||||||
return res.json({
|
res.json({
|
||||||
error: 'Missing access_token',
|
error: 'Invalid or expired access_token',
|
||||||
|
data: []
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(500);
|
||||||
|
res.json({
|
||||||
|
error: err.message || err,
|
||||||
data: []
|
data: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
module.exports.tryAuthByRestrictedAccessToken = (req, res, next) => {
|
||||||
const user = await users.getByAccessToken(req.query.access_token);
|
if (req.cookies.restricted_access_token) {
|
||||||
|
users.getByRestrictedAccessToken(req.cookies.restricted_access_token).then(user => {
|
||||||
req.user = user;
|
req.user = user;
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
}).catch(err => {
|
||||||
if (err instanceof interoperableErrors.NotFoundError) {
|
next();
|
||||||
res.status(403);
|
});
|
||||||
return res.json({
|
} else {
|
||||||
error: 'Invalid or expired access_token',
|
next();
|
||||||
data: []
|
}
|
||||||
});
|
|
||||||
} else {
|
|
||||||
res.status(500);
|
|
||||||
return res.json({
|
|
||||||
error: err.message || err,
|
|
||||||
data: []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})(), next);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports.setup = app => {
|
module.exports.setup = app => {
|
||||||
|
|
|
@ -8,25 +8,25 @@ const fs = require('fs');
|
||||||
const tryRequire = require('try-require');
|
const tryRequire = require('try-require');
|
||||||
const posix = tryRequire('posix');
|
const posix = tryRequire('posix');
|
||||||
|
|
||||||
function _getConfigUidGid(prefix, defaultUid, defaultGid) {
|
function _getConfigUidGid(userKey, groupKey, defaultUid, defaultGid) {
|
||||||
let uid = defaultUid;
|
let uid = defaultUid;
|
||||||
let gid = defaultGid;
|
let gid = defaultGid;
|
||||||
|
|
||||||
if (posix) {
|
if (posix) {
|
||||||
try {
|
try {
|
||||||
if (config.user) {
|
if (config[userKey]) {
|
||||||
uid = posix.getpwnam(config[prefix + 'user']).uid;
|
uid = posix.getpwnam(config[userKey]).uid;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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 {
|
try {
|
||||||
if (config.user) {
|
if (config[groupKey]) {
|
||||||
gid = posix.getpwnam(config[prefix + 'group']).gid;
|
gid = posix.getpwnam(config[groupKey]).gid;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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 {
|
} else {
|
||||||
log.info('PrivilegeHelpers', 'Posix module not installed. Cannot resolve uid/gid');
|
log.info('PrivilegeHelpers', 'Posix module not installed. Cannot resolve uid/gid');
|
||||||
|
@ -36,12 +36,12 @@ function _getConfigUidGid(prefix, defaultUid, defaultGid) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConfigUidGid() {
|
function getConfigUidGid() {
|
||||||
return _getConfigUidGid('', process.getuid(), process.getgid());
|
return _getConfigUidGid('user', 'group', process.getuid(), process.getgid());
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConfigROUidGid() {
|
function getConfigROUidGid() {
|
||||||
let rwIds = getConfigUidGid();
|
const rwIds = getConfigUidGid();
|
||||||
return _getConfigUidGid('ro', rwIds.uid, rwIds.gid);
|
return _getConfigUidGid('roUser', 'roGroup', rwIds.uid, rwIds.gid);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureMailtrainOwner(file, callback) {
|
function ensureMailtrainOwner(file, callback) {
|
||||||
|
|
|
@ -1,5 +1,50 @@
|
||||||
'use strict';
|
'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 = {
|
module.exports = {
|
||||||
workers: new Set()
|
workers,
|
||||||
|
spawn
|
||||||
};
|
};
|
||||||
|
|
|
@ -133,7 +133,8 @@ async function createFiles(context, type, entityId, files, dontReplace = false)
|
||||||
originalName: originalName,
|
originalName: originalName,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
type: file.mimetype,
|
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)) {
|
if (existingNameMap.has(originalName)) {
|
||||||
|
@ -145,10 +146,10 @@ async function createFiles(context, type, entityId, files, dontReplace = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalNameArray = Array.from(originalNameSet);
|
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) {
|
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 dtHelpers = require('../lib/dt-helpers');
|
||||||
const permissions = require('../lib/permissions');
|
const permissions = require('../lib/permissions');
|
||||||
const interoperableErrors = require('../shared/interoperable-errors');
|
const interoperableErrors = require('../shared/interoperable-errors');
|
||||||
|
const log = require('npmlog');
|
||||||
|
|
||||||
// TODO: This would really benefit from some permission cache connected to rebuildPermissions
|
// 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
|
// 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.user.admin) { // This handles the getAdminContext() case
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof requiredOperations === 'string') {
|
if (typeof requiredOperations === 'string') {
|
||||||
requiredOperations = [ requiredOperations ];
|
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];
|
const roleSpec = config.roles.global[context.user.role];
|
||||||
let success = false;
|
let success = false;
|
||||||
if (roleSpec) {
|
if (roleSpec) {
|
||||||
|
@ -453,6 +466,24 @@ async function _checkPermissionTx(tx, context, entityTypeId, entityId, requiredO
|
||||||
|
|
||||||
const entityType = permissions.getEntityType(entityTypeId);
|
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.
|
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);
|
const existsQuery = tx(entityType.entitiesTable);
|
||||||
|
|
||||||
|
@ -465,10 +496,6 @@ async function _checkPermissionTx(tx, context, entityTypeId, entityId, requiredO
|
||||||
return !!exists;
|
return !!exists;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
if (typeof requiredOperations === 'string') {
|
|
||||||
requiredOperations = [ requiredOperations ];
|
|
||||||
}
|
|
||||||
|
|
||||||
const permsQuery = tx(entityType.permissionsTable)
|
const permsQuery = tx(entityType.permissionsTable)
|
||||||
.where('user', context.user.id)
|
.where('user', context.user.id)
|
||||||
.whereIn('operation', requiredOperations);
|
.whereIn('operation', requiredOperations);
|
||||||
|
@ -564,7 +591,6 @@ async function getPermissionsTx(tx, context, entityTypeId, entityId) {
|
||||||
return rows.map(x => x.operation);
|
return rows.map(x => x.operation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
listByEntityDTAjax,
|
listByEntityDTAjax,
|
||||||
listByUserDTAjax,
|
listByUserDTAjax,
|
||||||
|
|
|
@ -19,6 +19,8 @@ async function getById(context, id) {
|
||||||
return await knex.transaction(async tx => {
|
return await knex.transaction(async tx => {
|
||||||
await shares.enforceEntityPermissionTx(tx, context, 'template', id, 'view');
|
await shares.enforceEntityPermissionTx(tx, context, 'template', id, 'view');
|
||||||
const entity = await tx('templates').where('id', id).first();
|
const entity = await tx('templates').where('id', id).first();
|
||||||
|
entity.data = JSON.parse(entity.data);
|
||||||
|
|
||||||
entity.permissions = await shares.getPermissionsTx(tx, context, 'template', id);
|
entity.permissions = await shares.getPermissionsTx(tx, context, 'template', id);
|
||||||
return entity;
|
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) {
|
async function create(context, entity) {
|
||||||
return await knex.transaction(async tx => {
|
return await knex.transaction(async tx => {
|
||||||
await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createTemplate');
|
await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createTemplate');
|
||||||
|
|
||||||
|
await _validateAndPreprocess(tx, entity, true);
|
||||||
|
|
||||||
await namespaceHelpers.validateEntity(tx, entity);
|
await namespaceHelpers.validateEntity(tx, entity);
|
||||||
|
|
||||||
const ids = await tx('templates').insert(filterObject(entity, allowedKeys));
|
const ids = await tx('templates').insert(filterObject(entity, allowedKeys));
|
||||||
|
@ -57,11 +66,15 @@ async function updateWithConsistencyCheck(context, entity) {
|
||||||
throw new interoperableErrors.NotFoundError();
|
throw new interoperableErrors.NotFoundError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
existing.data = JSON.parse(existing.data);
|
||||||
|
|
||||||
const existingHash = hash(existing);
|
const existingHash = hash(existing);
|
||||||
if (existingHash !== entity.originalHash) {
|
if (existingHash !== entity.originalHash) {
|
||||||
throw new interoperableErrors.ChangedError();
|
throw new interoperableErrors.ChangedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await _validateAndPreprocess(tx, entity, false);
|
||||||
|
|
||||||
await namespaceHelpers.validateEntity(tx, entity);
|
await namespaceHelpers.validateEntity(tx, entity);
|
||||||
await namespaceHelpers.validateMove(context, entity, existing, 'template', 'createTemplate', 'delete');
|
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 passwordValidator = require('../shared/password-validator')();
|
||||||
const dtHelpers = require('../lib/dt-helpers');
|
const dtHelpers = require('../lib/dt-helpers');
|
||||||
const tools = require('../lib/tools-async');
|
const tools = require('../lib/tools-async');
|
||||||
let crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const settings = require('./settings');
|
const settings = require('./settings');
|
||||||
const urllib = require('url');
|
const urllib = require('url');
|
||||||
const _ = require('../lib/translate')._;
|
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();
|
const user = await knex('users').select(columns).where(key, value).first();
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
if (context) {
|
shares.throwPermissionDenied();
|
||||||
shares.throwPermissionDenied();
|
|
||||||
} else {
|
|
||||||
throw new interoperableErrors.NotFoundError();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await shares.enforceEntityPermission(context, 'namespace', user.namespace, 'manageUsers');
|
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 = {
|
module.exports = {
|
||||||
listDTAjax,
|
listDTAjax,
|
||||||
remove,
|
remove,
|
||||||
|
@ -382,5 +422,8 @@ module.exports = {
|
||||||
resetAccessToken,
|
resetAccessToken,
|
||||||
sendPasswordReset,
|
sendPasswordReset,
|
||||||
isPasswordResetTokenValid,
|
isPasswordResetTokenValid,
|
||||||
resetPassword
|
resetPassword,
|
||||||
|
getByRestrictedAccessToken,
|
||||||
|
getRestrictedAccessToken,
|
||||||
|
registerRestrictedAccessTokenMethod
|
||||||
};
|
};
|
|
@ -7,6 +7,7 @@ const clientHelpers = require('../lib/client-helpers');
|
||||||
const gm = require('gm').subClass({
|
const gm = require('gm').subClass({
|
||||||
imageMagick: true
|
imageMagick: true
|
||||||
});
|
});
|
||||||
|
const users = require('../models/users');
|
||||||
|
|
||||||
const bluebird = require('bluebird');
|
const bluebird = require('bluebird');
|
||||||
const fsReadFile = bluebird.promisify(require('fs').readFile);
|
const fsReadFile = bluebird.promisify(require('fs').readFile);
|
||||||
|
@ -16,6 +17,21 @@ const path = require('path');
|
||||||
const files = require('../models/files');
|
const files = require('../models/files');
|
||||||
const fileHelpers = require('../lib/file-helpers');
|
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
|
// FIXME - add authentication by sandboxToken
|
||||||
|
|
||||||
async function placeholderImage(width, height) {
|
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 resourceType = req.query.type;
|
||||||
const resourceId = req.query.id;
|
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) => {
|
router.postAsync('/password-reset-validate', passport.csrfProtection, async (req, res) => {
|
||||||
const isValid = await users.isPasswordResetTokenValid(req.body.username, req.body.resetToken);
|
const isValid = await users.isPasswordResetTokenValid(req.body.username, req.body.resetToken);
|
||||||
return res.json(isValid);
|
return res.json(isValid);
|
||||||
})
|
});
|
||||||
|
|
||||||
router.postAsync('/password-reset', passport.csrfProtection, async (req, res) => {
|
router.postAsync('/password-reset', passport.csrfProtection, async (req, res) => {
|
||||||
await users.resetPassword(req.body.username, req.body.resetToken, req.body.password);
|
await users.resetPassword(req.body.username, req.body.resetToken, req.body.password);
|
||||||
return res.json();
|
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;
|
module.exports = router;
|
||||||
|
|
|
@ -100,7 +100,7 @@ async function injectCustomFormData(customFormId, viewKey, data) {
|
||||||
const configItems = await settings.get(['uaCode']);
|
const configItems = await settings.get(['uaCode']);
|
||||||
|
|
||||||
data.uaCode = configItems.uaCode;
|
data.uaCode = configItems.uaCode;
|
||||||
data.customSubscriptionScripts = config.customsubscriptionscripts || [];
|
data.customSubscriptionScripts = config.customSubscriptionScripts || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMjmlTemplate(template) {
|
async function getMjmlTemplate(template) {
|
||||||
|
|
|
@ -76,8 +76,8 @@ useradd zone-mta || true
|
||||||
cat >> config/production.toml <<EOT
|
cat >> config/production.toml <<EOT
|
||||||
user="mailtrain"
|
user="mailtrain"
|
||||||
group="mailtrain"
|
group="mailtrain"
|
||||||
rouser="nobody"
|
roUser="nobody"
|
||||||
rogroup="nobody"
|
roGroup="nobody"
|
||||||
[log]
|
[log]
|
||||||
level="error"
|
level="error"
|
||||||
[www]
|
[www]
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue