Basic support for Mosaico-based email templates.

This commit is contained in:
Tomas Bures 2018-04-02 11:58:32 +02:00
parent b5cdf57f72
commit 7b5642e911
38 changed files with 1271 additions and 751 deletions

388
app-builder.js Normal file
View 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">&times;</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
View file

@ -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">&times;</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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

@ -61,7 +61,7 @@ export default class Login extends Component {
const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.POST, '/rest/login');
if (submitSuccessful) {
const nextUrl = qs.parse(this.props.location.search).next || '/';
const nextUrl = qs.parse(this.props.location.search).next || mailtrainConfig.urlBase;
/* This ensures we get config for the authenticated user */
window.location = nextUrl;

View file

@ -25,7 +25,6 @@ import { parseDate, parseBirthday, formatDate, formatBirthday, DateFormat, birth
import styles from "./styles.scss";
import moment from "moment";
const FormState = {
Loading: 0,
LoadingWithNotice: 1,

View 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
View 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);
};

View 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;
}

View file

@ -319,7 +319,6 @@ class SectionContent extends Component {
ensureAuthenticated() {
if (!mailtrainConfig.isAuthenticated) {
/* FIXME, once we turn Mailtrain to single-page application, this should become navigateTo */
this.navigateTo('/account/login?next=' + encodeURIComponent(window.location.pathname));
}
}
@ -383,7 +382,7 @@ class Section extends Component {
render() {
return (
<Router>
<Router basename={mailtrainConfig.urlBase}>
<SectionContent root={this.props.root} structure={this.structure} />
</Router>
);

View file

@ -100,3 +100,15 @@
font-size: 20px;
color: #808080;
}
.untrustedContent {
border: 0px none;
width: 100%;
overflow: hidden;
}
.withElementInFullscreen {
height: 0px;
overflow: hidden;
}

View file

@ -13,7 +13,7 @@ import 'datatables.net-bs/css/dataTables.bootstrap.css';
import axios from './axios';
import { withPageHelpers } from '../lib/page'
import { withPageHelpers } from './page'
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
import styles from "./styles.scss";
@ -394,7 +394,7 @@ class Table extends Component {
The reference to the table can be obtained by ref.
*/
Table.prototype.refresh = function() {
this.getWrappedInstance().refresh()
this.getWrappedInstance().refresh();
};
export {

View file

@ -12,7 +12,7 @@ import '../../vendor/fancytree/skin-bootstrap/ui.fancytree.min.css';
import './tree.css';
import axios from './axios';
import { withPageHelpers } from '../lib/page'
import { withPageHelpers } from './page'
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
import styles from "./styles.scss";

227
client/src/lib/untrusted.js Normal file
View 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
View 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
}

View file

@ -1,12 +1,7 @@
'use strict';
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n';
import qs from 'querystringify';
import { Section } from '../lib/page';
import ListsList from './List';
import ListsCUD from './CUD';
import FormsList from './forms/List';

View file

@ -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')
);
};

View file

@ -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;
}

View file

@ -1,11 +1,6 @@
'use strict';
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n';
import { Section } from '../lib/page';
import CUD from './CUD';
import List from './List';
import Share from '../shares/Share';

View file

@ -1,11 +1,6 @@
'use strict';
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n';
import { Section } from '../lib/page';
import ReportsCUD from './CUD';
import ReportsList from './List';
import ReportsView from './View';
@ -13,7 +8,7 @@ import ReportsOutput from './Output';
import ReportTemplatesCUD from './templates/CUD';
import ReportTemplatesList from './templates/List';
import Share from '../shares/Share';
import { ReportState } from '../../../shared/reports';
import {ReportState} from '../../../shared/reports';
import mailtrainConfig from 'mailtrainConfig';

View file

@ -139,7 +139,7 @@ class Root extends Component {
async logout() {
await axios.post('/rest/logout');
window.location = '/';
window.location = mailtrainConfig.urlBase;
}
render() {

View file

@ -39,7 +39,8 @@ export default class CUD extends Component {
this.templateTypes = getTemplateTypes(props.t);
this.state = {
showMergeTagReference: false
showMergeTagReference: false,
elementInFullscreen: false
};
this.initForm();
@ -66,7 +67,8 @@ export default class CUD extends Component {
namespace: mailtrainConfig.user.namespace,
type: mailtrainConfig.editors[0],
text: '',
html: ''
html: '',
data: {}
});
}
}
@ -92,6 +94,11 @@ export default class CUD extends Component {
async submitHandler() {
const t = this.props.t;
if (this.props.entity) {
const typeKey = this.getFormValue('type');
await this.templateTypes[typeKey].htmlEditorBeforeSave(this);
}
let sendMethod, url;
if (this.props.entity) {
sendMethod = FormSendMethod.PUT;
@ -120,6 +127,9 @@ export default class CUD extends Component {
}
async extractPlainText() {
const typeKey = this.getFormValue('type');
await this.templateTypes[typeKey].htmlEditorBeforeSave(this);
const html = this.getFormValue('html');
if (!html) {
alert('Missing HTML content');
@ -145,6 +155,12 @@ export default class CUD extends Component {
});
}
async setElementInFullscreen(elementInFullscreen) {
this.setState({
elementInFullscreen
});
}
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
@ -241,7 +257,7 @@ export default class CUD extends Component {
</div>}
</AlignedRow>
{this.templateTypes[typeKey].form}
{this.templateTypes[typeKey].getHTMLEditor(this)}
<ACEEditor id="text" height="400px" mode="text" label={t('Template content (plain text)')} help={<Trans>To extract the text from HTML click <ActionLink onClickAsync={::this.extractPlainText}>here</ActionLink>. Please note that your existing plaintext in the field above will be overwritten. This feature uses the <a href="http://premailer.dialect.ca/api">Premailer API</a>, a third party service. Their Terms of Service and Privacy Policy apply.</Trans>}/>
</div>
@ -249,7 +265,7 @@ export default class CUD extends Component {
return (
<div>
<div className={this.state.elementInFullscreen ? styles.withElementInFullscreen : ''}>
{canDelete &&
<DeleteModalDialog
stateOwner={this}

View file

@ -3,17 +3,27 @@
import React from "react";
import {
ACEEditor,
AlignedRow,
CKEditor
} from "../lib/form";
import 'brace/mode/text';
import 'brace/mode/html'
import {MosaicoEditor, ResourceType} from "../lib/mosaico";
export function getTemplateTypes(t) {
const templateTypes = {};
templateTypes.mosaico = {
typeName: t('Mosaico')
typeName: t('Mosaico'),
getHTMLEditor: owner => <AlignedRow label={t('Template content (HTML)')}><MosaicoEditor ref={node => owner.editorNode = node} entity={owner.props.entity} entityTypeId={ResourceType.TEMPLATE} title={t('Mosaico Template Designer')} onFullscreenAsync={::owner.setElementInFullscreen}/></AlignedRow>,
htmlEditorBeforeSave: async owner => {
const {html, metadata, model} = await owner.editorNode.exportState();
owner.updateFormValue('html', html);
owner.updateFormValue('data', {metadata, model});
}
};
templateTypes.grapejs = {
@ -22,12 +32,14 @@ export function getTemplateTypes(t) {
templateTypes.ckeditor = {
typeName: t('CKEditor'),
form: <CKEditor id="html" height="600px" label={t('Template content (HTML)')}/>
getHTMLEditor: owner => <CKEditor id="html" height="600px" label={t('Template content (HTML)')}/>,
htmlEditorBeforeSave: async owner => {}
};
templateTypes.codeeditor = {
typeName: t('Code Editor'),
form: <ACEEditor id="html" height="600px" mode="html" label={t('Template content (HTML)')}/>
getHTMLEditor: owner => <ACEEditor id="html" height="600px" mode="html" label={t('Template content (HTML)')}/>,
htmlEditorBeforeSave: async owner => {}
};
templateTypes.mjml = {

View file

@ -1,11 +1,7 @@
'use strict';
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n';
import { Section } from '../lib/page';
import TemplatesCUD from './CUD';
import TemplatesList from './List';
import Share from '../shares/Share';

View file

@ -4,7 +4,7 @@ const path = require('path');
module.exports = {
entry: {
root: ['babel-polyfill', './src/root.js'],
mosaico: ['babel-polyfill', './src/mosaico/root.js'],
mosaico: ['babel-polyfill', './src/lib/mosaico-sandbox-root.js'],
},
output: {
library: 'MailtrainReactBody',

View file

@ -22,19 +22,13 @@
title="mailtrain"
# Enabled HTML editors
editors=["ckeditor", "codeeditor"]
editors=["ckeditor", "codeeditor", "mosaico"]
# Default language to use
language="en"
# Inject custom styles in layout.hbs
# customstyles=["/custom/hello-world.css"]
# Inject custom scripts in layout.hbs
# customscripts=["/custom/hello-world.js"]
# Inject custom scripts in subscription/layout.mjml.hbs
# customsubscriptionscripts=["/custom/hello-world.js"]
# customSubscriptionScripts=["/custom/hello-world.js"]
# If you start out as a root user (eg. if you want to use ports lower than 1000)
# then you can downgrade the user once all services are up and running
@ -43,9 +37,9 @@ language="en"
# If Mailtrain is started as root, "Reports" feature drops the privileges of script generating the report to disallow
# any modifications of Mailtrain code and even prohibits reading the production configuration (which contains the MySQL
# password for read/write operations). The rouser/rogroup determines the user to be used
#rouser="nobody"
#rogroup="nogroup"
# password for read/write operations). The roUser/roGroup determines the user to be used
#roUser="nobody"
#roGroup="nogroup"
[log]
# silly|verbose|info|http|warn|error|silent
@ -54,8 +48,18 @@ level="verbose"
[www]
# HTTP port to listen on
port=3000
# HTTP port to listen on for sandboxed requests
sandboxPort=8081
# HTTP interface to listen on
host="0.0.0.0"
# URL Base (must end with slash). It can be either relative (starting with slash) or absolute (starting with http:// or https://).
# If it is relative, an absolute URL will be constructed automatically based on the domain where it is served and the "port" config parameter
urlBase="/"
# URL Base for sandbox urls (must end with slash). It can be either relative (starting with slash) or absolute (starting with http:// or https://)
# If it is relative, an absolute URL will be constructed automatically based on the domain where it is served and the "port" config parameter
# If not given at all, it is automatically constructed based on urlBase and sandboxPort.
# sandboxUrlBase="/"
# Secret for signing the session ID cookie
secret="a cat"
# Session length in seconds when "remember me" is checked
@ -66,10 +70,7 @@ log="dev"
# Set this to true if you are serving Mailtrain as a virtual domain through Nginx or Apache
proxy=false
# maximum POST body size
postsize="2MB"
# Uncomment to set uploads folder location for temporary data. Defaults to os.tmpdir()
# If the service is started by `npm start` then os.tmpdir() points to CWD
#tmpdir="/tmp"
postSize="2MB"
[mysql]
host="localhost"

121
index.js
View file

@ -1,14 +1,9 @@
'use strict';
/**
* Module dependencies.
*/
const config = require('config');
const log = require('npmlog');
const app = require('./app');
const appBuilder = require('./app-builder');
const http = require('http');
const fork = require('child_process').fork;
const triggers = require('./services/triggers');
const importer = require('./services/importer');
const verpServer = require('./services/verp-server');
@ -24,28 +19,29 @@ const privilegeHelpers = require('./lib/privilege-helpers');
const knex = require('./lib/knex');
const shares = require('./models/shares');
let port = config.www.port;
let host = config.www.host;
const trustedPort = config.www.port;
const sandboxPort = config.www.sandboxPort;
const host = config.www.host;
if (config.title) {
process.title = config.title;
}
log.level = config.log.level;
app.set('port', port);
/**
* Create HTTP server.
*/
let server = http.createServer(app);
function startHTTPServer(trusted, port, callback) {
const app = appBuilder.createApp(trusted);
app.set('port', port);
server.on('error', err => {
const server = http.createServer(app);
server.on('error', err => {
if (err.syscall !== 'listen') {
throw err;
}
let bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port;
const bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port;
// handle specific listen errors with friendly messages
switch (err.code) {
@ -58,62 +54,44 @@ server.on('error', err => {
default:
throw err;
}
});
function spawnSenders(callback) {
let processes = Math.max(Number(config.queue.processes) || 1, 1);
let spawned = 0;
let returned = false;
if (processes > 1 && !config.redis.enabled) {
log.error('Queue', '%s processes requested but Redis is not enabled, spawning 1 process', processes);
processes = 1;
}
let spawnSender = function () {
if (spawned >= processes) {
if (!returned) {
returned = true;
return callback();
}
return false;
}
let child = fork(__dirname + '/services/sender.js', []);
let pid = child.pid;
senders.workers.add(child);
child.on('close', (code, signal) => {
spawned--;
senders.workers.delete(child);
log.error('Child', 'Sender process %s exited with %s', pid, code || signal);
// Respawn after 5 seconds
setTimeout(() => spawnSender(), 5 * 1000).unref();
});
spawned++;
setImmediate(spawnSender);
};
server.on('listening', () => {
const addr = server.address();
const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port;
log.info('Express', 'WWW server listening on %s', bind);
});
spawnSender();
server.listen({port, host}, callback);
}
server.on('listening', () => {
let addr = server.address();
let bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port;
log.info('Express', 'WWW server listening on %s', bind);
// start additional services
function startNextServices() {
// ---------------------------------------------------------------------------------------
// Start the whole circus here
// ---------------------------------------------------------------------------------------
dbcheck(err => { // Check if database needs upgrading before starting the server - legacy migration first
if (err) {
log.error('DB', err.message || err);
return process.exit(1);
}
knex.migrate.latest() // And now the current migration with Knex
.then(() => shares.regenerateRoleNamesTable())
.then(() => shares.rebuildPermissions())
.then(() =>
executor.spawn(() => {
testServer(() => {
verpServer(() => {
startHTTPServer(true, trustedPort, () => {
startHTTPServer(false, sandboxPort, () => {
privilegeHelpers.dropRootPrivileges();
tzupdate(() => {
importer(() => {
triggers(() => {
spawnSenders(() => {
senders.spawn(() => {
feedcheck(() => {
postfixBounceServer(async () => {
await reportProcessor.init();
@ -126,29 +104,10 @@ server.on('listening', () => {
});
});
});
}
if (config.reports && config.reports.enabled === true) {
executor.spawn(startNextServices);
} else {
startNextServices();
}
});
// Check if database needs upgrading before starting the server
// First, the legacy migration
dbcheck(err => {
if (err) {
log.error('DB', err.message || err);
return process.exit(1);
}
// And now the current migration with Knex
knex.migrate.latest()
.then(() => shares.regenerateRoleNamesTable())
.then(() => shares.rebuildPermissions())
.then(() => server.listen(port, host)); // Listen on provided port, on all network interfaces.
});
});
})
);
});

View file

@ -12,7 +12,11 @@ async function getAnonymousConfig(context) {
isAuthMethodLocal: passport.isAuthMethodLocal,
externalPasswordResetLink: config.ldap.passwordresetlink,
language: config.language || 'en',
isAuthenticated: !!context.user
isAuthenticated: !!context.user,
urlBase: config.www.urlBase,
sandboxUrlBase: config.www.sandboxUrlBase,
port: config.www.port,
sandboxPort: config.www.sandboxPort
}
}

View file

@ -75,7 +75,7 @@ module.exports.csrfProtection = csrf({
module.exports.parseForm = bodyParser.urlencoded({
extended: false,
limit: config.www.postsize
limit: config.www.postSize
});
module.exports.loggedIn = (req, res, next) => {
@ -87,35 +87,45 @@ module.exports.loggedIn = (req, res, next) => {
};
module.exports.authByAccessToken = (req, res, next) => {
nodeifyPromise((async () => {
if (!req.query.access_token) {
res.status(403);
return res.json({
res.json({
error: 'Missing access_token',
data: []
});
}
try {
const user = await users.getByAccessToken(req.query.access_token);
users.getByAccessToken(req.query.access_token).then(user => {
req.user = user;
next();
} catch (err) {
if (err instanceof interoperableErrors.NotFoundError) {
}).catch(err => {
if (err instanceof interoperableErrors.PermissionDeniedError) {
res.status(403);
return res.json({
res.json({
error: 'Invalid or expired access_token',
data: []
});
} else {
res.status(500);
return res.json({
res.json({
error: err.message || err,
data: []
});
}
});
};
module.exports.tryAuthByRestrictedAccessToken = (req, res, next) => {
if (req.cookies.restricted_access_token) {
users.getByRestrictedAccessToken(req.cookies.restricted_access_token).then(user => {
req.user = user;
next();
}).catch(err => {
next();
});
} else {
next();
}
})(), next);
};
module.exports.setup = app => {

View file

@ -8,25 +8,25 @@ const fs = require('fs');
const tryRequire = require('try-require');
const posix = tryRequire('posix');
function _getConfigUidGid(prefix, defaultUid, defaultGid) {
function _getConfigUidGid(userKey, groupKey, defaultUid, defaultGid) {
let uid = defaultUid;
let gid = defaultGid;
if (posix) {
try {
if (config.user) {
uid = posix.getpwnam(config[prefix + 'user']).uid;
if (config[userKey]) {
uid = posix.getpwnam(config[userKey]).uid;
}
} catch (err) {
log.info('PrivilegeHelpers', 'Failed to resolve user id "%s"', config[prefix + 'user']);
log.info('PrivilegeHelpers', 'Failed to resolve user id "%s"', config[userKey]);
}
try {
if (config.user) {
gid = posix.getpwnam(config[prefix + 'group']).gid;
if (config[groupKey]) {
gid = posix.getpwnam(config[groupKey]).gid;
}
} catch (err) {
log.info('PrivilegeHelpers', 'Failed to resolve group id "%s"', config[prefix + 'group']);
log.info('PrivilegeHelpers', 'Failed to resolve group id "%s"', config[groupKey]);
}
} else {
log.info('PrivilegeHelpers', 'Posix module not installed. Cannot resolve uid/gid');
@ -36,12 +36,12 @@ function _getConfigUidGid(prefix, defaultUid, defaultGid) {
}
function getConfigUidGid() {
return _getConfigUidGid('', process.getuid(), process.getgid());
return _getConfigUidGid('user', 'group', process.getuid(), process.getgid());
}
function getConfigROUidGid() {
let rwIds = getConfigUidGid();
return _getConfigUidGid('ro', rwIds.uid, rwIds.gid);
const rwIds = getConfigUidGid();
return _getConfigUidGid('roUser', 'roGroup', rwIds.uid, rwIds.gid);
}
function ensureMailtrainOwner(file, callback) {

View file

@ -1,5 +1,50 @@
'use strict';
const fork = require('child_process').fork;
const config = require('config');
const log = require('npmlog');
const workers = new Set();
function spawn(callback) {
let processes = Math.max(Number(config.queue.processes) || 1, 1);
let spawned = 0;
let returned = false;
if (processes > 1 && !config.redis.enabled) {
log.error('Queue', '%s processes requested but Redis is not enabled, spawning 1 process', processes);
processes = 1;
}
let spawnSender = function () {
if (spawned >= processes) {
if (!returned) {
returned = true;
return callback();
}
return false;
}
let child = fork(__dirname + '/../services/sender.js', []);
let pid = child.pid;
workers.add(child);
child.on('close', (code, signal) => {
spawned--;
workers.delete(child);
log.error('Child', 'Sender process %s exited with %s', pid, code || signal);
// Respawn after 5 seconds
setTimeout(() => spawnSender(), 5 * 1000).unref();
});
spawned++;
setImmediate(spawnSender);
};
spawnSender();
}
module.exports = {
workers: new Set()
workers,
spawn
};

View file

@ -133,7 +133,8 @@ async function createFiles(context, type, entityId, files, dontReplace = false)
originalName: originalName,
size: file.size,
type: file.mimetype,
url: `/files/${type}/${entityId}/${file.filename}`
url: `/files/${type}/${entityId}/${file.filename}`,
thumbnailUrl: `/files/${type}/${entityId}/${file.filename}` // TODO - use smaller thumbnails
});
if (existingNameMap.has(originalName)) {
@ -145,10 +146,10 @@ async function createFiles(context, type, entityId, files, dontReplace = false)
}
const originalNameArray = Array.from(originalNameSet);
await knex(getFilesTable(type)).where('entity', entityId).whereIn('originalname', originalNameArray).del();
await tx(getFilesTable(type)).where('entity', entityId).whereIn('originalname', originalNameArray).del();
if (fileEntities) {
await knex(getFilesTable(type)).insert(fileEntities);
await tx(getFilesTable(type)).insert(fileEntities);
}
});

View file

@ -7,6 +7,7 @@ const { enforce } = require('../lib/helpers');
const dtHelpers = require('../lib/dt-helpers');
const permissions = require('../lib/permissions');
const interoperableErrors = require('../shared/interoperable-errors');
const log = require('npmlog');
// TODO: This would really benefit from some permission cache connected to rebuildPermissions
// A bit of the problem is that the cache would have to expunged as the result of other processes modifying entites/permissions
@ -418,14 +419,26 @@ function checkGlobalPermission(context, requiredOperations) {
return false;
}
if (context.user.admin) { // This handles the getAdminContext() case
return true;
}
if (typeof requiredOperations === 'string') {
requiredOperations = [ requiredOperations ];
}
if (context.user.restrictedAccessHandler) {
log.verbose('check global permissions with restrictedAccessHandler -- requiredOperations: ' + requiredOperations);
const allowedPerms = context.user.restrictedAccessHandler.globalPermissions;
if (allowedPerms) {
requiredOperations = requiredOperations.filter(perm => allowedPerms.has(perm));
}
}
if (requiredOperations.length === 0) {
return false;
}
if (context.user.admin) { // This handles the getAdminContext() case
return true;
}
const roleSpec = config.roles.global[context.user.role];
let success = false;
if (roleSpec) {
@ -453,6 +466,24 @@ async function _checkPermissionTx(tx, context, entityTypeId, entityId, requiredO
const entityType = permissions.getEntityType(entityTypeId);
if (typeof requiredOperations === 'string') {
requiredOperations = [ requiredOperations ];
}
if (context.user.restrictedAccessHandler) {
log.verbose('check permissions with restrictedAccessHandler -- entityTypeId: ' + entityTypeId + ' entityId: ' + entityId + ' requiredOperations: ' + requiredOperations);
if (context.user.restrictedAccessHandler.permissions && context.user.restrictedAccessHandler.permissions[entityTypeId]) {
const allowedPerms = context.user.restrictedAccessHandler.permissions[entityTypeId][entityId];
if (allowedPerms) {
requiredOperations = requiredOperations.filter(perm => allowedPerms.has(perm));
}
}
}
if (requiredOperations.length === 0) {
return false;
}
if (context.user.admin) { // This handles the getAdminContext() case. In this case we don't check the permission, but just the existence.
const existsQuery = tx(entityType.entitiesTable);
@ -465,10 +496,6 @@ async function _checkPermissionTx(tx, context, entityTypeId, entityId, requiredO
return !!exists;
} else {
if (typeof requiredOperations === 'string') {
requiredOperations = [ requiredOperations ];
}
const permsQuery = tx(entityType.permissionsTable)
.where('user', context.user.id)
.whereIn('operation', requiredOperations);
@ -564,7 +591,6 @@ async function getPermissionsTx(tx, context, entityTypeId, entityId) {
return rows.map(x => x.operation);
}
module.exports = {
listByEntityDTAjax,
listByUserDTAjax,

View file

@ -19,6 +19,8 @@ async function getById(context, id) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'template', id, 'view');
const entity = await tx('templates').where('id', id).first();
entity.data = JSON.parse(entity.data);
entity.permissions = await shares.getPermissionsTx(tx, context, 'template', id);
return entity;
});
@ -34,9 +36,16 @@ async function listDTAjax(context, params) {
);
}
async function _validateAndPreprocess(tx, entity, isCreate) {
entity.data = JSON.stringify(entity.data);
}
async function create(context, entity) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createTemplate');
await _validateAndPreprocess(tx, entity, true);
await namespaceHelpers.validateEntity(tx, entity);
const ids = await tx('templates').insert(filterObject(entity, allowedKeys));
@ -57,11 +66,15 @@ async function updateWithConsistencyCheck(context, entity) {
throw new interoperableErrors.NotFoundError();
}
existing.data = JSON.parse(existing.data);
const existingHash = hash(existing);
if (existingHash !== entity.originalHash) {
throw new interoperableErrors.ChangedError();
}
await _validateAndPreprocess(tx, entity, false);
await namespaceHelpers.validateEntity(tx, entity);
await namespaceHelpers.validateMove(context, entity, existing, 'template', 'createTemplate', 'delete');

View file

@ -8,7 +8,7 @@ const interoperableErrors = require('../shared/interoperable-errors');
const passwordValidator = require('../shared/password-validator')();
const dtHelpers = require('../lib/dt-helpers');
const tools = require('../lib/tools-async');
let crypto = require('crypto');
const crypto = require('crypto');
const settings = require('./settings');
const urllib = require('url');
const _ = require('../lib/translate')._;
@ -43,11 +43,7 @@ async function _getBy(context, key, value, extraColumns = []) {
const user = await knex('users').select(columns).where(key, value).first();
if (!user) {
if (context) {
shares.throwPermissionDenied();
} else {
throw new interoperableErrors.NotFoundError();
}
}
await shares.enforceEntityPermission(context, 'namespace', user.namespace, 'manageUsers');
@ -367,6 +363,50 @@ async function resetPassword(username, resetToken, password) {
}
const restrictedAccessTokenMethods = {};
const restrictedAccessTokens = new Map();
function registerRestrictedAccessTokenMethod(method, getHandlerFromParams) {
restrictedAccessTokenMethods[method] = getHandlerFromParams;
}
function getRestrictedAccessToken(context, method, params) {
const token = crypto.randomBytes(24).toString('hex').toLowerCase();
const tokenEntry = {
token,
userId: context.user.id,
handler: restrictedAccessTokenMethods[method](params),
expires: Date.now() + 120 * 1000
};
restrictedAccessTokens.set(token, tokenEntry);
return token;
}
async function getByRestrictedAccessToken(token) {
const now = Date.now();
for (const entry of restrictedAccessTokens.values()) {
if (entry.expires < now) {
restrictedAccessTokens.delete(entry.token);
}
}
const tokenEntry = restrictedAccessTokens.get(token);
if (tokenEntry) {
const user = await getById(contextHelpers.getAdminContext(), tokenEntry.userId);
user.restrictedAccessHandler = tokenEntry.handler;
return user;
} else {
shares.throwPermissionDenied();
}
}
module.exports = {
listDTAjax,
remove,
@ -382,5 +422,8 @@ module.exports = {
resetAccessToken,
sendPasswordReset,
isPasswordResetTokenValid,
resetPassword
resetPassword,
getByRestrictedAccessToken,
getRestrictedAccessToken,
registerRestrictedAccessTokenMethod
};

View file

@ -7,6 +7,7 @@ const clientHelpers = require('../lib/client-helpers');
const gm = require('gm').subClass({
imageMagick: true
});
const users = require('../models/users');
const bluebird = require('bluebird');
const fsReadFile = bluebird.promisify(require('fs').readFile);
@ -16,6 +17,21 @@ const path = require('path');
const files = require('../models/files');
const fileHelpers = require('../lib/file-helpers');
users.registerRestrictedAccessTokenMethod('mosaico', ({entityTypeId, entityId}) => {
if (entityTypeId === 'template' || entityTypeId === 'campaign') {
return {
permissions: {
[entityTypeId]: {
[entityId]: new Set(['manageFiles', 'view'])
}
}
};
}
});
// FIXME - add authentication by sandboxToken
async function placeholderImage(width, height) {
@ -136,7 +152,7 @@ router.getAsync('/upload/:type/:fileId', passport.loggedIn, async (req, res) =>
});
router.getAsync('/editor', passport.csrfProtection, passport.loggedIn, async (req, res) => {
router.getAsync('/editor', passport.csrfProtection, async (req, res) => {
const resourceType = req.query.type;
const resourceId = req.query.id;

View file

@ -52,12 +52,17 @@ router.postAsync('/password-reset-send', passport.csrfProtection, async (req, re
router.postAsync('/password-reset-validate', passport.csrfProtection, async (req, res) => {
const isValid = await users.isPasswordResetTokenValid(req.body.username, req.body.resetToken);
return res.json(isValid);
})
});
router.postAsync('/password-reset', passport.csrfProtection, async (req, res) => {
await users.resetPassword(req.body.username, req.body.resetToken, req.body.password);
return res.json();
})
});
router.postAsync('/restricted-access-token', passport.loggedIn, async (req, res) => {
const restrictedAccessToken = await users.getRestrictedAccessToken(req.context, req.body.method, req.body.params);
return res.json(restrictedAccessToken);
});
module.exports = router;

View file

@ -100,7 +100,7 @@ async function injectCustomFormData(customFormId, viewKey, data) {
const configItems = await settings.get(['uaCode']);
data.uaCode = configItems.uaCode;
data.customSubscriptionScripts = config.customsubscriptionscripts || [];
data.customSubscriptionScripts = config.customSubscriptionScripts || [];
}
async function getMjmlTemplate(template) {

View file

@ -76,8 +76,8 @@ useradd zone-mta || true
cat >> config/production.toml <<EOT
user="mailtrain"
group="mailtrain"
rouser="nobody"
rogroup="nobody"
roUser="nobody"
roGroup="nobody"
[log]
level="error"
[www]