Implemented basic support for GDPR

This commit is contained in:
Tomas Bures 2018-11-22 00:02:14 +03:00
parent 9f9cbc4c2b
commit 92ca1c0f28
21 changed files with 271 additions and 105 deletions

View file

@ -34,6 +34,8 @@ async function search(context, offset, limit, search) {
}
async function add(context, email) {
enforce(email, 'Email has to be set');
return await knex.transaction(async tx => {
shares.enforceGlobalPermission(context, 'manageBlacklist');
@ -45,11 +47,15 @@ async function add(context, email) {
}
async function remove(context, email) {
enforce(email, 'Email has to be set');
shares.enforceGlobalPermission(context, 'manageBlacklist');
await knex('blacklist').where('email', email).del();
}
async function isBlacklisted(email) {
enforce(email, 'Email has to be set');
const existing = await knex('blacklist').where('email', email).first();
return !!existing;
}

View file

@ -9,7 +9,10 @@ const shares = require('./shares');
const namespaceHelpers = require('../lib/namespace-helpers');
const fs = require('fs-extra');
const path = require('path');
const mjml = require('mjml');
const mjml2html = mjml.default;
const lists = require('./lists');
const dependencyHelpers = require('../lib/dependency-helpers');
@ -43,7 +46,8 @@ const allowedFormKeys = new Set([
'web_unsubscribed_notice',
'mail_unsubscription_confirmed_html',
'mail_unsubscription_confirmed_text',
'web_manual_unsubscribe_notice'
'web_manual_unsubscribe_notice',
'web_privacy_policy_notice'
]);
const hashKeys = new Set([...formAllowedKeys, ...allowedFormKeys]);
@ -84,11 +88,15 @@ async function _getById(tx, id) {
}
async function getById(context, id) {
async function getById(context, id, withPermissions = true) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'customForm', id, 'view');
const entity = await _getById(tx, id);
entity.permissions = await shares.getPermissionsTx(tx, context, 'customForm', id);
if (withPermissions) {
entity.permissions = await shares.getPermissionsTx(tx, context, 'customForm', id);
}
return entity;
});
}
@ -219,7 +227,7 @@ function checkForMjmlErrors(form) {
let compiled;
try {
compiled = mjml(source);
compiled = mjml2html(source);
} catch (err) {
return err;
}
@ -230,7 +238,7 @@ function checkForMjmlErrors(form) {
const errors = {};
for (const key in form) {
if (key.startsWith('mail_') || key.startsWith('web_')) {
if ((key.startsWith('mail_') && key.endsWith('_html')) || key.startsWith('web_')) {
const template = form[key];
const errs = hasMjmlError(template);

View file

@ -121,7 +121,7 @@ async function create(context, entity) {
await knex.schema.raw('CREATE TABLE `subscription__' + id + '` (\n' +
' `id` int(10) unsigned NOT NULL AUTO_INCREMENT,\n' +
' `cid` varchar(255) CHARACTER SET ascii NOT NULL,\n' +
' `email` varchar(255) CHARACTER SET utf8 NOT NULL,\n' +
' `email` varchar(255) CHARACTER SET utf8 DEFAULT NULL,\n' +
' `hash_email` varchar(255) CHARACTER SET ascii NOT NULL,\n' +
' `source_email` int(10) unsigned,\n' + // This references imports if the source is an import, 0 means some import in version 1, NULL if the source is via subscription or edit of the subscription
' `opt_in_ip` varchar(100) DEFAULT NULL,\n' +
@ -134,8 +134,9 @@ async function create(context, entity) {
' `latest_click` timestamp NULL DEFAULT NULL,\n' +
' `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n' +
' PRIMARY KEY (`id`),\n' +
' UNIQUE KEY `email` (`email`),\n' +
' UNIQUE KEY `hash_email` (`hash_email`),\n' +
' UNIQUE KEY `cid` (`cid`),\n' +
' KEY `email` (`email`),\n' +
' KEY `status` (`status`),\n' +
' KEY `subscriber_tz` (`tz`),\n' +
' KEY `is_test` (`is_test`),\n' +

View file

@ -1,5 +1,6 @@
'use strict';
const config = require('config');
const knex = require('../lib/knex');
const hasher = require('node-object-hash')();
const shortid = require('shortid');
@ -7,7 +8,7 @@ const dtHelpers = require('../lib/dt-helpers');
const interoperableErrors = require('../../shared/interoperable-errors');
const shares = require('./shares');
const fields = require('./fields');
const { SubscriptionStatus, getFieldColumn } = require('../../shared/lists');
const { SubscriptionSource, SubscriptionStatus, getFieldColumn } = require('../../shared/lists');
const segments = require('./segments');
const { enforce, filterObject } = require('../lib/helpers');
const moment = require('moment');
@ -475,7 +476,7 @@ async function _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, meta
}
const existingWithKey = await existingWithKeyQuery.first();
if (existingWithKey) {
if (meta && (meta.updateAllowed || (meta.updateOfUnsubscribedAllowed && existingWithKey.status === SubscriptionStatus.UNSUBSCRIBED))) {
if (existingWithKey.email === null || meta.updateAllowed || (meta.updateOfUnsubscribedAllowed && existingWithKey.status === SubscriptionStatus.UNSUBSCRIBED)) {
meta.update = true;
meta.existing = existingWithKey;
} else {
@ -486,12 +487,12 @@ async function _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, meta
// The same for import where we need to subscribed only those (existing and new) that have not been unsubscribed already.
// In the case, the subscription is existing, we should not change the status. If it does not exist, we are fine with changing the status to SUBSCRIBED
if (meta && meta.subscribeIfNoExisting && !entity.status) {
if (meta.subscribeIfNoExisting && !entity.status) {
entity.status = SubscriptionStatus.SUBSCRIBED;
}
}
if ((isCreate && !(meta && meta.update)) || 'status' in entity) {
if ((isCreate && !meta.update) || 'status' in entity) {
enforce(entity.status >= SubscriptionStatus.MIN && entity.status <= SubscriptionStatus.MAX, 'Invalid status');
}
@ -532,26 +533,69 @@ function updateSourcesAndHashEmail(subscription, source, groupedFieldsMap) {
}
}
async function _update(tx, listId, existing, filteredEntity) {
function purgeSensitiveData(subscription, groupedFieldsMap) {
subscription.email = null;
for (const fldCol in groupedFieldsMap) {
const fld = groupedFieldsMap[fldCol];
const fieldType = fields.getFieldType(fld.type);
if (fieldType.grouped) {
for (const optionKey in fld.groupedOptions) {
const option = fld.groupedOptions[optionKey];
subscription[option.column] = null;
subscription['source_' + option.column] = SubscriptionSource.ERASED;
}
} else {
subscription[fldCol] = null;
subscription['source_' + fldCol] = SubscriptionSource.ERASED;
}
}
}
async function _update(tx, listId, groupedFieldsMap, existing, filteredEntity) {
if ('status' in filteredEntity) {
if (existing.status !== filteredEntity.status) {
filteredEntity.status_change = new Date();
}
}
console.log(filteredEntity);
await tx(getSubscriptionTableName(listId)).where('id', existing.id).update(filteredEntity);
if ('status' in filteredEntity) {
let countIncrement = 0;
if (existing.status === SubscriptionStatus.SUBSCRIBED && filteredEntity.status !== SubscriptionStatus.SUBSCRIBED) {
countIncrement = -1;
} else if (existing.status !== SubscriptionStatus.SUBSCRIBED && filteredEntity.status === SubscriptionStatus.SUBSCRIBED) {
countIncrement = 1;
if (filteredEntity.status === SubscriptionStatus.UNSUBSCRIBED || filteredEntity.status === SubscriptionStatus.COMPLAINED) {
if (existing.unsubscribed === null) {
filteredEntity.unsubscribed = new Date();
}
if (countIncrement) {
await tx('lists').where('id', listId).increment('subscribers', countIncrement);
if (config.gdpr.deleteSubscriptionAfterUnsubscribe.enabled && config.gdpr.deleteSubscriptionAfterUnsubscribe.secondsAfterUnsubscribe === 0) {
filteredEntity = null;
} else if (config.gdpr.deleteDataAfterUnsubscribe.enabled && config.gdpr.deleteDataAfterUnsubscribe.secondsAfterUnsubscribe === 0) {
purgeSensitiveData(filteredEntity, groupedFieldsMap);
}
} else if (filteredEntity.status === SubscriptionStatus.SUBSCRIBED) {
filteredEntity.unsubscribed = null;
}
if (filteredEntity) {
await tx(getSubscriptionTableName(listId)).where('id', existing.id).update(filteredEntity);
if ('status' in filteredEntity) {
let countIncrement = 0;
if (existing.status === SubscriptionStatus.SUBSCRIBED && filteredEntity.status !== SubscriptionStatus.SUBSCRIBED) {
countIncrement = -1;
} else if (existing.status !== SubscriptionStatus.SUBSCRIBED && filteredEntity.status === SubscriptionStatus.SUBSCRIBED) {
countIncrement = 1;
}
if (countIncrement) {
await tx('lists').where('id', listId).increment('subscribers', countIncrement);
}
}
} else {
await tx(getSubscriptionTableName(listId)).where('id', existing.id).del();
if (existing.status === SubscriptionStatus.SUBSCRIBED) {
await tx('lists').where('id', listId).decrement('subscribers', 1);
}
}
}
@ -586,20 +630,17 @@ async function createTxWithGroupedFieldsMap(tx, context, listId, groupedFieldsMa
updateSourcesAndHashEmail(filteredEntity, source, groupedFieldsMap);
filteredEntity.opt_in_ip = meta && meta.ip;
filteredEntity.opt_in_country = meta && meta.country;
filteredEntity.opt_in_ip = meta.ip;
filteredEntity.opt_in_country = meta.country;
if (meta && meta.update) { // meta.update is set by _validateAndPreprocess
await _update(tx, listId, meta.existing, filteredEntity);
if (meta.update) { // meta.update is set by _validateAndPreprocess
await _update(tx, listId, groupedFieldsMap, meta.existing, filteredEntity);
meta.cid = meta.existing.cid; // The cid is needed by /confirm/subscribe/:cid
return meta.existing.id;
} else {
filteredEntity.cid = shortid.generate();
if (meta) {
meta.cid = filteredEntity.cid; // The cid is needed by /confirm/subscribe/:cid
}
meta.cid = filteredEntity.cid; // The cid is needed by /confirm/subscribe/:cid
return await _create(tx, listId, filteredEntity);
}
}
@ -630,7 +671,7 @@ async function updateWithConsistencyCheck(context, listId, entity, source) {
throw new interoperableErrors.ChangedError();
}
await _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, null, false);
await _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, {}, false);
const filteredEntity = filterObject(entity, allowedKeys);
@ -638,7 +679,7 @@ async function updateWithConsistencyCheck(context, listId, entity, source) {
updateSourcesAndHashEmail(filteredEntity, source, groupedFieldsMap);
await _update(tx, listId, existing, filteredEntity);
await _update(tx, listId, groupedFieldsMap, existing, filteredEntity);
});
}
@ -674,15 +715,13 @@ async function removeByEmailAndGet(context, listId, email) {
async function _changeStatusTx(tx, context, listId, existing, newStatus) {
enforce(newStatus !== SubscriptionStatus.SUBSCRIBED);
const groupedFieldsMap = await getGroupedFieldsMapTx(tx, listId);
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSubscriptions');
await tx(getSubscriptionTableName(listId)).where('id', existing.id).update({
await _update(tx, listId, groupedFieldsMap, existing, {
status: newStatus
});
if (existing.status === SubscriptionStatus.SUBSCRIBED) {
await tx('lists').where('id', listId).decrement('subscribers', 1);
}
}
async function _unsubscribeExistingAndGetTx(tx, context, listId, existing) {
@ -824,4 +863,5 @@ module.exports.unsubscribeByEmailAndGetTx = unsubscribeByEmailAndGetTx;
module.exports.updateAddressAndGet = updateAddressAndGet;
module.exports.updateManaged = updateManaged;
module.exports.getListsWithEmail = getListsWithEmail;
module.exports.changeStatusTx = changeStatusTx;
module.exports.changeStatusTx = changeStatusTx;
module.exports.purgeSensitiveData = purgeSensitiveData;