New project structure
Beta of extract.js for extracting english locale
This commit is contained in:
parent
e18d2b2f84
commit
2edbd67205
247 changed files with 6405 additions and 4237 deletions
407
server/lib/campaign-sender.js
Normal file
407
server/lib/campaign-sender.js
Normal 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;
|
50
server/lib/client-helpers.js
Normal file
50
server/lib/client-helpers.js
Normal 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;
|
||||
|
30
server/lib/context-helpers.js
Normal file
30
server/lib/context-helpers.js
Normal 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
232
server/lib/dbcheck.js
Normal 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);
|
||||
});
|
||||
});
|
||||
};
|
58
server/lib/dependency-helpers.js
Normal file
58
server/lib/dependency-helpers.js
Normal 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
189
server/lib/dt-helpers.js
Normal 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
|
||||
};
|
151
server/lib/entity-settings.js
Normal file
151
server/lib/entity-settings.js
Normal 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
83
server/lib/executor.js
Normal 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
36
server/lib/feedcheck.js
Normal 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);
|
||||
});
|
||||
}
|
22
server/lib/file-helpers.js
Normal file
22
server/lib/file-helpers.js
Normal 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
39
server/lib/helpers.js
Normal 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
60
server/lib/importer.js
Normal 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
14
server/lib/knex.js
Normal 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
8
server/lib/log.js
Normal 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
232
server/lib/mailers.js
Normal 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;
|
24
server/lib/namespace-helpers.js
Normal file
24
server/lib/namespace-helpers.js
Normal 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
15
server/lib/nodeify.js
Normal 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
229
server/lib/passport.js
Normal 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));
|
||||
}
|
||||
|
77
server/lib/privilege-helpers.js
Normal file
77
server/lib/privilege-helpers.js
Normal 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
|
||||
};
|
32
server/lib/report-helpers.js
Normal file
32
server/lib/report-helpers.js
Normal 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
|
||||
};
|
132
server/lib/report-processor.js
Normal file
132
server/lib/report-processor.js
Normal 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);
|
||||
}
|
||||
};
|
31
server/lib/router-async.js
Normal file
31
server/lib/router-async.js
Normal 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
63
server/lib/senders.js
Normal 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
|
||||
};
|
||||
|
164
server/lib/subscription-mail-helpers.js
Normal file
164
server/lib/subscription-mail-helpers.js
Normal 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
193
server/lib/tools.js
Normal 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
51
server/lib/translate.js
Normal 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
71
server/lib/urls.js
Normal 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
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue