New project structure

Beta of extract.js for extracting english locale
This commit is contained in:
Tomas Bures 2018-11-18 15:38:52 +01:00
parent e18d2b2f84
commit 2edbd67205
247 changed files with 6405 additions and 4237 deletions

View file

@ -0,0 +1,407 @@
'use strict';
const config = require('config');
const mailers = require('./mailers');
const knex = require('./knex');
const subscriptions = require('../models/subscriptions');
const contextHelpers = require('./context-helpers');
const campaigns = require('../models/campaigns');
const templates = require('../models/templates');
const lists = require('../models/lists');
const fields = require('../models/fields');
const sendConfigurations = require('../models/send-configurations');
const links = require('../models/links');
const {CampaignSource, CampaignType} = require('../../shared/campaigns');
const {SubscriptionStatus} = require('../../shared/lists');
const tools = require('./tools');
const request = require('request-promise');
const files = require('../models/files');
const htmlToText = require('html-to-text');
const {getPublicUrl} = require('./urls');
const blacklist = require('../models/blacklist');
const libmime = require('libmime');
class CampaignSender {
constructor() {
}
static async testSend(context, listCid, subscriptionCid, campaignId, sendConfigurationId, html, text) {
let sendConfiguration, list, fieldsGrouped, campaign, subscriptionGrouped, useVerp, useVerpSenderHeader, mergeTags, attachments;
await knex.transaction(async tx => {
sendConfiguration = await sendConfigurations.getByIdTx(tx, context, sendConfigurationId, false, true);
list = await lists.getByCidTx(tx, context, listCid);
fieldsGrouped = await fields.listGroupedTx(tx, list.id);
useVerp = config.verp.enabled && sendConfiguration.verp_hostname;
useVerpSenderHeader = this.useVerp && config.verp.disablesenderheader !== true;
subscriptionGrouped = await subscriptions.getByCid(context, list.id, subscriptionCid);
mergeTags = fields.getMergeTags(fieldsGrouped, subscriptionGrouped);
if (campaignId) {
campaign = await campaigns.getByIdTx(tx, context, campaignId, false, campaigns.Content.WITHOUT_SOURCE_CUSTOM);
} else {
// This is to fake the campaign for getMessageLinks, which is called inside formatMessage
campaign = {
cid: '[CAMPAIGN_ID]'
};
}
});
const encryptionKeys = [];
for (const fld of fieldsGrouped) {
if (fld.type === 'gpg' && mergeTags[fld.key]) {
encryptionKeys.push(mergeTags[fld.key].trim());
}
}
attachments = [];
// replace data: images with embedded attachments
html = html.replace(/(<img\b[^>]* src\s*=[\s"']*)(data:[^"'>\s]+)/gi, (match, prefix, dataUri) => {
const cid = shortid.generate() + '-attachments';
attachments.push({
path: dataUri,
cid
});
return prefix + 'cid:' + cid;
});
html = tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, html, true);
text = (text || '').trim()
? tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, text)
: htmlToText.fromString(html, {wordwrap: 130});
const mailer = await mailers.getOrCreateMailer(sendConfiguration.id);
const getOverridable = key => {
return sendConfiguration[key];
}
const campaignAddress = [campaign.cid, list.cid, subscriptionGrouped.cid].join('.');
const mail = {
from: {
name: getOverridable('from_name'),
address: getOverridable('from_email')
},
replyTo: getOverridable('reply_to'),
xMailer: sendConfiguration.x_mailer ? sendConfiguration.x_mailer : false,
to: {
name: tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, list.to_name, false),
address: subscriptionGrouped.email
},
sender: this.useVerpSenderHeader ? campaignAddress + '@' + sendConfiguration.verp_hostname : false,
envelope: this.useVerp ? {
from: campaignAddress + '@' + sendConfiguration.verp_hostname,
to: subscriptionGrouped.email
} : false,
headers: {
'x-fbl': campaignAddress,
// custom header for SparkPost
'x-msys-api': JSON.stringify({
campaign_id: campaignAddress
}),
// custom header for SendGrid
'x-smtpapi': JSON.stringify({
unique_args: {
campaign_id: campaignAddress
}
}),
// custom header for Mailgun
'x-mailgun-variables': JSON.stringify({
campaign_id: campaignAddress
}),
'List-ID': {
prepared: true,
value: libmime.encodeWords(list.name) + ' <' + list.cid + '.' + getPublicUrl() + '>'
}
},
list: {
unsubscribe: null
},
subject: tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, getOverridable('subject'), false),
html,
text,
attachments,
encryptionKeys
};
let response;
try {
const info = await mailer.sendMassMail(mail);
response = info.response || info.messageId;
} catch (err) {
response = err.response || err.message;
}
return response;
}
async init(settings) {
this.listsById = new Map(); // listId -> list
this.listsByCid = new Map(); // listCid -> list
this.listsFieldsGrouped = new Map(); // listId -> fieldsGrouped
this.attachments = [];
await knex.transaction(async tx => {
if (settings.campaignCid) {
this.campaign = await campaigns.rawGetByTx(tx, 'cid', settings.campaignCid);
} else {
this.campaign = await campaigns.rawGetByTx(tx, 'id', settings.campaignId);
}
const campaign = this.campaign;
this.sendConfiguration = await sendConfigurations.getByIdTx(tx, contextHelpers.getAdminContext(), campaign.send_configuration, false, true);
for (const listSpec of campaign.lists) {
const list = await lists.getByIdTx(tx, contextHelpers.getAdminContext(), listSpec.list);
this.listsById.set(list.id, list);
this.listsByCid.set(list.cid, list);
this.listsFieldsGrouped.set(list.id, await fields.listGroupedTx(tx, list.id));
}
if (campaign.source === CampaignSource.TEMPLATE) {
this.template = await templates.getByIdTx(tx, contextHelpers.getAdminContext(), campaign.data.sourceTemplate, false);
}
const attachments = await files.listTx(tx, contextHelpers.getAdminContext(), 'campaign', 'attachment', campaign.id);
for (const attachment of attachments) {
this.attachments.push({
filename: attachment.originalname,
path: files.getFilePath('campaign', 'attachment', campaign.id, attachment.filename)
});
}
});
this.useVerp = config.verp.enabled && sendConfiguration.verp_hostname;
this.useVerpSenderHeader = this.useVerp && config.verp.disablesenderheader !== true;
}
async _getMessage(campaign, list, subscriptionGrouped, mergeTags, replaceDataImgs) {
let html = '';
let text = '';
let renderTags = false;
if (campaign.source === CampaignSource.URL) {
const form = tools.getMessageLinks(campaign, list, subscriptionGrouped);
for (const key in mergeTags) {
form[key] = mergeTags[key];
}
const response = await request.post({
uri: campaign.sourceUrl,
form,
resolveWithFullResponse: true
});
if (response.statusCode !== 200) {
throw new Error(`Received status code ${httpResponse.statusCode} from ${campaign.sourceUrl}`);
}
html = response.body;
text = '';
renderTags = false;
} else if (campaign.source === CampaignSource.CUSTOM || campaign.source === CampaignSource.CUSTOM_FROM_CAMPAIGN || campaign.source === CampaignSource.CUSTOM_FROM_TEMPLATE) {
html = campaign.data.sourceCustom.html;
text = campaign.data.sourceCustom.text;
renderTags = true;
} else if (campaign.source === CampaignSource.TEMPLATE) {
const template = this.template;
html = template.html;
text = template.text;
renderTags = true;
}
html = await links.updateLinks(campaign, list, subscriptionGrouped, mergeTags, html);
const attachments = this.attachments.slice();
if (replaceDataImgs) {
// replace data: images with embedded attachments
html = html.replace(/(<img\b[^>]* src\s*=[\s"']*)(data:[^"'>\s]+)/gi, (match, prefix, dataUri) => {
const cid = shortid.generate() + '-attachments';
attachments.push({
path: dataUri,
cid
});
return prefix + 'cid:' + cid;
});
}
html = renderTags ? tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, html, true) : html;
text = (text || '').trim()
? (renderTags ? tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, text) : text)
: htmlToText.fromString(html, {wordwrap: 130});
return {
html,
text,
attachments
};
}
_getExtraTags(campaign) {
const tags = {};
if (campaign.type === CampaignType.RSS_ENTRY) {
const rssEntry = campaign.data.rssEntry;
tags['RSS_ENTRY_TITLE'] = rssEntry.title;
tags['RSS_ENTRY_DATE'] = rssEntry.date;
tags['RSS_ENTRY_LINK'] = rssEntry.link;
tags['RSS_ENTRY_CONTENT'] = rssEntry.content;
tags['RSS_ENTRY_SUMMARY'] = rssEntry.summary;
tags['RSS_ENTRY_IMAGE_URL'] = rssEntry.image_url;
}
return tags;
}
async getMessage(listCid, subscriptionCid) {
const list = this.listsByCid.get(listCid);
const subscriptionGrouped = await subscriptions.getByCid(contextHelpers.getAdminContext(), list.id, subscriptionCid);
const flds = this.listsFieldsGrouped.get(list.id);
const campaign = this.campaign;
const mergeTags = fields.getMergeTags(flds, subscriptionGrouped, this._getExtraTags(campaign));
return await this._getMessage(campaign, list, subscriptionGrouped, mergeTags, false);
}
async sendMessage(listId, email) {
if (await blacklist.isBlacklisted(email)) {
return;
}
const list = this.listsById.get(listId);
const subscriptionGrouped = await subscriptions.getByEmail(contextHelpers.getAdminContext(), list.id, email);
const flds = this.listsFieldsGrouped.get(listId);
const campaign = this.campaign;
const mergeTags = fields.getMergeTags(flds, subscriptionGrouped, this._getExtraTags(campaign));
const encryptionKeys = [];
for (const fld of flds) {
if (fld.type === 'gpg' && mergeTags[fld.key]) {
encryptionKeys.push(mergeTags[fld.key].trim());
}
}
const sendConfiguration = this.sendConfiguration;
const {html, text, attachments} = await this._getMessage(campaign, list, subscriptionGrouped, mergeTags, true);
const campaignAddress = [campaign.cid, list.cid, subscriptionGrouped.cid].join('.');
let listUnsubscribe = null;
if (!list.listunsubscribe_disabled) {
listUnsubscribe = campaign.unsubscribe_url
? tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, campaign.unsubscribe_url)
: getPublicUrl('/subscription/' + list.cid + '/unsubscribe/' + subscriptionGrouped.cid);
}
const mailer = await mailers.getOrCreateMailer(sendConfiguration.id);
await mailer.throttleWait();
const getOverridable = key => {
if (sendConfiguration[key + '_overridable'] && this.campaign[key + '_override'] !== null) {
return campaign[key + '_override'];
} else {
return sendConfiguration[key];
}
}
const mail = {
from: {
name: getOverridable('from_name'),
address: getOverridable('from_email')
},
replyTo: getOverridable('reply_to'),
xMailer: sendConfiguration.x_mailer ? sendConfiguration.x_mailer : false,
to: {
name: tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, list.to_name, false),
address: subscriptionGrouped.email
},
sender: this.useVerpSenderHeader ? campaignAddress + '@' + sendConfiguration.verp_hostname : false,
envelope: this.useVerp ? {
from: campaignAddress + '@' + sendConfiguration.verp_hostname,
to: subscriptionGrouped.email
} : false,
headers: {
'x-fbl': campaignAddress,
// custom header for SparkPost
'x-msys-api': JSON.stringify({
campaign_id: campaignAddress
}),
// custom header for SendGrid
'x-smtpapi': JSON.stringify({
unique_args: {
campaign_id: campaignAddress
}
}),
// custom header for Mailgun
'x-mailgun-variables': JSON.stringify({
campaign_id: campaignAddress
}),
'List-ID': {
prepared: true,
value: libmime.encodeWords(list.name) + ' <' + list.cid + '.' + getPublicUrl() + '>'
}
},
list: {
unsubscribe: listUnsubscribe
},
subject: tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, getOverridable('subject'), false),
html,
text,
attachments,
encryptionKeys
};
let status;
let response;
try {
const info = await mailer.sendMassMail(mail);
status = SubscriptionStatus.SUBSCRIBED;
response = info.response || info.messageId;
await knex('campaigns').where('id', campaign.id).increment('delivered');
} catch (err) {
status = SubscriptionStatus.BOUNCED;
response = err.response || err.message;
await knex('campaigns').where('id', campaign.id).increment('delivered').increment('bounced');
}
const responseId = response.split(/\s+/).pop();
const now = new Date();
await knex('campaign_messages').insert({
campaign: this.campaign.id,
list: listId,
subscription: subscriptionGrouped.id,
send_configuration: sendConfiguration.id,
status,
response,
response_id: responseId,
updated: now
});
}
}
module.exports = CampaignSender;

View file

@ -0,0 +1,50 @@
'use strict';
const passport = require('./passport');
const config = require('config');
const forms = require('../models/forms');
const shares = require('../models/shares');
const urls = require('./urls');
async function getAnonymousConfig(context, appType) {
return {
authMethod: passport.authMethod,
isAuthMethodLocal: passport.isAuthMethodLocal,
externalPasswordResetLink: config.ldap.passwordresetlink,
language: config.language || 'en',
isAuthenticated: !!context.user,
trustedUrlBase: urls.getTrustedUrlBase(),
trustedUrlBaseDir: urls.getTrustedUrlBaseDir(),
sandboxUrlBase: urls.getSandboxUrlBase(),
sandboxUrlBaseDir: urls.getSandboxUrlBaseDir(),
publicUrlBase: urls.getPublicUrlBase(),
publicUrlBaseDir: urls.getPublicUrlBaseDir(),
appType
}
}
async function getAuthenticatedConfig(context) {
const globalPermissions = {};
for (const perm of shares.getGlobalPermissions(context)) {
globalPermissions[perm] = true;
}
return {
defaultCustomFormValues: await forms.getDefaultCustomFormValues(),
user: {
id: context.user.id,
username: context.user.username,
namespace: context.user.namespace
},
globalPermissions,
editors: config.editors,
mosaico: config.mosaico,
verpEnabled: config.verp.enabled
}
}
module.exports.getAuthenticatedConfig = getAuthenticatedConfig;
module.exports.getAnonymousConfig = getAnonymousConfig;

View file

@ -0,0 +1,30 @@
'use strict';
const knex = require('./knex');
function getRequestContext(req) {
const context = {
user: req.user
};
return context;
}
const adminContext = {
user: {
admin: true,
id: 0,
username: '',
name: '',
email: ''
}
};
function getAdminContext() {
return adminContext;
}
module.exports = {
getRequestContext,
getAdminContext
};

232
server/lib/dbcheck.js Normal file
View file

@ -0,0 +1,232 @@
'use strict';
/*
This module handles Mailtrain database initialization and upgrades
*/
const config = require('config');
const mysql = require('mysql2');
const log = require('./log');
const fs = require('fs');
const pathlib = require('path');
const Handlebars = require('handlebars');
const highestLegacySchemaVersion = 33;
const mysqlConfig = {
multipleStatements: true
};
Object.keys(config.mysql).forEach(key => mysqlConfig[key] = config.mysql[key]);
const db = mysql.createPool(mysqlConfig);
function listTables(callback) {
db.getConnection((err, connection) => {
if (err) {
if (err.code === 'ER_ACCESS_DENIED_ERROR') {
err = new Error('Could not access the database. Check MySQL config and authentication credentials');
}
if (err.code === 'ECONNREFUSED' || err.code === 'PROTOCOL_SEQUENCE_TIMEOUT') {
err = new Error('Could not connect to the database. Check MySQL host and port configuration');
}
return callback(err);
}
let query = 'SHOW TABLES';
connection.query(query, (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
let tables = {};
[].concat(rows || []).forEach(row => {
let name;
let table;
Object.keys(row).forEach(key => {
if (/^Tables_in_/i.test(key)) {
table = name = row[key];
}
});
if (/__\d+$/.test(name)) {
let parts = name.split('__');
parts.pop();
table = parts.join('__');
}
if (tables.hasOwnProperty(table)) {
tables[table].push(name);
} else {
tables[table] = [name];
}
return table;
});
return callback(null, tables);
});
});
}
function getSchemaVersion(callback) {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SHOW TABLES LIKE "knex_migrations"', (err, rows) => {
if (err) {
return callback(err);
}
if (rows.length > 0) {
connection.release();
callback(null, highestLegacySchemaVersion);
} else {
connection.query('SELECT `value` FROM `settings` WHERE `key`=?', ['db_schema_version'], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
let dbSchemaVersion = rows && rows[0] && Number(rows[0].value) || 0;
callback(null, dbSchemaVersion);
});
}
});
});
}
function listUpdates(current, callback) {
current = current || 0;
fs.readdir(pathlib.join(__dirname, '..', 'setup', 'sql'), (err, list) => {
if (err) {
return callback(err);
}
let updates = [];
[].concat(list || []).forEach(row => {
if (/^upgrade-\d+\.sql$/i.test(row)) {
let seq = row.match(/\d+/)[0];
if (seq > current) {
updates.push({
seq: Number(seq),
path: pathlib.join(__dirname, '..', 'setup', 'sql', row)
});
}
}
});
return callback(null, updates);
});
}
function getSql(path, data, callback) {
fs.readFile(path, 'utf-8', (err, source) => {
if (err) {
return callback(err);
}
const rendered = data ? Handlebars.compile(source)(data) : source;
return callback(null, rendered);
});
}
function runInitial(callback) {
let dump = process.env.NODE_ENV === 'test' ? 'mailtrain-test.sql' : 'mailtrain.sql';
let fname = process.env.DB_FROM_START ? 'base.sql' : dump;
let path = pathlib.join(__dirname, '..', 'setup', 'sql', fname);
log.info('sql', 'Loading tables from %s', fname);
applyUpdate({
path
}, callback);
}
function runUpdates(callback, runCount) {
runCount = Number(runCount) || 0;
listTables((err, tables) => {
if (err) {
return callback(err);
}
if (!tables.settings) {
if (runCount) {
return callback(new Error('Settings table not found from database'));
}
log.info('sql', 'SQL not set up, initializing');
return runInitial(runUpdates.bind(null, callback, ++runCount));
}
getSchemaVersion((err, schemaVersion) => {
if (err) {
return callback(err);
}
if (schemaVersion >= highestLegacySchemaVersion) {
// nothing to do here, already updated
return callback(null, false);
}
listUpdates(schemaVersion, (err, updates) => {
if (err) {
return callback(err);
}
let pos = 0;
let runNext = () => {
if (pos >= updates.length) {
return callback(null, pos);
}
let update = updates[pos++];
update.data = {
tables
};
applyUpdate(update, (err, status) => {
if (err) {
return callback(err);
}
if (status) {
log.info('sql', 'Update %s applied', update.seq);
} else {
log.info('sql', 'Update %s not applied', update.seq);
}
runNext();
});
};
runNext();
});
});
});
}
function applyUpdate(update, callback) {
getSql(update.path, update.data, (err, sql) => {
if (err) {
return callback(err);
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query(sql, err => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, true);
});
});
});
}
module.exports = callback => {
runUpdates(err => {
if (err) {
return callback(err);
}
db.end(() => {
log.info('sql', 'Database check completed');
return callback(null, true);
});
});
};

View file

@ -0,0 +1,58 @@
'use strict';
const knex = require('./knex');
const interoperableErrors = require('../../shared/interoperable-errors');
const entitySettings = require('./entity-settings');
const shares = require('../models/shares');
const { enforce } = require('./helpers');
const defaultNoOfDependenciesReported = 20;
async function ensureNoDependencies(tx, context, id, depSpecs) {
const deps = [];
let andMore = false;
for (const depSpec of depSpecs) {
const entityType = entitySettings.getEntityType(depSpec.entityTypeId);
let rows;
if (depSpec.query) {
rows = await depSpec.query(tx).limit(defaultNoOfDependenciesReported + 1);
} else if (depSpec.column) {
rows = await tx(entityType.entitiesTable).where(depSpec.column, id).select(['id', 'name']).limit(defaultNoOfDependenciesReported + 1);
} else if (depSpec.rows) {
rows = await depSpec.rows(tx, defaultNoOfDependenciesReported + 1)
}
for (const row of rows) {
if (deps.length === defaultNoOfDependenciesReported) {
andMore = true;
break;
}
if (await shares.checkEntityPermissionTx(tx, context, depSpec.entityTypeId, row.id, 'view')) {
deps.push({
entityTypeId: depSpec.entityTypeId,
name: row.name,
link: entityType.clientLink(row.id)
});
} else {
deps.push({
entityTypeId: depSpec.entityTypeId,
id: row.id
});
}
}
}
if (deps.length > 0) {
throw new interoperableErrors.DependencyPresentError('', {
dependencies: deps,
andMore
});
}
}
module.exports.ensureNoDependencies = ensureNoDependencies;

189
server/lib/dt-helpers.js Normal file
View file

@ -0,0 +1,189 @@
'use strict';
const knex = require('./knex');
const entitySettings = require('./entity-settings');
async function ajaxListTx(tx, params, queryFun, columns, options) {
options = options || {};
const columnsNames = [];
const columnsSelect = [];
for (const col of columns) {
if (typeof col === 'string') {
columnsNames.push(col);
columnsSelect.push(col);
} else {
columnsNames.push(col.name);
if (col.raw) {
columnsSelect.push(tx.raw(col.raw));
} else if (col.query) {
columnsSelect.push(function () { return col.query(this); });
}
}
}
if (params.operation === 'getBy') {
const query = queryFun(tx);
query.whereIn(columnsNames[parseInt(params.column)], params.values);
query.select(columnsSelect);
query.options({rowsAsArray:true});
const rows = await query;
const rowsOfArray = rows.map(row => Object.keys(row).map(key => row[key]));
return rowsOfArray;
} else {
const whereFun = function() {
let searchVal = '%' + params.search.value.replace(/\\/g, '\\\\').replace(/([%_])/g, '\\$1') + '%';
for (let colIdx = 0; colIdx < params.columns.length; colIdx++) {
const col = params.columns[colIdx];
if (col.searchable) {
this.orWhere(columnsNames[parseInt(col.data)], 'like', searchVal);
}
}
}
/* There are a few SQL peculiarities that make this query a bit weird:
- Group by (which is used in getting permissions) don't go well with count(*). Thus we run the actual query
as a sub-query and then count the number of results.
- SQL does not like if it have columns with the same name in the subquery. This happens multiple tables are joined.
To circumvent this, we select only the first column (whatever it is). Since this is not "distinct", it is supposed
to give us the right number of rows anyway.
*/
const recordsTotalQuery = tx.count('* as recordsTotal').from(function () { return queryFun(this).select(columnsSelect[0]).as('records'); }).first();
const recordsTotal = (await recordsTotalQuery).recordsTotal;
const recordsFilteredQuery = tx.count('* as recordsFiltered').from(function () { return queryFun(this).select(columnsSelect[0]).where(whereFun).as('records'); }).first();
const recordsFiltered = (await recordsFilteredQuery).recordsFiltered;
const query = queryFun(tx);
query.where(whereFun);
query.offset(parseInt(params.start));
const limit = parseInt(params.length);
if (limit >= 0) {
query.limit(limit);
}
query.select([...columnsSelect, ...options.extraColumns || [] ]);
for (const order of params.order) {
if (options.orderByBuilder) {
options.orderByBuilder(query, columnsNames[params.columns[order.column].data], order.dir);
} else {
query.orderBy(columnsNames[params.columns[order.column].data], order.dir);
}
}
query.options({rowsAsArray:true});
const rows = await query;
const rowsOfArray = rows.map(row => {
const arr = Object.keys(row).map(field => row[field]);
if (options.mapFun) {
const result = options.mapFun(arr);
return result || arr;
} else {
return arr;
}
});
const result = {
draw: params.draw,
recordsTotal,
recordsFiltered,
data: rowsOfArray
};
return result;
}
}
async function ajaxListWithPermissionsTx(tx, context, fetchSpecs, params, queryFun, columns, options) {
// Note that this function is not intended to be used with the synthetic admin context obtained by contextHelpers.getAdminContext()
options = options || {};
const permCols = [];
for (const fetchSpec of fetchSpecs) {
const entityType = entitySettings.getEntityType(fetchSpec.entityTypeId);
const entityIdColumn = fetchSpec.column ? fetchSpec.column : entityType.entitiesTable + '.id';
permCols.push({
name: `permissions_${fetchSpec.entityTypeId}`,
query: builder => builder
.from(entityType.permissionsTable)
.select(knex.raw('GROUP_CONCAT(operation SEPARATOR \';\')'))
.whereRaw(`${entityType.permissionsTable}.entity = ${entityIdColumn}`)
.where(`${entityType.permissionsTable}.user`, context.user.id)
.as(`permissions_${fetchSpec.entityTypeId}`)
});
}
return await ajaxListTx(
tx,
params,
builder => {
let query = queryFun(builder);
for (const fetchSpec of fetchSpecs) {
const entityType = entitySettings.getEntityType(fetchSpec.entityTypeId);
if (fetchSpec.requiredOperations) {
const entityIdColumn = fetchSpec.column ? fetchSpec.column : entityType.entitiesTable + '.id';
query = query.innerJoin(
function () {
return this.from(entityType.permissionsTable).distinct('entity').where('user', context.user.id).whereIn('operation', fetchSpec.requiredOperations).as(`permitted__${fetchSpec.entityTypeId}`);
},
`permitted__${fetchSpec.entityTypeId}.entity`, entityIdColumn)
}
}
return query;
},
[
...columns,
...permCols
],
{
mapFun: data => {
for (let idx = 0; idx < fetchSpecs.length; idx++) {
data[columns.length + idx] = data[columns.length + idx].split(';');
}
if (options.mapFun) {
const result = options.mapFun(data);
return result || data;
} else {
return data;
}
},
orderByBuilder: options.orderByBuilder,
extraColumns: options.extraColumns
}
);
}
async function ajaxList(params, queryFun, columns, options) {
return await knex.transaction(async tx => {
return await ajaxListTx(tx, params, queryFun, columns, options)
});
}
async function ajaxListWithPermissions(context, fetchSpecs, params, queryFun, columns, options) {
return await knex.transaction(async tx => {
return await ajaxListWithPermissionsTx(tx, context, fetchSpecs, params, queryFun, columns, options)
});
}
module.exports = {
ajaxListTx,
ajaxList,
ajaxListWithPermissionsTx,
ajaxListWithPermissions
};

View file

@ -0,0 +1,151 @@
'use strict';
const ReplacementBehavior = {
NONE: 1,
REPLACE: 2,
RENAME: 3
};
const entityTypes = {
namespace: {
entitiesTable: 'namespaces',
sharesTable: 'shares_namespace',
permissionsTable: 'permissions_namespace',
clientLink: id => `/namespaces/${id}`
},
list: {
entitiesTable: 'lists',
sharesTable: 'shares_list',
permissionsTable: 'permissions_list',
clientLink: id => `/lists/${id}`
},
customForm: {
entitiesTable: 'custom_forms',
sharesTable: 'shares_custom_form',
permissionsTable: 'permissions_custom_form',
clientLink: id => `/lists/forms/${id}`
},
campaign: {
entitiesTable: 'campaigns',
sharesTable: 'shares_campaign',
permissionsTable: 'permissions_campaign',
dependentPermissions: {
extraColumns: ['parent'],
getParent: entity => entity.parent
},
files: {
file: {
table: 'files_campaign_file',
permissions: {
view: 'viewFiles',
manage: 'manageFiles'
},
defaultReplacementBehavior: ReplacementBehavior.REPLACE
},
attachment: {
table: 'files_campaign_attachment',
permissions: {
view: 'viewAttachments',
manage: 'manageAttachments'
},
defaultReplacementBehavior: ReplacementBehavior.NONE
}
},
clientLink: id => `/campaigns/${id}`
},
template: {
entitiesTable: 'templates',
sharesTable: 'shares_template',
permissionsTable: 'permissions_template',
files: {
file: {
table: 'files_template_file',
permissions: {
view: 'viewFiles',
manage: 'manageFiles'
},
defaultReplacementBehavior: ReplacementBehavior.REPLACE
}
},
clientLink: id => `/templates/${id}`
},
sendConfiguration: {
entitiesTable: 'send_configurations',
sharesTable: 'shares_send_configuration',
permissionsTable: 'permissions_send_configuration',
clientLink: id => `/send-configurations/${id}`
},
report: {
entitiesTable: 'reports',
sharesTable: 'shares_report',
permissionsTable: 'permissions_report',
clientLink: id => `/reports/${id}`
},
reportTemplate: {
entitiesTable: 'report_templates',
sharesTable: 'shares_report_template',
permissionsTable: 'permissions_report_template',
clientLink: id => `/reports/templates/${id}`
},
mosaicoTemplate: {
entitiesTable: 'mosaico_templates',
sharesTable: 'shares_mosaico_template',
permissionsTable: 'permissions_mosaico_template',
files: {
file: {
table: 'files_mosaico_template_file',
permissions: {
view: 'viewFiles',
manage: 'manageFiles'
},
defaultReplacementBehavior: ReplacementBehavior.REPLACE
},
block: {
table: 'files_mosaico_template_block',
permissions: {
view: 'viewFiles',
manage: 'manageFiles'
},
defaultReplacementBehavior: ReplacementBehavior.REPLACE
}
},
clientLink: id => `/templates/mosaico/${id}`
},
user: {
entitiesTable: 'users',
clientLink: id => `/users/${id}`
}
};
const entityTypesWithPermissions = {};
for (const key in entityTypes) {
if (entityTypes[key].permissionsTable) {
entityTypesWithPermissions[key] = entityTypes[key];
}
}
function getEntityTypes() {
return entityTypes;
}
function getEntityTypesWithPermissions() {
return entityTypesWithPermissions;
}
function getEntityType(entityTypeId) {
const entityType = entityTypes[entityTypeId];
if (!entityType) {
throw new Error(`Unknown entity type ${entityTypeId}`);
}
return entityType
}
module.exports = {
getEntityTypes,
getEntityTypesWithPermissions,
getEntityType,
ReplacementBehavior
}

83
server/lib/executor.js Normal file
View file

@ -0,0 +1,83 @@
'use strict';
const fork = require('child_process').fork;
const log = require('./log');
const path = require('path');
const requestCallbacks = {};
let messageTid = 0;
let executorProcess;
module.exports = {
spawn,
start,
stop
};
function spawn(callback) {
log.verbose('Executor', 'Spawning executor process');
executorProcess = fork(path.join(__dirname, '..', 'services', 'executor.js'), [], {
cwd: path.join(__dirname, '..'),
env: {NODE_ENV: process.env.NODE_ENV}
});
executorProcess.on('message', msg => {
if (msg) {
if (msg.type === 'process-started') {
let requestCallback = requestCallbacks[msg.tid];
if (requestCallback && requestCallback.startedCallback) {
requestCallback.startedCallback(msg.tid, );
}
} else if (msg.type === 'process-failed') {
let requestCallback = requestCallbacks[msg.tid];
if (requestCallback && requestCallback.failedCallback) {
requestCallback.failedCallback(msg.msg);
}
delete requestCallbacks[msg.tid];
} else if (msg.type === 'process-finished') {
let requestCallback = requestCallbacks[msg.tid];
if (requestCallback && requestCallback.startedCallback) {
requestCallback.finishedCallback(msg.code, msg.signal);
}
delete requestCallbacks[msg.tid];
} else if (msg.type === 'executor-started') {
log.info('Executor', 'Executor process started.');
return callback();
}
}
});
executorProcess.on('close', (code, signal) => {
log.info('Executor', 'Executor process exited with code %s signal %s', code, signal);
});
}
function start(type, data, startedCallback, finishedCallback, failedCallback) {
requestCallbacks[messageTid] = {
startedCallback,
finishedCallback,
failedCallback
};
executorProcess.send({
type: 'start-' + type,
data,
tid: messageTid
});
messageTid++;
}
function stop(tid) {
executorProcess.send({
type: 'stop-process',
tid
});
}

36
server/lib/feedcheck.js Normal file
View file

@ -0,0 +1,36 @@
'use strict';
const fork = require('child_process').fork;
const log = require('./log');
const path = require('path');
const senders = require('./senders');
let feedcheckProcess;
module.exports = {
spawn
};
function spawn(callback) {
log.verbose('Feed', 'Spawning feedcheck process');
feedcheckProcess = fork(path.join(__dirname, '..', 'services', 'feedcheck.js'), [], {
cwd: path.join(__dirname, '..'),
env: {NODE_ENV: process.env.NODE_ENV}
});
feedcheckProcess.on('message', msg => {
if (msg) {
if (msg.type === 'feedcheck-started') {
log.info('Feed', 'Feedcheck process started');
return callback();
} else if (msg.type === 'entries-added') {
senders.scheduleCheck();
}
}
});
feedcheckProcess.on('close', (code, signal) => {
log.error('Feed', 'Feedcheck process exited with code %s signal %s', code, signal);
});
}

View file

@ -0,0 +1,22 @@
'use strict';
const passport = require('./passport');
const files = require('../models/files');
const path = require('path');
const uploadedFilesDir = path.join(files.filesDir, 'uploaded');
const {castToInteger} = require('./helpers');
const multer = require('multer')({
dest: uploadedFilesDir
});
function installUploadHandler(router, url, replacementBehavior, type, subType, transformResponseFn) {
router.postAsync(url, passport.loggedIn, multer.array('files[]'), async (req, res) => {
return res.json(await files.createFiles(req.context, type || req.params.type, subType || req.params.subType, castToInteger(req.params.entityId), req.files, replacementBehavior, transformResponseFn));
});
}
module.exports = {
installUploadHandler
};

39
server/lib/helpers.js Normal file
View file

@ -0,0 +1,39 @@
'use strict';
module.exports = {
enforce,
cleanupFromPost,
filterObject,
castToInteger
};
function enforce(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function cleanupFromPost(value) {
return (value || '').toString().trim();
}
function filterObject(obj, allowedKeys) {
const result = {};
for (const key in obj) {
if (allowedKeys.has(key)) {
result[key] = obj[key];
}
}
return result;
}
function castToInteger(id) {
const val = parseInt(id);
if (!Number.isInteger(val)) {
throw new Error('Invalid id');
}
return val;
}

60
server/lib/importer.js Normal file
View file

@ -0,0 +1,60 @@
'use strict';
const knex = require('./knex');
const fork = require('child_process').fork;
const log = require('./log');
const path = require('path');
const {ImportStatus, RunStatus} = require('../../shared/imports');
let messageTid = 0;
let importerProcess;
module.exports = {
spawn,
scheduleCheck
};
function spawn(callback) {
log.verbose('Importer', 'Spawning importer process');
knex.transaction(async tx => {
await tx('imports').where('status', ImportStatus.PREP_RUNNING).update({status: ImportStatus.PREP_SCHEDULED});
await tx('imports').where('status', ImportStatus.PREP_STOPPING).update({status: ImportStatus.PREP_FAILED});
await tx('imports').where('status', ImportStatus.RUN_RUNNING).update({status: ImportStatus.RUN_SCHEDULED});
await tx('imports').where('status', ImportStatus.RUN_STOPPING).update({status: ImportStatus.RUN_FAILED});
await tx('import_runs').where('status', RunStatus.RUNNING).update({status: RunStatus.SCHEDULED});
await tx('import_runs').where('status', RunStatus.STOPPING).update({status: RunStatus.FAILED});
}).then(() => {
importerProcess = fork(path.join(__dirname, '..', 'services', 'importer.js'), [], {
cwd: path.join(__dirname, '..'),
env: {NODE_ENV: process.env.NODE_ENV}
});
importerProcess.on('message', msg => {
if (msg) {
if (msg.type === 'importer-started') {
log.info('Importer', 'Importer process started');
return callback();
}
}
});
importerProcess.on('close', (code, signal) => {
log.error('Importer', 'Importer process exited with code %s signal %s', code, signal);
});
});
}
function scheduleCheck() {
importerProcess.send({
type: 'scheduleCheck',
tid: messageTid
});
messageTid++;
}

14
server/lib/knex.js Normal file
View file

@ -0,0 +1,14 @@
'use strict';
const config = require('config');
const knex = require('server/lib/knex')({
client: 'mysql2',
connection: config.mysql,
migrations: {
directory: __dirname + '/../setup/knex/migrations'
}
//, debug: true
});
module.exports = knex;

8
server/lib/log.js Normal file
View file

@ -0,0 +1,8 @@
'use strict';
const config = require('config');
const log = require('npmlog');
log.level = config.log.level;
module.exports = log;

232
server/lib/mailers.js Normal file
View file

@ -0,0 +1,232 @@
'use strict';
const log = require('./log');
const config = require('config');
const Handlebars = require('handlebars');
const util = require('util');
const nodemailer = require('nodemailer');
const aws = require('aws-sdk');
const openpgpEncrypt = require('nodemailer-openpgp').openpgpEncrypt;
const sendConfigurations = require('../models/send-configurations');
const contextHelpers = require('./context-helpers');
const settings = require('../models/settings');
const tools = require('./tools');
const htmlToText = require('html-to-text');
const bluebird = require('bluebird');
const _ = require('./translate')._;
const transports = new Map();
async function getOrCreateMailer(sendConfigurationId) {
let sendConfiguration;
if (!sendConfiguration) {
sendConfiguration = await sendConfigurations.getSystemSendConfiguration();
} else {
sendConfiguration = await sendConfigurations.getById(contextHelpers.getAdminContext(), sendConfigurationId, false, true);
}
const transport = transports.get(sendConfiguration.id) || await _createTransport(sendConfiguration);
return transport.mailer;
}
function invalidateMailer(sendConfigurationId) {
transports.delete(sendConfigurationId);
}
async function _sendMail(transport, mail, template) {
let tryCount = 0;
const trySend = (callback) => {
tryCount++;
transport.sendMail(mail, (err, info) => {
if (err) {
log.error('Mail', err);
if (err.responseCode && err.responseCode >= 400 && err.responseCode < 500 && tryCount <= 5) {
// temporary error, try again
log.verbose('Mail', 'Retrying after %s sec. ...', tryCount);
return setTimeout(trySend, tryCount * 1000);
}
return callback(err);
}
return callback(null, info);
});
};
const trySendAsync = bluebird.promisify(trySend);
return await trySendAsync();
}
async function _sendTransactionalMail(transport, mail, template) {
if (!mail.headers) {
mail.headers = {};
}
mail.headers['X-Sending-Zone'] = 'transactional';
const htmlRenderer = await tools.getTemplate(template.html);
if (htmlRenderer) {
mail.html = htmlRenderer(template.data || {});
}
const preparedHtml = await tools.prepareHtml(mail.html);
if (preparedHtml) {
mail.html = preparedHtml;
}
const textRenderer = await tools.getTemplate(template.text);
if (textRenderer) {
mail.text = textRenderer(template.data || {});
} else if (mail.html) {
mail.text = htmlToText.fromString(mail.html, {
wordwrap: 130
});
}
return await _sendMail(transport, mail);
}
async function _createTransport(sendConfiguration) {
const mailerSettings = sendConfiguration.mailer_settings;
const mailerType = sendConfiguration.mailer_type;
const configItems = await settings.get(contextHelpers.getAdminContext(), ['pgpPrivateKey', 'pgpPassphrase']);
const existingTransport = transports.get(sendConfiguration.id);
let existingListeners = [];
if (existingTransport) {
existingListeners = existingTransport.listeners('idle');
existingTransport.removeAllListeners('idle');
existingTransport.removeAllListeners('stream');
existingTransport.throttleWait = null;
}
const logFunc = (...args) => {
const level = args.shift();
args.shift();
args.unshift('Mail');
log[level](...args);
};
let transportOptions;
if (mailerType === sendConfigurations.MailerType.GENERIC_SMTP || mailerType === sendConfigurations.MailerType.ZONE_MTA) {
transportOptions = {
pool: true,
host: mailerSettings.hostname,
port: mailerSettings.port || false,
secure: mailerSettings.encryption === 'TLS',
ignoreTLS: mailerSettings.encryption === 'NONE',
auth: mailerSettings.useAuth ? {
user: mailerSettings.user,
pass: mailerSettings.password
} : false,
debug: mailerSettings.logTransactions,
logger: mailerSettings.logTransactions ? {
debug: logFunc.bind(null, 'verbose'),
info: logFunc.bind(null, 'info'),
error: logFunc.bind(null, 'error')
} : false,
maxConnections: mailerSettings.maxConnections,
maxMessages: mailerSettings.maxMessages,
tls: {
rejectUnauthorized: !mailerSettings.allowSelfSigned
}
};
} else if (mailerType === sendConfigurations.MailerType.AWS_SES) {
const sendingRate = mailerSettings.throttling / 3600; // convert to messages/second
transportOptions = {
SES: new aws.SES({
apiVersion: '2010-12-01',
accessKeyId: mailerSettings.key,
secretAccessKey: mailerSettings.secret,
region: mailerSettings.region
}),
debug: mailerSettings.logTransactions,
logger: mailerSettings.logTransactions ? {
debug: logFunc.bind(null, 'verbose'),
info: logFunc.bind(null, 'info'),
error: logFunc.bind(null, 'error')
} : false,
maxConnections: mailerSettings.maxConnections,
sendingRate
};
} else {
throw new Error('Invalid mail transport');
}
const transport = nodemailer.createTransport(transportOptions, config.nodemailer);
transport.use('stream', openpgpEncrypt({
signingKey: configItems.pgpPrivateKey,
passphrase: configItems.pgpPassphrase
}));
if (existingListeners.length) {
log.info('Mail', 'Reattaching %s idle listeners', existingListeners.length);
existingListeners.forEach(listener => transport.on('idle', listener));
}
let throttleWait;
if (mailerType === sendConfigurations.MailerType.GENERIC_SMTP || mailerType === sendConfigurations.MailerType.ZONE_MTA) {
let throttling = mailerSettings.throttling;
if (throttling) {
throttling = 1 / (throttling / (3600 * 1000));
}
let lastCheck = Date.now();
throttleWait = function (next) {
if (!throttling) {
return next();
}
let nextCheck = Date.now();
let checkDiff = (nextCheck - lastCheck);
if (checkDiff < throttling) {
log.verbose('Mail', 'Throttling next message in %s sec.', (throttling - checkDiff) / 1000);
setTimeout(() => {
lastCheck = Date.now();
next();
}, throttling - checkDiff);
} else {
lastCheck = nextCheck;
next();
}
};
} else {
throttleWait = next => next();
}
transport.mailer = {
throttleWait: bluebird.promisify(throttleWait),
sendTransactionalMail: async (mail, template) => await _sendTransactionalMail(transport, mail, template),
sendMassMail: async (mail, template) => await _sendMail(transport, mail)
};
transports.set(sendConfiguration.id, transport);
return transport;
}
class MailerError extends Error {
constructor(msg, responseCode) {
super(msg);
this.responseCode = responseCode;
}
}
module.exports.getOrCreateMailer = getOrCreateMailer;
module.exports.invalidateMailer = invalidateMailer;
module.exports.MailerError = MailerError;

View file

@ -0,0 +1,24 @@
'use strict';
const { enforce } = require('./helpers');
const shares = require('../models/shares');
const interoperableErrors = require('../../shared/interoperable-errors');
async function validateEntity(tx, entity) {
enforce(entity.namespace, 'Entity namespace not set');
if (!await tx('namespaces').where('id', entity.namespace).first()) {
throw new interoperableErrors.NamespaceNotFoundError();
}
}
async function validateMove(context, entity, existing, entityTypeId, createOperation, deleteOperation) {
if (existing.namespace !== entity.namespace) {
await shares.enforceEntityPermission(context, 'namespace', entity.namespace, createOperation);
await shares.enforceEntityPermission(context, entityTypeId, entity.id, deleteOperation);
}
}
module.exports = {
validateEntity,
validateMove
};

15
server/lib/nodeify.js Normal file
View file

@ -0,0 +1,15 @@
'use strict';
const nodeify = require('server/lib/nodeify');
module.exports.nodeifyPromise = nodeify;
module.exports.nodeifyFunction = (asyncFun) => {
return (...args) => {
const callback = args.pop();
const promise = asyncFun(...args);
return module.exports.nodeifyPromise(promise, callback);
};
};

229
server/lib/passport.js Normal file
View file

@ -0,0 +1,229 @@
'use strict';
const config = require('config');
const log = require('./log');
const _ = require('./translate')._;
const util = require('util');
const passport = require('server/lib/passport');
const LocalStrategy = require('passport-local').Strategy;
const csrf = require('csurf');
const bodyParser = require('body-parser');
const users = require('../models/users');
const { nodeifyFunction, nodeifyPromise } = require('./nodeify');
const interoperableErrors = require('../../shared/interoperable-errors');
const contextHelpers = require('./context-helpers');
let authMode = 'local';
let LdapStrategy;
let ldapStrategyOpts;
if (config.ldap.enabled) {
if (!config.ldap.method || config.ldap.method == 'ldapjs') {
try {
LdapStrategy = require('passport-ldapjs').Strategy; // eslint-disable-line global-require
authMode = 'ldapjs';
log.info('LDAP', 'Found module "passport-ldapjs". It will be used for LDAP auth.');
ldapStrategyOpts = {
server: {
url: 'ldap://' + config.ldap.host + ':' + config.ldap.port
},
base: config.ldap.baseDN,
search: {
filter: config.ldap.filter,
attributes: [config.ldap.uidTag, config.ldap.nameTag, 'mail'],
scope: 'sub'
},
uidTag: config.ldap.uidTag,
bindUser: config.ldap.bindUser,
bindPassword: config.ldap.bindPassword
};
} catch (exc) {
log.info('LDAP', 'Module "passport-ldapjs" not installed. It will not be used for LDAP auth.');
}
}
if (!LdapStrategy && (!config.ldap.method || config.ldap.method == 'ldapauth')) {
try {
LdapStrategy = require('passport-ldapauth').Strategy; // eslint-disable-line global-require
authMode = 'ldapauth';
log.info('LDAP', 'Found module "passport-ldapauth". It will be used for LDAP auth.');
ldapStrategyOpts = {
server: {
url: 'ldap://' + config.ldap.host + ':' + config.ldap.port,
searchBase: config.ldap.baseDN,
searchFilter: config.ldap.filter,
searchAttributes: [config.ldap.uidTag, config.ldap.nameTag, 'mail'],
bindDN: config.ldap.bindUser,
bindCredentials: config.ldap.bindPassword
},
};
} catch (exc) {
log.info('LDAP', 'Module "passport-ldapauth" not installed. It will not be used for LDAP auth.');
}
}
}
module.exports.csrfProtection = csrf({
cookie: true
});
module.exports.parseForm = bodyParser.urlencoded({
extended: false,
limit: config.www.postSize
});
module.exports.loggedIn = (req, res, next) => {
if (!req.user) {
next(new interoperableErrors.NotLoggedInError());
} else {
next();
}
};
module.exports.authByAccessToken = (req, res, next) => {
if (!req.query.access_token) {
res.status(403);
res.json({
error: 'Missing access_token',
data: []
});
}
users.getByAccessToken(req.query.access_token).then(user => {
req.user = user;
next();
}).catch(err => {
if (err instanceof interoperableErrors.PermissionDeniedError) {
res.status(403);
res.json({
error: 'Invalid or expired access_token',
data: []
});
} else {
res.status(500);
res.json({
error: err.message || err,
data: []
});
}
});
};
module.exports.tryAuthByRestrictedAccessToken = (req, res, next) => {
const pathComps = req.url.split('/');
pathComps.shift();
const restrictedAccessToken = pathComps.shift();
pathComps.unshift('');
const url = pathComps.join('/');
req.url = url;
users.getByRestrictedAccessToken(restrictedAccessToken).then(user => {
req.user = user;
next();
}).catch(err => {
next();
});
};
module.exports.setupRegularAuth = app => {
app.use(passport.initialize());
app.use(passport.session());
};
module.exports.restLogout = (req, res) => {
req.logout();
res.json();
};
module.exports.restLogin = (req, res, next) => {
passport.authenticate(authMode, (err, user, info) => {
if (err) {
return next(err);
}
if (!user) {
return next(new interoperableErrors.IncorrectPasswordError());
}
req.logIn(user, err => {
if (err) {
return next(err);
}
if (req.body.remember) {
// Cookie expires after 30 days
req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000;
} else {
// Cookie expires at end of session
req.session.cookie.expires = false;
}
return res.json();
});
})(req, res, next);
};
if (LdapStrategy) {
log.info('Using LDAP auth (passport-' + authMode + ')');
module.exports.authMethod = 'ldap';
module.exports.isAuthMethodLocal = false;
passport.use(new LdapStrategy(ldapStrategyOpts, nodeifyFunction(async (profile) => {
try {
const user = await users.getByUsername(profile[config.ldap.uidTag]);
return {
id: user.id,
username: user.username,
name: profile[config.ldap.nameTag],
email: profile.mail,
role: user.role
};
} catch (err) {
if (err instanceof interoperableErrors.NotFoundError) {
const userId = await users.create(null, {
username: profile[config.ldap.uidTag],
role: config.ldap.newUserRole,
namespace: config.ldap.newUserNamespaceId
});
return {
id: userId,
username: profile[config.ldap.uidTag],
name: profile[config.ldap.nameTag],
email: profile.mail,
role: config.ldap.newUserRole
};
} else {
throw err;
}
}
})));
passport.serializeUser((user, done) => done(null, user));
passport.deserializeUser((user, done) => done(null, user));
} else {
log.info('Using local auth');
module.exports.authMethod = 'local';
module.exports.isAuthMethodLocal = true;
passport.use(new LocalStrategy(nodeifyFunction(async (username, password) => {
return await users.getByUsernameIfPasswordMatch(username, password);
})));
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser((id, done) => nodeifyPromise(users.getById(contextHelpers.getAdminContext(), id), done));
}

View file

@ -0,0 +1,77 @@
'use strict';
const log = require('./log');
const config = require('config');
const fs = require('fs');
const tryRequire = require('try-require');
const posix = tryRequire('posix');
function _getConfigUidGid(userKey, groupKey, defaultUid, defaultGid) {
let uid = defaultUid;
let gid = defaultGid;
if (posix) {
try {
if (config[userKey]) {
uid = posix.getpwnam(config[userKey]).uid;
}
} catch (err) {
log.info('PrivilegeHelpers', 'Failed to resolve user id "%s"', config[userKey]);
}
try {
if (config[groupKey]) {
gid = posix.getpwnam(config[groupKey]).gid;
}
} catch (err) {
log.info('PrivilegeHelpers', 'Failed to resolve group id "%s"', config[groupKey]);
}
} else {
log.info('PrivilegeHelpers', 'Posix module not installed. Cannot resolve uid/gid');
}
return { uid, gid };
}
function getConfigUidGid() {
return _getConfigUidGid('user', 'group', process.getuid(), process.getgid());
}
function getConfigROUidGid() {
const rwIds = getConfigUidGid();
return _getConfigUidGid('roUser', 'roGroup', rwIds.uid, rwIds.gid);
}
function ensureMailtrainOwner(file, callback) {
const ids = getConfigUidGid();
fs.chown(file, ids.uid, ids.gid, callback);
}
function dropRootPrivileges() {
if (config.group) {
try {
process.setgid(config.group);
log.info('PrivilegeHelpers', 'Changed group to "%s" (%s)', config.group, process.getgid());
} catch (E) {
log.info('PrivilegeHelpers', 'Failed to change group to "%s" (%s)', config.group, E.message);
}
}
if (config.user) {
try {
process.setuid(config.user);
log.info('PrivilegeHelpers', 'Changed user to "%s" (%s)', config.user, process.getuid());
} catch (E) {
log.info('PrivilegeHelpers', 'Failed to change user to "%s" (%s)', config.user, E.message);
}
}
}
module.exports = {
dropRootPrivileges,
ensureMailtrainOwner,
getConfigUidGid,
getConfigROUidGid
};

View file

@ -0,0 +1,32 @@
'use strict';
const path = require('path');
function nameToFileName(name) {
return name.
trim().
toLowerCase().
replace(/[ .+/]/g, '-').
replace(/[^a-z0-9\-_]/gi, '').
replace(/--*/g, '-');
}
function getReportFileBase(report) {
return path.join(__dirname, '..', 'protected', 'reports', report.id + '-' + nameToFileName(report.name));
}
function getReportContentFile(report) {
return getReportFileBase(report) + '.out';
}
function getReportOutputFile(report) {
return getReportFileBase(report) + '.err';
}
module.exports = {
getReportContentFile,
getReportOutputFile,
nameToFileName
};

View file

@ -0,0 +1,132 @@
'use strict';
const log = require('./log');
const reports = require('../models/reports');
const executor = require('./executor');
const contextHelpers = require('./context-helpers');
let runningWorkersCount = 0;
let maxWorkersCount = 1;
const workers = {};
function startWorker(report) {
async function onStarted(tid) {
log.info('ReportProcessor', 'Worker process for "%s" started with tid %s. Current worker count is %s.', report.name, tid, runningWorkersCount);
workers[report.id] = tid;
}
async function onFinished(code, signal) {
runningWorkersCount--;
log.info('ReportProcessor', 'Worker process for "%s" (tid %s) exited with code %s signal %s. Current worker count is %s.', report.name, workers[report.id], code, signal, runningWorkersCount);
delete workers[report.id];
const fields = {};
if (code === 0) {
fields.state = reports.ReportState.FINISHED;
fields.last_run = new Date();
} else {
fields.state = reports.ReportState.FAILED;
}
try {
await reports.updateFields(report.id, fields);
setImmediate(tryStartWorkers);
} catch (err) {
log.error('ReportProcessor', err);
}
}
async function onFailed(msg) {
runningWorkersCount--;
log.error('ReportProcessor', 'Executing worker process for "%s" (tid %s) failed with message "%s". Current worker count is %s.', report.name, workers[report.id], msg, runningWorkersCount);
delete workers[report.id];
const fields = {
state: reports.ReportState.FAILED
};
try {
await reports.updateFields(report.id, fields);
setImmediate(tryStartWorkers);
} catch (err) {
log.error('ReportProcessor', err);
}
}
const reportData = {
id: report.id,
name: report.name
};
runningWorkersCount++;
executor.start('report-processor-worker', reportData, onStarted, onFinished, onFailed);
}
let isStartingWorkers = false;
async function tryStartWorkers() {
if (isStartingWorkers) {
// Generally it is possible that this function is invoked simultaneously multiple times. This is to prevent it.
return;
}
isStartingWorkers = true;
try {
while (runningWorkersCount < maxWorkersCount) {
log.info('ReportProcessor', 'Trying to start worker because runningWorkersCount=%s maxWorkersCount=%s', runningWorkersCount, maxWorkersCount);
const reportList = await reports.listByState(reports.ReportState.SCHEDULED, 1);
if (reportList.length > 0) {
log.info('ReportProcessor', 'Starting worker');
const report = reportList[0];
await reports.updateFields(report.id, {state: reports.ReportState.PROCESSING});
startWorker(report);
} else {
log.info('ReportProcessor', 'No more reports to start a worker for');
break;
}
}
} catch (err) {
log.error('ReportProcessor', err);
}
isStartingWorkers = false;
}
module.exports.start = async (reportId) => {
if (!workers[reportId]) {
log.info('ReportProcessor', 'Scheduling report id: %s', reportId);
await reports.updateFields(reportId, { state: reports.ReportState.SCHEDULED, last_run: null});
await tryStartWorkers();
} else {
log.info('ReportProcessor', 'Worker for report id: %s is already running.', reportId);
}
};
module.exports.stop = async reportId => {
const tid = workers[reportId];
if (tid) {
log.info('ReportProcessor', 'Killing worker for report id: %s', reportId);
executor.stop(tid);
await reports.updateFields(reportId, { state: reports.ReportState.FAILED });
} else {
log.info('ReportProcessor', 'No running worker found for report id: %s', reportId);
}
};
module.exports.init = async () => {
try {
await reports.bulkChangeState(reports.ReportState.PROCESSING, reports.ReportState.SCHEDULED);
await tryStartWorkers();
} catch (err) {
log.error('ReportProcessor', err);
}
};

View file

@ -0,0 +1,31 @@
'use strict';
const express = require('express');
function replaceLastBySafeHandler(handlers) {
if (handlers.length === 0) {
return [];
}
const lastHandler = handlers[handlers.length - 1];
const ret = handlers.slice();
ret[handlers.length - 1] = (req, res, next) => lastHandler(req, res, next).catch(error => next(error));
return ret;
}
function create() {
const router = new express.Router();
router.allAsync = (path, ...handlers) => router.all(path, ...replaceLastBySafeHandler(handlers));
router.getAsync = (path, ...handlers) => router.get(path, ...replaceLastBySafeHandler(handlers));
router.postAsync = (path, ...handlers) => router.post(path, ...replaceLastBySafeHandler(handlers));
router.putAsync = (path, ...handlers) => router.put(path, ...replaceLastBySafeHandler(handlers));
router.deleteAsync = (path, ...handlers) => router.delete(path, ...replaceLastBySafeHandler(handlers));
return router;
}
module.exports = {
create
};

63
server/lib/senders.js Normal file
View file

@ -0,0 +1,63 @@
'use strict';
const fork = require('child_process').fork;
const log = require('./log');
const path = require('path');
const knex = require('./knex');
const {CampaignStatus} = require('../../shared/campaigns');
let messageTid = 0;
let senderProcess;
function spawn(callback) {
log.verbose('Senders', 'Spawning master sender process');
knex('campaigns').where('status', CampaignStatus.SENDING).update({status: CampaignStatus.SCHEDULED})
.then(() => {
senderProcess = fork(path.join(__dirname, '..', 'services', 'sender-master.js'), [], {
cwd: path.join(__dirname, '..'),
env: {NODE_ENV: process.env.NODE_ENV}
});
senderProcess.on('message', msg => {
if (msg) {
if (msg.type === 'master-sender-started') {
log.info('Senders', 'Master sender process started');
return callback();
}
}
});
senderProcess.on('close', (code, signal) => {
log.error('Senders', 'Master sender process exited with code %s signal %s', code, signal);
});
});
}
function scheduleCheck() {
senderProcess.send({
type: 'schedule-check',
tid: messageTid
});
messageTid++;
}
function reloadConfig(sendConfigurationId) {
senderProcess.send({
type: 'reload-config',
data: {
sendConfigurationId
},
tid: messageTid
});
messageTid++;
}
module.exports = {
spawn,
scheduleCheck,
reloadConfig
};

View file

@ -0,0 +1,164 @@
'use strict';
const log = require('npmlog');
const fields = require('../models/fields');
const settings = require('../models/settings');
const {getTrustedUrl, getPublicUrl} = require('./urls');
const { tUI, tMark } = require('./translate');
const util = require('util');
const contextHelpers = require('./context-helpers');
const {getFieldColumn} = require('../../shared/lists');
const forms = require('../models/forms');
const mailers = require('./mailers');
module.exports = {
sendAlreadySubscribed,
sendConfirmAddressChange,
sendConfirmSubscription,
sendConfirmUnsubscription,
sendSubscriptionConfirmed,
sendUnsubscriptionConfirmed
};
async function sendSubscriptionConfirmed(lang, list, email, subscription) {
const relativeUrls = {
preferencesUrl: '/subscription/' + list.cid + '/manage/' + subscription.cid,
unsubscribeUrl: '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid
};
await _sendMail(list, email, 'subscription_confirmed', lang, tMark('subscription.confirmed'), relativeUrls, subscription);
}
async function sendAlreadySubscribed(lang, list, email, subscription) {
const relativeUrls = {
preferencesUrl: '/subscription/' + list.cid + '/manage/' + subscription.cid,
unsubscribeUrl: '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid
};
await _sendMail(list, email, 'already_subscribed', lang, tMark('subscription.alreadyRegistered'), relativeUrls, subscription);
}
async function sendConfirmAddressChange(lang, list, email, cid, subscription) {
const relativeUrls = {
confirmUrl: '/subscription/confirm/change-address/' + cid
};
await _sendMail(list, email, 'confirm_address_change', lang, tMark('subscription.confirmEmailChange'), relativeUrls, subscription);
}
async function sendConfirmSubscription(lang, list, email, cid, subscription) {
const relativeUrls = {
confirmUrl: '/subscription/confirm/subscribe/' + cid
};
await _sendMail(list, email, 'confirm_subscription', lang, tMark('subscription.confirmSubscription'), relativeUrls, subscription);
}
async function sendConfirmUnsubscription(lang, list, email, cid, subscription) {
const relativeUrls = {
confirmUrl: '/subscription/confirm/unsubscribe/' + cid
};
await _sendMail(list, email, 'confirm_unsubscription', lang, tMark('subscription.confirmUnsubscription'), relativeUrls, subscription);
}
async function sendUnsubscriptionConfirmed(lang, list, email, subscription) {
const relativeUrls = {
subscribeUrl: '/subscription/' + list.cid + '?cid=' + subscription.cid
};
await _sendMail(list, email, 'unsubscription_confirmed', lang, tMark('subscription.unsubscriptionConfirmed'), relativeUrls, subscription);
}
function getDisplayName(flds, subscription) {
let firstName, lastName, name;
for (const fld of flds) {
if (fld.key === 'FIRST_NAME') {
firstName = subscription[fld.column];
}
if (fld.key === 'LAST_NAME') {
lastName = subscription[fld.column];
}
if (fld.key === 'NAME') {
name = subscription[fld.column];
}
}
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, language, subjectKey, relativeUrls, subscription) {
const flds = await fields.list(contextHelpers.getAdminContext(), list.id);
const encryptionKeys = [];
for (const fld of flds) {
if (fld.type === 'gpg' && fld.value) {
encryptionKeys.push(subscription[getFieldColumn(fld)].value.trim());
}
}
const configItems = await settings.get(contextHelpers.getAdminContext(), ['defaultHomepage', 'adminEmail']);
const data = {
title: list.name,
homepage: configItems.defaultHomepage || getTrustedUrl(),
contactAddress: list.from_email || configItems.adminEmail,
};
for (let relativeUrlKey in relativeUrls) {
data[relativeUrlKey] = getPublicUrl(relativeUrls[relativeUrlKey], {language});
}
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) {
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 {
if (list.send_configuration) {
const mailer = await mailers.getOrCreateMailer(list.send_configuration);
await mailer.sendTransactionalMail({
from: {
name: configItems.defaultFrom,
address: configItems.defaultAddress
},
to: {
name: getDisplayName(flds, subscription),
address: email
},
subject: tUI(subjectKey, language, { list: list.name }),
encryptionKeys
}, {
html,
text,
data
});
} else {
log.warn('Subscription', `Not sending email for list id:${list.id} because not send configuration is set.`);
}
} catch (err) {
log.error('Subscription', err);
}
}

193
server/lib/tools.js Normal file
View file

@ -0,0 +1,193 @@
'use strict';
const util = require('util');
const isemail = require('isemail');
const path = require('path');
const {getPublicUrl} = require('./urls');
const bluebird = require('bluebird');
const hasher = require('node-object-hash')();
const mjml = require('mjml');
const mjml2html = mjml.default;
const hbs = require('hbs');
const juice = require('juice');
const he = require('he');
const fs = require('fs-extra');
const { JSDOM } = require('jsdom');
const { tUI, tLog } = require('./translate');
const templates = new Map();
async function getTemplate(template) {
if (!template) {
return false;
}
const key = (typeof template === 'object') ? hasher.hash(template) : template;
if (templates.has(key)) {
return templates.get(key);
}
let source;
if (typeof template === 'object') {
source = await mergeTemplateIntoLayout(template.template, template.layout);
} else {
source = await fs.readFile(path.join(__dirname, '..', 'views', template), 'utf-8');
}
if (template.type === 'mjml') {
const compiled = mjml2html(source);
if (compiled.errors.length) {
throw new Error(compiled.errors[0].message || compiled.errors[0]);
}
source = compiled.html;
}
const renderer = hbs.handlebars.compile(source);
templates.set(key, renderer);
return renderer;
}
async function mergeTemplateIntoLayout(template, layout) {
layout = layout || '{{{body}}}';
async function readFile(relPath) {
return await fs.readFile(path.join(__dirname, '..', 'views', relPath), 'utf-8');
}
// Please dont end your custom messages with .hbs ...
if (layout.endsWith('.hbs')) {
layout = await readFile(layout);
}
if (template.endsWith('.hbs')) {
template = await readFile(template);
}
const source = layout.replace(/\{\{\{body\}\}\}/g, template);
return source;
}
async function validateEmail(address) {
const result = await new Promise(resolve => {
const result = isemail.validate(address, {
checkDNS: true,
errorLevel: 1
}, resolve);
});
return result;
}
function validateEmailGetMessage(result, address, language) {
let t;
if (language) {
t = (key, args) => tUI(key, language, args);
} else {
t = (key, args) => tLog(key, args);
}
if (result !== 0) {
switch (result) {
case 5:
return t('addressCheck.mxNotFound', {email: address});
case 6:
return t('addressCheck.domainNotFound', {email: address});
case 12:
return t('addressCheck.domainRequired', {email: address});
default:
return t('invalidEmailGeneric', {email: address});
}
}
}
function formatMessage(campaign, list, subscription, mergeTags, message, isHTML) {
const links = getMessageLinks(campaign, list, subscription);
const getValue = key => {
key = (key || '').toString().toUpperCase().trim();
if (links.hasOwnProperty(key)) {
return links[key];
}
if (mergeTags.hasOwnProperty(key)) {
const value = (mergeTags[key] || '').toString();
const containsHTML = /<[a-z][\s\S]*>/.test(value);
return isHTML ? he.encode((containsHTML ? value : value.replace(/(?:\r\n|\r|\n)/g, '<br/>')), {
useNamedReferences: true,
allowUnsafeSymbols: true
}) : (containsHTML ? htmlToText.fromString(value) : value);
}
return false;
};
return message.replace(/\[([a-z0-9_]+)(?:\/([^\]]+))?\]/ig, (match, identifier, fallback) => {
let value = getValue(identifier);
if (value === false) {
return match;
}
value = (value || fallback || '').trim();
return value;
});
}
async function prepareHtml(html) {
if (!(html || '').toString().trim()) {
return false;
}
const { window } = new JSDOM(html);
const head = window.document.querySelector('head');
let hasCharsetTag = false;
const metaTags = window.document.querySelectorAll('meta');
if (metaTags) {
for (let i = 0; i < metaTags.length; i++) {
if (metaTags[i].hasAttribute('charset')) {
metaTags[i].setAttribute('charset', 'utf-8');
hasCharsetTag = true;
break;
}
}
}
if (!hasCharsetTag) {
const charsetTag = window.document.createElement('meta');
charsetTag.setAttribute('charset', 'utf-8');
head.appendChild(charsetTag);
}
const preparedHtml = '<!doctype html><html>' + window.document.documentElement.innerHTML + '</html>';
return juice(preparedHtml);
}
function getMessageLinks(campaign, list, subscription) {
return {
LINK_UNSUBSCRIBE: getPublicUrl('/subscription/' + list.cid + '/unsubscribe/' + subscription.cid + '?c=' + campaign.cid),
LINK_PREFERENCES: getPublicUrl('/subscription/' + list.cid + '/manage/' + subscription.cid),
LINK_BROWSER: getPublicUrl('/archive/' + campaign.cid + '/' + list.cid + '/' + subscription.cid),
CAMPAIGN_ID: campaign.cid,
LIST_ID: list.cid,
SUBSCRIPTION_ID: subscription.cid
};
}
module.exports = {
validateEmail,
validateEmailGetMessage,
mergeTemplateIntoLayout,
getTemplate,
prepareHtml,
getMessageLinks,
formatMessage
};

51
server/lib/translate.js Normal file
View file

@ -0,0 +1,51 @@
'use strict';
const config = require('config');
const i18n = require("i18next");
const Backend = require("i18next-node-fs-backend");
const path = require('path');
i18n
.use(Backend)
// .use(Cache)
.init({
lng: config.language,
wait: true, // globally set to wait for loaded translations in translate hoc
// have a common namespace used around the full app
ns: ['common'],
defaultNS: 'common',
debug: true,
backend: {
loadPath: path.join(__dirname, 'locales/{{lng}}/{{ns}}.json')
}
})
function tLog(key, args) {
if (!args) {
args = {};
}
return JSON.stringify([key, args]);
}
function tUI(key, lang, args) {
if (!args) {
args = {};
}
return i18n.t(key, { ...args, defaultValue, lng: lang });
}
function tMark(key) {
return key;
}
module.exports.tLog = tLog;
module.exports.tUI = tUI;
module.exports.tMark = tMark;

71
server/lib/urls.js Normal file
View file

@ -0,0 +1,71 @@
'use strict';
const config = require('config');
const urllib = require('url');
const {anonymousRestrictedAccessToken} = require('../../shared/urls');
function getTrustedUrlBase() {
return urllib.resolve(config.www.trustedUrlBase, '');
}
function getSandboxUrlBase() {
return urllib.resolve(config.www.sandboxUrlBase, '');
}
function getPublicUrlBase() {
return urllib.resolve(config.www.publicUrlBase, '');
}
function _getUrl(urlBase, path, opts) {
const url = new URL(path || '', urlBase);
if (opts && opts.language) {
url.searchParams.append('lang', opts.language)
}
return url.toString();
}
function getTrustedUrl(path, opts) {
return _getUrl(config.www.trustedUrlBase, path || '', opts);
}
function getSandboxUrl(path, context, opts) {
if (context && context.user && context.user.restrictedAccessToken) {
return _getUrl(config.www.sandboxUrlBase, context.user.restrictedAccessToken + '/' + (path || ''), opts);
} else {
return _getUrl(config.www.sandboxUrlBase, anonymousRestrictedAccessToken + '/' + (path || ''), opts);
}
}
function getPublicUrl(path, opts) {
return _getUrl(config.www.publicUrlBase, path || '', opts);
}
function getTrustedUrlBaseDir() {
const mailtrainUrl = urllib.parse(config.www.trustedUrlBase);
return mailtrainUrl.pathname;
}
function getSandboxUrlBaseDir() {
const mailtrainUrl = urllib.parse(config.www.sandboxUrlBase);
return mailtrainUrl.pathname;
}
function getPublicUrlBaseDir() {
const mailtrainUrl = urllib.parse(config.www.publicUrlBase);
return mailtrainUrl.pathname;
}
module.exports = {
getTrustedUrl,
getSandboxUrl,
getPublicUrl,
getTrustedUrlBase,
getSandboxUrlBase,
getPublicUrlBase,
getTrustedUrlBaseDir,
getSandboxUrlBaseDir,
getPublicUrlBaseDir
};