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

View file

@ -18,11 +18,6 @@ module.exports = {
getDefaultMergeTags,
getRSSMergeTags,
getListMergeTags,
captureFlashMessages,
injectCustomFormData,
injectCustomFormTemplates,
filterCustomFields,
getMjmlTemplate,
rollbackAndReleaseConnection,
filterObject,
enforce
@ -119,176 +114,7 @@ function getListMergeTags(listId, callback) {
});
}
function filterCustomFields(customFieldsIn = [], fieldIds = [], method = 'include') {
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);
});
}
// FIXME - remove once we get rid of non-async models
function rollbackAndReleaseConnection(connection, callback) {
connection.rollback(() => {
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 => {
app.use(passport.initialize());
app.use(passport.session());

View file

@ -1,13 +1,17 @@
'use strict';
const log = require('npmlog');
let fields = require('./models/fields');
let settings = require('./models/settings');
let mailer = require('./mailer');
let urllib = require('url');
let helpers = require('./helpers');
let _ = require('./translate')._;
let util = require('util');
const fields = require('../models/fields');
const settings = require('../models/settings');
const urllib = require('url');
const helpers = require('./helpers');
const _ = require('./translate')._;
const 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 = {
@ -19,16 +23,16 @@ module.exports = {
sendUnsubscriptionConfirmed
};
function sendSubscriptionConfirmed(list, email, subscription, callback) {
async function sendSubscriptionConfirmed(list, email, subscription) {
const relativeUrls = {
preferencesUrl: '/subscription/' + list.cid + '/manage/' + 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 = {
ignoreDisableConfirmations: true
};
@ -36,120 +40,141 @@ function sendAlreadySubscribed(list, email, subscription, callback) {
preferencesUrl: '/subscription/' + list.cid + '/manage/' + 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 = {
ignoreDisableConfirmations: true
};
const relativeUrls = {
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 = {
ignoreDisableConfirmations: true
};
const relativeUrls = {
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 = {
ignoreDisableConfirmations: true
};
const relativeUrls = {
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 = {
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) {
fields.list(list.id, (err, fieldList) => {
if (err) {
return callback(err);
for (const fld of flds) {
if (fld.key === 'FIRST_NAME') {
firstName = subscription[fld.column];
}
let encryptionKeys = [];
fields.getRow(fieldList, subscription).forEach(field => {
if (field.type === 'gpg' && field.value) {
encryptionKeys.push(field.value.trim());
}
});
if (fld.key === 'LAST_NAME') {
lastName = subscription[fld.column];
}
settings.list(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'defaultPostaddress', 'serviceUrl', 'disableConfirmations'], (err, configItems) => {
if (err) {
return callback(err);
}
if (fld.key === 'NAME') {
name = subscription[fld.column];
}
}
if (!mailOpts.ignoreDisableConfirmations && configItems.disableConfirmations) {
return callback();
}
const data = {
title: list.name,
homepage: configItems.defaultHomepage || configItems.serviceUrl,
contactAddress: configItems.defaultAddress,
defaultPostaddress: configItems.defaultPostaddress
};
for (let relativeUrlKey in relativeUrls) {
data[relativeUrlKey] = urllib.resolve(configItems.serviceUrl, relativeUrls[relativeUrlKey]);
}
let text = {
template: 'subscription/mail-' + template + '-text.hbs'
};
let html = {
template: 'subscription/mail-' + template + '-html.mjml.hbs',
layout: 'subscription/layout.mjml.hbs',
type: 'mjml'
};
helpers.injectCustomFormTemplates(list.defaultForm, { text, html }, (err, tmpl) => {
if (!err && tmpl) {
text = tmpl.text || text;
html = tmpl.html || html;
}
mailer.sendMail({
from: {
name: configItems.defaultFrom,
address: configItems.defaultAddress
},
to: {
name: [].concat(subscription.firstName || []).concat(subscription.lastName || []).join(' '),
address: email
},
subject: util.format(subject, list.name),
encryptionKeys
}, {
html,
text,
data
}, err => {
if (err) {
log.error('Subscription', err);
}
});
callback();
});
});
});
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) {
return;
}
const data = {
title: list.name,
homepage: configItems.defaultHomepage || configItems.serviceUrl,
contactAddress: configItems.defaultAddress,
defaultPostaddress: configItems.defaultPostaddress
};
for (let relativeUrlKey in relativeUrls) {
data[relativeUrlKey] = urllib.resolve(configItems.serviceUrl, relativeUrls[relativeUrlKey]);
}
const fsTemplate = template.replace(/_/g, '-');
const text = {
template: 'subscription/mail-' + fsTemplate + '-text.hbs'
};
const html = {
template: 'subscription/mail-' + fsTemplate + '-html.mjml.hbs',
layout: 'subscription/layout.mjml.hbs',
type: 'mjml'
};
if (list.default_form !== null) {
const form = await forms.getById(contextHelpers.getAdminContext(), list.default_form);
text.template = form['mail_' + template + '_text'] || text.template;
html.template = form['mail_' + template + '_html'] || html.template;
html.layout = form.layout || html.layout;
}
try {
await sendMail({
from: {
name: configItems.defaultFrom,
address: configItems.defaultAddress
},
to: {
name: getDisplayName(flds, subscription),
address: email
},
subject: util.format(subject, list.name),
encryptionKeys
}, {
html,
text,
data
});
} catch (err) {
log.error('Subscription', err);
}
}

View file

@ -4,8 +4,12 @@ const _ = require('./translate')._;
const util = require('util');
const isemail = require('isemail');
const bluebird = require('bluebird');
const mergeTemplateIntoLayout = bluebird.promisify(require('./tools').mergeTemplateIntoLayout);
module.exports = {
validateEmail
validateEmail,
mergeTemplateIntoLayout
};
async function validateEmail(address, checkBlocked) {
@ -24,3 +28,4 @@ async function validateEmail(address, checkBlocked) {
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'];
module.exports = {
toDbKey,
fromDbKey,
convertKeys,
queryParams,
createSlug,
updateMenu,
@ -33,42 +30,6 @@ module.exports = {
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) {
return Object.keys(obj).
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) {
if (!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) {
let user = (address || '').toString().split('@').shift().toLowerCase().replace(/[^a-z0-9]/g, '');
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) {
return await knex.transaction(async tx => {
shares.enforceGlobalPermission(context, 'manageBlacklist');
@ -56,6 +103,7 @@ module.exports = {
listDTAjax,
add,
remove,
search,
isBlacklisted,
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 interoperableErrors = require('../shared/interoperable-errors');
const shares = require('./shares');
const bluebird = require('bluebird');
const validators = require('../shared/validators');
const shortid = require('shortid');
const segments = require('./segments');
@ -154,7 +153,7 @@ async function listTx(tx, listId) {
async function list(context, listId) {
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);
});
}
@ -193,6 +192,7 @@ async function listGroupedTx(tx, listId) {
async function listGrouped(context, listId) {
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']);
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
Object.assign(module.exports, {
Cardinality,
@ -491,5 +517,6 @@ Object.assign(module.exports, {
updateWithConsistencyCheck,
remove,
removeAllByListIdTx,
serverValidate
serverValidate,
getRow
});

View file

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

View file

@ -157,14 +157,14 @@ const campaignFieldsMapping = {
};
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);
for (const field of fieldList) {
/* Dropdowns and checkboxes are aggregated. As such, they have field.column == null
TODO - For the time being, we ignore groupped fields. */
if (field.column) {
fieldsMapping[field.key.toLowerCase()] = 'subscribers.' + field.column;
for (const fld of flds) {
/* Dropdown and checkbox groups have field.column == null
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 (fld.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 => {
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);
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) {
return await knex.transaction(async tx => {
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 => {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSubscriptions');
@ -384,10 +397,9 @@ async function create(context, listId, entity) {
ungroupSubscription(groupedFieldsMap, filteredEntity);
// FIXME - process:
// filteredEntity.opt_in_ip =
// filteredEntity.opt_in_country =
// filteredEntity.imported =
filteredEntity.opt_in_ip = meta.ip;
filteredEntity.opt_in_country = meta.country;
filteredEntity.imported = meta.imported || false;
const ids = await tx(getTableName(listId)).insert(filteredEntity);
const id = ids[0];
@ -466,35 +478,58 @@ async function remove(context, listId, id) {
});
}
async function unsubscribe(context, listId, id) {
await knex.transaction(async tx => {
async function unsubscribeAndGet(context, listId, subscriptionId) {
return await knex.transaction(async tx => {
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) {
throw new interoperableErrors.NotFoundError();
}
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
});
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 = {
hashByList,
getById,
getByCid,
getByEmail,
list,
listDTAjax,
serverValidate,
create,
updateWithConsistencyCheck,
remove,
unsubscribe
unsubscribeAndGet,
updateAddressAndGet
};

View file

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

View file

@ -11,7 +11,7 @@ router.postAsync('/lists-table', 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);
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) => {
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();
});

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';
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');
const log = require('npmlog');
const config = require('config');
const router = require('../lib/router-async').create();
const confirmations = require('../models/confirmations');
const subscriptions = require('../models/subscriptions');
const lists = require('../models/lists');
const fields = require('../models/fields');
const settings = require('../models/settings');
const _ = require('../lib/translate')._;
const contextHelpers = require('../lib/context-helpers');
const forms = require('../models/forms');
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'],
methods: ['GET', 'POST'],
optionsSuccessStatus: 200, // IE11 chokes on 204
@ -30,14 +45,14 @@ let corsOptions = {
if (originWhitelist.includes(origin)) {
callback(null, true);
} else {
let err = new Error(_('Not allowed by CORS'));
const err = new Error(_('Not allowed by CORS'));
err.status = 403;
callback(err);
}
}
};
let corsOrCsrfProtection = (req, res, next) => {
const corsOrCsrfProtection = (req, res, next) => {
if (req.get('X-Requested-With') === 'XMLHttpRequest') {
cors(corsOptions)(req, res, next);
} else {
@ -45,518 +60,356 @@ let corsOrCsrfProtection = (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;
}
async function takeConfirmationAndValidate(req, action, errorFactory) {
const confirmation = await confirmations.takeConfirmation(req.params.cid);
if (err) {
return next(err);
}
if (!confirmation || confirmation.action !== action) {
throw errorFactory();
}
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);
});
});
return confirmation;
}
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;
async function injectCustomFormData(customFormId, viewKey, data) {
function sortAndFilterCustomFieldsBy(key) {
data.customFields = data.customFields.filter(fld => fld[key] !== null);
data.customFields.sort((a, b) => a[key] - b[key]);
}
const meta = {
cid: req.params.cid,
email: data.email,
optInIp: confirmation.ip,
optInCountry,
status: subscriptions.Status.SUBSCRIBED
};
if (viewKey === 'web_subscribe') {
sortAndFilterCustomFieldsBy('order_subscribe');
} else if (viewKey === 'web_manage') {
sortAndFilterCustomFieldsBy('order_manage');
}
subscriptions.insert(list.id, meta, data.subscriptionData, (err, result) => {
if (err) {
return next(err);
}
if (!customFormId) {
data.formInputStyle = '@import url(/subscription/form-input-style.css);';
return;
}
if (!result.entryId) {
return next(new Error(_('Could not save subscription')));
}
const form = await forms.getById(contextHelpers.getAdminContext(), customFormId);
subscriptions.getById(list.id, result.entryId, (err, subscription) => {
if (err) {
return next(err);
}
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);';
mailHelpers.sendSubscriptionConfirmed(list, data.email, subscription, err => {
if (err) {
return next(err);
}
const configItems = await settings.get(['ua_code']);
res.redirect('/subscription/' + list.cid + '/subscribed-notice');
});
});
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 = {
cid: req.params.cid,
ip: confirmation.ip,
country: geoip.lookupCountry(confirmation.ip) || null
};
subscription.status = SubscriptionStatus.SUBSCRIBED;
await subscriptions.create(contextHelpers.getAdminContext(), confirmation.list, subscription, meta);
const list = await lists.getById(contextHelpers.getAdminContext(), confirmation.list);
await mailHelpers.sendSubscriptionConfirmed(list, subscription.email, subscription);
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')));
}
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;
subscriptions.updateAddress(list.id, data.subscriptionId, data.emailNew, err => {
if (err) {
return next(err);
}
const subscription = await subscriptions.updateAddressAndGet(contextHelpers.getAdminContext(), list.id, data.subscriptionId, data.emailNew);
subscriptions.getById(list.id, data.subscriptionId, (err, subscription) => {
if (err) {
return next(err);
}
await mailHelpers.sendSubscriptionConfirmed(list, data.emailNew, subscription);
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);
});
});
});
});
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);
}
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;
// TODO: Shall we do anything with "found"?
const subscription = await subscriptions.unsubscribeAndGet(contextHelpers.getAdminContext(), list.id, data.subscriptionId);
subscriptions.getById(list.id, confirmation.data.subscriptionId, (err, subscription) => {
if (err) {
return next(err);
}
await mailHelpers.sendUnsubscriptionConfirmed(list, subscription.email, subscription);
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) => {
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);
}
router.getAsync('/:cid', passport.csrfProtection, async (req, res) => {
const list = await lists.getByCid(req.params.cid);
// TODO: process subscriber cid param for resubscription requests
if (!list.publicSubscribe) {
throw new interoperableErrors.SubscriptionNotAllowedError('The list does not allow public subscriptions.');
}
let data = tools.convertKeys(req.query, {
skip: ['layout']
});
data.layout = 'subscription/layout';
data.title = list.name;
data.cid = list.cid;
data.csrfToken = req.csrfToken();
const ucid = req.query.cid;
const data = {};
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 = [];
}
let subscription;
if (ucid) {
subscription = await subscriptions.getById(contextHelpers.getAdminContext(), list.id, ucid);
}
data.customFields = fields.getRow(fieldList, data);
data.useEditor = true;
data.customFields = fields.getRow(contextHelpers.getAdminContext(), list.id, subscription);
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;
const configItems = await settings.get(['pgpPrivateKey', 'defaultAddress', 'defaultPostaddress']);
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'
};
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);
}
await injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-subscribe', data);
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => {
if (err) {
return next(err);
}
const htmlRenderer = await getMjmlTemplate(data.template);
helpers.captureFlashMessages(req, res, (err, flash) => {
if (err) {
return next(err);
}
data.isWeb = true;
data.needsJsWarning = true;
data.flashMessages = await captureFlashMessages(res);
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();
}
});
res.send(htmlRenderer(data));
});
router.options('/:cid/widget', cors(corsOptions));
router.get('/:cid/widget', cors(corsOptions), (req, res, next) => {
let cached = cache.get(req.path);
router.getAsync('/:cid/widget', cors(corsOptions), async (req, res) => {
req.needsAPIJSONResponse = true;
const 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
});
const list = await lists.getByCid(req.params.cid);
const configItems = settings.get(['serviceUrl', 'pgpPrivateKey']);
const data = {
title: list.name,
cid: list.cid,
serviceUrl: configItems.serviceUrl,
hasPubkey: !!configItems.pgpPrivateKey,
customFields: fields.getRow(contextHelpers.getAdminContext(), list.id),
template: {},
layout: null,
};
lists.getByCid(req.params.cid, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
await injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-subscribe', data);
const renderAsync = bluebird.promisify(res.render);
const html = await renderAsync('subscription/widget-subscribe', data);
const response = {
data: {
title: data.title,
cid: data.cid,
html
}
};
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);
});
});
});
});
});
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
});
};
router.postAsync('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, async (req, res) => {
const email = (req.body.email || '').toString().trim();
if (req.xhr) {
req.needsAPIJSONResponse = true;
}
let email = (req.body.email || '').toString().trim();
if (!email) {
if (req.xhr) {
return sendJsonError(_('Email address not set'), 400);
throw new Error('Email address not set');
}
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));
const emailErr = await tools.validateEmail(email);
if (emailErr) {
if (req.xhr) {
throw new Error(emailErr.message);
}
// 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;
req.flash('danger', emailErr.message);
return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body));
}
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;
}
}
// 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;
if (err) {
return req.xhr ? sendJsonError(err) : next(err);
}
const list = await lists.getByCid(req.params.cid);
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);
if (!list.publicSubscribe) {
throw new interoperableErrors.SubscriptionNotAllowedError('The list does not allow public subscriptions.');
}
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();
})
}
});
}
});
});
let subscriptionData = {};
Object.keys(req.body).forEach(key => {
if (key !== 'email' && key.charAt(0) !== '_') {
subscriptionData[key] = (req.body[key] || '').toString().trim();
}
});
const subscription = subscriptions.getByEmail(list.id, email)
if (subscription && subscription.status === subscriptions.Status.SUBSCRIBED) {
await mailHelpers.sendAlreadySubscribed(list, email, subscription);
res.redirect('/subscription/' + req.params.cid + '/confirm-subscription-notice');
} else {
const data = {
email,
subscriptionData
};
const confirmCid = await confirmations.addConfirmation(list.id, 'subscribe', req.ip, data);
if (!testsPass) {
log.info('Subscription', 'Confirmation message for %s marked to be skipped (%s)', email, JSON.stringify(data));
} else {
await mailHelpers.sendConfirmSubscription(list, email, confirmCid, subscriptionData);
}
if (req.xhr) {
return res.status(200).json({
msg: _('Please Confirm Subscription')
});
}
res.redirect('/subscription/' + req.params.cid + '/confirm-subscription-notice');
}
});
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;
}
router.getAsync('/:lcid/manage/:ucid', passport.csrfProtection, async (req, res) => {
const list = await lists.getByCid(req.params.lcid);
const subscription = await subscriptions.getByCid(contextHelpers.getAdminContext(), list.id, req.params.ucid);
if (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED) {
throw new Error(_('Subscription not found in this list'));
}
subscription.lcid = req.params.lcid;
subscription.title = list.name;
subscription.csrfToken = req.csrfToken();
subscription.layout = 'subscription/layout';
subscription.customFields = await fields.getRow(contextHelpers.getAdminContext(), list.id, subscription);
subscription.useEditor = true;
const configItems = await settings.get(['pgpPrivateKey', 'defaultAddress', 'defaultPostaddress']);
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'
};
await injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-manage', subscription);
const htmlRenderer = await getMjmlTemplate(data.template);
data.isWeb = true;
data.needsJsWarning = true;
data.isManagePreferences = true;
data.flashMessages = await captureFlashMessages(res);
res.send(htmlRenderer(data));
});
router.postAsync('/:lcid/manage', passport.parseForm, passport.csrfProtection, async (req, res) => {
const list = await lists.getByCid(req.params.lcid);
const subscription = await subscriptions.getByCid(contextHelpers.getAdminContext(), list.id, req.body.cid);
if (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED) {
throw new Error(_('Subscription not found in this list'));
}
delete req.body.email; // email change is not allowed
delete req.body.status; // status change is not allowed
// FIXME - az sem
// 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 => {
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');
});
});
res.redirect('/subscription/' + req.params.lcid + '/updated-notice');
});
});

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 = {
InteroperableError,
NotLoggedInError,
@ -101,7 +126,11 @@ const errorTypes = {
InvalidTokenError,
DependencyNotFoundError,
NamespaceNotFoundError,
PermissionDeniedError
PermissionDeniedError,
InvalidConfirmationForSubscriptionError,
InvalidConfirmationForAddressChangeError,
InvalidConfirmationForUnsubscriptionError,
SubscriptionNotAllowedError
};
function deserialize(errorObj) {