Work in progress on subscriptions

This commit is contained in:
Tomas Bures 2017-12-10 21:44:35 +01:00
parent eecb3cd067
commit b22a87e712
18 changed files with 1729 additions and 884 deletions

55
app.js
View file

@ -226,11 +226,30 @@ app.use((req, res, next) => {
}); });
}); });
// Endpoint under /api are authenticated by access token
app.all('/api/*', passport.authByPanelToken);
// 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) => { app.use((req, res, next) => {
req.context = contextHelpers.getRequestContext(req); req.context = contextHelpers.getRequestContext(req);
next(); next();
}); });
// Regular endpoints
app.use('/', routes); app.use('/', routes);
app.use('/lists', lists); app.use('/lists', lists);
app.use('/templates', templates); app.use('/templates', templates);
@ -244,12 +263,15 @@ app.use('/triggers', triggers);
app.use('/webhooks', webhooks); app.use('/webhooks', webhooks);
app.use('/subscription', subscription); app.use('/subscription', subscription);
app.use('/archive', archive); app.use('/archive', archive);
app.use('/api', api);
app.use('/editorapi', editorapi); app.use('/editorapi', editorapi);
app.use('/grapejs', grapejs); app.use('/grapejs', grapejs);
app.use('/mosaico', mosaico); app.use('/mosaico', mosaico);
// API endpoints
app.use('/api', api);
if (config.reports && config.reports.enabled === true) { if (config.reports && config.reports.enabled === true) {
app.use('/reports', reports); app.use('/reports', reports);
} }
@ -267,11 +289,7 @@ if (config.reports && config.reports.enabled === true) {
} }
/* ------------------------------------------------------------------- */ /* ------------------------------------------------------------------- */
app.all('/rest/*', (req, res, next) => { // REST endpoints
req.needsJSONResponse = true;
next();
});
app.use('/rest', namespacesRest); app.use('/rest', namespacesRest);
app.use('/rest', usersRest); app.use('/rest', usersRest);
app.use('/rest', accountRest); app.use('/rest', accountRest);
@ -289,6 +307,7 @@ if (config.reports && config.reports.enabled === true) {
app.use('/rest', reportsRest); app.use('/rest', reportsRest);
} }
// catch 404 and forward to error handler // catch 404 and forward to error handler
app.use((req, res, next) => { app.use((req, res, next) => {
let err = new Error(_('Not Found')); let err = new Error(_('Not Found'));
@ -296,8 +315,8 @@ app.use((req, res, next) => {
next(err); next(err);
}); });
// error handlers
// Error handlers
if (app.get('env') === 'development') { if (app.get('env') === 'development') {
// development error handler // development error handler
// will print stacktrace // will print stacktrace
@ -306,7 +325,7 @@ if (app.get('env') === 'development') {
return next(); return next();
} }
if (req.needsJSONResponse) { if (req.needsRESTJSONResponse) {
const resp = { const resp = {
message: err.message, message: err.message,
error: err error: err
@ -319,6 +338,14 @@ if (app.get('env') === 'development') {
res.status(err.status || 500).json(resp); 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 { } else {
if (err instanceof interoperableErrors.NotLoggedInError) { if (err instanceof interoperableErrors.NotLoggedInError) {
req.flash('danger', _('Need to be logged in to access restricted content')); req.flash('danger', _('Need to be logged in to access restricted content'));
@ -342,7 +369,7 @@ if (app.get('env') === 'development') {
} }
console.log(err); console.log(err);
if (req.needsJSONResponse) { if (req.needsRESTJSONResponse) {
const resp = { const resp = {
message: err.message, message: err.message,
error: {} error: {}
@ -355,7 +382,17 @@ if (app.get('env') === 'development') {
res.status(err.status || 500).json(resp); 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 { } else {
// TODO: Render interoperable errors using a special client that does internationalization of the error message
if (err instanceof interoperableErrors.NotLoggedInError) { if (err instanceof interoperableErrors.NotLoggedInError) {
req.flash('danger', _('Need to be logged in to access restricted content')); req.flash('danger', _('Need to be logged in to access restricted content'));
return res.redirect('/account/login?next=' + encodeURIComponent(req.originalUrl)); return res.redirect('/account/login?next=' + encodeURIComponent(req.originalUrl));

View file

@ -18,11 +18,6 @@ module.exports = {
getDefaultMergeTags, getDefaultMergeTags,
getRSSMergeTags, getRSSMergeTags,
getListMergeTags, getListMergeTags,
captureFlashMessages,
injectCustomFormData,
injectCustomFormTemplates,
filterCustomFields,
getMjmlTemplate,
rollbackAndReleaseConnection, rollbackAndReleaseConnection,
filterObject, filterObject,
enforce enforce
@ -119,176 +114,7 @@ function getListMergeTags(listId, callback) {
}); });
} }
function filterCustomFields(customFieldsIn = [], fieldIds = [], method = 'include') { // FIXME - remove once we get rid of non-async models
let customFields = customFieldsIn.slice();
fieldIds = typeof fieldIds === 'string' ? fieldIds.split(',') : fieldIds;
customFields.unshift({
id: 'email',
name: 'Email Address',
type: 'Email',
typeSubscriptionEmail: true
}, {
id: 'firstname',
name: 'First Name',
type: 'Text',
typeFirstName: true
}, {
id: 'lastname',
name: 'Last Name',
type: 'Text',
typeLastName: true
});
let filtered = [];
if (method === 'include') {
fieldIds.forEach(id => {
let field = customFields.find(f => f.id.toString() === id);
field && filtered.push(field);
});
} else {
customFields.forEach(field => {
!fieldIds.includes(field.id.toString()) && filtered.push(field);
});
}
return filtered;
}
function injectCustomFormData(customFormId, viewPath, data, callback) {
let injectDefaultData = data => {
data.customFields = filterCustomFields(data.customFields, [], 'exclude');
data.formInputStyle = '@import url(/subscription/form-input-style.css);';
return data;
};
if (Number(customFormId) < 1) {
return callback(null, injectDefaultData(data));
}
forms.get(customFormId, (err, form) => {
if (err) {
return callback(null, injectDefaultData(data));
}
let view = viewPath.split('/')[1];
if (view === 'web-subscribe') {
data.customFields = form.fieldsShownOnSubscribe
? filterCustomFields(data.customFields, form.fieldsShownOnSubscribe)
: filterCustomFields(data.customFields, [], 'exclude');
} else if (view === 'web-manage') {
data.customFields = form.fieldsShownOnManage
? filterCustomFields(data.customFields, form.fieldsShownOnManage)
: filterCustomFields(data.customFields, [], 'exclude');
}
let key = tools.fromDbKey(view);
data.template.template = form[key] || data.template.template;
data.template.layout = form.layout || data.template.layout;
data.formInputStyle = form.formInputStyle || '@import url(/subscription/form-input-style.css);';
settings.list(['ua_code'], (err, configItems) => {
if (err) {
return callback(err);
}
data.uaCode = configItems.uaCode;
data.customSubscriptionScripts = config.customsubscriptionscripts || [];
callback(null, data);
});
});
}
function injectCustomFormTemplates(customFormId, templates, callback) {
if (Number(customFormId) < 1) {
return callback(null, templates);
}
forms.get(customFormId, (err, form) => {
if (err) {
return callback(null, templates);
}
let lookUp = name => {
let key = tools.fromDbKey(
/subscription\/([^.]*)/.exec(name)[1]
);
return form[key] || name;
};
Object.keys(templates).forEach(key => {
let value = templates[key];
if (typeof value === 'string') {
templates[key] = lookUp(value);
}
if (typeof value === 'object' && value.template) {
templates[key].template = lookUp(value.template);
}
if (typeof value === 'object' && value.layout) {
templates[key].layout = lookUp(value.layout);
}
});
callback(null, templates);
});
}
function getMjmlTemplate(template, callback) {
if (!template) {
return callback(null, false);
}
let key = (typeof template === 'object') ? objectHash(template) : template;
if (mjmlTemplates.has(key)) {
return callback(null, mjmlTemplates.get(key));
}
let done = source => {
let compiled;
try {
compiled = mjml.mjml2html(source);
} catch (err) {
return callback(err);
}
if (compiled.errors.length) {
return callback(compiled.errors[0].message || compiled.errors[0]);
}
let renderer = hbs.handlebars.compile(compiled.html);
mjmlTemplates.set(key, renderer);
callback(null, renderer);
};
if (typeof template === 'object') {
tools.mergeTemplateIntoLayout(template.template, template.layout, (err, source) => {
if (err) {
return callback(err);
}
done(source);
});
} else {
fs.readFile(path.join(__dirname, '..', 'views', template), 'utf-8', (err, source) => {
if (err) {
return callback(err);
}
done(source);
});
}
}
function captureFlashMessages(req, res, callback) {
res.render('subscription/capture-flash-messages', { layout: null }, (err, flash) => {
if (err) {
return callback(err);
}
callback(null, flash);
});
}
function rollbackAndReleaseConnection(connection, callback) { function rollbackAndReleaseConnection(connection, callback) {
connection.rollback(() => { connection.rollback(() => {
connection.release(); connection.release();

View file

@ -42,6 +42,38 @@ 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({
error: 'Missing access_token',
data: []
});
}
try {
const user = await users.getByAccessToken(req.query.access_token);
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);
};
module.exports.setup = app => { module.exports.setup = app => {
app.use(passport.initialize()); app.use(passport.initialize());
app.use(passport.session()); app.use(passport.session());

View file

@ -1,13 +1,17 @@
'use strict'; 'use strict';
const log = require('npmlog'); const log = require('npmlog');
let fields = require('./models/fields'); const fields = require('../models/fields');
let settings = require('./models/settings'); const settings = require('../models/settings');
let mailer = require('./mailer'); const urllib = require('url');
let urllib = require('url'); const helpers = require('./helpers');
let helpers = require('./helpers'); const _ = require('./translate')._;
let _ = require('./translate')._; const util = require('util');
let util = require('util'); const contextHelpers = require('./context-helpers');
const {getFieldKey} = require('../shared/lists');
const forms = require('../models/forms');
const bluebird = require('bluebird');
const sendMail = bluebird.promisify(require('./mailer').sendMail);
module.exports = { module.exports = {
@ -19,16 +23,16 @@ module.exports = {
sendUnsubscriptionConfirmed sendUnsubscriptionConfirmed
}; };
function sendSubscriptionConfirmed(list, email, subscription, callback) { async function sendSubscriptionConfirmed(list, email, subscription) {
const relativeUrls = { const relativeUrls = {
preferencesUrl: '/subscription/' + list.cid + '/manage/' + subscription.cid, preferencesUrl: '/subscription/' + list.cid + '/manage/' + subscription.cid,
unsubscribeUrl: '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid unsubscribeUrl: '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid
}; };
sendMail(list, email, 'subscription-confirmed', _('%s: Subscription Confirmed'), relativeUrls, {}, subscription, callback); await sendMail(list, email, 'subscription_confirmed', _('%s: Subscription Confirmed'), relativeUrls, {}, subscription);
} }
function sendAlreadySubscribed(list, email, subscription, callback) { async function sendAlreadySubscribed(list, email, subscription) {
const mailOpts = { const mailOpts = {
ignoreDisableConfirmations: true ignoreDisableConfirmations: true
}; };
@ -36,69 +40,93 @@ function sendAlreadySubscribed(list, email, subscription, callback) {
preferencesUrl: '/subscription/' + list.cid + '/manage/' + subscription.cid, preferencesUrl: '/subscription/' + list.cid + '/manage/' + subscription.cid,
unsubscribeUrl: '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid unsubscribeUrl: '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid
}; };
sendMail(list, email, 'already-subscribed', _('%s: Email Address Already Registered'), relativeUrls, mailOpts, subscription, callback); await sendMail(list, email, 'already_subscribed', _('%s: Email Address Already Registered'), relativeUrls, mailOpts, subscription);
} }
function sendConfirmAddressChange(list, email, cid, subscription, callback) { async function sendConfirmAddressChange(list, email, cid, subscription) {
const mailOpts = { const mailOpts = {
ignoreDisableConfirmations: true ignoreDisableConfirmations: true
}; };
const relativeUrls = { const relativeUrls = {
confirmUrl: '/subscription/confirm/change-address/' + cid confirmUrl: '/subscription/confirm/change-address/' + cid
}; };
sendMail(list, email, 'confirm-address-change', _('%s: Please Confirm Email Change in Subscription'), relativeUrls, mailOpts, subscription, callback); await sendMail(list, email, 'confirm_address_change', _('%s: Please Confirm Email Change in Subscription'), relativeUrls, mailOpts, subscription);
} }
function sendConfirmSubscription(list, email, cid, subscription, callback) { async function sendConfirmSubscription(list, email, cid, subscription) {
const mailOpts = { const mailOpts = {
ignoreDisableConfirmations: true ignoreDisableConfirmations: true
}; };
const relativeUrls = { const relativeUrls = {
confirmUrl: '/subscription/confirm/subscribe/' + cid confirmUrl: '/subscription/confirm/subscribe/' + cid
}; };
sendMail(list, email, 'confirm-subscription', _('%s: Please Confirm Subscription'), relativeUrls, mailOpts, subscription, callback); await sendMail(list, email, 'confirm_subscription', _('%s: Please Confirm Subscription'), relativeUrls, mailOpts, subscription);
} }
function sendConfirmUnsubscription(list, email, cid, subscription, callback) { async function sendConfirmUnsubscription(list, email, cid, subscription) {
const mailOpts = { const mailOpts = {
ignoreDisableConfirmations: true ignoreDisableConfirmations: true
}; };
const relativeUrls = { const relativeUrls = {
confirmUrl: '/subscription/confirm/unsubscribe/' + cid confirmUrl: '/subscription/confirm/unsubscribe/' + cid
}; };
sendMail(list, email, 'confirm-unsubscription', _('%s: Please Confirm Unsubscription'), relativeUrls, mailOpts, subscription, callback); await sendMail(list, email, 'confirm_unsubscription', _('%s: Please Confirm Unsubscription'), relativeUrls, mailOpts, subscription);
} }
function sendUnsubscriptionConfirmed(list, email, subscription, callback) { async function sendUnsubscriptionConfirmed(list, email, subscription) {
const relativeUrls = { const relativeUrls = {
subscribeUrl: '/subscription/' + list.cid + '?cid=' + subscription.cid subscribeUrl: '/subscription/' + list.cid + '?cid=' + subscription.cid
}; };
sendMail(list, email, 'unsubscription-confirmed', _('%s: Unsubscription Confirmed'), relativeUrls, {}, subscription, callback); await sendMail(list, email, 'unsubscription_confirmed', _('%s: Unsubscription Confirmed'), relativeUrls, {}, subscription);
} }
function getDisplayName(flds, subscription) {
let firstName, lastName, name;
function sendMail(list, email, template, subject, relativeUrls, mailOpts, subscription, callback) { for (const fld of flds) {
fields.list(list.id, (err, fieldList) => { if (fld.key === 'FIRST_NAME') {
if (err) { firstName = subscription[fld.column];
return callback(err);
} }
let encryptionKeys = []; if (fld.key === 'LAST_NAME') {
fields.getRow(fieldList, subscription).forEach(field => { lastName = subscription[fld.column];
if (field.type === 'gpg' && field.value) {
encryptionKeys.push(field.value.trim());
} }
});
settings.list(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'defaultPostaddress', 'serviceUrl', 'disableConfirmations'], (err, configItems) => { if (fld.key === 'NAME') {
if (err) { name = subscription[fld.column];
return callback(err);
} }
}
if (name) {
return name;
} else if (firstName && lastName) {
return firstName + ' ' + lastName;
} else if (lastName) {
return lastName;
} else if (firstName) {
return firstName;
} else {
return '';
}
}
async function sendMail(list, email, template, subject, relativeUrls, mailOpts, subscription) {
console.log(subscription);
const flds = await fields.list(contextHelpers.getAdminContext(), list.id);
const encryptionKeys = [];
for (const fld of flds) {
if (fld.type === 'gpg' && field.value) {
encryptionKeys.push(subscription[getFieldKey(fld)].value.trim());
}
}
const configItems = await settings.get(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'defaultPostaddress', 'serviceUrl', 'disableConfirmations']);
if (!mailOpts.ignoreDisableConfirmations && configItems.disableConfirmations) { if (!mailOpts.ignoreDisableConfirmations && configItems.disableConfirmations) {
return callback(); return;
} }
const data = { const data = {
title: list.name, title: list.name,
homepage: configItems.defaultHomepage || configItems.serviceUrl, homepage: configItems.defaultHomepage || configItems.serviceUrl,
@ -110,29 +138,33 @@ function sendMail(list, email, template, subject, relativeUrls, mailOpts, subscr
data[relativeUrlKey] = urllib.resolve(configItems.serviceUrl, relativeUrls[relativeUrlKey]); data[relativeUrlKey] = urllib.resolve(configItems.serviceUrl, relativeUrls[relativeUrlKey]);
} }
let text = { const fsTemplate = template.replace(/_/g, '-');
template: 'subscription/mail-' + template + '-text.hbs' const text = {
template: 'subscription/mail-' + fsTemplate + '-text.hbs'
}; };
let html = { const html = {
template: 'subscription/mail-' + template + '-html.mjml.hbs', template: 'subscription/mail-' + fsTemplate + '-html.mjml.hbs',
layout: 'subscription/layout.mjml.hbs', layout: 'subscription/layout.mjml.hbs',
type: 'mjml' type: 'mjml'
}; };
helpers.injectCustomFormTemplates(list.defaultForm, { text, html }, (err, tmpl) => { if (list.default_form !== null) {
if (!err && tmpl) { const form = await forms.getById(contextHelpers.getAdminContext(), list.default_form);
text = tmpl.text || text;
html = tmpl.html || html; text.template = form['mail_' + template + '_text'] || text.template;
html.template = form['mail_' + template + '_html'] || html.template;
html.layout = form.layout || html.layout;
} }
mailer.sendMail({ try {
await sendMail({
from: { from: {
name: configItems.defaultFrom, name: configItems.defaultFrom,
address: configItems.defaultAddress address: configItems.defaultAddress
}, },
to: { to: {
name: [].concat(subscription.firstName || []).concat(subscription.lastName || []).join(' '), name: getDisplayName(flds, subscription),
address: email address: email
}, },
subject: util.format(subject, list.name), subject: util.format(subject, list.name),
@ -141,15 +173,8 @@ function sendMail(list, email, template, subject, relativeUrls, mailOpts, subscr
html, html,
text, text,
data data
}, err => { });
if (err) { } catch (err) {
log.error('Subscription', err); log.error('Subscription', err);
} }
});
callback();
});
});
});
} }

View file

@ -4,8 +4,12 @@ const _ = require('./translate')._;
const util = require('util'); const util = require('util');
const isemail = require('isemail'); const isemail = require('isemail');
const bluebird = require('bluebird');
const mergeTemplateIntoLayout = bluebird.promisify(require('./tools').mergeTemplateIntoLayout);
module.exports = { module.exports = {
validateEmail validateEmail,
mergeTemplateIntoLayout
}; };
async function validateEmail(address, checkBlocked) { async function validateEmail(address, checkBlocked) {
@ -24,3 +28,4 @@ async function validateEmail(address, checkBlocked) {
return result; return result;
} }

View file

@ -18,9 +18,6 @@ let htmlToText = require('html-to-text');
let blockedUsers = ['abuse', 'admin', 'billing', 'compliance', 'devnull', 'dns', 'ftp', 'hostmaster', 'inoc', 'ispfeedback', 'ispsupport', 'listrequest', 'list', 'maildaemon', 'noc', 'noreply', 'noreply', 'null', 'phish', 'phishing', 'postmaster', 'privacy', 'registrar', 'root', 'security', 'spam', 'support', 'sysadmin', 'tech', 'undisclosedrecipients', 'unsubscribe', 'usenet', 'uucp', 'webmaster', 'www']; let blockedUsers = ['abuse', 'admin', 'billing', 'compliance', 'devnull', 'dns', 'ftp', 'hostmaster', 'inoc', 'ispfeedback', 'ispsupport', 'listrequest', 'list', 'maildaemon', 'noc', 'noreply', 'noreply', 'null', 'phish', 'phishing', 'postmaster', 'privacy', 'registrar', 'root', 'security', 'spam', 'support', 'sysadmin', 'tech', 'undisclosedrecipients', 'unsubscribe', 'usenet', 'uucp', 'webmaster', 'www'];
module.exports = { module.exports = {
toDbKey,
fromDbKey,
convertKeys,
queryParams, queryParams,
createSlug, createSlug,
updateMenu, updateMenu,
@ -33,42 +30,6 @@ module.exports = {
workers: new Set() workers: new Set()
}; };
function toDbKey(key) {
return key.
replace(/[^a-z0-9\-_]/gi, '').
replace(/-+/g, '_').
replace(/[A-Z]/g, c => '_' + c.toLowerCase()).
replace(/^_+|_+$/g, '').
replace(/_+/g, '_').
trim();
}
function fromDbKey(key) {
let prefix = '';
if (key.startsWith('_')) {
key = key.substring(1);
prefix = '_';
}
return prefix + key.replace(/[_-]([a-z])/g, (m, c) => c.toUpperCase());
}
function convertKeys(obj, options) {
options = options || {};
let response = {};
Object.keys(obj || {}).forEach(key => {
let lKey = fromDbKey(key);
if (options.skip && options.skip.indexOf(lKey) >= 0) {
return;
}
if (options.keep && options.keep.indexOf(lKey) < 0) {
return;
}
response[lKey] = obj[key];
});
return response;
}
function queryParams(obj) { function queryParams(obj) {
return Object.keys(obj). return Object.keys(obj).
filter(key => key !== '_csrf'). filter(key => key !== '_csrf').
@ -116,6 +77,7 @@ function createSlug(table, name, callback) {
}); });
} }
// FIXME - remove once we fully manage the menu in the client
function updateMenu(res) { function updateMenu(res) {
if (!res.locals.menu) { if (!res.locals.menu) {
res.locals.menu = []; res.locals.menu = [];
@ -148,6 +110,7 @@ function updateMenu(res) {
} }
} }
// FIXME - either remove of delegate to validateEmail in tools-async (or vice-versa)
function validateEmail(address, checkBlocked, callback) { function validateEmail(address, checkBlocked, callback) {
let user = (address || '').toString().split('@').shift().toLowerCase().replace(/[^a-z0-9]/g, ''); let user = (address || '').toString().split('@').shift().toLowerCase().replace(/[^a-z0-9]/g, '');
if (checkBlocked && blockedUsers.indexOf(user) >= 0) { if (checkBlocked && blockedUsers.indexOf(user) >= 0) {

View file

@ -16,6 +16,53 @@ async function listDTAjax(context, params) {
); );
} }
/*
module.exports.get = (start, limit, search, callback) => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
search = '%' + search + '%';
connection.query('SELECT SQL_CALC_FOUND_ROWS `email` FROM blacklist WHERE `email` LIKE ? ORDER BY `email` LIMIT ? OFFSET ?', [search, limit, start], (err, rows) => {
if (err) {
return callback(err);
}
connection.query('SELECT FOUND_ROWS() AS total', (err, total) => {
connection.release();
if (err) {
return callback(err);
}
let emails = [];
rows.forEach(email => {
emails.push(email.email);
});
return callback(null, emails, total && total[0] && total[0].total);
});
});
});
};
*/
async function search(context, start, limit, search) {
return await knex.transaction(async tx => {
shares.enforceGlobalPermission(context, 'manageBlacklist');
search = '%' + search + '%';
const count = await tx('blacklist').where('email', 'like', search).count();
// FIXME - the count won't likely work;
console.log(count);
const rows = await tx('blacklist').where('email', 'like', search).offset(start).limit(limit);
return {
emails: rows.map(row => row.email),
total: count
};
});
}
async function add(context, email) { async function add(context, email) {
return await knex.transaction(async tx => { return await knex.transaction(async tx => {
shares.enforceGlobalPermission(context, 'manageBlacklist'); shares.enforceGlobalPermission(context, 'manageBlacklist');
@ -56,6 +103,7 @@ module.exports = {
listDTAjax, listDTAjax,
add, add,
remove, remove,
search,
isBlacklisted, isBlacklisted,
serverValidate serverValidate
}; };

51
models/confirmations.js Normal file
View file

@ -0,0 +1,51 @@
'use strict';
const knex = require('../lib/knex');
const shortid = require('shortid');
async function addConfirmation(listId, action, ip, data) {
const cid = shortid.generate();
await knex('confirmations').insert({
cid,
list: listId,
action,
ip,
data: JSON.stringify(data || {})
});
return cid;
}
/*
Atomically retrieves confirmation from the database, removes it from the database and returns it.
*/
async function takeConfirmation(cid) {
return await knex.transaction(async tx => {
const entry = await tx('confirmations').select(['cid', 'list', 'action', 'ip', 'data']).where('cid', cid).first();
if (!entry) {
return false;
}
await tx('confirmations').where('cid', cid).del();
let data;
try {
data = JSON.parse(entry.data);
} catch (err) {
data = {};
}
return {
list: entry.list,
action: entry.action,
ip: entry.ip,
data
};
});
}
module.exports = {
addConfirmation,
takeConfirmation
};

View file

@ -7,7 +7,6 @@ const { enforce, filterObject } = require('../lib/helpers');
const dtHelpers = require('../lib/dt-helpers'); const dtHelpers = require('../lib/dt-helpers');
const interoperableErrors = require('../shared/interoperable-errors'); const interoperableErrors = require('../shared/interoperable-errors');
const shares = require('./shares'); const shares = require('./shares');
const bluebird = require('bluebird');
const validators = require('../shared/validators'); const validators = require('../shared/validators');
const shortid = require('shortid'); const shortid = require('shortid');
const segments = require('./segments'); const segments = require('./segments');
@ -154,7 +153,7 @@ async function listTx(tx, listId) {
async function list(context, listId) { async function list(context, listId) {
return await knex.transaction(async tx => { return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, ['manageFields', 'manageSegments']); await shares.enforceEntityPermissionTx(tx, context, 'list', listId, ['viewSubscriptions', 'manageFields', 'manageSegments']);
return await listTx(tx, listId); return await listTx(tx, listId);
}); });
} }
@ -193,6 +192,7 @@ async function listGroupedTx(tx, listId) {
async function listGrouped(context, listId) { async function listGrouped(context, listId) {
return await knex.transaction(async tx => { return await knex.transaction(async tx => {
// It may seem odd why there is not 'manageFields' here. But it's just a result of strictly apply the "need-to-know" principle. Simply, at this point this function is needed only in managing subscriptions.
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, ['manageSubscriptions']); await shares.enforceEntityPermissionTx(tx, context, 'list', listId, ['manageSubscriptions']);
return await listGroupedTx(tx, listId); return await listGroupedTx(tx, listId);
}); });
@ -474,6 +474,32 @@ async function removeAllByListIdTx(tx, context, listId) {
} }
} }
async function getRow(context, listId, subscription) {
const customFields = [{
name: 'Email Address',
column: 'email',
typeSubscriptionEmail: true,
value: subscription ? subscription.email : '',
order_subscribe: -1,
order_manage: -1
}];
const flds = await list(context, listId);
for (const fld of flds) {
if (fld.column) {
customFields.push({
name: fld.name,
column: fld.column,
['type' + fld.type.replace(/(?:^|-)([a-z])/g, (m, c) => c.toUpperCase())]: true,
value: subscription ? subscription[fld.column] : ''
});
}
}
return customFields;
}
// This is to handle circular dependency with segments.js // This is to handle circular dependency with segments.js
Object.assign(module.exports, { Object.assign(module.exports, {
Cardinality, Cardinality,
@ -491,5 +517,6 @@ Object.assign(module.exports, {
updateWithConsistencyCheck, updateWithConsistencyCheck,
remove, remove,
removeAllByListIdTx, removeAllByListIdTx,
serverValidate serverValidate,
getRow
}); });

View file

@ -32,16 +32,40 @@ async function listDTAjax(context, params) {
); );
} }
async function getById(context, id) { async function _getByIdTx(tx, context, id) {
return await knex.transaction(async tx => {
shares.enforceEntityPermissionTx(tx, context, 'list', id, 'view'); shares.enforceEntityPermissionTx(tx, context, 'list', id, 'view');
const entity = await tx('lists').where('id', id).first(); const entity = await tx('lists').where('id', id).first();
entity.permissions = await shares.getPermissionsTx(tx, context, 'list', id); entity.permissions = await shares.getPermissionsTx(tx, context, 'list', id);
return entity;
}
async function getById(context, id) {
return await knex.transaction(async tx => {
return _getByIdTx(tx, context, id);
});
}
async function getByIdWithListFields(context, id) {
return await knex.transaction(async tx => {
const entity = _getByIdTx(tx, context, id);
entity.listFields = await fields.listByOrderListTx(tx, id); entity.listFields = await fields.listByOrderListTx(tx, id);
return entity; return entity;
}); });
} }
async function getByCid(context, cid) {
return await knex.transaction(async tx => {
const entity = await tx('lists').where('cid', cid).first();
if (!entity) {
shares.throwPermissionDenied();
}
shares.enforceEntityPermissionTx(tx, context, 'list', entity.id, 'view');
entity.permissions = await shares.getPermissionsTx(tx, context, 'list', entity.id);
return entity;
});
}
async function create(context, entity) { async function create(context, entity) {
return await knex.transaction(async tx => { return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createList'); await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createList');
@ -116,6 +140,8 @@ module.exports = {
hash, hash,
listDTAjax, listDTAjax,
getById, getById,
getByIdWithListFields,
getByCid,
create, create,
updateWithConsistencyCheck, updateWithConsistencyCheck,
remove, remove,

View file

@ -157,14 +157,14 @@ const campaignFieldsMapping = {
}; };
async function getCampaignResults(context, campaign, select, extra) { async function getCampaignResults(context, campaign, select, extra) {
const fieldList = await fields.list(context, campaign.list); const flds = await fields.list(context, campaign.list);
const fieldsMapping = Object.assign({}, campaignFieldsMapping); const fieldsMapping = Object.assign({}, campaignFieldsMapping);
for (const field of fieldList) { for (const fld of flds) {
/* Dropdowns and checkboxes are aggregated. As such, they have field.column == null /* Dropdown and checkbox groups have field.column == null
TODO - For the time being, we ignore groupped fields. */ TODO - For the time being, we don't group options and we don't expand enums. We just provide it as it is in the DB. */
if (field.column) { if (fld.column) {
fieldsMapping[field.key.toLowerCase()] = 'subscribers.' + field.column; fieldsMapping[fld.key.toLowerCase()] = 'subscribers.' + fld.column;
} }
} }

View file

@ -191,11 +191,11 @@ async function hashByList(listId, entity) {
}); });
} }
async function getById(context, listId, id) { async function _getBy(context, listId, key, value) {
return await knex.transaction(async tx => { return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSubscriptions'); await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSubscriptions');
const entity = await tx(getTableName(listId)).where('id', id).first(); const entity = await tx(getTableName(listId)).where(key, value).first();
const groupedFieldsMap = await getGroupedFieldsMap(tx, listId); const groupedFieldsMap = await getGroupedFieldsMap(tx, listId);
groupSubscription(groupedFieldsMap, entity); groupSubscription(groupedFieldsMap, entity);
@ -204,6 +204,19 @@ async function getById(context, listId, id) {
}); });
} }
async function getById(context, listId, id) {
return await _getBy(context, listId, 'id', id);
}
async function getByEmail(context, listId, email) {
return await _getBy(context, listId, 'email', email);
}
async function getByCid(context, listId, cid) {
return await _getBy(context, listId, 'cid', cid);
}
async function listDTAjax(context, listId, segmentId, params) { async function listDTAjax(context, listId, segmentId, params) {
return await knex.transaction(async tx => { return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSubscriptions'); await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSubscriptions');
@ -369,7 +382,7 @@ async function _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, isCr
} }
} }
async function create(context, listId, entity) { async function create(context, listId, entity, meta = {}) {
return await knex.transaction(async tx => { return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSubscriptions'); await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSubscriptions');
@ -384,10 +397,9 @@ async function create(context, listId, entity) {
ungroupSubscription(groupedFieldsMap, filteredEntity); ungroupSubscription(groupedFieldsMap, filteredEntity);
// FIXME - process: filteredEntity.opt_in_ip = meta.ip;
// filteredEntity.opt_in_ip = filteredEntity.opt_in_country = meta.country;
// filteredEntity.opt_in_country = filteredEntity.imported = meta.imported || false;
// filteredEntity.imported =
const ids = await tx(getTableName(listId)).insert(filteredEntity); const ids = await tx(getTableName(listId)).insert(filteredEntity);
const id = ids[0]; const id = ids[0];
@ -466,35 +478,58 @@ async function remove(context, listId, id) {
}); });
} }
async function unsubscribe(context, listId, id) { async function unsubscribeAndGet(context, listId, subscriptionId) {
await knex.transaction(async tx => { return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSubscriptions'); await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSubscriptions');
const existing = await tx(getTableName(listId)).where('id', id).first(); const existing = await tx(getTableName(listId)).where('id', subscriptionId).first();
if (!existing) { if (!existing) {
throw new interoperableErrors.NotFoundError(); throw new interoperableErrors.NotFoundError();
} }
if (existing.status === SubscriptionStatus.SUBSCRIBED) { if (existing.status === SubscriptionStatus.SUBSCRIBED) {
await tx(getTableName(listId)).where('id', id).update({ existing.status = SubscriptionStatus.UNSUBSCRIBED;
await tx(getTableName(listId)).where('id', subscriptionId).update({
status: SubscriptionStatus.UNSUBSCRIBED status: SubscriptionStatus.UNSUBSCRIBED
}); });
await tx('lists').where('id', listId).decrement('subscribers', 1); await tx('lists').where('id', listId).decrement('subscribers', 1);
} }
return existing;
}); });
} }
async function updateAddressAndGet(context, listId, subscriptionId, emailNew) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSubscriptions');
const existing = await tx(getTableName(listId)).where('id', subscriptionId).first();
if (!existing) {
throw new interoperableErrors.NotFoundError();
}
await tx(getTableName(listId)).where('id', subscriptionId).update({
email: emailNew
});
existing.email = emailNew;
return existing;
});
}
module.exports = { module.exports = {
hashByList, hashByList,
getById, getById,
getByCid,
getByEmail,
list, list,
listDTAjax, listDTAjax,
serverValidate, serverValidate,
create, create,
updateWithConsistencyCheck, updateWithConsistencyCheck,
remove, remove,
unsubscribe unsubscribeAndGet,
updateAddressAndGet
}; };

View file

@ -1,9 +1,8 @@
'use strict'; 'use strict';
let users = require('../models/users');
let lists = require('../lib/models/lists'); let lists = require('../lib/models/lists');
let fields = require('../lib/models/fields'); let fields = require('../lib/models/fields');
let blacklist = require('../lib/models/blacklist'); let blacklist = require('../models/blacklist');
let subscriptions = require('../lib/models/subscriptions'); let subscriptions = require('../lib/models/subscriptions');
let confirmations = require('../lib/models/confirmations'); let confirmations = require('../lib/models/confirmations');
let tools = require('../lib/tools'); let tools = require('../lib/tools');
@ -12,35 +11,6 @@ const router = require('../lib/router-async').create();
let mailHelpers = require('../lib/subscription-mail-helpers'); let mailHelpers = require('../lib/subscription-mail-helpers');
const interoperableErrors = require('../shared/interoperable-errors'); const interoperableErrors = require('../shared/interoperable-errors');
router.allAsync('/*', async (req, res, next) => {
if (!req.query.access_token) {
res.status(403);
return res.json({
error: 'Missing access_token',
data: []
});
}
try {
await users.getByAccessToken(req.query.access_token);
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: []
});
}
}
});
router.post('/subscribe/:listId', (req, res) => { router.post('/subscribe/:listId', (req, res) => {
let input = {}; let input = {};
Object.keys(req.body).forEach(key => { Object.keys(req.body).forEach(key => {
@ -365,83 +335,53 @@ router.post('/field/:listId', (req, res) => {
}); });
}); });
router.post('/blacklist/add', (req, res) => { router.postAsync('/blacklist/add', async (req, res) => {
let input = {}; let input = {};
Object.keys(req.body).forEach(key => { Object.keys(req.body).forEach(key => {
input[(key || '').toString().trim().toUpperCase()] = (req.body[key] || '').toString().trim(); input[(key || '').toString().trim().toUpperCase()] = (req.body[key] || '').toString().trim();
}); });
if (!(input.EMAIL) || (input.EMAIL === '')) { if (!(input.EMAIL) || (input.EMAIL === '')) {
res.status(500); throw new Error('EMAIL argument is required');
return res.json({
error: 'EMAIL argument are required',
data: []
});
} }
blacklist.add(input.EMAIL, (err) =>{
if (err) { await blacklist.add(req.context, input.EMAIL);
res.status(500);
return res.json({
error: err.message || err,
data: []
});
}
res.status(200);
res.json({ res.json({
data: [] data: []
}); });
});
}); });
router.post('/blacklist/delete', (req, res) => { router.postAsync('/blacklist/delete', async (req, res) => {
let input = {}; let input = {};
Object.keys(req.body).forEach(key => { Object.keys(req.body).forEach(key => {
input[(key || '').toString().trim().toUpperCase()] = (req.body[key] || '').toString().trim(); input[(key || '').toString().trim().toUpperCase()] = (req.body[key] || '').toString().trim();
}); });
if (!(input.EMAIL) || (input.EMAIL === '')) { if (!(input.EMAIL) || (input.EMAIL === '')) {
res.status(500); throw new Error('EMAIL argument is required');
return res.json({
error: 'EMAIL argument are required',
data: []
});
} }
blacklist.delete(input.EMAIL, (err) =>{
if (err) { await blacklist.remove(req.oontext, input.EMAIL);
res.status(500);
return res.json({
error: err.message || err,
data: []
});
}
res.status(200);
res.json({ res.json({
data: [] data: []
}); });
});
}); });
router.get('/blacklist/get', (req, res) => { router.getAsync('/blacklist/get', async (req, res) => {
let start = parseInt(req.query.start || 0, 10); let start = parseInt(req.query.start || 0, 10);
let limit = parseInt(req.query.limit || 10000, 10); let limit = parseInt(req.query.limit || 10000, 10);
let search = req.query.search || ''; let search = req.query.search || '';
blacklist.get(start, limit, search, (err, data, total) => { const { emails, total } = await blacklist.search(req.context, start, limit, search);
if (err) {
res.status(500);
return res.json({ return res.json({
error: err.message || err,
data: []
});
}
res.status(200);
res.json({
data: { data: {
total: total, total,
start: start, start: start,
limit: limit, limit: limit,
emails: data emails
} }
}); });
});
}); });
module.exports = router; module.exports = router;

View file

@ -11,7 +11,7 @@ router.postAsync('/lists-table', passport.loggedIn, async (req, res) => {
}); });
router.getAsync('/lists/:listId', passport.loggedIn, async (req, res) => { router.getAsync('/lists/:listId', passport.loggedIn, async (req, res) => {
const list = await lists.getById(req.context, req.params.listId); const list = await lists.getByIdWithListFields(req.context, req.params.listId);
list.hash = lists.hash(list); list.hash = lists.hash(list);
return res.json(list); return res.json(list);
}); });

View file

@ -39,7 +39,7 @@ router.postAsync('/subscriptions-validate/:listId', passport.loggedIn, async (re
}); });
router.postAsync('/subscriptions-unsubscribe/:listId/:subscriptionId', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.postAsync('/subscriptions-unsubscribe/:listId/:subscriptionId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await subscriptions.unsubscribe(req.context, req.params.listId, req.params.subscriptionId); await subscriptions.unsubscribeAndGet(req.context, req.params.listId, req.params.subscriptionId);
return res.json(); return res.json();
}); });

View file

@ -0,0 +1,948 @@
'use strict';
let log = require('npmlog');
let config = require('config');
let tools = require('../lib/tools');
let helpers = require('../lib/helpers');
let passport = require('../lib/passport');
let express = require('express');
let router = new express.Router();
let lists = require('../lib/models/lists');
let fields = require('../lib/models/fields');
let subscriptions = require('../lib/models/subscriptions');
let settings = require('../lib/models/settings');
let openpgp = require('openpgp');
let _ = require('../lib/translate')._;
let util = require('util');
let cors = require('cors');
let cache = require('memory-cache');
let geoip = require('geoip-ultralight');
let confirmations = require('../lib/models/confirmations');
let mailHelpers = require('../lib/subscription-mail-helpers');
let originWhitelist = config.cors && config.cors.origins || [];
let corsOptions = {
allowedHeaders: ['Content-Type', 'Origin', 'Accept', 'X-Requested-With'],
methods: ['GET', 'POST'],
optionsSuccessStatus: 200, // IE11 chokes on 204
origin: (origin, callback) => {
if (originWhitelist.includes(origin)) {
callback(null, true);
} else {
let err = new Error(_('Not allowed by CORS'));
err.status = 403;
callback(err);
}
}
};
let corsOrCsrfProtection = (req, res, next) => {
if (req.get('X-Requested-With') === 'XMLHttpRequest') {
cors(corsOptions)(req, res, next);
} else {
passport.csrfProtection(req, res, next);
}
};
function checkAndExecuteConfirmation(req, action, errorMsg, next, exec) {
confirmations.takeConfirmation(req.params.cid, (err, confirmation) => {
if (!err && (!confirmation || confirmation.action !== action)) {
err = new Error(_(errorMsg));
err.status = 404;
}
if (err) {
return next(err);
}
lists.get(confirmation.listId, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
exec(confirmation, list);
});
});
}
router.get('/confirm/subscribe/:cid', (req, res, next) => {
checkAndExecuteConfirmation(req, 'subscribe', 'Request invalid or already completed. If your subscription request is still pending, please subscribe again.', next, (confirmation, list) => {
const data = confirmation.data;
let optInCountry = geoip.lookupCountry(confirmation.ip) || null;
const meta = {
cid: req.params.cid,
email: data.email,
optInIp: confirmation.ip,
optInCountry,
status: subscriptions.Status.SUBSCRIBED
};
subscriptions.insert(list.id, meta, data.subscriptionData, (err, result) => {
if (err) {
return next(err);
}
if (!result.entryId) {
return next(new Error(_('Could not save subscription')));
}
subscriptions.getById(list.id, result.entryId, (err, subscription) => {
if (err) {
return next(err);
}
mailHelpers.sendSubscriptionConfirmed(list, data.email, subscription, err => {
if (err) {
return next(err);
}
res.redirect('/subscription/' + list.cid + '/subscribed-notice');
});
});
});
});
});
router.get('/confirm/change-address/:cid', (req, res, next) => {
checkAndExecuteConfirmation(req, 'change-address', 'Request invalid or already completed. If your address change request is still pending, please change the address again.', next, (confirmation, list) => {
const data = confirmation.data;
if (!data.subscriptionId) { // Something went terribly wrong and we don't have data that we have originally provided
return next(new Error(_('Subscriber info corrupted or missing')));
}
subscriptions.updateAddress(list.id, data.subscriptionId, data.emailNew, err => {
if (err) {
return next(err);
}
subscriptions.getById(list.id, data.subscriptionId, (err, subscription) => {
if (err) {
return next(err);
}
mailHelpers.sendSubscriptionConfirmed(list, data.emailNew, subscription, err => {
if (err) {
return next(err);
}
req.flash('info', _('Email address changed'));
res.redirect('/subscription/' + list.cid + '/manage/' + subscription.cid);
});
});
});
});
});
router.get('/confirm/unsubscribe/:cid', (req, res, next) => {
checkAndExecuteConfirmation(req, 'unsubscribe', 'Request invalid or already completed. If your unsubscription request is still pending, please unsubscribe again.', next, (confirmation, list) => {
const data = confirmation.data;
subscriptions.changeStatus(list.id, confirmation.data.subscriptionId, confirmation.data.campaignId, subscriptions.Status.UNSUBSCRIBED, (err, found) => {
if (err) {
return next(err);
}
// TODO: Shall we do anything with "found"?
subscriptions.getById(list.id, confirmation.data.subscriptionId, (err, subscription) => {
if (err) {
return next(err);
}
mailHelpers.sendUnsubscriptionConfirmed(list, subscription.email, subscription, err => {
if (err) {
return next(err);
}
res.redirect('/subscription/' + list.cid + '/unsubscribed-notice');
});
});
});
});
});
router.get('/:cid', passport.csrfProtection, (req, res, next) => {
lists.getByCid(req.params.cid, (err, list) => {
if (!err) {
if (!list) {
err = new Error(_('Selected list not found'));
err.status = 404;
} else if (!list.publicSubscribe) {
err = new Error(_('The list does not allow public subscriptions.'));
err.status = 403;
}
}
if (err) {
return next(err);
}
// TODO: process subscriber cid param for resubscription requests
let data = tools.convertKeys(req.query, {
skip: ['layout']
});
data.layout = 'subscription/layout';
data.title = list.name;
data.cid = list.cid;
data.csrfToken = req.csrfToken();
function nextStep() {
fields.list(list.id, (err, fieldList) => {
if (err && !fieldList) {
fieldList = [];
}
data.customFields = fields.getRow(fieldList, data);
data.useEditor = true;
settings.list(['pgpPrivateKey', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => {
if (err) {
return next(err);
}
data.hasPubkey = !!configItems.pgpPrivateKey;
data.defaultAddress = configItems.defaultAddress;
data.defaultPostaddress = configItems.defaultPostaddress;
data.template = {
template: 'subscription/web-subscribe.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-subscribe', data, (err, data) => {
if (err) {
return next(err);
}
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => {
if (err) {
return next(err);
}
helpers.captureFlashMessages(req, res, (err, flash) => {
if (err) {
return next(err);
}
data.isWeb = true;
data.needsJsWarning = true;
data.flashMessages = flash;
res.send(htmlRenderer(data));
});
});
});
});
});
}
const ucid = req.query.cid;
if (ucid) {
subscriptions.get(list.id, ucid, (err, subscription) => {
if (err) {
return next(err);
}
for (let key in subscription) {
if (!(key in data)) {
data[key] = subscription[key];
}
}
nextStep();
});
} else {
nextStep();
}
});
});
router.options('/:cid/widget', cors(corsOptions));
router.get('/:cid/widget', cors(corsOptions), (req, res, next) => {
let cached = cache.get(req.path);
if (cached) {
return res.status(200).json(cached);
}
let sendError = err => {
res.status(err.status || 500);
res.json({
error: err.message || err
});
};
lists.getByCid(req.params.cid, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return sendError(err);
}
fields.list(list.id, (err, fieldList) => {
if (err && !fieldList) {
fieldList = [];
}
settings.list(['serviceUrl', 'pgpPrivateKey'], (err, configItems) => {
if (err) {
return sendError(err);
}
let data = {
title: list.name,
cid: list.cid,
serviceUrl: configItems.serviceUrl,
hasPubkey: !!configItems.pgpPrivateKey,
customFields: fields.getRow(fieldList),
template: {},
layout: null,
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-subscribe', data, (err, data) => {
if (err) {
return sendError(err);
}
res.render('subscription/widget-subscribe', data, (err, html) => {
if (err) {
return sendError(err);
}
let response = {
data: {
title: data.title,
cid: data.cid,
html
}
};
cache.put(req.path, response, 30000); // ms
res.status(200).json(response);
});
});
});
});
});
});
router.options('/:cid/subscribe', cors(corsOptions));
router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, res, next) => {
let sendJsonError = (err, status) => {
res.status(status || err.status || 500);
res.json({
error: err.message || err
});
};
let email = (req.body.email || '').toString().trim();
if (!email) {
if (req.xhr) {
return sendJsonError(_('Email address not set'), 400);
}
req.flash('danger', _('Email address not set'));
return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body));
}
tools.validateEmail(email, false, err => {
if (err) {
if (req.xhr) {
return sendJsonError(err.message, 400);
}
req.flash('danger', err.message);
return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body));
}
// Check if the subscriber seems legit. This is a really simple check, the only requirement is that
// the subscriber has JavaScript turned on and thats it. If Mailtrain gets more targeted then this
// simple check should be replaced with an actual captcha
let subTime = Number(req.body.sub) || 0;
// allow clock skew 24h in the past and 24h to the future
let subTimeTest = !!(subTime > Date.now() - 24 * 3600 * 1000 && subTime < Date.now() + 24 * 3600 * 1000);
let addressTest = !req.body.address;
let testsPass = subTimeTest && addressTest;
lists.getByCid(req.params.cid, (err, list) => {
if (!err) {
if (!list) {
err = new Error(_('Selected list not found'));
err.status = 404;
} else if (!list.publicSubscribe) {
err = new Error(_('The list does not allow public subscriptions.'));
err.status = 403;
}
}
if (err) {
return req.xhr ? sendJsonError(err) : next(err);
}
let subscriptionData = {};
Object.keys(req.body).forEach(key => {
if (key !== 'email' && key.charAt(0) !== '_') {
subscriptionData[key] = (req.body[key] || '').toString().trim();
}
});
subscriptionData = tools.convertKeys(subscriptionData);
subscriptions.getByEmail(list.id, email, (err, subscription) => {
if (err) {
return req.xhr ? sendJsonError(err) : next(err);
}
if (subscription && subscription.status === subscriptions.Status.SUBSCRIBED) {
mailHelpers.sendAlreadySubscribed(list, email, subscription, (err) => {
if (err) {
return req.xhr ? sendJsonError(err) : next(err);
}
res.redirect('/subscription/' + req.params.cid + '/confirm-subscription-notice');
});
} else {
const data = {
email,
subscriptionData
};
confirmations.addConfirmation(list.id, 'subscribe', req.ip, data, (err, confirmCid) => {
if (err) {
if (req.xhr) {
return sendJsonError(err);
}
req.flash('danger', err.message || err);
return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body));
}
function sendWebResponse() {
if (req.xhr) {
return res.status(200).json({
msg: _('Please Confirm Subscription')
});
}
res.redirect('/subscription/' + req.params.cid + '/confirm-subscription-notice');
}
if (!testsPass) {
log.info('Subscription', 'Confirmation message for %s marked to be skipped (%s)', email, JSON.stringify(data));
sendWebResponse();
} else {
mailHelpers.sendConfirmSubscription(list, email, confirmCid, subscriptionData, (err) => {
if (err) {
return req.xhr ? sendJsonError(err) : sendWebResponse(err);
}
sendWebResponse();
})
}
});
}
});
});
});
});
router.get('/:lcid/manage/:ucid', passport.csrfProtection, (req, res, next) => {
lists.getByCid(req.params.lcid, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
fields.list(list.id, (err, fieldList) => {
if (err) {
return next(err);
}
subscriptions.get(list.id, req.params.ucid, (err, subscription) => {
if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) {
err = new Error(_('Subscription not found in this list'));
err.status = 404;
}
if (err) {
return next(err);
}
subscription.lcid = req.params.lcid;
subscription.title = list.name;
subscription.csrfToken = req.csrfToken();
subscription.layout = 'subscription/layout';
subscription.customFields = fields.getRow(fieldList, subscription);
subscription.useEditor = true;
settings.list(['pgpPrivateKey', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => {
if (err) {
return next(err);
}
subscription.hasPubkey = !!configItems.pgpPrivateKey;
subscription.defaultAddress = configItems.defaultAddress;
subscription.defaultPostaddress = configItems.defaultPostaddress;
subscription.template = {
template: 'subscription/web-manage.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-manage', subscription, (err, data) => {
if (err) {
return next(err);
}
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => {
if (err) {
return next(err);
}
helpers.captureFlashMessages(req, res, (err, flash) => {
if (err) {
return next(err);
}
data.isWeb = true;
data.needsJsWarning = true;
data.isManagePreferences = true;
data.flashMessages = flash;
res.send(htmlRenderer(data));
});
});
});
});
});
});
});
});
router.post('/:lcid/manage', passport.parseForm, passport.csrfProtection, (req, res, next) => {
lists.getByCid(req.params.lcid, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
subscriptions.get(list.id, req.body.cid, (err, subscription) => {
if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) {
err = new Error(_('Subscription not found in this list'));
err.status = 404;
}
if (err) {
return next(err);
}
subscriptions.update(list.id, subscription.cid, req.body, false, err => {
if (err) {
return next(err);
}
res.redirect('/subscription/' + req.params.lcid + '/updated-notice');
});
});
});
});
router.get('/:lcid/manage-address/:ucid', passport.csrfProtection, (req, res, next) => {
lists.getByCid(req.params.lcid, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
settings.list(['defaultAddress', 'defaultPostaddress'], (err, configItems) => {
if (err) {
return next(err);
}
subscriptions.get(list.id, req.params.ucid, (err, subscription) => {
if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) {
err = new Error(_('Subscription not found in this list'));
err.status = 404;
}
subscription.lcid = req.params.lcid;
subscription.title = list.name;
subscription.csrfToken = req.csrfToken();
subscription.defaultAddress = configItems.defaultAddress;
subscription.defaultPostaddress = configItems.defaultPostaddress;
subscription.template = {
template: 'subscription/web-manage-address.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-manage-address', subscription, (err, data) => {
if (err) {
return next(err);
}
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => {
if (err) {
return next(err);
}
helpers.captureFlashMessages(req, res, (err, flash) => {
if (err) {
return next(err);
}
data.isWeb = true;
data.needsJsWarning = true;
data.flashMessages = flash;
res.send(htmlRenderer(data));
});
});
});
});
});
});
});
router.post('/:lcid/manage-address', passport.parseForm, passport.csrfProtection, (req, res, next) => {
lists.getByCid(req.params.lcid, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
let bodyData = tools.convertKeys(req.body); // This is here to convert "email-new" to "emailNew"
const emailOld = (bodyData.email || '').toString().trim();
const emailNew = (bodyData.emailNew || '').toString().trim();
if (emailOld === emailNew) {
req.flash('info', _('Nothing seems to be changed'));
res.redirect('/subscription/' + req.params.lcid + '/manage/' + req.body.cid);
} else {
subscriptions.updateAddressCheck(list, req.body.cid, emailNew, req.ip, (err, subscription, newEmailAvailable) => {
if (err) {
return next(err);
}
function sendWebResponse(err) {
if (err) {
return next(err);
}
req.flash('info', _('An email with further instructions has been sent to the provided address'));
res.redirect('/subscription/' + req.params.lcid + '/manage/' + req.body.cid);
}
if (newEmailAvailable) {
const data = {
subscriptionId: subscription.id,
emailNew
};
confirmations.addConfirmation(list.id, 'change-address', req.ip, data, (err, confirmCid) => {
if (err) {
return next(err);
}
mailHelpers.sendConfirmAddressChange(list, emailNew, confirmCid, subscription, sendWebResponse);
});
} else {
mailHelpers.sendAlreadySubscribed(list, emailNew, subscription, sendWebResponse);
}
});
}
});
});
router.get('/:lcid/unsubscribe/:ucid', passport.csrfProtection, (req, res, next) => {
lists.getByCid(req.params.lcid, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
settings.list(['defaultAddress', 'defaultPostaddress'], (err, configItems) => {
if (err) {
return next(err);
}
subscriptions.get(list.id, req.params.ucid, (err, subscription) => {
if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) {
err = new Error(_('Subscription not found in this list'));
err.status = 404;
}
if (err) {
return next(err);
}
const autoUnsubscribe = req.query.auto === 'yes';
if (autoUnsubscribe) {
handleUnsubscribe(list, subscription, autoUnsubscribe, req.query.c, req.ip, res, next);
} else if (req.query.formTest ||
list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM ||
list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) {
subscription.lcid = req.params.lcid;
subscription.ucid = req.params.ucid;
subscription.title = list.name;
subscription.csrfToken = req.csrfToken();
subscription.campaign = req.query.c;
subscription.defaultAddress = configItems.defaultAddress;
subscription.defaultPostaddress = configItems.defaultPostaddress;
subscription.template = {
template: 'subscription/web-unsubscribe.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-unsubscribe', subscription, (err, data) => {
if (err) {
return next(err);
}
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => {
if (err) {
return next(err);
}
helpers.captureFlashMessages(req, res, (err, flash) => {
if (err) {
return next(err);
}
data.isWeb = true;
data.flashMessages = flash;
res.send(htmlRenderer(data));
});
});
});
} else { // UnsubscriptionMode.ONE_STEP || UnsubscriptionMode.TWO_STEP || UnsubscriptionMode.MANUAL
handleUnsubscribe(list, subscription, autoUnsubscribe, req.query.c, req.ip, res, next);
}
});
});
});
});
router.post('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtection, (req, res, next) => {
lists.getByCid(req.params.lcid, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
const campaignId = (req.body.campaign || '').toString().trim() || false;
subscriptions.get(list.id, req.body.ucid, (err, subscription) => {
if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) {
err = new Error(_('Subscription not found in this list'));
err.status = 404;
}
if (err) {
return next(err);
}
handleUnsubscribe(list, subscription, false, campaignId, req.ip, res, next);
});
});
});
function handleUnsubscribe(list, subscription, autoUnsubscribe, campaignId, ip, res, next) {
if ((list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM) ||
(autoUnsubscribe && (list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM)) ) {
subscriptions.changeStatus(list.id, subscription.id, campaignId, subscriptions.Status.UNSUBSCRIBED, (err, found) => {
if (err) {
return next(err);
}
// TODO: Shall we do anything with "found"?
mailHelpers.sendUnsubscriptionConfirmed(list, subscription.email, subscription, err => {
if (err) {
return next(err);
}
res.redirect('/subscription/' + list.cid + '/unsubscribed-notice');
});
});
} else if (list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) {
const data = {
subscriptionId: subscription.id,
campaignId
};
confirmations.addConfirmation(list.id, 'unsubscribe', ip, data, (err, confirmCid) => {
if (err) {
return next(err);
}
mailHelpers.sendConfirmUnsubscription(list, subscription.email, confirmCid, subscription, err => {
if (err) {
return next(err);
}
res.redirect('/subscription/' + list.cid + '/confirm-unsubscription-notice');
});
});
} else { // UnsubscriptionMode.MANUAL
res.redirect('/subscription/' + list.cid + '/manual-unsubscribe-notice');
}
}
router.get('/:cid/confirm-subscription-notice', (req, res, next) => {
webNotice('confirm-subscription', req, res, next);
});
router.get('/:cid/confirm-unsubscription-notice', (req, res, next) => {
webNotice('confirm-unsubscription', req, res, next);
});
router.get('/:cid/subscribed-notice', (req, res, next) => {
webNotice('subscribed', req, res, next);
});
router.get('/:cid/updated-notice', (req, res, next) => {
webNotice('updated', req, res, next);
});
router.get('/:cid/unsubscribed-notice', (req, res, next) => {
webNotice('unsubscribed', req, res, next);
});
router.get('/:cid/manual-unsubscribe-notice', (req, res, next) => {
webNotice('manual-unsubscribe', req, res, next);
});
router.post('/publickey', passport.parseForm, (req, res, next) => {
settings.list(['pgpPassphrase', 'pgpPrivateKey'], (err, configItems) => {
if (err) {
return next(err);
}
if (!configItems.pgpPrivateKey) {
err = new Error(_('Public key is not set'));
err.status = 404;
return next(err);
}
let privKey;
try {
privKey = openpgp.key.readArmored(configItems.pgpPrivateKey).keys[0];
if (configItems.pgpPassphrase && !privKey.decrypt(configItems.pgpPassphrase)) {
privKey = false;
}
} catch (E) {
// just ignore if failed
}
if (!privKey) {
err = new Error(_('Public key is not set'));
err.status = 404;
return next(err);
}
let pubkey = privKey.toPublic().armor();
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Disposition': 'attachment; filename=public.asc'
});
res.end(pubkey);
});
});
function webNotice(type, req, res, next) {
lists.getByCid(req.params.cid, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
settings.list(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'defaultPostaddress', 'adminEmail'], (err, configItems) => {
if (err) {
return next(err);
}
let data = {
title: list.name,
homepage: configItems.defaultHomepage || configItems.serviceUrl,
defaultAddress: configItems.defaultAddress,
defaultPostaddress: configItems.defaultPostaddress,
contactAddress: configItems.defaultAddress,
template: {
template: 'subscription/web-' + type + '-notice.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
}
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-' + type + '-notice', data, (err, data) => {
if (err) {
return next(err);
}
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => {
if (err) {
return next(err);
}
helpers.captureFlashMessages(req, res, (err, flash) => {
if (err) {
return next(err);
}
data.isWeb = true;
data.isConfirmNotice = true;
data.flashMessages = flash;
res.send(htmlRenderer(data));
});
});
});
});
});
}
module.exports = router;

View file

@ -1,28 +1,43 @@
'use strict'; 'use strict';
let log = require('npmlog'); const log = require('npmlog');
let config = require('config'); const config = require('config');
let tools = require('../lib/tools'); const router = require('../lib/router-async').create();
let helpers = require('../lib/helpers'); const confirmations = require('../models/confirmations');
let passport = require('../lib/passport'); const subscriptions = require('../models/subscriptions');
let express = require('express'); const lists = require('../models/lists');
let router = new express.Router(); const fields = require('../models/fields');
let lists = require('../lib/models/lists'); const settings = require('../models/settings');
let fields = require('../lib/models/fields'); const _ = require('../lib/translate')._;
let subscriptions = require('../lib/models/subscriptions'); const contextHelpers = require('../lib/context-helpers');
let settings = require('../lib/models/settings'); const forms = require('../models/forms');
let openpgp = require('openpgp');
let _ = require('../lib/translate')._;
let util = require('util');
let cors = require('cors');
let cache = require('memory-cache');
let geoip = require('geoip-ultralight');
let confirmations = require('../lib/models/confirmations');
let mailHelpers = require('../lib/subscription-mail-helpers');
let originWhitelist = config.cors && config.cors.origins || []; const openpgp = require('openpgp');
const util = require('util');
const cors = require('cors');
const cache = require('memory-cache');
const geoip = require('geoip-ultralight');
const passport = require('../lib/passport');
let corsOptions = { const tools = require('../lib/tools-async');
const helpers = require('../lib/helpers');
const mailHelpers = require('../lib/subscription-mail-helpers');
const interoperableErrors = require('../shared/interoperable-errors');
const mjml = require('mjml');
const hbs = require('hbs');
const mjmlTemplates = new Map();
const objectHash = require('object-hash');
const bluebird = require('bluebird');
const fsReadFile = bluebird.promisify(require('fs').readFile);
const originWhitelist = config.cors && config.cors.origins || [];
const corsOptions = {
allowedHeaders: ['Content-Type', 'Origin', 'Accept', 'X-Requested-With'], allowedHeaders: ['Content-Type', 'Origin', 'Accept', 'X-Requested-With'],
methods: ['GET', 'POST'], methods: ['GET', 'POST'],
optionsSuccessStatus: 200, // IE11 chokes on 204 optionsSuccessStatus: 200, // IE11 chokes on 204
@ -30,14 +45,14 @@ let corsOptions = {
if (originWhitelist.includes(origin)) { if (originWhitelist.includes(origin)) {
callback(null, true); callback(null, true);
} else { } else {
let err = new Error(_('Not allowed by CORS')); const err = new Error(_('Not allowed by CORS'));
err.status = 403; err.status = 403;
callback(err); callback(err);
} }
} }
}; };
let corsOrCsrfProtection = (req, res, next) => { const corsOrCsrfProtection = (req, res, next) => {
if (req.get('X-Requested-With') === 'XMLHttpRequest') { if (req.get('X-Requested-With') === 'XMLHttpRequest') {
cors(corsOptions)(req, res, next); cors(corsOptions)(req, res, next);
} else { } else {
@ -45,170 +60,152 @@ let corsOrCsrfProtection = (req, res, next) => {
} }
}; };
function checkAndExecuteConfirmation(req, action, errorMsg, next, exec) { async function takeConfirmationAndValidate(req, action, errorFactory) {
confirmations.takeConfirmation(req.params.cid, (err, confirmation) => { const confirmation = await confirmations.takeConfirmation(req.params.cid);
if (!err && (!confirmation || confirmation.action !== action)) {
err = new Error(_(errorMsg)); if (!confirmation || confirmation.action !== action) {
err.status = 404; throw errorFactory();
} }
if (err) { return confirmation;
return next(err);
}
lists.get(confirmation.listId, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
exec(confirmation, list);
});
});
} }
router.get('/confirm/subscribe/:cid', (req, res, next) => { async function injectCustomFormData(customFormId, viewKey, data) {
checkAndExecuteConfirmation(req, 'subscribe', 'Request invalid or already completed. If your subscription request is still pending, please subscribe again.', next, (confirmation, list) => { function sortAndFilterCustomFieldsBy(key) {
const data = confirmation.data; data.customFields = data.customFields.filter(fld => fld[key] !== null);
let optInCountry = geoip.lookupCountry(confirmation.ip) || null; data.customFields.sort((a, b) => a[key] - b[key]);
}
if (viewKey === 'web_subscribe') {
sortAndFilterCustomFieldsBy('order_subscribe');
} else if (viewKey === 'web_manage') {
sortAndFilterCustomFieldsBy('order_manage');
}
if (!customFormId) {
data.formInputStyle = '@import url(/subscription/form-input-style.css);';
return;
}
const form = await forms.getById(contextHelpers.getAdminContext(), customFormId);
data.template.template = form[viewKey] || data.template.template;
data.template.layout = form.layout || data.template.layout;
data.formInputStyle = form.formInputStyle || '@import url(/subscription/form-input-style.css);';
const configItems = await settings.get(['ua_code']);
data.uaCode = configItems.uaCode;
data.customSubscriptionScripts = config.customsubscriptionscripts || [];
}
async function getMjmlTemplate(template) {
let key = (typeof template === 'object') ? objectHash(template) : template;
if (mjmlTemplates.has(key)) {
return mjmlTemplates.get(key);
}
let source;
if (typeof template === 'object') {
source = await tools.mergeTemplateIntoLayout(template.template, template.layout);
} else {
source = await fsReadFile(path.join(__dirname, '..', 'views', template), 'utf-8');
}
const compiled = mjml.mjml2html(source);
if (compiled.errors.length) {
throw new Error(compiled.errors[0].message || compiled.errors[0]);
}
const renderer = hbs.handlebars.compile(compiled.html);
mjmlTemplates.set(key, renderer);
return renderer;
}
function captureFlashMessages(req, res) {
return new Promise((resolve, reject) => {
res.render('subscription/capture-flash-messages', { layout: null }, (err, flash) => {
reject(err);
resolve(flash);
});
})
}
router.getAsync('/confirm/subscribe/:cid', async (req, res) => {
const confirmation = await takeConfirmationAndValidate(req, 'subscribe', () => new interoperableErrors.InvalidConfirmationForSubscriptionError('Request invalid or already completed. If your subscription request is still pending, please subscribe again.'));
const subscription = confirmation.data;
const meta = { const meta = {
cid: req.params.cid, cid: req.params.cid,
email: data.email, ip: confirmation.ip,
optInIp: confirmation.ip, country: geoip.lookupCountry(confirmation.ip) || null
optInCountry,
status: subscriptions.Status.SUBSCRIBED
}; };
subscriptions.insert(list.id, meta, data.subscriptionData, (err, result) => { subscription.status = SubscriptionStatus.SUBSCRIBED;
if (err) {
return next(err);
}
if (!result.entryId) { await subscriptions.create(contextHelpers.getAdminContext(), confirmation.list, subscription, meta);
return next(new Error(_('Could not save subscription')));
}
subscriptions.getById(list.id, result.entryId, (err, subscription) => { const list = await lists.getById(contextHelpers.getAdminContext(), confirmation.list);
if (err) { await mailHelpers.sendSubscriptionConfirmed(list, subscription.email, subscription);
return next(err);
}
mailHelpers.sendSubscriptionConfirmed(list, data.email, subscription, err => {
if (err) {
return next(err);
}
res.redirect('/subscription/' + list.cid + '/subscribed-notice'); res.redirect('/subscription/' + list.cid + '/subscribed-notice');
});
});
});
});
}); });
router.get('/confirm/change-address/:cid', (req, res, next) => {
checkAndExecuteConfirmation(req, 'change-address', 'Request invalid or already completed. If your address change request is still pending, please change the address again.', next, (confirmation, list) => { router.getAsync('/confirm/change-address/:cid', async (req, res) => {
const confirmation = await takeConfirmationAndValidate(req, 'change-address', () => new interoperableErrors.InvalidConfirmationForAddressChangeError('Request invalid or already completed. If your address change request is still pending, please change the address again.'));
const data = confirmation.data; const data = confirmation.data;
if (!data.subscriptionId) { // Something went terribly wrong and we don't have data that we have originally provided const subscription = await subscriptions.updateAddressAndGet(contextHelpers.getAdminContext(), list.id, data.subscriptionId, data.emailNew);
return next(new Error(_('Subscriber info corrupted or missing')));
}
subscriptions.updateAddress(list.id, data.subscriptionId, data.emailNew, err => { await mailHelpers.sendSubscriptionConfirmed(list, data.emailNew, subscription);
if (err) {
return next(err);
}
subscriptions.getById(list.id, data.subscriptionId, (err, subscription) => {
if (err) {
return next(err);
}
mailHelpers.sendSubscriptionConfirmed(list, data.emailNew, subscription, err => {
if (err) {
return next(err);
}
req.flash('info', _('Email address changed')); req.flash('info', _('Email address changed'));
res.redirect('/subscription/' + list.cid + '/manage/' + subscription.cid); res.redirect('/subscription/' + list.cid + '/manage/' + subscription.cid);
});
});
});
});
}); });
router.get('/confirm/unsubscribe/:cid', (req, res, next) => {
checkAndExecuteConfirmation(req, 'unsubscribe', 'Request invalid or already completed. If your unsubscription request is still pending, please unsubscribe again.', next, (confirmation, list) => { router.getAsync('/confirm/unsubscribe/:cid', async (req, res) => {
const confirmation = await takeConfirmationAndValidate(req, 'unsubscribe', () => new interoperableErrors.InvalidConfirmationForUnsubscriptionError('Request invalid or already completed. If your unsubscription request is still pending, please unsubscribe again.'));
const data = confirmation.data; const data = confirmation.data;
subscriptions.changeStatus(list.id, confirmation.data.subscriptionId, confirmation.data.campaignId, subscriptions.Status.UNSUBSCRIBED, (err, found) => { const subscription = await subscriptions.unsubscribeAndGet(contextHelpers.getAdminContext(), list.id, data.subscriptionId);
if (err) {
return next(err);
}
// TODO: Shall we do anything with "found"? await mailHelpers.sendUnsubscriptionConfirmed(list, subscription.email, subscription);
subscriptions.getById(list.id, confirmation.data.subscriptionId, (err, subscription) => {
if (err) {
return next(err);
}
mailHelpers.sendUnsubscriptionConfirmed(list, subscription.email, subscription, err => {
if (err) {
return next(err);
}
res.redirect('/subscription/' + list.cid + '/unsubscribed-notice'); res.redirect('/subscription/' + list.cid + '/unsubscribed-notice');
});
});
});
});
}); });
router.get('/:cid', passport.csrfProtection, (req, res, next) => {
lists.getByCid(req.params.cid, (err, list) => { router.getAsync('/:cid', passport.csrfProtection, async (req, res) => {
if (!err) { const list = await lists.getByCid(req.params.cid);
if (!list) {
err = new Error(_('Selected list not found')); if (!list.publicSubscribe) {
err.status = 404; throw new interoperableErrors.SubscriptionNotAllowedError('The list does not allow public subscriptions.');
} else if (!list.publicSubscribe) {
err = new Error(_('The list does not allow public subscriptions.'));
err.status = 403;
}
} }
if (err) { const ucid = req.query.cid;
return next(err);
}
// TODO: process subscriber cid param for resubscription requests const data = {};
let data = tools.convertKeys(req.query, {
skip: ['layout']
});
data.layout = 'subscription/layout'; data.layout = 'subscription/layout';
data.title = list.name; data.title = list.name;
data.cid = list.cid; data.cid = list.cid;
data.csrfToken = req.csrfToken(); data.csrfToken = req.csrfToken();
let subscription;
function nextStep() { if (ucid) {
fields.list(list.id, (err, fieldList) => { subscription = await subscriptions.getById(contextHelpers.getAdminContext(), list.id, ucid);
if (err && !fieldList) {
fieldList = [];
} }
data.customFields = fields.getRow(fieldList, data); data.customFields = fields.getRow(contextHelpers.getAdminContext(), list.id, subscription);
data.useEditor = true; data.useEditor = true;
settings.list(['pgpPrivateKey', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => { const configItems = await settings.get(['pgpPrivateKey', 'defaultAddress', 'defaultPostaddress']);
if (err) {
return next(err);
}
data.hasPubkey = !!configItems.pgpPrivateKey; data.hasPubkey = !!configItems.pgpPrivateKey;
data.defaultAddress = configItems.defaultAddress; data.defaultAddress = configItems.defaultAddress;
data.defaultPostaddress = configItems.defaultPostaddress; data.defaultPostaddress = configItems.defaultPostaddress;
@ -218,110 +215,48 @@ router.get('/:cid', passport.csrfProtection, (req, res, next) => {
layout: 'subscription/layout.mjml.hbs' layout: 'subscription/layout.mjml.hbs'
}; };
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-subscribe', data, (err, data) => { await injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-subscribe', data);
if (err) {
return next(err);
}
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => { const htmlRenderer = await getMjmlTemplate(data.template);
if (err) {
return next(err);
}
helpers.captureFlashMessages(req, res, (err, flash) => {
if (err) {
return next(err);
}
data.isWeb = true; data.isWeb = true;
data.needsJsWarning = true; data.needsJsWarning = true;
data.flashMessages = flash; data.flashMessages = await captureFlashMessages(res);
res.send(htmlRenderer(data)); res.send(htmlRenderer(data));
});
});
});
});
});
}
const ucid = req.query.cid;
if (ucid) {
subscriptions.get(list.id, ucid, (err, subscription) => {
if (err) {
return next(err);
}
for (let key in subscription) {
if (!(key in data)) {
data[key] = subscription[key];
}
}
nextStep();
});
} else {
nextStep();
}
});
}); });
router.options('/:cid/widget', cors(corsOptions)); router.options('/:cid/widget', cors(corsOptions));
router.get('/:cid/widget', cors(corsOptions), (req, res, next) => { router.getAsync('/:cid/widget', cors(corsOptions), async (req, res) => {
let cached = cache.get(req.path); req.needsAPIJSONResponse = true;
const cached = cache.get(req.path);
if (cached) { if (cached) {
return res.status(200).json(cached); return res.status(200).json(cached);
} }
let sendError = err => { const list = await lists.getByCid(req.params.cid);
res.status(err.status || 500);
res.json({
error: err.message || err
});
};
lists.getByCid(req.params.cid, (err, list) => { const configItems = settings.get(['serviceUrl', 'pgpPrivateKey']);
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) { const data = {
return sendError(err);
}
fields.list(list.id, (err, fieldList) => {
if (err && !fieldList) {
fieldList = [];
}
settings.list(['serviceUrl', 'pgpPrivateKey'], (err, configItems) => {
if (err) {
return sendError(err);
}
let data = {
title: list.name, title: list.name,
cid: list.cid, cid: list.cid,
serviceUrl: configItems.serviceUrl, serviceUrl: configItems.serviceUrl,
hasPubkey: !!configItems.pgpPrivateKey, hasPubkey: !!configItems.pgpPrivateKey,
customFields: fields.getRow(fieldList), customFields: fields.getRow(contextHelpers.getAdminContext(), list.id),
template: {}, template: {},
layout: null, layout: null,
}; };
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-subscribe', data, (err, data) => { await injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-subscribe', data);
if (err) {
return sendError(err);
}
res.render('subscription/widget-subscribe', data, (err, html) => { const renderAsync = bluebird.promisify(res.render);
if (err) { const html = await renderAsync('subscription/widget-subscribe', data);
return sendError(err);
}
let response = { const response = {
data: { data: {
title: data.title, title: data.title,
cid: data.cid, cid: data.cid,
@ -331,39 +266,35 @@ router.get('/:cid/widget', cors(corsOptions), (req, res, next) => {
cache.put(req.path, response, 30000); // ms cache.put(req.path, response, 30000); // ms
res.status(200).json(response); res.status(200).json(response);
});
});
});
});
});
}); });
router.options('/:cid/subscribe', cors(corsOptions)); router.options('/:cid/subscribe', cors(corsOptions));
router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, res, next) => { router.postAsync('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, async (req, res) => {
let sendJsonError = (err, status) => { const email = (req.body.email || '').toString().trim();
res.status(status || err.status || 500);
res.json({ if (req.xhr) {
error: err.message || err req.needsAPIJSONResponse = true;
}); }
};
let email = (req.body.email || '').toString().trim();
if (!email) { if (!email) {
if (req.xhr) { if (req.xhr) {
return sendJsonError(_('Email address not set'), 400); throw new Error('Email address not set');
} }
req.flash('danger', _('Email address not set')); req.flash('danger', _('Email address not set'));
return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body)); return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body));
} }
tools.validateEmail(email, false, err => { const emailErr = await tools.validateEmail(email);
if (err) { if (emailErr) {
if (req.xhr) { if (req.xhr) {
return sendJsonError(err.message, 400); throw new Error(emailErr.message);
} }
req.flash('danger', err.message);
req.flash('danger', emailErr.message);
return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body)); return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body));
} }
@ -376,20 +307,12 @@ router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, r
let addressTest = !req.body.address; let addressTest = !req.body.address;
let testsPass = subTimeTest && addressTest; let testsPass = subTimeTest && addressTest;
lists.getByCid(req.params.cid, (err, list) => { const list = await lists.getByCid(req.params.cid);
if (!err) {
if (!list) { if (!list.publicSubscribe) {
err = new Error(_('Selected list not found')); throw new interoperableErrors.SubscriptionNotAllowedError('The list does not allow public subscriptions.');
err.status = 404;
} else if (!list.publicSubscribe) {
err = new Error(_('The list does not allow public subscriptions.'));
err.status = 403;
}
} }
if (err) {
return req.xhr ? sendJsonError(err) : next(err);
}
let subscriptionData = {}; let subscriptionData = {};
Object.keys(req.body).forEach(key => { Object.keys(req.body).forEach(key => {
@ -397,36 +320,27 @@ router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, r
subscriptionData[key] = (req.body[key] || '').toString().trim(); subscriptionData[key] = (req.body[key] || '').toString().trim();
} }
}); });
subscriptionData = tools.convertKeys(subscriptionData);
subscriptions.getByEmail(list.id, email, (err, subscription) => { const subscription = subscriptions.getByEmail(list.id, email)
if (err) {
return req.xhr ? sendJsonError(err) : next(err);
}
if (subscription && subscription.status === subscriptions.Status.SUBSCRIBED) { if (subscription && subscription.status === subscriptions.Status.SUBSCRIBED) {
mailHelpers.sendAlreadySubscribed(list, email, subscription, (err) => { await mailHelpers.sendAlreadySubscribed(list, email, subscription);
if (err) {
return req.xhr ? sendJsonError(err) : next(err);
}
res.redirect('/subscription/' + req.params.cid + '/confirm-subscription-notice'); res.redirect('/subscription/' + req.params.cid + '/confirm-subscription-notice');
});
} else { } else {
const data = { const data = {
email, email,
subscriptionData subscriptionData
}; };
confirmations.addConfirmation(list.id, 'subscribe', req.ip, data, (err, confirmCid) => { const confirmCid = await confirmations.addConfirmation(list.id, 'subscribe', req.ip, data);
if (err) {
if (req.xhr) { if (!testsPass) {
return sendJsonError(err); log.info('Subscription', 'Confirmation message for %s marked to be skipped (%s)', email, JSON.stringify(data));
} } else {
req.flash('danger', err.message || err); await mailHelpers.sendConfirmSubscription(list, email, confirmCid, subscriptionData);
return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body));
} }
function sendWebResponse() {
if (req.xhr) { if (req.xhr) {
return res.status(200).json({ return res.status(200).json({
msg: _('Please Confirm Subscription') msg: _('Please Confirm Subscription')
@ -434,48 +348,14 @@ router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, r
} }
res.redirect('/subscription/' + req.params.cid + '/confirm-subscription-notice'); res.redirect('/subscription/' + req.params.cid + '/confirm-subscription-notice');
} }
if (!testsPass) {
log.info('Subscription', 'Confirmation message for %s marked to be skipped (%s)', email, JSON.stringify(data));
sendWebResponse();
} else {
mailHelpers.sendConfirmSubscription(list, email, confirmCid, subscriptionData, (err) => {
if (err) {
return req.xhr ? sendJsonError(err) : sendWebResponse(err);
}
sendWebResponse();
})
}
});
}
});
});
});
}); });
router.get('/:lcid/manage/:ucid', passport.csrfProtection, (req, res, next) => { router.getAsync('/:lcid/manage/:ucid', passport.csrfProtection, async (req, res) => {
lists.getByCid(req.params.lcid, (err, list) => { const list = await lists.getByCid(req.params.lcid);
if (!err && !list) { const subscription = await subscriptions.getByCid(contextHelpers.getAdminContext(), list.id, req.params.ucid);
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) { if (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED) {
return next(err); throw new Error(_('Subscription not found in this list'));
}
fields.list(list.id, (err, fieldList) => {
if (err) {
return next(err);
}
subscriptions.get(list.id, req.params.ucid, (err, subscription) => {
if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) {
err = new Error(_('Subscription not found in this list'));
err.status = 404;
}
if (err) {
return next(err);
} }
subscription.lcid = req.params.lcid; subscription.lcid = req.params.lcid;
@ -483,14 +363,11 @@ router.get('/:lcid/manage/:ucid', passport.csrfProtection, (req, res, next) => {
subscription.csrfToken = req.csrfToken(); subscription.csrfToken = req.csrfToken();
subscription.layout = 'subscription/layout'; subscription.layout = 'subscription/layout';
subscription.customFields = fields.getRow(fieldList, subscription); subscription.customFields = await fields.getRow(contextHelpers.getAdminContext(), list.id, subscription);
subscription.useEditor = true; subscription.useEditor = true;
settings.list(['pgpPrivateKey', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => { const configItems = await settings.get(['pgpPrivateKey', 'defaultAddress', 'defaultPostaddress']);
if (err) {
return next(err);
}
subscription.hasPubkey = !!configItems.pgpPrivateKey; subscription.hasPubkey = !!configItems.pgpPrivateKey;
subscription.defaultAddress = configItems.defaultAddress; subscription.defaultAddress = configItems.defaultAddress;
subscription.defaultPostaddress = configItems.defaultPostaddress; subscription.defaultPostaddress = configItems.defaultPostaddress;
@ -500,64 +377,40 @@ router.get('/:lcid/manage/:ucid', passport.csrfProtection, (req, res, next) => {
layout: 'subscription/layout.mjml.hbs' layout: 'subscription/layout.mjml.hbs'
}; };
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-manage', subscription, (err, data) => { await injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-manage', subscription);
if (err) {
return next(err);
}
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => { const htmlRenderer = await getMjmlTemplate(data.template);
if (err) {
return next(err);
}
helpers.captureFlashMessages(req, res, (err, flash) => {
if (err) {
return next(err);
}
data.isWeb = true; data.isWeb = true;
data.needsJsWarning = true; data.needsJsWarning = true;
data.isManagePreferences = true; data.isManagePreferences = true;
data.flashMessages = flash; data.flashMessages = await captureFlashMessages(res);
res.send(htmlRenderer(data)); res.send(htmlRenderer(data));
});
});
});
});
});
});
});
}); });
router.post('/:lcid/manage', passport.parseForm, passport.csrfProtection, (req, res, next) => { router.postAsync('/:lcid/manage', passport.parseForm, passport.csrfProtection, async (req, res) => {
lists.getByCid(req.params.lcid, (err, list) => { const list = await lists.getByCid(req.params.lcid);
if (!err && !list) { const subscription = await subscriptions.getByCid(contextHelpers.getAdminContext(), list.id, req.body.cid);
err = new Error(_('Selected list not found'));
err.status = 404; if (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED) {
throw new Error(_('Subscription not found in this list'));
} }
if (err) {
return next(err);
}
subscriptions.get(list.id, req.body.cid, (err, subscription) => { delete req.body.email; // email change is not allowed
if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) { delete req.body.status; // status change is not allowed
err = new Error(_('Subscription not found in this list'));
err.status = 404;
}
if (err) { // FIXME - az sem
return next(err); // FIXME, allow update of only fields that have order_manage
}
await subscriptions.updateWithConsistencyCheck(contextHelpers.getAdminContext(), list.id, subscription)
subscriptions.update(list.id, subscription.cid, req.body, false, err => { subscriptions.update(list.id, subscription.cid, req.body, false, err => {
if (err) { if (err) {
return next(err); return next(err);
} }
res.redirect('/subscription/' + req.params.lcid + '/updated-notice'); res.redirect('/subscription/' + req.params.lcid + '/updated-notice');
}); });
});
});
}); });
router.get('/:lcid/manage-address/:ucid', passport.csrfProtection, (req, res, next) => { router.get('/:lcid/manage-address/:ucid', passport.csrfProtection, (req, res, next) => {

View file

@ -87,6 +87,31 @@ class PermissionDeniedError extends InteroperableError {
} }
} }
class InvalidConfirmationForSubscriptionError extends InteroperableError {
constructor(msg, data) {
super('InvalidConfirmationForSubscriptionError', msg, data);
}
}
class InvalidConfirmationForAddressChangeError extends InteroperableError {
constructor(msg, data) {
super('InvalidConfirmationForAddressChangeError', msg, data);
}
}
class InvalidConfirmationForUnsubscriptionError extends InteroperableError {
constructor(msg, data) {
super('InvalidConfirmationForUnsubscriptionError', msg, data);
}
}
class SubscriptionNotAllowedError extends InteroperableError {
constructor(msg, data) {
super('SubscriptionNotAllowedError', msg, data);
this.status = 403;
}
}
const errorTypes = { const errorTypes = {
InteroperableError, InteroperableError,
NotLoggedInError, NotLoggedInError,
@ -101,7 +126,11 @@ const errorTypes = {
InvalidTokenError, InvalidTokenError,
DependencyNotFoundError, DependencyNotFoundError,
NamespaceNotFoundError, NamespaceNotFoundError,
PermissionDeniedError PermissionDeniedError,
InvalidConfirmationForSubscriptionError,
InvalidConfirmationForAddressChangeError,
InvalidConfirmationForUnsubscriptionError,
SubscriptionNotAllowedError
}; };
function deserialize(errorObj) { function deserialize(errorObj) {