diff --git a/app.js b/app.js index d500258f..13268044 100644 --- a/app.js +++ b/app.js @@ -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)); diff --git a/lib/helpers.js b/lib/helpers.js index a2624cd1..aee1af94 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -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(); diff --git a/lib/passport.js b/lib/passport.js index dacd73c1..151f72ea 100644 --- a/lib/passport.js +++ b/lib/passport.js @@ -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()); diff --git a/lib/subscription-mail-helpers.js b/lib/subscription-mail-helpers.js index 71ed13af..98b7882a 100644 --- a/lib/subscription-mail-helpers.js +++ b/lib/subscription-mail-helpers.js @@ -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); + } } diff --git a/lib/tools-async.js b/lib/tools-async.js index 027aaa55..5d344924 100644 --- a/lib/tools-async.js +++ b/lib/tools-async.js @@ -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; } + diff --git a/lib/tools.js b/lib/tools.js index ad62a45a..f84a72ce 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -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) { diff --git a/models/blacklist.js b/models/blacklist.js index b9e9ed7c..78db9bc3 100644 --- a/models/blacklist.js +++ b/models/blacklist.js @@ -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 }; \ No newline at end of file diff --git a/models/confirmations.js b/models/confirmations.js new file mode 100644 index 00000000..3377739f --- /dev/null +++ b/models/confirmations.js @@ -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 +}; \ No newline at end of file diff --git a/models/fields.js b/models/fields.js index 115143f9..9adefbec 100644 --- a/models/fields.js +++ b/models/fields.js @@ -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 }); \ No newline at end of file diff --git a/models/lists.js b/models/lists.js index fd1c7381..9b994a30 100644 --- a/models/lists.js +++ b/models/lists.js @@ -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, diff --git a/models/reports.js b/models/reports.js index 96ae0ad8..68dd58ba 100644 --- a/models/reports.js +++ b/models/reports.js @@ -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; } } diff --git a/models/subscriptions.js b/models/subscriptions.js index 4a212f26..355d6a5d 100644 --- a/models/subscriptions.js +++ b/models/subscriptions.js @@ -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 }; \ No newline at end of file diff --git a/routes/api.js b/routes/api.js index f0cd0c15..ce799e02 100644 --- a/routes/api.js +++ b/routes/api.js @@ -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 + } }); }); diff --git a/routes/rest/lists.js b/routes/rest/lists.js index 4ad95135..61c4b194 100644 --- a/routes/rest/lists.js +++ b/routes/rest/lists.js @@ -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); }); diff --git a/routes/rest/subscriptions.js b/routes/rest/subscriptions.js index c117a139..af087992 100644 --- a/routes/rest/subscriptions.js +++ b/routes/rest/subscriptions.js @@ -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(); }); diff --git a/routes/subscription-legacy.js b/routes/subscription-legacy.js new file mode 100644 index 00000000..2693deda --- /dev/null +++ b/routes/subscription-legacy.js @@ -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; diff --git a/routes/subscription.js b/routes/subscription.js index 2693deda..bbb6b914 100644 --- a/routes/subscription.js +++ b/routes/subscription.js @@ -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'); }); }); diff --git a/shared/interoperable-errors.js b/shared/interoperable-errors.js index 5b1ac01b..42f49d2e 100644 --- a/shared/interoperable-errors.js +++ b/shared/interoperable-errors.js @@ -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) {