diff --git a/graph/connectors.js b/graph/connectors.js index 15220b2d5..3ba71fbfb 100644 --- a/graph/connectors.js +++ b/graph/connectors.js @@ -1,10 +1,16 @@ const debug = require('debug')('talk:graph:connectors'); const merge = require('lodash/merge'); +// Config. +const config = require('../config'); + +// Secrets. +const secrets = require('../secrets'); + // Errors. const errors = require('../errors'); -// Graph +// Graph. const { getBroker } = require('./subscriptions/broker'); const { getPubsub } = require('./subscriptions/pubsub'); const resolvers = require('./resolvers'); @@ -50,6 +56,8 @@ const Wordlist = require('../services/wordlist'); // Connectors. const defaultConnectors = { errors, + config, + secrets, models: { Action, Asset, diff --git a/plugins/talk-plugin-notifications/server/NotificationManager.js b/plugins/talk-plugin-notifications/server/NotificationManager.js index 1e9859ffc..13a829cb1 100644 --- a/plugins/talk-plugin-notifications/server/NotificationManager.js +++ b/plugins/talk-plugin-notifications/server/NotificationManager.js @@ -1,5 +1,7 @@ const { groupBy, forEach } = require('lodash'); const debug = require('debug')('talk-plugin-notifications'); +const uuid = require('uuid/v4'); +const { UNSUBSCRIBE_SUBJECT } = require('./config'); class NotificationManager { constructor(context) { @@ -73,7 +75,11 @@ class NotificationManager { async send(ctx, userID, date, handler, context) { const { - connectors: { services: { Mailer, I18n: { t } } }, + connectors: { + secrets: { jwt }, + config: { JWT_ISSUER, JWT_AUDIENCE }, + services: { Mailer, I18n: { t } }, + }, loaders: { Settings }, } = ctx; const { category } = handler; @@ -90,6 +96,16 @@ class NotificationManager { return; } + // unsubscribeToken is the token used to perform the one-click + // unsubscribe. + const unsubscribeToken = jwt.sign({ + jti: uuid(), + iss: JWT_ISSUER, + aud: JWT_AUDIENCE, + sub: UNSUBSCRIBE_SUBJECT, + user: userID, + }); + // Compose the subject for the email. const subject = t( `talk-plugin-notifications.categories.${category}.subject`, @@ -102,7 +118,7 @@ class NotificationManager { // Send the notification to the user. const task = await Mailer.send({ template: 'notification', - locals: { body, organizationName }, + locals: { body, organizationName, unsubscribeToken }, subject, user: userID, }); diff --git a/plugins/talk-plugin-notifications/server/config.js b/plugins/talk-plugin-notifications/server/config.js new file mode 100644 index 000000000..17bd94bf9 --- /dev/null +++ b/plugins/talk-plugin-notifications/server/config.js @@ -0,0 +1,3 @@ +module.exports = { + UNSUBSCRIBE_SUBJECT: 'nunsub', +}; diff --git a/plugins/talk-plugin-notifications/server/connect.js b/plugins/talk-plugin-notifications/server/connect.js index 86088434b..497025a5e 100644 --- a/plugins/talk-plugin-notifications/server/connect.js +++ b/plugins/talk-plugin-notifications/server/connect.js @@ -13,12 +13,12 @@ module.exports = connectors => { // notification template by passing the same name + format for the template // registration. Mailer.templates.register( - path.join(__dirname, 'templates', 'notification.html.ejs'), + path.join(__dirname, 'emails', 'notification.html.ejs'), 'notification', 'html' ); Mailer.templates.register( - path.join(__dirname, 'templates', 'notification.txt.ejs'), + path.join(__dirname, 'emails', 'notification.txt.ejs'), 'notification', 'txt' ); diff --git a/plugins/talk-plugin-notifications/server/emails/notification.html.ejs b/plugins/talk-plugin-notifications/server/emails/notification.html.ejs new file mode 100644 index 000000000..acc67a849 --- /dev/null +++ b/plugins/talk-plugin-notifications/server/emails/notification.html.ejs @@ -0,0 +1,3 @@ +

<%= linkify(body, {nl2br: true}) %>

+

<%= t('talk-plugin-notifications.templates.footer', organizationName) %>

+

<%= t('talk-plugin-notifications.templates.links.unsubscribe') %>

\ No newline at end of file diff --git a/plugins/talk-plugin-notifications/server/templates/notification.txt.ejs b/plugins/talk-plugin-notifications/server/emails/notification.txt.ejs similarity index 66% rename from plugins/talk-plugin-notifications/server/templates/notification.txt.ejs rename to plugins/talk-plugin-notifications/server/emails/notification.txt.ejs index 8d34e507a..dd6dc0ba3 100644 --- a/plugins/talk-plugin-notifications/server/templates/notification.txt.ejs +++ b/plugins/talk-plugin-notifications/server/emails/notification.txt.ejs @@ -4,4 +4,4 @@ <%= t('talk-plugin-notifications.templates.links.unsubscribe') %> - <%= BASE_URL %>account/unsubscribe-notifications \ No newline at end of file + <%= BASE_URL %>account/unsubscribe-notifications#<%= unsubscribeToken %> \ No newline at end of file diff --git a/plugins/talk-plugin-notifications/server/router.js b/plugins/talk-plugin-notifications/server/router.js index 724c3755e..68919a936 100644 --- a/plugins/talk-plugin-notifications/server/router.js +++ b/plugins/talk-plugin-notifications/server/router.js @@ -1,6 +1,95 @@ +const path = require('path'); +const { UNSUBSCRIBE_SUBJECT } = require('./config'); +const { get, isEmpty, reduce } = require('lodash'); + module.exports = router => { - router.get('/account/unsubscribe-notifications', async (req, res) => { - // TODO: implement - res.json({ ok: true }); + router.get('/account/unsubscribe-notifications', (req, res) => { + res.render(path.join(__dirname, 'views/unsubscribe-notifications')); }); + + /** + * Verifies that the token is valid. + */ + const verifyToken = (req, res, next) => { + const { + connectors: { secrets: { jwt }, config: { JWT_ISSUER, JWT_AUDIENCE } }, + } = req.context; + const { token: tokenString = '' } = req.body; + if (!tokenString) { + return res.status(400).end(); + } + + jwt.verify( + tokenString, + { + issuer: JWT_ISSUER, + subject: UNSUBSCRIBE_SUBJECT, + audience: JWT_AUDIENCE, + }, + (err, token) => { + if (err) { + return res.status(400).end(); + } + + req.token = token; + next(); + } + ); + }; + + // Verifies that a token is valid. + router.post( + '/api/v1/account/unsubscribe-notifications/verify', + verifyToken, + (req, res) => { + res.status(204).end(); + } + ); + + router.post( + '/api/v1/account/unsubscribe-notifications', + verifyToken, + async (req, res, next) => { + const { connectors: { models: { User } } } = req.context; + const { user: userID } = req.token; + + try { + const user = await User.findOne({ id: userID }); + if (!user) { + return res.status(400).end(); + } + + // Get the notification settings. + const settings = get(user, 'metadata.notifications.settings', {}); + + // If they have no notification settings set to true, then we're done. + if (isEmpty(settings)) { + return res.status(204).end(); + } + + const update = reduce( + settings, + (updates, value, key) => { + if (value) { + updates[`metadata.notifications.settings.${key}`] = false; + } + + return updates; + }, + {} + ); + + if (isEmpty(update)) { + return res.status(204).end(); + } + + // Save the user. + await User.update({ id: userID }, { $set: update }); + + res.status(204).end(); + } catch (err) { + res.status(400).end(); + } + } + ); }; diff --git a/plugins/talk-plugin-notifications/server/templates/notification.html.ejs b/plugins/talk-plugin-notifications/server/templates/notification.html.ejs deleted file mode 100644 index 608ee11d4..000000000 --- a/plugins/talk-plugin-notifications/server/templates/notification.html.ejs +++ /dev/null @@ -1,3 +0,0 @@ -

<%= linkify(body, {nl2br: true}) %>

-

<%= t('talk-plugin-notifications.templates.footer', organizationName) %>

-

<%= t('talk-plugin-notifications.templates.links.unsubscribe') %>

\ No newline at end of file diff --git a/plugins/talk-plugin-notifications/server/translations.yml b/plugins/talk-plugin-notifications/server/translations.yml index 4500eda4e..97c931a91 100644 --- a/plugins/talk-plugin-notifications/server/translations.yml +++ b/plugins/talk-plugin-notifications/server/translations.yml @@ -3,4 +3,10 @@ en: templates: footer: "You received this notification because you are a commenter on {0} and you opted in to receive notifications." links: - unsubscribe: "Unsubscribe from comment notifications" \ No newline at end of file + unsubscribe: "Unsubscribe from comment notifications" + unsubscribe_page: + unsubscribe: "Unsubscribe from comment notifications" + click_to_confirm: "Click below to confirm that you would like to unsubscribe from all notifications" + confirm: "Confirm" + are_unsubscribed: "You are now unsubscribed from all notifications." + token_invalid: "Unsubscribe link is invalid, click the link from a more recent email or visit a comment stream and login to change your notification preferences" \ No newline at end of file diff --git a/plugins/talk-plugin-notifications/server/views/unsubscribe-notifications.ejs b/plugins/talk-plugin-notifications/server/views/unsubscribe-notifications.ejs new file mode 100644 index 000000000..a34a45dbb --- /dev/null +++ b/plugins/talk-plugin-notifications/server/views/unsubscribe-notifications.ejs @@ -0,0 +1,64 @@ + + + + + <%= t('talk-plugin-notifications.unsubscribe_page.unsubscribe') %> + + + <%- include(root + '/partials/head') %> + + +
+
<%= t('talk-plugin-notifications.unsubscribe_page.token_invalid') %>
+ +
+ <%= t('talk-plugin-notifications.unsubscribe_page.click_to_confirm') %> + +
+
+ + + + \ No newline at end of file diff --git a/routes/plugins.js b/routes/plugins.js index 0e5a7636a..647acdb75 100644 --- a/routes/plugins.js +++ b/routes/plugins.js @@ -1,9 +1,17 @@ const express = require('express'); const debug = require('debug')('talk:routes:plugins'); const plugins = require('../services/plugins'); +const staticTemplate = require('../middleware/staticTemplate'); const router = express.Router(); +// Routes mounted from plugins won't have access to our internal partials +// directory, so we should make that available. +router.use(staticTemplate, (req, res, next) => { + res.locals.root = res.app.get('views'); + next(); +}); + // Inject server route plugins. plugins.get('server', 'router').forEach(plugin => { debug(`added plugin '${plugin.plugin.name}'`); diff --git a/services/jwt.js b/services/jwt.js index 356adbb62..c36ea4c97 100644 --- a/services/jwt.js +++ b/services/jwt.js @@ -101,9 +101,12 @@ class Secret { jwt.verify( token, this.verifiyingKey, - Object.assign({}, options, { - algorithms: [this.algorithm], - }), + omitBy( + merge({}, options, { + algorithms: [this.algorithm], + }), + isUndefined + ), callback ); }