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