commit
c4b80fff93
62 changed files with 3364 additions and 823 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -3,6 +3,8 @@ npm-debug.log
|
|||
.DS_Store
|
||||
config/development.*
|
||||
config/production.*
|
||||
workers/reports/config/development.*
|
||||
workers/reports/config/production.*
|
||||
dump.rdb
|
||||
|
||||
# generate POT file every time you want to update your PO file
|
||||
|
|
99
app.js
99
app.js
|
@ -1,47 +1,49 @@
|
|||
'use strict';
|
||||
|
||||
let config = require('config');
|
||||
let log = require('npmlog');
|
||||
const config = require('config');
|
||||
const log = require('npmlog');
|
||||
|
||||
let _ = require('./lib/translate')._;
|
||||
let util = require('util');
|
||||
const _ = require('./lib/translate')._;
|
||||
|
||||
let express = require('express');
|
||||
let bodyParser = require('body-parser');
|
||||
let path = require('path');
|
||||
let favicon = require('serve-favicon');
|
||||
let logger = require('morgan');
|
||||
let cookieParser = require('cookie-parser');
|
||||
let session = require('express-session');
|
||||
let RedisStore = require('connect-redis')(session);
|
||||
let flash = require('connect-flash');
|
||||
let hbs = require('hbs');
|
||||
let compression = require('compression');
|
||||
let passport = require('./lib/passport');
|
||||
let tools = require('./lib/tools');
|
||||
const express = require('express');
|
||||
const bodyParser = require('body-parser');
|
||||
const path = require('path');
|
||||
const favicon = require('serve-favicon');
|
||||
const logger = require('morgan');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const session = require('express-session');
|
||||
const RedisStore = require('connect-redis')(session);
|
||||
const flash = require('connect-flash');
|
||||
const hbs = require('hbs');
|
||||
const handlebarsHelpers = require('./lib/handlebars-helpers');
|
||||
const compression = require('compression');
|
||||
const passport = require('./lib/passport');
|
||||
const tools = require('./lib/tools');
|
||||
|
||||
let routes = require('./routes/index');
|
||||
let users = require('./routes/users');
|
||||
let lists = require('./routes/lists');
|
||||
let settings = require('./routes/settings');
|
||||
let settingsModel = require('./lib/models/settings');
|
||||
let templates = require('./routes/templates');
|
||||
let campaigns = require('./routes/campaigns');
|
||||
let links = require('./routes/links');
|
||||
let fields = require('./routes/fields');
|
||||
let forms = require('./routes/forms');
|
||||
let segments = require('./routes/segments');
|
||||
let triggers = require('./routes/triggers');
|
||||
let webhooks = require('./routes/webhooks');
|
||||
let subscription = require('./routes/subscription');
|
||||
let archive = require('./routes/archive');
|
||||
let api = require('./routes/api');
|
||||
let blacklist = require('./routes/blacklist');
|
||||
let editorapi = require('./routes/editorapi');
|
||||
let grapejs = require('./routes/grapejs');
|
||||
let mosaico = require('./routes/mosaico');
|
||||
const routes = require('./routes/index');
|
||||
const users = require('./routes/users');
|
||||
const lists = require('./routes/lists');
|
||||
const settings = require('./routes/settings');
|
||||
const settingsModel = require('./lib/models/settings');
|
||||
const templates = require('./routes/templates');
|
||||
const campaigns = require('./routes/campaigns');
|
||||
const links = require('./routes/links');
|
||||
const fields = require('./routes/fields');
|
||||
const forms = require('./routes/forms');
|
||||
const segments = require('./routes/segments');
|
||||
const triggers = require('./routes/triggers');
|
||||
const webhooks = require('./routes/webhooks');
|
||||
const subscription = require('./routes/subscription');
|
||||
const archive = require('./routes/archive');
|
||||
const api = require('./routes/api');
|
||||
const blacklist = require('./routes/blacklist');
|
||||
const editorapi = require('./routes/editorapi');
|
||||
const grapejs = require('./routes/grapejs');
|
||||
const mosaico = require('./routes/mosaico');
|
||||
const reports = require('./routes/reports');
|
||||
const reportsTemplates = require('./routes/report-templates');
|
||||
|
||||
let app = express();
|
||||
const app = express();
|
||||
|
||||
// view engine setup
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
|
@ -57,6 +59,8 @@ app.disable('x-powered-by');
|
|||
|
||||
hbs.registerPartials(__dirname + '/views/partials');
|
||||
hbs.registerPartials(__dirname + '/views/subscription/partials/');
|
||||
hbs.registerPartials(__dirname + '/views/report-templates/partials/');
|
||||
hbs.registerPartials(__dirname + '/views/reports/partials/');
|
||||
|
||||
/**
|
||||
* We need this helper to make sure that we consume flash messages only
|
||||
|
@ -104,20 +108,8 @@ hbs.registerHelper('flash_messages', function () { // eslint-disable-line prefer
|
|||
);
|
||||
});
|
||||
|
||||
// {{#translate}}abc{{/translate}}
|
||||
hbs.registerHelper('translate', function (context, options) { // eslint-disable-line prefer-arrow-callback
|
||||
if (typeof options === 'undefined' && context) {
|
||||
options = context;
|
||||
context = false;
|
||||
}
|
||||
handlebarsHelpers.registerHelpers(hbs.handlebars);
|
||||
|
||||
let result = _(options.fn(this)); // eslint-disable-line no-invalid-this
|
||||
|
||||
if (Array.isArray(context)) {
|
||||
result = util.format(result, ...context);
|
||||
}
|
||||
return new hbs.handlebars.SafeString(result);
|
||||
});
|
||||
|
||||
app.use(compression());
|
||||
app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
|
||||
|
@ -222,6 +214,11 @@ app.use('/editorapi', editorapi);
|
|||
app.use('/grapejs', grapejs);
|
||||
app.use('/mosaico', mosaico);
|
||||
|
||||
if (config.reports && config.reports.enabled === true) {
|
||||
app.use('/reports', reports);
|
||||
app.use('/report-templates', reportsTemplates);
|
||||
}
|
||||
|
||||
// catch 404 and forward to error handler
|
||||
app.use((req, res, next) => {
|
||||
let err = new Error(_('Not Found'));
|
||||
|
|
|
@ -43,8 +43,14 @@ language="en"
|
|||
|
||||
# If you start out as a root user (eg. if you want to use ports lower than 1000)
|
||||
# then you can downgrade the user once all services are up and running
|
||||
#user="nobody"
|
||||
#group="nogroup"
|
||||
#user="mailtrain"
|
||||
#group="mailtrain"
|
||||
|
||||
# If Mailtrain is started as root, "Reports" feature drops the privileges of script generating the report to disallow
|
||||
# any modifications of Mailtrain code and even prohibits reading the production configuration (which contains the MySQL
|
||||
# password for read/write operations). The rouser/rogroup determines the user to be used
|
||||
#rouser="nobody"
|
||||
#rogroup="nogroup"
|
||||
|
||||
[log]
|
||||
# silly|verbose|info|http|warn|error|silent
|
||||
|
@ -150,3 +156,18 @@ templates=[["versafix-1", "Versafix One"]]
|
|||
[grapejs]
|
||||
# Installed templates
|
||||
templates=[["demo", "Demo Template"]]
|
||||
|
||||
[reports]
|
||||
# The whole reporting functionality can be disabled below if the they are not needed and the DB cannot be
|
||||
# properly protected.
|
||||
# Reports rely on custom user defined Javascript snippets defined in the report template. The snippets are run on the
|
||||
# server when generating a report. As these snippets are stored in the DB, they pose a security risk because they can
|
||||
# help gaining access to the server if the DB cannot
|
||||
# be properly protected (e.g. if it is shared with another application with security weaknesses).
|
||||
# Mailtrain mitigates this problem by running the custom Javascript snippets in a chrooted environment and under a
|
||||
# DB user that cannot modify the database (see userRO in [mysql] above). However the chrooted environment is available
|
||||
# only if Mailtrain is started as root. The chrooted environment still does not prevent the custom JS script in
|
||||
# performing network operations and in generating XSS attacks as part of the report.
|
||||
# The bottom line is that if people who are creating report templates or have write access to the DB cannot be trusted,
|
||||
# then it's safer to switch off the reporting functionality below.
|
||||
enabled=false
|
||||
|
|
80
index.js
80
index.js
|
@ -4,20 +4,23 @@
|
|||
* Module dependencies.
|
||||
*/
|
||||
|
||||
let config = require('config');
|
||||
let log = require('npmlog');
|
||||
let app = require('./app');
|
||||
let http = require('http');
|
||||
let fork = require('child_process').fork;
|
||||
let triggers = require('./services/triggers');
|
||||
let importer = require('./services/importer');
|
||||
let verpServer = require('./services/verp-server');
|
||||
let testServer = require('./services/test-server');
|
||||
let postfixBounceServer = require('./services/postfix-bounce-server');
|
||||
let tzupdate = require('./services/tzupdate');
|
||||
let feedcheck = require('./services/feedcheck');
|
||||
let dbcheck = require('./lib/dbcheck');
|
||||
let tools = require('./lib/tools');
|
||||
const config = require('config');
|
||||
const log = require('npmlog');
|
||||
const app = require('./app');
|
||||
const http = require('http');
|
||||
const fork = require('child_process').fork;
|
||||
const triggers = require('./services/triggers');
|
||||
const importer = require('./services/importer');
|
||||
const verpServer = require('./services/verp-server');
|
||||
const testServer = require('./services/test-server');
|
||||
const postfixBounceServer = require('./services/postfix-bounce-server');
|
||||
const tzupdate = require('./services/tzupdate');
|
||||
const feedcheck = require('./services/feedcheck');
|
||||
const dbcheck = require('./lib/dbcheck');
|
||||
const tools = require('./lib/tools');
|
||||
const reportProcessor = require('./lib/report-processor');
|
||||
const executor = require('./lib/executor');
|
||||
const privilegeHelpers = require('./lib/privilege-helpers');
|
||||
|
||||
let port = config.www.port;
|
||||
let host = config.www.host;
|
||||
|
@ -112,31 +115,22 @@ server.on('listening', () => {
|
|||
log.info('Express', 'WWW server listening on %s', bind);
|
||||
|
||||
// start additional services
|
||||
testServer(() => {
|
||||
verpServer(() => {
|
||||
tzupdate(() => {
|
||||
importer(() => {
|
||||
triggers(() => {
|
||||
spawnSenders(() => {
|
||||
feedcheck(() => {
|
||||
postfixBounceServer(() => {
|
||||
log.info('Service', 'All services started');
|
||||
if (config.group) {
|
||||
try {
|
||||
process.setgid(config.group);
|
||||
log.info('Service', 'Changed group to "%s" (%s)', config.group, process.getgid());
|
||||
} catch (E) {
|
||||
log.info('Service', 'Failed to change group to "%s" (%s)', config.group, E.message);
|
||||
}
|
||||
}
|
||||
if (config.user) {
|
||||
try {
|
||||
process.setuid(config.user);
|
||||
log.info('Service', 'Changed user to "%s" (%s)', config.user, process.getuid());
|
||||
} catch (E) {
|
||||
log.info('Service', 'Failed to change user to "%s" (%s)', config.user, E.message);
|
||||
}
|
||||
}
|
||||
function startNextServices() {
|
||||
testServer(() => {
|
||||
verpServer(() => {
|
||||
|
||||
privilegeHelpers.dropRootPrivileges();
|
||||
|
||||
tzupdate(() => {
|
||||
importer(() => {
|
||||
triggers(() => {
|
||||
spawnSenders(() => {
|
||||
feedcheck(() => {
|
||||
postfixBounceServer(() => {
|
||||
reportProcessor.init(() => {
|
||||
log.info('Service', 'All services started');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -144,5 +138,11 @@ server.on('listening', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (config.reports && config.reports.enabled === true) {
|
||||
executor.spawn(startNextServices);
|
||||
} else {
|
||||
startNextServices();
|
||||
}
|
||||
});
|
||||
|
|
|
@ -6,7 +6,7 @@ let redis = require('redis');
|
|||
let Lock = require('redfour');
|
||||
|
||||
module.exports = mysql.createPool(config.mysql);
|
||||
if (config.redis.enabled) {
|
||||
if (config.redis && config.redis.enabled) {
|
||||
|
||||
module.exports.redis = redis.createClient(config.redis);
|
||||
|
||||
|
|
83
lib/executor.js
Normal file
83
lib/executor.js
Normal file
|
@ -0,0 +1,83 @@
|
|||
'use strict';
|
||||
|
||||
const fork = require('child_process').fork;
|
||||
const log = require('npmlog');
|
||||
const path = require('path');
|
||||
|
||||
const requestCallbacks = {};
|
||||
let messageTid = 0;
|
||||
let executorProcess;
|
||||
|
||||
module.exports = {
|
||||
spawn,
|
||||
start,
|
||||
stop
|
||||
};
|
||||
|
||||
function spawn(callback) {
|
||||
log.info('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
|
||||
});
|
||||
}
|
||||
|
32
lib/file-helpers.js
Normal file
32
lib/file-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
|
||||
};
|
49
lib/handlebars-helpers.js
Normal file
49
lib/handlebars-helpers.js
Normal file
|
@ -0,0 +1,49 @@
|
|||
'use strict';
|
||||
|
||||
const util = require('util');
|
||||
|
||||
const _ = require('../lib/translate')._;
|
||||
|
||||
module.exports.registerHelpers = handlebars => {
|
||||
// {{#translate}}abc{{/translate}}
|
||||
handlebars.registerHelper('translate', function (context, options) { // eslint-disable-line prefer-arrow-callback
|
||||
if (typeof options === 'undefined' && context) {
|
||||
options = context;
|
||||
context = false;
|
||||
}
|
||||
|
||||
let result = _(options.fn(this)); // eslint-disable-line no-invalid-this
|
||||
|
||||
if (Array.isArray(context)) {
|
||||
result = util.format(result, ...context);
|
||||
}
|
||||
return new handlebars.SafeString(result);
|
||||
});
|
||||
|
||||
|
||||
/* Credits to http://chrismontrois.net/2016/01/30/handlebars-switch/
|
||||
|
||||
{{#switch letter}}
|
||||
{{#case "a"}}
|
||||
A is for alpaca
|
||||
{{/case}}
|
||||
{{#case "b"}}
|
||||
B is for bluebird
|
||||
{{/case}}
|
||||
{{/switch}}
|
||||
*/
|
||||
/* eslint no-invalid-this: "off" */
|
||||
handlebars.registerHelper('switch', function(value, options) {
|
||||
this._switch_value_ = value;
|
||||
const html = options.fn(this); // Process the body of the switch block
|
||||
delete this._switch_value_;
|
||||
return html;
|
||||
});
|
||||
|
||||
handlebars.registerHelper('case', function(value, options) {
|
||||
if (value === this._switch_value_) {
|
||||
return options.fn(this);
|
||||
}
|
||||
});
|
||||
|
||||
};
|
|
@ -14,186 +14,44 @@ let mailer = require('../mailer');
|
|||
let humanize = require('humanize');
|
||||
let _ = require('../translate')._;
|
||||
let util = require('util');
|
||||
let tableHelpers = require('../table-helpers');
|
||||
|
||||
let allowedKeys = ['description', 'from', 'address', 'reply_to', 'subject', 'editor_name', 'editor_data', 'template', 'source_url', 'list', 'segment', 'html', 'text', 'tracking_disabled'];
|
||||
|
||||
module.exports.list = (start, limit, callback) => {
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
connection.query('SELECT SQL_CALC_FOUND_ROWS * FROM campaigns ORDER BY scheduled DESC LIMIT ? OFFSET ?', [limit, start], (err, rows) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
connection.query('SELECT FOUND_ROWS() AS total', (err, total) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
return callback(null, rows, total && total[0] && total[0].total);
|
||||
});
|
||||
});
|
||||
});
|
||||
tableHelpers.list('campaigns', ['*'], 'scheduled', null, start, limit, callback);
|
||||
};
|
||||
|
||||
module.exports.filter = (request, parent, callback) => {
|
||||
let columns = ['#', 'name', 'description', 'status', 'created'];
|
||||
let processQuery = queryData => {
|
||||
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let query = 'SELECT COUNT(id) AS total FROM `campaigns`';
|
||||
let values = [];
|
||||
|
||||
if (queryData.where) {
|
||||
query += ' WHERE ' + queryData.where;
|
||||
values = values.concat(queryData.values || []);
|
||||
}
|
||||
|
||||
connection.query(query, values, (err, total) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
total = total && total[0] && total[0].total || 0;
|
||||
|
||||
let ordering = [];
|
||||
|
||||
if (request.order && request.order.length) {
|
||||
|
||||
request.order.forEach(order => {
|
||||
let orderField = columns[Number(order.column)];
|
||||
let orderDirection = (order.dir || '').toString().toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
||||
if (orderField) {
|
||||
ordering.push('`' + orderField + '` ' + orderDirection);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!ordering.length) {
|
||||
ordering.push('`created` DESC');
|
||||
}
|
||||
|
||||
let args = [Number(request.length) || 50, Number(request.start) || 0];
|
||||
let query;
|
||||
|
||||
if (request.search && request.search.value) {
|
||||
query = 'SELECT SQL_CALC_FOUND_ROWS * FROM `campaigns` WHERE name LIKE ? ' + (queryData.where ? ' AND (' + queryData.where + ')' : '') + ' ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?';
|
||||
|
||||
let searchVal = '%' + request.search.value.replace(/\\/g, '\\\\').replace(/([%_])/g, '\\$1') + '%';
|
||||
args = [searchVal].concat(queryData.values || []).concat(args);
|
||||
} else {
|
||||
query = 'SELECT SQL_CALC_FOUND_ROWS * FROM `campaigns` WHERE 1 ' + (queryData.where ? ' AND (' + queryData.where + ')' : '') + ' ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?';
|
||||
args = [].concat(queryData.values || []).concat(args);
|
||||
}
|
||||
|
||||
connection.query(query, args, (err, rows) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
connection.query('SELECT FOUND_ROWS() AS total', (err, filteredTotal) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let subscriptions = rows.map(row => tools.convertKeys(row));
|
||||
|
||||
filteredTotal = filteredTotal && filteredTotal[0] && filteredTotal[0].total || 0;
|
||||
return callback(null, subscriptions, total, filteredTotal);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
let queryData;
|
||||
if (parent) {
|
||||
processQuery({
|
||||
queryData = {
|
||||
// only find normal and RSS parent campaigns at this point
|
||||
where: '`parent`=?',
|
||||
values: [parent]
|
||||
});
|
||||
};
|
||||
} else {
|
||||
|
||||
processQuery({
|
||||
queryData = {
|
||||
// only find normal and RSS parent campaigns at this point
|
||||
where: '`type` IN (?,?,?)',
|
||||
values: [1, 2, 4]
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
tableHelpers.filter('campaigns', ['*'], request, ['#', 'name', 'description', 'status', 'created'], ['name'], 'created DESC', queryData, callback);
|
||||
};
|
||||
|
||||
module.exports.filterQuicklist = (request, callback) => {
|
||||
tableHelpers.filter('campaigns', ['id', 'name', 'description', 'created'], request, ['#', 'name', 'description', 'created'], ['name'], 'name ASC', null, callback);
|
||||
};
|
||||
|
||||
module.exports.filterClickedSubscribers = (campaign, linkId, request, columns, callback) => {
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
let queryData = {
|
||||
where: 'campaign_tracker__' + campaign.id + '.list=? AND campaign_tracker__' + campaign.id + '.link=?',
|
||||
values: [campaign.list, linkId]
|
||||
};
|
||||
|
||||
let query = 'SELECT COUNT(`subscription__' + campaign.list + '`.`id`) AS total FROM `subscription__' + campaign.list + '` JOIN `campaign_tracker__' + campaign.id + '` ON `campaign_tracker__' + campaign.id + '`.`list`=? AND `campaign_tracker__' + campaign.id + '`.`subscriber`=`subscription__' + campaign.list + '`.`id` AND `campaign_tracker__' + campaign.id + '`.`link`=?';
|
||||
let values = [campaign.list, linkId];
|
||||
|
||||
connection.query(query, values, (err, total) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
total = total && total[0] && total[0].total || 0;
|
||||
|
||||
let ordering = [];
|
||||
|
||||
if (request.order && request.order.length) {
|
||||
|
||||
request.order.forEach(order => {
|
||||
let orderField = columns[Number(order.column)];
|
||||
let orderDirection = (order.dir || '').toString().toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
||||
if (orderField) {
|
||||
ordering.push('`' + orderField + '` ' + orderDirection);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!ordering.length) {
|
||||
ordering.push('`email` ASC');
|
||||
}
|
||||
|
||||
let args = [Number(request.length) || 50, Number(request.start) || 0];
|
||||
|
||||
if (request.search && request.search.value) {
|
||||
query = 'SELECT SQL_CALC_FOUND_ROWS * FROM `subscription__' + campaign.list + '` JOIN `campaign_tracker__' + campaign.id + '` ON `campaign_tracker__' + campaign.id + '`.`list`=? AND `campaign_tracker__' + campaign.id + '`.`subscriber`=`subscription__' + campaign.list + '`.`id` AND `campaign_tracker__' + campaign.id + '`.`link`=? WHERE email LIKE ? OR first_name LIKE ? OR last_name LIKE ? ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?';
|
||||
|
||||
let searchVal = '%' + request.search.value.replace(/\\/g, '\\\\').replace(/([%_])/g, '\\$1') + '%';
|
||||
args = values.concat([searchVal, searchVal, searchVal]).concat(args);
|
||||
} else {
|
||||
query = 'SELECT SQL_CALC_FOUND_ROWS * FROM `subscription__' + campaign.list + '` JOIN `campaign_tracker__' + campaign.id + '` ON `campaign_tracker__' + campaign.id + '`.`list`=? AND `campaign_tracker__' + campaign.id + '`.`subscriber`=`subscription__' + campaign.list + '`.`id` AND `campaign_tracker__' + campaign.id + '`.`link`=? ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?';
|
||||
args = values.concat(args);
|
||||
}
|
||||
|
||||
connection.query(query, args, (err, rows) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
connection.query('SELECT FOUND_ROWS() AS total', (err, filteredTotal) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let subscriptions = rows.map(row => tools.convertKeys(row));
|
||||
|
||||
filteredTotal = filteredTotal && filteredTotal[0] && filteredTotal[0].total || 0;
|
||||
return callback(null, subscriptions, total, filteredTotal);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
tableHelpers.filter('subscription__' + campaign.list + ' JOIN campaign_tracker__' + campaign.id + ' ON campaign_tracker__' + campaign.id + '.subscriber=subscription__' + campaign.list + '.id', ['*'], request, columns, ['email', 'first_name', 'last_name'], 'email ASC', queryData, callback);
|
||||
};
|
||||
|
||||
module.exports.statsClickedSubscribersByColumn = (campaign, linkId, request, column, limit, callback) => {
|
||||
|
@ -233,72 +91,12 @@ module.exports.statsClickedSubscribersByColumn = (campaign, linkId, request, col
|
|||
};
|
||||
|
||||
module.exports.filterStatusSubscribers = (campaign, status, request, columns, callback) => {
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
status = Number(status) || 0;
|
||||
let query = 'SELECT COUNT(`subscription__' + campaign.list + '`.`id`) AS total FROM `subscription__' + campaign.list + '` JOIN `campaign__' + campaign.id + '` ON `campaign__' + campaign.id + '`.`list`=? AND `campaign__' + campaign.id + '`.`segment`=? AND `campaign__' + campaign.id + '`.`subscription`=`subscription__' + campaign.list + '`.`id` WHERE `campaign__' + campaign.id + '`.`status`=?';
|
||||
let values = [campaign.list, campaign.segment && campaign.segment.id || 0, status];
|
||||
|
||||
connection.query(query, values, (err, total) => {
|
||||
if (err) {
|
||||
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
total = total && total[0] && total[0].total || 0;
|
||||
|
||||
let ordering = [];
|
||||
|
||||
if (request.order && request.order.length) {
|
||||
|
||||
request.order.forEach(order => {
|
||||
let orderField = columns[Number(order.column)];
|
||||
let orderDirection = (order.dir || '').toString().toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
||||
if (orderField) {
|
||||
ordering.push('`' + orderField + '` ' + orderDirection);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!ordering.length) {
|
||||
ordering.push('`email` ASC');
|
||||
}
|
||||
|
||||
let args = [Number(request.length) || 50, Number(request.start) || 0];
|
||||
|
||||
if (request.search && request.search.value) {
|
||||
query = 'SELECT SQL_CALC_FOUND_ROWS * FROM `subscription__' + campaign.list + '` JOIN `campaign__' + campaign.id + '` ON `campaign__' + campaign.id + '`.`list`=? AND `campaign__' + campaign.id + '`.`segment`=? AND `campaign__' + campaign.id + '`.`subscription`=`subscription__' + campaign.list + '`.`id` WHERE `campaign__' + campaign.id + '`.`status`=? AND (email LIKE ? OR first_name LIKE ? OR last_name LIKE ?) ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?';
|
||||
|
||||
let searchVal = '%' + request.search.value.replace(/\\/g, '\\\\').replace(/([%_])/g, '\\$1') + '%';
|
||||
args = values.concat([searchVal, searchVal, searchVal]).concat(args);
|
||||
} else {
|
||||
query = 'SELECT SQL_CALC_FOUND_ROWS * FROM `subscription__' + campaign.list + '` JOIN `campaign__' + campaign.id + '` ON `campaign__' + campaign.id + '`.`list`=? AND `campaign__' + campaign.id + '`.`segment`=? AND `campaign__' + campaign.id + '`.`subscription`=`subscription__' + campaign.list + '`.`id` WHERE `campaign__' + campaign.id + '`.`status`=? ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?';
|
||||
args = values.concat(args);
|
||||
}
|
||||
|
||||
connection.query(query, args, (err, rows) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
connection.query('SELECT FOUND_ROWS() AS total', (err, filteredTotal) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let subscriptions = rows.map(row => tools.convertKeys(row));
|
||||
|
||||
filteredTotal = filteredTotal && filteredTotal[0] && filteredTotal[0].total || 0;
|
||||
return callback(null, subscriptions, total, filteredTotal);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
let queryData = {
|
||||
where: 'campaign__' + campaign.id + '.list=? AND campaign__' + campaign.id + '.segment=? AND campaign__' + campaign.id + '.status=?',
|
||||
values: [campaign.list, campaign.segment && campaign.segment.id || 0, status]
|
||||
};
|
||||
|
||||
tableHelpers.filter('subscription__' + campaign.list + ' JOIN campaign__' + campaign.id + ' ON campaign__' + campaign.id + '.subscription=subscription__' + campaign.list + '.id', ['*'], request, columns, ['email', 'first_name', 'last_name'], 'email ASC', queryData, callback);
|
||||
};
|
||||
|
||||
module.exports.getByCid = (cid, callback) => {
|
||||
|
|
|
@ -5,29 +5,20 @@ let tools = require('../tools');
|
|||
let shortid = require('shortid');
|
||||
let segments = require('./segments');
|
||||
let _ = require('../translate')._;
|
||||
let tableHelpers = require('../table-helpers');
|
||||
|
||||
let allowedKeys = ['description', 'default_form'];
|
||||
let allowedKeys = ['description', 'default_form', 'public_subscribe'];
|
||||
|
||||
module.exports.list = (start, limit, callback) => {
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
tableHelpers.list('lists', ['*'], 'name', null, start, limit, callback);
|
||||
};
|
||||
|
||||
connection.query('SELECT SQL_CALC_FOUND_ROWS * FROM lists ORDER BY name LIMIT ? OFFSET ?', [limit, start], (err, rows) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
connection.query('SELECT FOUND_ROWS() AS total', (err, total) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
return callback(null, rows, total && total[0] && total[0].total);
|
||||
});
|
||||
});
|
||||
});
|
||||
module.exports.filter = (request, parent, callback) => {
|
||||
tableHelpers.filter('lists', ['*'], request, ['#', 'name', 'cid', 'subscribers', 'description'], ['name'], 'name ASC', null, callback);
|
||||
};
|
||||
|
||||
module.exports.filterQuicklist = (request, callback) => {
|
||||
tableHelpers.filter('lists', ['id', 'name', 'subscribers'], request, ['#', 'name', 'subscribers'], ['name'], 'name ASC', null, callback);
|
||||
};
|
||||
|
||||
module.exports.quicklist = callback => {
|
||||
|
@ -111,6 +102,8 @@ module.exports.get = (id, callback) => {
|
|||
module.exports.create = (list, callback) => {
|
||||
|
||||
let data = tools.convertKeys(list);
|
||||
data.publicSubscribe = data.publicSubscribe ? 1 : 0;
|
||||
|
||||
let name = (data.name || '').toString().trim();
|
||||
|
||||
if (!data) {
|
||||
|
@ -120,8 +113,8 @@ module.exports.create = (list, callback) => {
|
|||
let keys = ['name'];
|
||||
let values = [name];
|
||||
|
||||
Object.keys(list).forEach(key => {
|
||||
let value = list[key].trim();
|
||||
Object.keys(data).forEach(key => {
|
||||
let value = data[key].toString().trim();
|
||||
key = tools.toDbKey(key);
|
||||
if (key === 'description') {
|
||||
value = tools.purifyHTML(value);
|
||||
|
@ -169,6 +162,7 @@ module.exports.update = (id, updates, callback) => {
|
|||
id = Number(id) || 0;
|
||||
|
||||
let data = tools.convertKeys(updates);
|
||||
data.publicSubscribe = data.publicSubscribe ? 1 : 0;
|
||||
|
||||
let name = (data.name || '').toString().trim();
|
||||
let keys = ['name'];
|
||||
|
@ -182,8 +176,8 @@ module.exports.update = (id, updates, callback) => {
|
|||
return callback(new Error(_('List Name must be set')));
|
||||
}
|
||||
|
||||
Object.keys(updates).forEach(key => {
|
||||
let value = updates[key].trim();
|
||||
Object.keys(data).forEach(key => {
|
||||
let value = data[key].toString().trim();
|
||||
key = tools.toDbKey(key);
|
||||
if (key === 'description') {
|
||||
value = tools.purifyHTML(value);
|
||||
|
|
161
lib/models/report-templates.js
Normal file
161
lib/models/report-templates.js
Normal file
|
@ -0,0 +1,161 @@
|
|||
'use strict';
|
||||
|
||||
const db = require('../db');
|
||||
const tableHelpers = require('../table-helpers');
|
||||
const tools = require('../tools');
|
||||
const _ = require('../translate')._;
|
||||
|
||||
const allowedKeys = ['name', 'description', 'mime_type', 'user_fields', 'js', 'hbs'];
|
||||
|
||||
module.exports.list = (start, limit, callback) => {
|
||||
tableHelpers.list('report_templates', ['*'], 'name', null, start, limit, callback);
|
||||
};
|
||||
|
||||
module.exports.quicklist = callback => {
|
||||
tableHelpers.quicklist('report_templates', ['id', 'name'], 'name', callback);
|
||||
};
|
||||
|
||||
module.exports.filter = (request, callback) => {
|
||||
tableHelpers.filter('report_templates', ['*'], request, ['#', 'name', 'description', 'created'], ['name'], 'created DESC', null, callback);
|
||||
};
|
||||
|
||||
module.exports.get = (id, callback) => {
|
||||
id = Number(id) || 0;
|
||||
|
||||
if (id < 1) {
|
||||
return callback(new Error(_('Missing report template ID')));
|
||||
}
|
||||
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
connection.query('SELECT * FROM report_templates WHERE id=?', [id], (err, rows) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (!rows || !rows.length) {
|
||||
return callback(null, false);
|
||||
}
|
||||
|
||||
const template = tools.convertKeys(rows[0]);
|
||||
|
||||
const userFields = template.userFields.trim();
|
||||
if (userFields !== '') {
|
||||
try {
|
||||
template.userFieldsObject = JSON.parse(userFields);
|
||||
} catch (err) {
|
||||
// This is to handle situation when for some reason we get corrupted JSON in the DB.
|
||||
template.userFieldsObject = {};
|
||||
template.userFields = '{}';
|
||||
}
|
||||
} else {
|
||||
template.userFieldsObject = {};
|
||||
}
|
||||
|
||||
return callback(null, template);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.createOrUpdate = (createMode, data, callback) => {
|
||||
data = data || {};
|
||||
|
||||
const id = 'id' in data ? Number(data.id) : 0;
|
||||
|
||||
if (!createMode && id < 1) {
|
||||
return callback(new Error(_('Missing report template ID')));
|
||||
}
|
||||
|
||||
const template = tools.convertKeys(data);
|
||||
const name = (template.name || '').toString().trim();
|
||||
|
||||
if (!name) {
|
||||
return callback(new Error(_('Report template name must be set')));
|
||||
}
|
||||
|
||||
const keys = ['name'];
|
||||
const values = [name];
|
||||
|
||||
Object.keys(template).forEach(key => {
|
||||
let value = typeof template[key] === 'number' ? template[key] : (template[key] || '').toString().trim();
|
||||
key = tools.toDbKey(key);
|
||||
|
||||
if (key === 'description') {
|
||||
value = tools.purifyHTML(value);
|
||||
}
|
||||
|
||||
if (key === 'user_fields') {
|
||||
value = value.trim();
|
||||
|
||||
if (value !== '') {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
} catch (err) {
|
||||
return callback(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allowedKeys.indexOf(key) >= 0 && keys.indexOf(key) < 0) {
|
||||
keys.push(key);
|
||||
values.push(value);
|
||||
}
|
||||
});
|
||||
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let query;
|
||||
|
||||
if (createMode) {
|
||||
query = 'INSERT INTO report_templates (`' + keys.join('`, `') + '`) VALUES (' + values.map(() => '?').join(',') + ')';
|
||||
} else {
|
||||
query = 'UPDATE report_templates SET ' + keys.map(key => '`' + key + '`=?').join(', ') + ' WHERE id=? LIMIT 1';
|
||||
values.push(id);
|
||||
}
|
||||
|
||||
connection.query(query, values, (err, result) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (createMode) {
|
||||
return callback(null, result && result.insertId || false);
|
||||
} else {
|
||||
return callback(null, result && result.affectedRows || false);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.delete = (id, callback) => {
|
||||
id = Number(id) || 0;
|
||||
|
||||
if (id < 1) {
|
||||
return callback(new Error(_('Missing report template ID')));
|
||||
}
|
||||
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
connection.query('DELETE FROM report_templates WHERE id=? LIMIT 1', [id], (err, result) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
const affected = result && result.affectedRows || 0;
|
||||
return callback(err, affected);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
261
lib/models/reports.js
Normal file
261
lib/models/reports.js
Normal file
|
@ -0,0 +1,261 @@
|
|||
'use strict';
|
||||
|
||||
const db = require('../db');
|
||||
const tableHelpers = require('../table-helpers');
|
||||
const fields = require('./fields');
|
||||
const reportTemplates = require('./report-templates');
|
||||
const tools = require('../tools');
|
||||
const _ = require('../translate')._;
|
||||
|
||||
const allowedKeys = ['name', 'description', 'report_template', 'params'];
|
||||
|
||||
const ReportState = {
|
||||
SCHEDULED: 0,
|
||||
PROCESSING: 1,
|
||||
FINISHED: 2,
|
||||
FAILED: 3
|
||||
};
|
||||
|
||||
module.exports.ReportState = ReportState;
|
||||
|
||||
module.exports.list = (start, limit, callback) => {
|
||||
tableHelpers.list('reports', ['*'], 'name', null, start, limit, callback);
|
||||
};
|
||||
|
||||
module.exports.listWithState = (state, start, limit, callback) => {
|
||||
tableHelpers.list('reports', ['*'], 'name', { where: 'state=?', values: [state] }, start, limit, callback);
|
||||
};
|
||||
|
||||
module.exports.filter = (request, callback) => {
|
||||
tableHelpers.filter('reports JOIN report_templates ON reports.report_template = report_templates.id',
|
||||
['reports.id AS id', 'reports.name AS name', 'reports.description AS description', 'reports.state AS state', 'reports.report_template AS report_template', 'reports.params AS params', 'reports.last_run AS last_run', 'report_templates.name AS report_template_name', 'report_templates.mime_type AS mime_type' ],
|
||||
request, ['#', 'name', 'report_templates.name', 'description', 'last_run'], ['name'], 'name ASC', null, callback);
|
||||
};
|
||||
|
||||
module.exports.get = (id, callback) => {
|
||||
id = Number(id) || 0;
|
||||
|
||||
if (id < 1) {
|
||||
return callback(new Error(_('Missing report ID')));
|
||||
}
|
||||
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
connection.query('SELECT * FROM reports WHERE id=?', [id], (err, rows) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (!rows || !rows.length) {
|
||||
return callback(null, false);
|
||||
}
|
||||
|
||||
const template = tools.convertKeys(rows[0]);
|
||||
|
||||
const params = template.params.trim();
|
||||
if (params !== '') {
|
||||
try {
|
||||
template.paramsObject = JSON.parse(params);
|
||||
} catch (err) {
|
||||
return callback(err);
|
||||
}
|
||||
} else {
|
||||
template.params = {};
|
||||
}
|
||||
|
||||
return callback(null, template);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// This method is not supposed to be used for unsanitized inputs. It does not do any checks.
|
||||
module.exports.updateFields = (id, fieldValueMap, callback) => {
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
const clauses = [];
|
||||
const values = [];
|
||||
for (let key of Object.keys(fieldValueMap)) {
|
||||
clauses.push(tools.toDbKey(key) + '=?');
|
||||
values.push(fieldValueMap[key]);
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
|
||||
const query = 'UPDATE reports SET ' + clauses.join(', ') + ' WHERE id=? LIMIT 1';
|
||||
connection.query(query, values, (err, result) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
return callback(null, result && result.affectedRows || false);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.createOrUpdate = (createMode, report, callback) => {
|
||||
report = report || {};
|
||||
|
||||
const id = 'id' in report ? Number(report.id) : 0;
|
||||
|
||||
if (!createMode && id < 1) {
|
||||
return callback(new Error(_('Missing report ID')));
|
||||
}
|
||||
|
||||
const name = (report.name || '').toString().trim();
|
||||
|
||||
if (!name) {
|
||||
return callback(new Error(_('Report name must be set')));
|
||||
}
|
||||
|
||||
const reportTemplateId = Number(report.reportTemplate);
|
||||
reportTemplates.get(reportTemplateId, (err, reportTemplate) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
const params = report.paramsObject;
|
||||
for (const spec of reportTemplate.userFieldsObject) {
|
||||
if (params[spec.id].length < spec.minOccurences) {
|
||||
return callback(new Error(_('At least ' + spec.minOccurences + ' rows in "' + spec.name + '" have to be selected.')));
|
||||
}
|
||||
|
||||
if (params[spec.id].length > spec.maxOccurences) {
|
||||
return callback(new Error(_('At most ' + spec.minOccurences + ' rows in "' + spec.name + '" can be selected.')));
|
||||
}
|
||||
}
|
||||
|
||||
const keys = ['name', 'params'];
|
||||
const values = [name, JSON.stringify(params)];
|
||||
|
||||
|
||||
Object.keys(report).forEach(key => {
|
||||
let value = typeof report[key] === 'number' ? report[key] : (report[key] || '').toString().trim();
|
||||
key = tools.toDbKey(key);
|
||||
|
||||
if (key === 'description') {
|
||||
value = tools.purifyHTML(value);
|
||||
}
|
||||
|
||||
if (allowedKeys.indexOf(key) >= 0 && keys.indexOf(key) < 0) {
|
||||
keys.push(key);
|
||||
values.push(value);
|
||||
}
|
||||
});
|
||||
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let query;
|
||||
|
||||
if (createMode) {
|
||||
query = 'INSERT INTO reports (`' + keys.join('`, `') + '`) VALUES (' + values.map(() => '?').join(',') + ')';
|
||||
} else {
|
||||
query = 'UPDATE reports SET ' + keys.map(key => '`' + key + '`=?').join(', ') + ' WHERE id=? LIMIT 1';
|
||||
values.push(id);
|
||||
}
|
||||
|
||||
connection.query(query, values, (err, result) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (createMode) {
|
||||
return callback(null, result && result.insertId || false);
|
||||
} else {
|
||||
return callback(null, result && result.affectedRows || false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.delete = (id, callback) => {
|
||||
id = Number(id) || 0;
|
||||
|
||||
if (id < 1) {
|
||||
return callback(new Error(_('Missing report ID')));
|
||||
}
|
||||
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
connection.query('DELETE FROM reports WHERE id=? LIMIT 1', [id], (err, result) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
const affected = result && result.affectedRows || 0;
|
||||
return callback(err, affected);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const campaignFieldsMapping = {
|
||||
tracker_count: 'tracker.count',
|
||||
country: 'tracker.country',
|
||||
device_type: 'tracker.device_type',
|
||||
status: 'campaign.status',
|
||||
first_name: 'subscribers.first_name',
|
||||
last_name: 'subscribers.last_name',
|
||||
email: 'subscribers.email'
|
||||
};
|
||||
|
||||
module.exports.getCampaignResults = (campaign, select, clause, callback) => {
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
fields.list(campaign.list, (err, fieldList) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
const fieldsMapping = fieldList.reduce((map, field) => {
|
||||
map[customFieldName(field.key)] = 'subscribers.' + field.column;
|
||||
return map;
|
||||
}, Object.assign({}, campaignFieldsMapping));
|
||||
|
||||
let selFields = [];
|
||||
for (let idx = 0; idx < select.length; idx++) {
|
||||
const item = select[idx];
|
||||
if (item in fieldsMapping) {
|
||||
selFields.push(fieldsMapping[item] + ' AS ' + item);
|
||||
} else if (item === '*') {
|
||||
selFields = selFields.concat(Object.keys(fieldsMapping).map(item => fieldsMapping[item] + ' AS ' + item));
|
||||
} else {
|
||||
selFields.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const query = 'SELECT ' + selFields.join(', ') + ' FROM `subscription__' + campaign.list + '` subscribers INNER JOIN `campaign__' + campaign.id + '` campaign on subscribers.id=campaign.subscription LEFT JOIN `campaign_tracker__' + campaign.id + '` tracker on subscribers.id=tracker.subscriber ' + clause;
|
||||
|
||||
connection.query(query, (err, results) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
return callback(null, results);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
function customFieldName(id) {
|
||||
return id.replace(/MERGE_/, 'CUSTOM_').toLowerCase();
|
||||
}
|
|
@ -13,6 +13,7 @@ let urllib = require('url');
|
|||
let log = require('npmlog');
|
||||
let _ = require('../translate')._;
|
||||
let util = require('util');
|
||||
let tableHelpers = require('../table-helpers');
|
||||
|
||||
module.exports.list = (listId, start, limit, callback) => {
|
||||
listId = Number(listId) || 0;
|
||||
|
@ -20,26 +21,11 @@ module.exports.list = (listId, start, limit, callback) => {
|
|||
return callback(new Error('Missing List ID'));
|
||||
}
|
||||
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
tableHelpers.list('subscription__' + listId, ['*'], 'email', null, start, limit, (err, rows, total) => {
|
||||
if (!err) {
|
||||
rows = rows.map(row => tools.convertKeys(row));
|
||||
}
|
||||
|
||||
connection.query('SELECT SQL_CALC_FOUND_ROWS * FROM `subscription__' + listId + '` ORDER BY email LIMIT ? OFFSET ?', [limit, start], (err, rows) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
connection.query('SELECT FOUND_ROWS() AS total', (err, total) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let subscriptions = rows.map(row => tools.convertKeys(row));
|
||||
return callback(null, subscriptions, total && total[0] && total[0].total);
|
||||
});
|
||||
});
|
||||
return callback(err, rows, total);
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -80,7 +66,6 @@ module.exports.listTestUsers = (listId, callback) => {
|
|||
});
|
||||
};
|
||||
|
||||
|
||||
module.exports.filter = (listId, request, columns, segmentId, callback) => {
|
||||
listId = Number(listId) || 0;
|
||||
segmentId = Number(segmentId) || 0;
|
||||
|
@ -89,88 +74,16 @@ module.exports.filter = (listId, request, columns, segmentId, callback) => {
|
|||
return callback(new Error(_('Missing List ID')));
|
||||
}
|
||||
|
||||
let processQuery = queryData => {
|
||||
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let query = 'SELECT COUNT(id) AS total FROM `subscription__' + listId + '`';
|
||||
let values = [];
|
||||
|
||||
if (queryData.where) {
|
||||
query += ' WHERE ' + queryData.where;
|
||||
values = values.concat(queryData.values || []);
|
||||
}
|
||||
|
||||
connection.query(query, values, (err, total) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
total = total && total[0] && total[0].total || 0;
|
||||
|
||||
let ordering = [];
|
||||
|
||||
if (request.order && request.order.length) {
|
||||
|
||||
request.order.forEach(order => {
|
||||
let orderField = columns[Number(order.column)];
|
||||
let orderDirection = (order.dir || '').toString().toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
||||
if (orderField) {
|
||||
ordering.push('`' + orderField + '` ' + orderDirection);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!ordering.length) {
|
||||
ordering.push('`email` ASC');
|
||||
}
|
||||
|
||||
let args = [Number(request.length) || 50, Number(request.start) || 0];
|
||||
let query;
|
||||
|
||||
if (request.search && request.search.value) {
|
||||
query = 'SELECT SQL_CALC_FOUND_ROWS * FROM `subscription__' + listId + '` WHERE email LIKE ? OR first_name LIKE ? OR last_name LIKE ? ' + (queryData.where ? ' AND (' + queryData.where + ')' : '') + ' ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?';
|
||||
|
||||
let searchVal = '%' + request.search.value.replace(/\\/g, '\\\\').replace(/([%_])/g, '\\$1') + '%';
|
||||
args = [searchVal, searchVal, searchVal].concat(queryData.values || []).concat(args);
|
||||
} else {
|
||||
query = 'SELECT SQL_CALC_FOUND_ROWS * FROM `subscription__' + listId + '` WHERE 1 ' + (queryData.where ? ' AND (' + queryData.where + ')' : '') + ' ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?';
|
||||
args = [].concat(queryData.values || []).concat(args);
|
||||
}
|
||||
|
||||
connection.query(query, args, (err, rows) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
connection.query('SELECT FOUND_ROWS() AS total', (err, filteredTotal) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let subscriptions = rows.map(row => tools.convertKeys(row));
|
||||
|
||||
filteredTotal = filteredTotal && filteredTotal[0] && filteredTotal[0].total || 0;
|
||||
return callback(null, subscriptions, total, filteredTotal);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (segmentId) {
|
||||
segments.getQuery(segmentId, false, (err, queryData) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
processQuery(queryData);
|
||||
|
||||
tableHelpers.filter('subscription__' + listId, ['*'], request, columns, ['email', 'first_name', 'last_name'], 'email ASC', queryData, callback);
|
||||
});
|
||||
} else {
|
||||
processQuery(false);
|
||||
tableHelpers.filter('subscription__' + listId, ['*'], request, columns, ['email', 'first_name', 'last_name'], 'email ASC', null, callback);
|
||||
}
|
||||
|
||||
};
|
||||
|
|
|
@ -3,45 +3,20 @@
|
|||
let db = require('../db');
|
||||
let tools = require('../tools');
|
||||
let _ = require('../translate')._;
|
||||
let tableHelpers = require('../table-helpers');
|
||||
|
||||
let allowedKeys = ['description', 'editor_name', 'editor_data', 'html', 'text'];
|
||||
|
||||
module.exports.list = (start, limit, callback) => {
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
tableHelpers.list('templates', ['*'], 'name', null, start, limit, callback);
|
||||
};
|
||||
|
||||
connection.query('SELECT SQL_CALC_FOUND_ROWS * FROM templates ORDER BY name LIMIT ? OFFSET ?', [limit, start], (err, rows) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
connection.query('SELECT FOUND_ROWS() AS total', (err, total) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
return callback(null, rows, total && total[0] && total[0].total);
|
||||
});
|
||||
});
|
||||
});
|
||||
module.exports.filter = (request, parent, callback) => {
|
||||
tableHelpers.filter('templates', ['*'], request, ['#', 'name', 'description'], ['name'], 'name ASC', null, callback);
|
||||
};
|
||||
|
||||
module.exports.quicklist = callback => {
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
connection.query('SELECT id, name FROM templates ORDER BY name LIMIT 1000', (err, rows) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
return callback(null, (rows || []).map(tools.convertKeys));
|
||||
});
|
||||
});
|
||||
tableHelpers.quicklist('templates', ['id', 'name'], 'name', callback);
|
||||
};
|
||||
|
||||
module.exports.get = (id, callback) => {
|
||||
|
|
|
@ -5,6 +5,7 @@ let db = require('../db');
|
|||
let lists = require('./lists');
|
||||
let util = require('util');
|
||||
let _ = require('../translate')._;
|
||||
let tableHelpers = require('../table-helpers');
|
||||
|
||||
module.exports.defaultColumns = [{
|
||||
column: 'created',
|
||||
|
@ -339,70 +340,12 @@ module.exports.delete = (id, callback) => {
|
|||
};
|
||||
|
||||
module.exports.filterSubscribers = (trigger, request, columns, callback) => {
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let query = 'SELECT COUNT(`subscription__' + trigger.list + '`.`id`) AS total FROM `subscription__' + trigger.list + '` JOIN `trigger__' + trigger.id + '` ON `trigger__' + trigger.id + '`.`list`=? AND `trigger__' + trigger.id + '`.`subscription`=`subscription__' + trigger.list + '`.`id`';
|
||||
let values = [trigger.list];
|
||||
|
||||
connection.query(query, values, (err, total) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
total = total && total[0] && total[0].total || 0;
|
||||
|
||||
let ordering = [];
|
||||
|
||||
if (request.order && request.order.length) {
|
||||
|
||||
request.order.forEach(order => {
|
||||
let orderField = columns[Number(order.column)];
|
||||
let orderDirection = (order.dir || '').toString().toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
||||
if (orderField) {
|
||||
ordering.push('`' + orderField + '` ' + orderDirection);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!ordering.length) {
|
||||
ordering.push('`email` ASC');
|
||||
}
|
||||
|
||||
let args = [Number(request.length) || 50, Number(request.start) || 0];
|
||||
|
||||
if (request.search && request.search.value) {
|
||||
query = 'SELECT SQL_CALC_FOUND_ROWS * FROM `subscription__' + trigger.list + '` JOIN `trigger__' + trigger.id + '` ON `trigger__' + trigger.id + '`.`list`=? AND `trigger__' + trigger.id + '`.`subscription`=`subscription__' + trigger.list + '`.`id AND (email LIKE ? OR first_name LIKE ? OR last_name LIKE ?) ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?';
|
||||
|
||||
let searchVal = '%' + request.search.value.replace(/\\/g, '\\\\').replace(/([%_])/g, '\\$1') + '%';
|
||||
args = values.concat([searchVal, searchVal, searchVal]).concat(args);
|
||||
} else {
|
||||
query = 'SELECT SQL_CALC_FOUND_ROWS * FROM `subscription__' + trigger.list + '` JOIN `trigger__' + trigger.id + '` ON `trigger__' + trigger.id + '`.`list`=? AND `trigger__' + trigger.id + '`.`subscription`=`subscription__' + trigger.list + '`.`id` ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?';
|
||||
args = values.concat(args);
|
||||
}
|
||||
|
||||
connection.query(query, args, (err, rows) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
connection.query('SELECT FOUND_ROWS() AS total', (err, filteredTotal) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let subscriptions = rows.map(row => tools.convertKeys(row));
|
||||
|
||||
filteredTotal = filteredTotal && filteredTotal[0] && filteredTotal[0].total || 0;
|
||||
return callback(null, subscriptions, total, filteredTotal);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
let queryData = {
|
||||
where: 'trigger__' + trigger.id + '.list=?',
|
||||
values: [trigger.list]
|
||||
};
|
||||
|
||||
tableHelpers.filter('subscription__' + trigger.list + ' JOIN trigger__' + trigger.id + ' ON trigger__' + trigger.id + '.subscription=subscription__' + trigger.list + '.id', ['*'], request, columns, ['email', 'first_name', 'last_name'], 'email ASC', queryData, callback);
|
||||
};
|
||||
|
||||
function createTriggerTable(id, callback) {
|
||||
|
|
77
lib/privilege-helpers.js
Normal file
77
lib/privilege-helpers.js
Normal file
|
@ -0,0 +1,77 @@
|
|||
'use strict';
|
||||
|
||||
const log = require('npmlog');
|
||||
const config = require('config');
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
const tryRequire = require('try-require');
|
||||
const posix = tryRequire('posix');
|
||||
|
||||
function _getConfigUidGid(prefix, defaultUid, defaultGid) {
|
||||
let uid = defaultUid;
|
||||
let gid = defaultGid;
|
||||
|
||||
if (posix) {
|
||||
try {
|
||||
if (config.user) {
|
||||
uid = posix.getpwnam(config[prefix + 'user']).uid;
|
||||
}
|
||||
} catch (err) {
|
||||
log.info('PrivilegeHelpers', 'Failed to resolve user id "%s"', config[prefix + 'user']);
|
||||
}
|
||||
|
||||
try {
|
||||
if (config.user) {
|
||||
gid = posix.getpwnam(config[prefix + 'group']).gid;
|
||||
}
|
||||
} catch (err) {
|
||||
log.info('PrivilegeHelpers', 'Failed to resolve group id "%s"', config[prefix + 'group']);
|
||||
}
|
||||
} else {
|
||||
log.info('PrivilegeHelpers', 'Posix module not installed. Cannot resolve uid/gid');
|
||||
}
|
||||
|
||||
return { uid, gid };
|
||||
}
|
||||
|
||||
function getConfigUidGid() {
|
||||
return _getConfigUidGid('', process.getuid(), process.getgid());
|
||||
}
|
||||
|
||||
function getConfigROUidGid() {
|
||||
let rwIds = getConfigUidGid();
|
||||
return _getConfigUidGid('ro', 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
|
||||
};
|
147
lib/report-processor.js
Normal file
147
lib/report-processor.js
Normal file
|
@ -0,0 +1,147 @@
|
|||
'use strict';
|
||||
|
||||
const log = require('npmlog');
|
||||
const reports = require('./models/reports');
|
||||
const executor = require('./executor');
|
||||
|
||||
let runningWorkersCount = 0;
|
||||
let maxWorkersCount = 1;
|
||||
|
||||
let workers = {};
|
||||
|
||||
function startWorker(report) {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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.lastRun = new Date();
|
||||
} else {
|
||||
fields.state = reports.ReportState.FAILED;
|
||||
}
|
||||
|
||||
reports.updateFields(report.id, fields, err => {
|
||||
if (err) {
|
||||
log.error('ReportProcessor', err);
|
||||
}
|
||||
|
||||
setImmediate(startWorkers);
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
reports.updateFields(report.id, fields, err => {
|
||||
if (err) {
|
||||
log.error('ReportProcessor', err);
|
||||
}
|
||||
|
||||
setImmediate(startWorkers);
|
||||
});
|
||||
}
|
||||
|
||||
const reportData = {
|
||||
id: report.id,
|
||||
name: report.name
|
||||
};
|
||||
|
||||
runningWorkersCount++;
|
||||
executor.start('report-processor-worker', reportData, onStarted, onFinished, onFailed);
|
||||
}
|
||||
|
||||
function startWorkers() {
|
||||
reports.listWithState(reports.ReportState.SCHEDULED, 0, maxWorkersCount - runningWorkersCount, (err, reportList) => {
|
||||
if (err) {
|
||||
log.error('ReportProcessor', err);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let report of reportList) {
|
||||
reports.updateFields(report.id, { state: reports.ReportState.PROCESSING }, err => {
|
||||
if (err) {
|
||||
log.error('ReportProcessor', err);
|
||||
return;
|
||||
}
|
||||
|
||||
startWorker(report);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports.start = (reportId, callback) => {
|
||||
if (!workers[reportId]) {
|
||||
log.info('ReportProcessor', 'Scheduling report id: %s', reportId);
|
||||
reports.updateFields(reportId, { state: reports.ReportState.SCHEDULED, lastRun: null}, err => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (runningWorkersCount < maxWorkersCount) {
|
||||
log.info('ReportProcessor', 'Starting worker because runningWorkersCount=%s maxWorkersCount=%s', runningWorkersCount, maxWorkersCount);
|
||||
|
||||
startWorkers();
|
||||
} else {
|
||||
log.info('ReportProcessor', 'Not starting worker because runningWorkersCount=%s maxWorkersCount=%s', runningWorkersCount, maxWorkersCount);
|
||||
}
|
||||
|
||||
callback(null);
|
||||
});
|
||||
} else {
|
||||
log.info('ReportProcessor', 'Worker for report id: %s is already running.', reportId);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports.stop = (reportId, callback) => {
|
||||
const tid = workers[reportId];
|
||||
if (tid) {
|
||||
log.info('ReportProcessor', 'Killing worker for report id: %s', reportId);
|
||||
executor.stop(tid);
|
||||
reports.updateFields(reportId, { state: reports.ReportState.FAILED}, callback);
|
||||
} else {
|
||||
log.info('ReportProcessor', 'No running worker found for report id: %s', reportId);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports.init = callback => {
|
||||
reports.listWithState(reports.ReportState.PROCESSING, 0, 0, (err, reportList) => {
|
||||
if (err) {
|
||||
log.error('ReportProcessor', err);
|
||||
}
|
||||
|
||||
function scheduleReport() {
|
||||
if (reportList.length > 0) {
|
||||
const report = reportList.shift();
|
||||
|
||||
reports.updateFields(report.id, { state: reports.ReportState.SCHEDULED}, err => {
|
||||
if (err) {
|
||||
log.error('ReportProcessor', err);
|
||||
}
|
||||
|
||||
scheduleReport();
|
||||
});
|
||||
}
|
||||
|
||||
startWorkers();
|
||||
return callback();
|
||||
}
|
||||
|
||||
scheduleReport();
|
||||
});
|
||||
};
|
134
lib/table-helpers.js
Normal file
134
lib/table-helpers.js
Normal file
|
@ -0,0 +1,134 @@
|
|||
'use strict';
|
||||
|
||||
const db = require('./db');
|
||||
const tools = require('./tools');
|
||||
|
||||
module.exports.list = (source, fields, orderBy, queryData, start, limit, callback) => {
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let limitQuery = '';
|
||||
let limitValues = [];
|
||||
if (limit) {
|
||||
limitQuery = ' LIMIT ?';
|
||||
limitValues.push(limit);
|
||||
|
||||
if (start) {
|
||||
limitQuery += ' OFFSET ?';
|
||||
limitValues.push(start);
|
||||
}
|
||||
}
|
||||
|
||||
let whereClause = '';
|
||||
let whereValues = [];
|
||||
|
||||
if (queryData) {
|
||||
whereClause = ' WHERE ' + queryData.where;
|
||||
whereValues = queryData.values;
|
||||
}
|
||||
|
||||
connection.query('SELECT SQL_CALC_FOUND_ROWS ' + fields.join(', ') + ' FROM ' + source + whereClause + ' ORDER BY ' + orderBy + ' DESC' + limitQuery, whereValues.concat(limitValues), (err, rows) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
connection.query('SELECT FOUND_ROWS() AS total', (err, total) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
return callback(null, rows, total && total[0] && total[0].total);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.quicklist = (source, fields, orderBy, callback) => {
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
connection.query('SELECT ' + fields.join(', ') + ' FROM ' + source + ' ORDER BY ' + orderBy + ' LIMIT 1000', (err, rows) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
return callback(null, (rows || []).map(tools.convertKeys));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.filter = (source, fields, request, columns, searchFields, defaultOrdering, queryData, callback) => {
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let query = 'SELECT COUNT(*) AS total FROM ' + source;
|
||||
let values = [];
|
||||
|
||||
if (queryData) {
|
||||
query += ' WHERE ' + queryData.where;
|
||||
values = values.concat(queryData.values || []);
|
||||
}
|
||||
|
||||
connection.query(query, values, (err, total) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
total = total && total[0] && total[0].total || 0;
|
||||
|
||||
let ordering = [];
|
||||
|
||||
if (request.order && request.order.length) {
|
||||
|
||||
request.order.forEach(order => {
|
||||
let orderField = columns[Number(order.column)];
|
||||
let orderDirection = (order.dir || '').toString().toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
||||
if (orderField) {
|
||||
ordering.push(orderField + ' ' + orderDirection);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!ordering.length) {
|
||||
ordering.push(defaultOrdering);
|
||||
}
|
||||
|
||||
let searchWhere = '';
|
||||
let searchArgs = [];
|
||||
|
||||
if (request.search && request.search.value) {
|
||||
let searchVal = '%' + request.search.value.replace(/\\/g, '\\\\').replace(/([%_])/g, '\\$1') + '%';
|
||||
|
||||
searchWhere = searchFields.map(field => field + ' LIKE ?').join(' OR ');
|
||||
searchArgs = searchFields.map(() => searchVal);
|
||||
}
|
||||
|
||||
let query = 'SELECT SQL_CALC_FOUND_ROWS ' + fields.join(', ') + ' FROM ' + source +' WHERE ' + (searchWhere ? '(' + searchWhere + ')': '1') + (queryData ? ' AND (' + queryData.where + ')' : '') + ' ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?';
|
||||
let args = searchArgs.concat(queryData ? queryData.values : []).concat([Number(request.length) || 50, Number(request.start) || 0]);
|
||||
|
||||
connection.query(query, args, (err, rows) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
connection.query('SELECT FOUND_ROWS() AS total', (err, filteredTotal) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
rows = rows.map(row => tools.convertKeys(row));
|
||||
|
||||
filteredTotal = filteredTotal && filteredTotal[0] && filteredTotal[0].total || 0;
|
||||
return callback(null, rows, total, filteredTotal);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
10
lib/tools.js
10
lib/tools.js
|
@ -1,5 +1,6 @@
|
|||
'use strict';
|
||||
|
||||
const config = require('config');
|
||||
let fs = require('fs');
|
||||
let path = require('path');
|
||||
let db = require('./db');
|
||||
|
@ -130,6 +131,14 @@ function updateMenu(res) {
|
|||
url: '/triggers',
|
||||
key: 'triggers'
|
||||
});
|
||||
|
||||
if (config.reports && config.reports.enabled === true) {
|
||||
res.locals.menu.push({
|
||||
title: _('Reports'),
|
||||
url: '/reports',
|
||||
key: 'reports'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateEmail(address, checkBlocked, callback) {
|
||||
|
@ -296,3 +305,4 @@ function mergeTemplateIntoLayout(template, layout, callback) {
|
|||
return done(template, layout);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"schemaVersion": 25
|
||||
"schemaVersion": 27
|
||||
}
|
||||
|
|
|
@ -33,6 +33,9 @@
|
|||
"grunt-eslint": "^19.0.0",
|
||||
"jsxgettext-andris": "^0.9.0-patch.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"posix": "^4.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"async": "^2.3.0",
|
||||
"aws-sdk": "^2.37.0",
|
||||
|
@ -97,6 +100,7 @@
|
|||
"slugify": "^1.1.0",
|
||||
"smtp-server": "^2.0.3",
|
||||
"striptags": "^3.0.1",
|
||||
"toml": "^2.3.2"
|
||||
"toml": "^2.3.2",
|
||||
"try-require": "^1.2.1"
|
||||
}
|
||||
}
|
||||
|
|
3
protected/reports/.gitignore
vendored
Normal file
3
protected/reports/.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
*
|
||||
!.gitignore
|
||||
!README.md
|
1
protected/reports/README.md
Normal file
1
protected/reports/README.md
Normal file
|
@ -0,0 +1 @@
|
|||
This directory serves for generated reports.
|
1
public/ace/mode-handlebars.js
Normal file
1
public/ace/mode-handlebars.js
Normal file
File diff suppressed because one or more lines are too long
1
public/ace/mode-javascript.js
Normal file
1
public/ace/mode-javascript.js
Normal file
File diff suppressed because one or more lines are too long
1
public/ace/mode-json.js
Normal file
1
public/ace/mode-json.js
Normal file
File diff suppressed because one or more lines are too long
1
public/ace/worker-javascript.js
Normal file
1
public/ace/worker-javascript.js
Normal file
File diff suppressed because one or more lines are too long
1
public/ace/worker-json.js
Normal file
1
public/ace/worker-json.js
Normal file
File diff suppressed because one or more lines are too long
|
@ -31,3 +31,19 @@ h2 .glyphicon {
|
|||
h3 .glyphicon {
|
||||
font-size: .8em;
|
||||
}
|
||||
|
||||
tbody>tr.selected {
|
||||
background-color: rgb(218, 231, 255);
|
||||
}
|
||||
|
||||
.table-hover>tbody>tr.selected:hover {
|
||||
background-color: rgb(205, 212, 226);
|
||||
}
|
||||
|
||||
.row-actions .row-action {
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.row-actions .row-action:last-child {
|
||||
padding-right: 0px;
|
||||
}
|
|
@ -39,6 +39,12 @@ $('div[class*="code-editor-"]').each(function () {
|
|||
editor.getSession().setUseWorker(false);
|
||||
} else if ($(this).hasClass('code-editor-css')) {
|
||||
mode = 'css';
|
||||
} else if ($(this).hasClass('code-editor-javascript')) {
|
||||
mode = 'javascript';
|
||||
} else if ($(this).hasClass('code-editor-json')) {
|
||||
mode = 'json';
|
||||
} else if ($(this).hasClass('code-editor-handlebars')) {
|
||||
mode = 'handlebars';
|
||||
}
|
||||
|
||||
editor.setTheme('ace/theme/chrome');
|
||||
|
|
|
@ -4,190 +4,307 @@
|
|||
|
||||
'use strict';
|
||||
|
||||
$('.data-table').each(function () {
|
||||
var rowSort = $(this).data('rowSort') || false;
|
||||
var columns = false;
|
||||
(function() {
|
||||
function refreshTargets(data) {
|
||||
for (var target in data) {
|
||||
var newContent = $(data[target]);
|
||||
|
||||
if (rowSort) {
|
||||
columns = rowSort.split(',').map(function (sort) {
|
||||
return {
|
||||
orderable: sort === '1'
|
||||
};
|
||||
$(target).replaceWith(newContent);
|
||||
installHandlers(newContent.parent());
|
||||
}
|
||||
}
|
||||
|
||||
function getAjaxUrl(self) {
|
||||
var topicId = self.data('topicId');
|
||||
var topicUrl = self.data('topicUrl');
|
||||
|
||||
return topicUrl + '/ajax/' + topicId;
|
||||
}
|
||||
|
||||
function setupAjaxRefresh() {
|
||||
var self = $(this);
|
||||
var ajaxUrl = getAjaxUrl(self);
|
||||
|
||||
var interval = Number(self.data('interval')) || 60;
|
||||
|
||||
setTimeout(function () {
|
||||
$.get(ajaxUrl, function(data) {
|
||||
refreshTargets(data);
|
||||
});
|
||||
|
||||
}, interval * 1000);
|
||||
}
|
||||
|
||||
function setupAjaxAction() {
|
||||
var self = $(this);
|
||||
var ajaxUrl = getAjaxUrl(self);
|
||||
|
||||
var processing = false;
|
||||
|
||||
self.click(function () {
|
||||
if (!processing) {
|
||||
$.get(ajaxUrl, function (data) {
|
||||
refreshTargets(data);
|
||||
});
|
||||
|
||||
processing = true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
$(this).DataTable({
|
||||
scrollX: true,
|
||||
order: [
|
||||
[1, 'asc']
|
||||
],
|
||||
columns: columns,
|
||||
pageLength: 50
|
||||
function setupDatestring() {
|
||||
var self = $(this);
|
||||
self.html(moment(self.data('date')).fromNow());
|
||||
}
|
||||
|
||||
|
||||
function getDataTableOptions(elem) {
|
||||
var rowSort = $(elem).data('rowSort') || false;
|
||||
|
||||
var columns = false;
|
||||
|
||||
var sortColumn = $(elem).data('sortColumn') === undefined ? 1 : Number($(elem).data('sortColumn'));
|
||||
var sortOrder = ($(elem).data('sortOrder') || 'asc').toString().trim().toLowerCase();
|
||||
|
||||
var paging = $(elem).data('paging') === false ? false : true;
|
||||
|
||||
// allow only asc and desc
|
||||
if (sortOrder !== 'desc') {
|
||||
sortOrder = 'asc';
|
||||
}
|
||||
|
||||
var columnsCount = 0;
|
||||
var columnsSort = []
|
||||
|
||||
if (rowSort) {
|
||||
columns = rowSort.split(',').map(function (sort) {
|
||||
return {
|
||||
orderable: sort === '1'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
var opts = {
|
||||
scrollX: true,
|
||||
order: [
|
||||
[sortColumn, sortOrder]
|
||||
],
|
||||
columns: columns,
|
||||
paging: paging,
|
||||
info: paging, /* This controls the "Showing 1 to 16 of 16 entries" */
|
||||
pageLength: 50
|
||||
};
|
||||
|
||||
if ($(elem).hasClass('data-table-selectable') || $(elem).hasClass('data-table-multiselectable')) {
|
||||
var isMulti = $(elem).hasClass('data-table-multiselectable');
|
||||
|
||||
var dataElem = $(elem).siblings("input").first();
|
||||
|
||||
opts.rowCallback = function( row, data ) {
|
||||
var selected = dataElem.val() == '' ? [] : dataElem.val().split(',').map(function(item) { return Number(item); });
|
||||
|
||||
if (!isMulti && selected.length > 0) {
|
||||
selected = [selected[0]];
|
||||
}
|
||||
|
||||
if ($.inArray(data.DT_RowId, selected) !== -1) {
|
||||
$(row).addClass('selected');
|
||||
}
|
||||
}
|
||||
|
||||
$(elem).on('click', 'tbody tr', function () {
|
||||
var id = this.id;
|
||||
var selected = dataElem.val() == '' ? [] : dataElem.val().split(',');
|
||||
|
||||
var index = $.inArray(id, selected);
|
||||
|
||||
if (isMulti) {
|
||||
if ( index === -1 ) {
|
||||
selected.push(id);
|
||||
} else {
|
||||
selected.splice(index, 1);
|
||||
}
|
||||
|
||||
$(this).toggleClass('selected');
|
||||
} else {
|
||||
for (var selIdx=0; selIdx < selected.length; selIdx++) {
|
||||
if (selected[selIdx] != id) {
|
||||
$('#' + selected[selIdx], elem).removeClass('selected');
|
||||
}
|
||||
}
|
||||
|
||||
$('#' + id, elem).addClass('selected');
|
||||
|
||||
selected = [id];
|
||||
}
|
||||
|
||||
dataElem.val(selected.join(','));
|
||||
} );
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
|
||||
function installHandlers(elem) {
|
||||
$('.ajax-refresh', elem).each(setupAjaxRefresh);
|
||||
$('.ajax-action', elem).each(setupAjaxAction);
|
||||
$('.datestring', elem).each(setupDatestring);
|
||||
}
|
||||
|
||||
installHandlers($(document));
|
||||
|
||||
$('.data-table').each(function () {
|
||||
var opts = getDataTableOptions(this);
|
||||
$(this).DataTable(opts);
|
||||
});
|
||||
});
|
||||
|
||||
$('.data-table-ajax').each(function () {
|
||||
var rowSort = $(this).data('rowSort') || false;
|
||||
var columns = false;
|
||||
$('.data-table-ajax').each(function () {
|
||||
var topicUrl = $(this).data('topicUrl') || '/lists';
|
||||
var topicArgs = $(this).data('topicArgs') || false;
|
||||
var topicId = $(this).data('topicId') || '';
|
||||
|
||||
var topicUrl = $(this).data('topicUrl') || '/lists';
|
||||
var topicArgs = $(this).data('topicArgs') || false;
|
||||
var topicId = $(this).data('topicId') || '';
|
||||
var ajaxUrl = topicUrl + '/ajax/' + topicId + (topicArgs ? '?' + topicArgs : '');
|
||||
|
||||
var sortColumn = Number($(this).data('sortColumn')) || 1;
|
||||
var sortOrder = ($(this).data('sortOrder') || 'asc').toString().trim().toLowerCase();
|
||||
|
||||
// allow only asc and desc
|
||||
if (sortOrder !== 'desc') {
|
||||
sortOrder = 'asc';
|
||||
}
|
||||
|
||||
var ajaxUrl = topicUrl + '/ajax/' + topicId + (topicArgs ? '?' + topicArgs : '');
|
||||
|
||||
if (rowSort) {
|
||||
columns = rowSort.split(',').map(function (sort) {
|
||||
return {
|
||||
orderable: sort === '1'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
$(this).DataTable({
|
||||
scrollX: true,
|
||||
serverSide: true,
|
||||
ajax: {
|
||||
var opts = getDataTableOptions(this);
|
||||
opts.ajax = {
|
||||
url: ajaxUrl,
|
||||
type: 'POST'
|
||||
},
|
||||
order: [
|
||||
[sortColumn, sortOrder]
|
||||
],
|
||||
columns: columns,
|
||||
pageLength: 50,
|
||||
processing: true
|
||||
}).on('draw', function () {
|
||||
$('.datestring').each(function () {
|
||||
$(this).html(moment($(this).data('date')).fromNow());
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
opts.serverSide = true;
|
||||
opts.processing = true;
|
||||
|
||||
$('.data-stats-pie-chart').each(function () {
|
||||
var column = $(this).data('column') || 'country';
|
||||
var limit = $(this).data('limit') || 20;
|
||||
var topicId = $(this).data('topicId');
|
||||
var topicUrl = $(this).data('topicUrl') || '/campaigns/clicked';
|
||||
var ajaxUrl = topicUrl + '/ajax/' + topicId + '/stats';
|
||||
var self = $(this);
|
||||
|
||||
$.post(ajaxUrl, {column: column, limit: limit}, function(data) {
|
||||
google.charts.load('current', {'packages':['corechart']});
|
||||
google.charts.setOnLoadCallback(drawChart);
|
||||
|
||||
function drawChart() {
|
||||
var gTable = new google.visualization.DataTable();
|
||||
gTable.addColumn('string', 'Column');
|
||||
gTable.addColumn('number', 'Value');
|
||||
gTable.addRows(data.data);
|
||||
|
||||
var options = {'width':500, 'height':400};
|
||||
var chart = new google.visualization.PieChart(self[0]);
|
||||
chart.draw(gTable, options);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('.datestring').each(function () {
|
||||
$(this).html(moment($(this).data('date')).fromNow());
|
||||
});
|
||||
|
||||
$('.delete-form,.confirm-submit').on('submit', function (e) {
|
||||
if (!confirm($(this).data('confirmMessage') || 'Are you sure? This action can not be undone')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
$('.fm-date-us.date').datepicker({
|
||||
format: 'mm/dd/yyyy',
|
||||
weekStart: 0,
|
||||
autoclose: true
|
||||
});
|
||||
|
||||
$('.fm-date-eur.date').datepicker({
|
||||
format: 'dd/mm/yyyy',
|
||||
weekStart: 1,
|
||||
autoclose: true
|
||||
});
|
||||
|
||||
$('.fm-date-generic.date').datepicker({
|
||||
format: 'yyyy-mm-dd',
|
||||
weekStart: 1,
|
||||
autoclose: true
|
||||
});
|
||||
|
||||
$('.fm-birthday-us.date').datepicker({
|
||||
format: 'mm/dd',
|
||||
weekStart: 0,
|
||||
autoclose: true
|
||||
});
|
||||
|
||||
$('.fm-birthday-eur.date').datepicker({
|
||||
format: 'dd/mm',
|
||||
weekStart: 1,
|
||||
autoclose: true
|
||||
});
|
||||
|
||||
$('.fm-birthday-generic.date').datepicker({
|
||||
format: 'mm-dd',
|
||||
weekStart: 1,
|
||||
autoclose: true
|
||||
});
|
||||
|
||||
$('.page-refresh').each(function () {
|
||||
var interval = Number($(this).data('interval')) || 60;
|
||||
setTimeout(function () {
|
||||
window.location.reload();
|
||||
}, interval * 1000);
|
||||
});
|
||||
|
||||
$('.click-select').on('click', function () {
|
||||
$(this).select();
|
||||
});
|
||||
|
||||
if (typeof moment.tz !== 'undefined') {
|
||||
(function () {
|
||||
var tz = moment.tz.guess();
|
||||
if (tz) {
|
||||
$('.tz-detect').val(tz);
|
||||
opts.createdRow = function( row, data, dataIndex ) {
|
||||
installHandlers($(row));
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// setup SMTP check
|
||||
var smtpForm = document.querySelector('form#smtp-verify');
|
||||
if (smtpForm) {
|
||||
smtpForm.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
var form = document.getElementById('settings-form');
|
||||
var formData = new FormData(form);
|
||||
var result = fetch('/settings/smtp-verify', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'same-origin'
|
||||
$(this).DataTable(opts).on('draw', function () {
|
||||
$('.datestring').each(setupDatestring);
|
||||
});
|
||||
|
||||
var $btn = $('#verify-button').button('loading');
|
||||
|
||||
result.then(function (res) {
|
||||
return res.json();
|
||||
}).then(function (data) {
|
||||
alert(data.error ? 'Invalid Mailer settings\n' + data.error : data.message);
|
||||
$btn.button('reset');
|
||||
}).catch(function (err) {
|
||||
alert(err.message);
|
||||
$btn.button('reset');
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
$('.data-stats-pie-chart').each(function () {
|
||||
var column = $(this).data('column') || 'country';
|
||||
var limit = $(this).data('limit') || 20;
|
||||
var topicId = $(this).data('topicId');
|
||||
var topicUrl = $(this).data('topicUrl') || '/campaigns/clicked';
|
||||
var ajaxUrl = topicUrl + '/ajax/' + topicId + '/stats';
|
||||
var self = $(this);
|
||||
|
||||
$.post(ajaxUrl, {column: column, limit: limit}, function(data) {
|
||||
google.charts.load('current', {'packages':['corechart']});
|
||||
google.charts.setOnLoadCallback(drawChart);
|
||||
|
||||
function drawChart() {
|
||||
var gTable = new google.visualization.DataTable();
|
||||
gTable.addColumn('string', 'Column');
|
||||
gTable.addColumn('number', 'Value');
|
||||
gTable.addRows(data.data);
|
||||
|
||||
var options = {'width':500, 'height':400};
|
||||
var chart = new google.visualization.PieChart(self[0]);
|
||||
chart.draw(gTable, options);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('.datestring').each(function () {
|
||||
$(this).html(moment($(this).data('date')).fromNow());
|
||||
});
|
||||
|
||||
$('.delete-form,.confirm-submit').on('submit', function (e) {
|
||||
if (!confirm($(this).data('confirmMessage') || 'Are you sure? This action can not be undone')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
$('.fm-date-us.date').datepicker({
|
||||
format: 'mm/dd/yyyy',
|
||||
weekStart: 0,
|
||||
autoclose: true
|
||||
});
|
||||
|
||||
$('.fm-date-eur.date').datepicker({
|
||||
format: 'dd/mm/yyyy',
|
||||
weekStart: 1,
|
||||
autoclose: true
|
||||
});
|
||||
|
||||
$('.fm-date-generic.date').datepicker({
|
||||
format: 'yyyy-mm-dd',
|
||||
weekStart: 1,
|
||||
autoclose: true
|
||||
});
|
||||
|
||||
$('.fm-birthday-us.date').datepicker({
|
||||
format: 'mm/dd',
|
||||
weekStart: 0,
|
||||
autoclose: true
|
||||
});
|
||||
|
||||
$('.fm-birthday-eur.date').datepicker({
|
||||
format: 'dd/mm',
|
||||
weekStart: 1,
|
||||
autoclose: true
|
||||
});
|
||||
|
||||
$('.fm-birthday-generic.date').datepicker({
|
||||
format: 'mm-dd',
|
||||
weekStart: 1,
|
||||
autoclose: true
|
||||
});
|
||||
|
||||
$('.page-refresh').each(function () {
|
||||
var interval = Number($(this).data('interval')) || 60;
|
||||
setTimeout(function () {
|
||||
window.location.reload();
|
||||
}, interval * 1000);
|
||||
});
|
||||
|
||||
|
||||
$('.click-select').on('click', function () {
|
||||
$(this).select();
|
||||
});
|
||||
|
||||
if (typeof moment.tz !== 'undefined') {
|
||||
(function () {
|
||||
var tz = moment.tz.guess();
|
||||
if (tz) {
|
||||
$('.tz-detect').val(tz);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// setup SMTP check
|
||||
var smtpForm = document.querySelector('form#smtp-verify');
|
||||
if (smtpForm) {
|
||||
smtpForm.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
var form = document.getElementById('settings-form');
|
||||
var formData = new FormData(form);
|
||||
var result = fetch('/settings/smtp-verify', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
var $btn = $('#verify-button').button('loading');
|
||||
|
||||
result.then(function (res) {
|
||||
return res.json();
|
||||
}).then(function (data) {
|
||||
alert(data.error ? 'Invalid Mailer settings\n' + data.error : data.message);
|
||||
$btn.button('reset');
|
||||
}).catch(function (err) {
|
||||
alert(err.message);
|
||||
$btn.button('reset');
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
|
|
|
@ -673,6 +673,80 @@ router.post('/status/ajax/:id/:status', (req, res) => {
|
|||
});
|
||||
});
|
||||
|
||||
router.post('/clicked/ajax/:id/:linkId', (req, res) => {
|
||||
let linkId = Number(req.params.linkId) || 0;
|
||||
|
||||
campaigns.get(req.params.id, true, (err, campaign) => {
|
||||
if (err || !campaign) {
|
||||
return res.json({
|
||||
error: err && err.message || err || _('Campaign not found'),
|
||||
data: []
|
||||
});
|
||||
}
|
||||
lists.get(campaign.list, (err, list) => {
|
||||
if (err) {
|
||||
return res.json({
|
||||
error: err && err.message || err,
|
||||
data: []
|
||||
});
|
||||
}
|
||||
|
||||
let campaignCid = campaign.cid;
|
||||
let listCid = list.cid;
|
||||
|
||||
let columns = ['#', 'email', 'first_name', 'last_name', 'campaign_tracker__' + campaign.id + '`.`created', 'count'];
|
||||
campaigns.filterClickedSubscribers(campaign, linkId, req.body, columns, (err, data, total, filteredTotal) => {
|
||||
if (err) {
|
||||
return res.json({
|
||||
error: err.message || err,
|
||||
data: []
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
draw: req.body.draw,
|
||||
recordsTotal: total,
|
||||
recordsFiltered: filteredTotal,
|
||||
data: data.map((row, i) => [
|
||||
'<a href="/archive/' + encodeURIComponent(campaignCid) + '/' + encodeURIComponent(listCid) + '/' + encodeURIComponent(row.cid) + '?track=no">' + ((Number(req.body.start) || 0) + 1 + i) + '</a>',
|
||||
htmlescape(row.email || ''),
|
||||
htmlescape(row.firstName || ''),
|
||||
htmlescape(row.lastName || ''),
|
||||
row.created && row.created.toISOString ? '<span class="datestring" data-date="' + row.created.toISOString() + '" title="' + row.created.toISOString() + '">' + row.created.toISOString() + '</span>' : 'N/A',
|
||||
row.count,
|
||||
'<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span><a href="/lists/subscription/' + campaign.list + '/edit/' + row.cid + '">' + _('Edit') + '</a>'
|
||||
])
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/quicklist/ajax', (req, res) => {
|
||||
campaigns.filterQuicklist(req.body, (err, data, total, filteredTotal) => {
|
||||
if (err) {
|
||||
return res.json({
|
||||
error: err.message || err,
|
||||
data: []
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
draw: req.body.draw,
|
||||
recordsTotal: total,
|
||||
recordsFiltered: filteredTotal,
|
||||
data: data.map((row, i) => ({
|
||||
"0": (Number(req.body.start) || 0) + 1 + i,
|
||||
"1": '<span class="glyphicon glyphicon-inbox" aria-hidden="true"></span> <a href="/campaigns/view/' + row.id + '">' + htmlescape(row.name || '') + '</a>',
|
||||
"2": htmlescape(striptags(row.description) || ''),
|
||||
"3": '<span class="datestring" data-date="' + row.created.toISOString() + '" title="' + row.created.toISOString() + '">' + row.created.toISOString() + '</span>',
|
||||
"DT_RowId": row.id
|
||||
}))
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
router.post('/delete', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
campaigns.delete(req.body.id, (err, deleted) => {
|
||||
if (err) {
|
||||
|
|
|
@ -55,23 +55,8 @@ router.all('/*', (req, res, next) => {
|
|||
});
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
let limit = 999999999;
|
||||
let start = 0;
|
||||
|
||||
lists.list(start, limit, (err, rows, total) => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
return res.redirect('/');
|
||||
}
|
||||
|
||||
res.render('lists/lists', {
|
||||
rows: rows.map((row, i) => {
|
||||
row.index = start + i + 1;
|
||||
row.description = striptags(row.description);
|
||||
return row;
|
||||
}),
|
||||
total
|
||||
});
|
||||
res.render('lists/lists', {
|
||||
title: _('Lists')
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -82,6 +67,10 @@ router.get('/create', passport.csrfProtection, (req, res) => {
|
|||
|
||||
data.csrfToken = req.csrfToken();
|
||||
|
||||
if (!('publicSubscribe' in data)) {
|
||||
data.publicSubscribe = true;
|
||||
}
|
||||
|
||||
res.render('lists/create', data);
|
||||
});
|
||||
|
||||
|
@ -155,6 +144,32 @@ router.post('/delete', passport.parseForm, passport.csrfProtection, (req, res) =
|
|||
});
|
||||
});
|
||||
|
||||
router.post('/ajax', (req, res) => {
|
||||
lists.filter(req.body, Number(req.query.parent) || false, (err, data, total, filteredTotal) => {
|
||||
if (err) {
|
||||
return res.json({
|
||||
error: err.message || err,
|
||||
data: []
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
draw: req.body.draw,
|
||||
recordsTotal: total,
|
||||
recordsFiltered: filteredTotal,
|
||||
data: data.map((row, i) => [
|
||||
(Number(req.body.start) || 0) + 1 + i,
|
||||
'<span class="glyphicon glyphicon-list-alt" aria-hidden="true"></span> <a href="/lists/view/' + row.id + '">' + htmlescape(row.name || '') + '</a>',
|
||||
'<code>' + row.cid + '</code>',
|
||||
row.subscribers,
|
||||
htmlescape(striptags(row.description) || ''),
|
||||
'<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span><a href="/lists/edit/' + row.id + '">' + _('Edit') + '</a>' ]
|
||||
)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
router.post('/ajax/:id', (req, res) => {
|
||||
lists.get(req.params.id, (err, list) => {
|
||||
if (err || !list) {
|
||||
|
@ -733,4 +748,27 @@ router.get('/subscription/:id/import/:importId/failed', (req, res) => {
|
|||
});
|
||||
});
|
||||
|
||||
router.post('/quicklist/ajax', (req, res) => {
|
||||
lists.filterQuicklist(req.body, (err, data, total, filteredTotal) => {
|
||||
if (err) {
|
||||
return res.json({
|
||||
error: err.message || err,
|
||||
data: []
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
draw: req.body.draw,
|
||||
recordsTotal: total,
|
||||
recordsFiltered: filteredTotal,
|
||||
data: data.map((row, i) => ({
|
||||
"0": (Number(req.body.start) || 0) + 1 + i,
|
||||
"1": '<span class="glyphicon glyphicon-inbox" aria-hidden="true"></span> <a href="/lists/view/' + row.id + '">' + htmlescape(row.name || '') + '</a>',
|
||||
"2": row.subscribers,
|
||||
"DT_RowId": row.id
|
||||
}))
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
307
routes/report-templates.js
Normal file
307
routes/report-templates.js
Normal file
|
@ -0,0 +1,307 @@
|
|||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const passport = require('../lib/passport');
|
||||
const router = new express.Router();
|
||||
const _ = require('../lib/translate')._;
|
||||
const reportTemplates = require('../lib/models/report-templates');
|
||||
const tools = require('../lib/tools');
|
||||
const util = require('util');
|
||||
const htmlescape = require('escape-html');
|
||||
const striptags = require('striptags');
|
||||
|
||||
const allowedMimeTypes = {
|
||||
'text/html': 'HTML',
|
||||
'text/csv': 'CSV'
|
||||
};
|
||||
|
||||
router.all('/*', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
req.flash('danger', _('Need to be logged in to access restricted content'));
|
||||
return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl));
|
||||
}
|
||||
res.setSelectedMenu('reports');
|
||||
next();
|
||||
});
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.render('report-templates/report-templates', {
|
||||
title: _('Report Templates')
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/ajax', (req, res) => {
|
||||
reportTemplates.filter(req.body, (err, data, total, filteredTotal) => {
|
||||
if (err) {
|
||||
return res.json({
|
||||
error: err.message || err,
|
||||
data: []
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
draw: req.body.draw,
|
||||
recordsTotal: total,
|
||||
recordsFiltered: filteredTotal,
|
||||
data: data.map((row, i) => [
|
||||
(Number(req.body.start) || 0) + 1 + i,
|
||||
htmlescape(row.name || ''),
|
||||
htmlescape(striptags(row.description) || ''),
|
||||
'<span class="datestring" data-date="' + row.created.toISOString() + '" title="' + row.created.toISOString() + '">' + row.created.toISOString() + '</span>',
|
||||
'<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span><a href="/report-templates/edit/' + row.id + '"> ' + _('Edit') + '</a>']
|
||||
)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/create', passport.csrfProtection, (req, res) => {
|
||||
const data = req.query;
|
||||
const wizard = req.query['type'] || '';
|
||||
|
||||
if (wizard == 'subscribers-all') {
|
||||
if (!('description' in data)) data.description = 'Generates a campaign report listing all subscribers along with their statistics.';
|
||||
|
||||
if (!('mimeType' in data)) data.mimeType = 'text/html';
|
||||
|
||||
if (!('userFields' in data)) data.userFields =
|
||||
'[\n' +
|
||||
' {\n' +
|
||||
' "id": "campaign",\n' +
|
||||
' "name": "Campaign",\n' +
|
||||
' "type": "campaign",\n' +
|
||||
' "minOccurences": 1,\n' +
|
||||
' "maxOccurences": 1\n' +
|
||||
' }\n' +
|
||||
']';
|
||||
|
||||
if (!('js' in data)) data.js =
|
||||
'campaigns.results(inputs.campaign, ["*"], "", (err, results) => {\n' +
|
||||
' if (err) {\n' +
|
||||
' return callback(err);\n' +
|
||||
' }\n' +
|
||||
'\n' +
|
||||
' const data = {\n' +
|
||||
' results: results\n' +
|
||||
' };\n' +
|
||||
'\n' +
|
||||
' return callback(null, data);\n' +
|
||||
'});';
|
||||
|
||||
if (!('hbs' in data)) data.hbs =
|
||||
'<h2>{{title}}</h2>\n' +
|
||||
'\n' +
|
||||
'<div class="table-responsive">\n' +
|
||||
' <table class="table table-bordered table-hover data-table display nowrap" width="100%" data-row-sort="1,1" data-paging="false">\n' +
|
||||
' <thead>\n' +
|
||||
' <th>\n' +
|
||||
' {{#translate}}Email{{/translate}}\n' +
|
||||
' </th>\n' +
|
||||
' <th>\n' +
|
||||
' {{#translate}}Tracker Count{{/translate}}\n' +
|
||||
' </th>\n' +
|
||||
' </thead>\n' +
|
||||
' {{#if results}}\n' +
|
||||
' <tbody>\n' +
|
||||
' {{#each results}}\n' +
|
||||
' <tr>\n' +
|
||||
' <th scope="row">\n' +
|
||||
' {{email}}\n' +
|
||||
' </th>\n' +
|
||||
' <td style="width: 20%;">\n' +
|
||||
' {{tracker_count}}\n' +
|
||||
' </td>\n' +
|
||||
' </tr>\n' +
|
||||
' {{/each}}\n' +
|
||||
' </tbody>\n' +
|
||||
' {{/if}}\n' +
|
||||
' </table>\n' +
|
||||
'</div>';
|
||||
|
||||
} else if (wizard == 'subscribers-grouped') {
|
||||
if (!('description' in data)) data.description = 'Generates a campaign report with results are aggregated by some "Country" custom field.';
|
||||
|
||||
if (!('mimeType' in data)) data.mimeType = 'text/html';
|
||||
|
||||
if (!('userFields' in data)) data.userFields =
|
||||
'[\n' +
|
||||
' {\n' +
|
||||
' "id": "campaign",\n' +
|
||||
' "name": "Campaign",\n' +
|
||||
' "type": "campaign",\n' +
|
||||
' "minOccurences": 1,\n' +
|
||||
' "maxOccurences": 1\n' +
|
||||
' }\n' +
|
||||
']';
|
||||
|
||||
if (!('js' in data)) data.js =
|
||||
'campaigns.results(inputs.campaign, ["custom_country", "count(*) AS count_all", "SUM(IF(tracker.count IS NULL, 0, 1)) AS count_opened"], "GROUP BY custom_country", (err, results) => {\n' +
|
||||
' if (err) {\n' +
|
||||
' return callback(err);\n' +
|
||||
' }\n' +
|
||||
'\n' +
|
||||
' for (let row of results) {\n' +
|
||||
' row["percentage"] = Math.round((row.count_opened / row.count_all) * 100);\n' +
|
||||
' }\n' +
|
||||
'\n' +
|
||||
' let data = {\n' +
|
||||
' results: results\n' +
|
||||
' };\n' +
|
||||
'\n' +
|
||||
' return callback(null, data);\n' +
|
||||
'});';
|
||||
|
||||
if (!('hbs' in data)) data.hbs =
|
||||
'<h2>{{title}}</h2>\n' +
|
||||
'\n' +
|
||||
'<div class="table-responsive">\n' +
|
||||
' <table class="table table-bordered table-hover data-table display nowrap" width="100%" data-row-sort="1,1,1,1" data-paging="false">\n' +
|
||||
' <thead>\n' +
|
||||
' <th>\n' +
|
||||
' {{#translate}}Country{{/translate}}\n' +
|
||||
' </th>\n' +
|
||||
' <th>\n' +
|
||||
' {{#translate}}Opened{{/translate}}\n' +
|
||||
' </th>\n' +
|
||||
' <th>\n' +
|
||||
' {{#translate}}All{{/translate}}\n' +
|
||||
' </th>\n' +
|
||||
' <th>\n' +
|
||||
' {{#translate}}Percentage{{/translate}}\n' +
|
||||
' </th>\n' +
|
||||
' </thead>\n' +
|
||||
' {{#if results}}\n' +
|
||||
' <tbody>\n' +
|
||||
' {{#each results}}\n' +
|
||||
' <tr>\n' +
|
||||
' <th scope="row">\n' +
|
||||
' {{custom_country}}\n' +
|
||||
' </th>\n' +
|
||||
' <td style="width: 20%;">\n' +
|
||||
' {{count_opened}}\n' +
|
||||
' </td>\n' +
|
||||
' <td style="width: 20%;">\n' +
|
||||
' {{count_all}}\n' +
|
||||
' </td>\n' +
|
||||
' <td style="width: 20%;">\n' +
|
||||
' {{percentage}}%\n' +
|
||||
' </td>\n' +
|
||||
' </tr>\n' +
|
||||
' {{/each}}\n' +
|
||||
' </tbody>\n' +
|
||||
' {{/if}}\n' +
|
||||
' </table>\n' +
|
||||
'</div>';
|
||||
|
||||
} else if (wizard == 'export-list-csv') {
|
||||
if (!('description' in data)) data.description = 'Exports a list as a CSV file.';
|
||||
|
||||
if (!('mimeType' in data)) data.mimeType = 'text/csv';
|
||||
|
||||
if (!('userFields' in data)) data.userFields =
|
||||
'[\n' +
|
||||
' {\n' +
|
||||
' "id": "list",\n' +
|
||||
' "name": "List",\n' +
|
||||
' "type": "list",\n' +
|
||||
' "minOccurences": 1,\n' +
|
||||
' "maxOccurences": 1\n' +
|
||||
' }\n' +
|
||||
']';
|
||||
|
||||
if (!('js' in data)) data.js =
|
||||
'subscriptions.list(inputs.list.id,0,0, (err, results) => {\n' +
|
||||
' if (err) {\n' +
|
||||
' return callback(err);\n' +
|
||||
' }\n' +
|
||||
'\n' +
|
||||
' let data = {\n' +
|
||||
' results: results\n' +
|
||||
' };\n' +
|
||||
'\n' +
|
||||
' return callback(null, data);\n' +
|
||||
'});';
|
||||
|
||||
if (!('hbs' in data)) data.hbs =
|
||||
'{{#each results}}\n' +
|
||||
'{{firstName}},{{lastName}},{{email}}\n' +
|
||||
'{{/each}}';
|
||||
}
|
||||
|
||||
data.csrfToken = req.csrfToken();
|
||||
data.title = _('Create Report Template');
|
||||
data.useEditor = true;
|
||||
|
||||
data.mimeTypes = Object.keys(allowedMimeTypes).map(key => ({
|
||||
key: key,
|
||||
value: allowedMimeTypes[key],
|
||||
selected: data.mimeType == key
|
||||
}));
|
||||
|
||||
res.render('report-templates/create', data);
|
||||
});
|
||||
|
||||
router.post('/create', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
reportTemplates.createOrUpdate(true, req.body, (err, id) => {
|
||||
if (err || !id) {
|
||||
req.flash('danger', err && err.message || err || _('Could not create report template'));
|
||||
return res.redirect('/report-templates/create?' + tools.queryParams(req.body));
|
||||
}
|
||||
req.flash('success', util.format(_('Report template “%s” created'), req.body.name));
|
||||
res.redirect('/report-templates');
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/edit/:id', passport.csrfProtection, (req, res) => {
|
||||
reportTemplates.get(req.params.id, (err, template) => {
|
||||
if (err || !template) {
|
||||
req.flash('danger', err && err.message || err || _('Could not find report template with specified ID'));
|
||||
return res.redirect('/report-templates');
|
||||
}
|
||||
|
||||
template.csrfToken = req.csrfToken();
|
||||
template.title = _('Edit Report Template');
|
||||
template.useEditor = true;
|
||||
|
||||
template.mimeTypes = Object.keys(allowedMimeTypes).map(key => ({
|
||||
key: key,
|
||||
value: allowedMimeTypes[key],
|
||||
selected: template.mimeType == key
|
||||
}));
|
||||
|
||||
res.render('report-templates/edit', template);
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/edit', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
reportTemplates.createOrUpdate(false, req.body, (err, updated) => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
} else if (updated) {
|
||||
req.flash('success', _('Report template updated'));
|
||||
} else {
|
||||
req.flash('info', _('Report template not updated'));
|
||||
}
|
||||
|
||||
if (req.body['submit'] == 'update-and-stay') {
|
||||
return res.redirect('/report-templates/edit/' + req.body.id);
|
||||
} else {
|
||||
return res.redirect('/report-templates');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/delete', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
reportTemplates.delete(req.body.id, (err, deleted) => {
|
||||
if (err) {
|
||||
req.flash('danger', err && err.message || err);
|
||||
} else if (deleted) {
|
||||
req.flash('success', _('Report template deleted'));
|
||||
} else {
|
||||
req.flash('info', _('Could not delete specified report template'));
|
||||
}
|
||||
|
||||
return res.redirect('/report-templates');
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
406
routes/reports.js
Normal file
406
routes/reports.js
Normal file
|
@ -0,0 +1,406 @@
|
|||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const passport = require('../lib/passport');
|
||||
const router = new express.Router();
|
||||
const _ = require('../lib/translate')._;
|
||||
const reportTemplates = require('../lib/models/report-templates');
|
||||
const reports = require('../lib/models/reports');
|
||||
const reportProcessor = require('../lib/report-processor');
|
||||
const campaigns = require('../lib/models/campaigns');
|
||||
const lists = require('../lib/models/lists');
|
||||
const tools = require('../lib/tools');
|
||||
const fileHelpers = require('../lib/file-helpers');
|
||||
const util = require('util');
|
||||
const htmlescape = require('escape-html');
|
||||
const striptags = require('striptags');
|
||||
const fs = require('fs');
|
||||
const hbs = require('hbs');
|
||||
|
||||
router.all('/*', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
req.flash('danger', _('Need to be logged in to access restricted content'));
|
||||
return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl));
|
||||
}
|
||||
res.setSelectedMenu('reports');
|
||||
next();
|
||||
});
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.render('reports/reports', {
|
||||
title: _('Reports')
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
router.post('/ajax', (req, res) => {
|
||||
reports.filter(req.body, (err, data, total, filteredTotal) => {
|
||||
if (err) {
|
||||
return res.json({
|
||||
error: err.message || err,
|
||||
data: []
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
draw: req.body.draw,
|
||||
recordsTotal: total,
|
||||
recordsFiltered: filteredTotal,
|
||||
data: data.map((row, i) => [
|
||||
(Number(req.body.start) || 0) + 1 + i,
|
||||
htmlescape(row.name || ''),
|
||||
htmlescape(row.reportTemplateName || ''),
|
||||
htmlescape(striptags(row.description) || ''),
|
||||
getRowLastRun(row),
|
||||
getRowActions(row)
|
||||
])
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/row/ajax/:id', (req, res) => {
|
||||
respondRowActions(req.params.id, res);
|
||||
});
|
||||
|
||||
router.get('/start/ajax/:id', (req, res) => {
|
||||
reportProcessor.start(req.params.id, () => {
|
||||
respondRowActions(req.params.id, res);
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/stop/ajax/:id', (req, res) => {
|
||||
reportProcessor.stop(req.params.id, () => {
|
||||
respondRowActions(req.params.id, res);
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/create', passport.csrfProtection, (req, res) => {
|
||||
const reqData = req.query;
|
||||
reqData.csrfToken = req.csrfToken();
|
||||
reqData.title = _('Create Report');
|
||||
reqData.useEditor = true;
|
||||
|
||||
reportTemplates.quicklist((err, items) => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
return res.redirect('/reports');
|
||||
}
|
||||
|
||||
const reportTemplateId = Number(reqData.reportTemplate);
|
||||
|
||||
if (reportTemplateId) {
|
||||
items.forEach(item => {
|
||||
if (item.id === reportTemplateId) {
|
||||
item.selected = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reqData.reportTemplates = items;
|
||||
|
||||
if (!reportTemplateId) {
|
||||
res.render('reports/create-select-template', reqData);
|
||||
} else {
|
||||
addUserFields(reportTemplateId, reqData, null, (err, data) => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
return res.redirect('/reports');
|
||||
}
|
||||
|
||||
res.render('reports/create', data);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/create', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
const reqData = req.body;
|
||||
|
||||
const reportTemplateId = Number(reqData.reportTemplate);
|
||||
|
||||
addParamsObject(reportTemplateId, reqData, (err, data) => {
|
||||
if (err) {
|
||||
req.flash('danger', err && err.message || err || _('Could not create report'));
|
||||
return res.redirect('/reports/create?' + tools.queryParams(data));
|
||||
}
|
||||
|
||||
reports.createOrUpdate(true, data, (err, id) => {
|
||||
if (err || !id) {
|
||||
req.flash('danger', err && err.message || err || _('Could not create report'));
|
||||
return res.redirect('/reports/create?' + tools.queryParams(data));
|
||||
}
|
||||
|
||||
reportProcessor.start(id, () => {
|
||||
req.flash('success', util.format(_('Report “%s” created'), data.name));
|
||||
res.redirect('/reports');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/edit/:id', passport.csrfProtection, (req, res) => {
|
||||
const reqData = req.query;
|
||||
reports.get(req.params.id, (err, report) => {
|
||||
if (err || !report) {
|
||||
req.flash('danger', err && err.message || err || _('Could not find report with specified ID'));
|
||||
return res.redirect('/reports');
|
||||
}
|
||||
|
||||
report.csrfToken = req.csrfToken();
|
||||
report.title = _('Edit Report');
|
||||
report.useEditor = true;
|
||||
|
||||
reportTemplates.quicklist((err, items) => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
return res.redirect('/');
|
||||
}
|
||||
|
||||
const reportTemplateId = report.reportTemplate;
|
||||
|
||||
items.forEach(item => {
|
||||
if (item.id === reportTemplateId) {
|
||||
item.selected = true;
|
||||
}
|
||||
});
|
||||
|
||||
report.reportTemplates = items;
|
||||
|
||||
addUserFields(reportTemplateId, reqData, report, (err, data) => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
return res.redirect('/reports');
|
||||
}
|
||||
|
||||
res.render('reports/edit', data);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/edit', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
const reqData = req.body;
|
||||
const reportTemplateId = Number(reqData.reportTemplate);
|
||||
|
||||
addParamsObject(reportTemplateId, reqData, (err, data) => {
|
||||
if (err) {
|
||||
req.flash('danger', err && err.message || err || _('Could not update report'));
|
||||
return res.redirect('/reports/create?' + tools.queryParams(data));
|
||||
}
|
||||
|
||||
reports.createOrUpdate(false, data, (err, updated) => {
|
||||
if (err) {
|
||||
req.flash('danger', err && err.message || err || _('Could not update report'));
|
||||
return res.redirect('/reports/edit/' + data.id + '?' + tools.queryParams(data));
|
||||
} else if (updated) {
|
||||
req.flash('success', _('Report updated'));
|
||||
} else {
|
||||
req.flash('info', _('Report not updated'));
|
||||
}
|
||||
|
||||
return res.redirect('/reports');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/delete', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
reports.delete(req.body.id, (err, deleted) => {
|
||||
if (err) {
|
||||
req.flash('danger', err && err.message || err);
|
||||
} else if (deleted) {
|
||||
req.flash('success', _('Report deleted'));
|
||||
} else {
|
||||
req.flash('info', _('Could not delete specified report'));
|
||||
}
|
||||
|
||||
return res.redirect('/reports');
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/view/:id', (req, res) => {
|
||||
reports.get(req.params.id, (err, report) => {
|
||||
if (err || !report) {
|
||||
req.flash('danger', err && err.message || err || _('Could not find report with specified ID'));
|
||||
return res.redirect('/reports');
|
||||
}
|
||||
|
||||
reportTemplates.get(report.reportTemplate, (err, reportTemplate) => {
|
||||
if (err) {
|
||||
req.flash('danger', err && err.message || err || _('Could not find report template'));
|
||||
return res.redirect('/reports');
|
||||
}
|
||||
|
||||
if (report.state == reports.ReportState.FINISHED) {
|
||||
if (reportTemplate.mimeType == 'text/html') {
|
||||
|
||||
fs.readFile(fileHelpers.getReportContentFile(report), (err, reportContent) => {
|
||||
if (err) {
|
||||
req.flash('danger', err && err.message || err || _('Could not find report with specified ID'));
|
||||
return res.redirect('/reports');
|
||||
}
|
||||
|
||||
const data = {
|
||||
report: new hbs.handlebars.SafeString(reportContent),
|
||||
title: report.name
|
||||
};
|
||||
|
||||
res.render('reports/view', data);
|
||||
});
|
||||
|
||||
} else if (reportTemplate.mimeType == 'text/csv') {
|
||||
const headers = {
|
||||
'Content-Disposition': 'attachment;filename=' + fileHelpers.nameToFileName(report.name) + '.csv',
|
||||
'Content-Type': 'text/csv'
|
||||
};
|
||||
|
||||
res.sendFile(fileHelpers.getReportContentFile(report), {headers: headers});
|
||||
|
||||
} else {
|
||||
req.flash('danger', _('Unknown type of template'));
|
||||
res.redirect('/reports');
|
||||
}
|
||||
|
||||
} else {
|
||||
req.flash('danger', err && err.message || err || _('Could not find report with specified ID'));
|
||||
return res.redirect('/reports');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/output/:id', (req, res) => {
|
||||
reports.get(req.params.id, (err, report) => {
|
||||
if (err || !report) {
|
||||
req.flash('danger', err && err.message || err || _('Could not find report with specified ID'));
|
||||
return res.redirect('/reports');
|
||||
}
|
||||
|
||||
fs.readFile(fileHelpers.getReportOutputFile(report), (err, output) => {
|
||||
let data = {
|
||||
title: 'Output for report ' + report.name
|
||||
};
|
||||
|
||||
if (err) {
|
||||
data.error = 'No output.';
|
||||
} else {
|
||||
data.output = output;
|
||||
}
|
||||
|
||||
res.render('reports/output', data);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function getRowLastRun(row) {
|
||||
return '<span id="row-last-run-' + row.id + '">' + (row.lastRun ? '<span class="datestring" data-date="' + row.lastRun.toISOString() + '" title="' + row.lastRun.toISOString() + '">' + row.lastRun.toISOString() + '</span>' : '') + '</span>';
|
||||
}
|
||||
|
||||
function getRowActions(row) {
|
||||
/* FIXME: add csrf protection to stop and refresh actions */
|
||||
|
||||
let requestRefresh = false;
|
||||
let view, startStop;
|
||||
let topic = 'data-topic-id="' + row.id + '"';
|
||||
|
||||
if (row.state == reports.ReportState.PROCESSING || row.state == reports.ReportState.SCHEDULED) {
|
||||
view = '<span class="row-action glyphicon glyphicon-hourglass" aria-hidden="true" title="Processing"></span>';
|
||||
startStop = '<a class="row-action ajax-action" href="" data-topic-url="/reports/stop" ' + topic + ' title="Stop"><span class="glyphicon glyphicon-stop" aria-hidden="true"></span></a>';
|
||||
requestRefresh = true;
|
||||
|
||||
} else if (row.state == reports.ReportState.FINISHED) {
|
||||
let icon = 'eye-open';
|
||||
if (row.mimeType == 'text/csv') icon = 'download-alt';
|
||||
|
||||
view = '<a class="row-action" href="/reports/view/' + row.id + '" title="View report"><span class="glyphicon glyphicon-' + icon + '" aria-hidden="true"></span></a>';
|
||||
startStop = '<a class="row-action ajax-action" href="" data-topic-url="/reports/start" ' + topic + ' title="Refresh report"><span class="glyphicon glyphicon-repeat" aria-hidden="true"></span></a>';
|
||||
|
||||
} else if (row.state == reports.ReportState.FAILED) {
|
||||
view = '<span class="row-action glyphicon glyphicon-thumbs-down" aria-hidden="true" title="Report generation failed"></span>';
|
||||
startStop = '<a class="row-action ajax-action" href="" data-topic-url="/reports/start" ' + topic + ' title="Refresh report"><span class="glyphicon glyphicon-repeat" aria-hidden="true"></span></a>';
|
||||
}
|
||||
|
||||
let actions = view;
|
||||
actions += '<a class="row-action" href="/reports/output/' + row.id + '" title="View console output"><span class="glyphicon glyphicon-modal-window" aria-hidden="true"></span></a>';
|
||||
actions += startStop;
|
||||
actions += '<a class="row-action" href="/reports/edit/' + row.id + '"><span class="glyphicon glyphicon-wrench" aria-hidden="true" title="Edit"></span></a>';
|
||||
|
||||
return '<span id="row-actions-' + row.id + '"' + (requestRefresh ? ' class="row-actions ajax-refresh" data-interval="5" data-topic-url="/reports/row" ' + topic : ' class="row-actions"') + '>' +
|
||||
actions +
|
||||
'</span>';
|
||||
}
|
||||
|
||||
function respondRowActions(id, res) {
|
||||
reports.get(id, (err, report) => {
|
||||
if (err) {
|
||||
return res.json({
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
|
||||
const data = {};
|
||||
data['#row-last-run-' + id] = getRowLastRun(report);
|
||||
data['#row-actions-' + id] = getRowActions(report);
|
||||
|
||||
res.json(data);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function addUserFields(reportTemplateId, reqData, report, callback) {
|
||||
reportTemplates.get(reportTemplateId, (err, reportTemplate) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
const userFields = [];
|
||||
|
||||
for (const spec of reportTemplate.userFieldsObject) {
|
||||
let value = '';
|
||||
if ((spec.id + 'Selection') in reqData) {
|
||||
value = reqData[spec.id + 'Selection'];
|
||||
} else if (report && report.paramsObject && spec.id in report.paramsObject) {
|
||||
value = report.paramsObject[spec.id].join(',');
|
||||
}
|
||||
|
||||
userFields.push({
|
||||
'id': spec.id,
|
||||
'name': spec.name,
|
||||
'type': spec.type,
|
||||
'value': value,
|
||||
'isMulti': !(spec.minOccurences == 1 && spec.maxOccurences == 1)
|
||||
});
|
||||
}
|
||||
|
||||
const data = report ? report : reqData;
|
||||
data.userFields = userFields;
|
||||
|
||||
callback(null, data);
|
||||
});
|
||||
}
|
||||
|
||||
function addParamsObject(reportTemplateId, data, callback) {
|
||||
reportTemplates.get(reportTemplateId, (err, reportTemplate) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
const paramsObject = {};
|
||||
|
||||
for (const spec of reportTemplate.userFieldsObject) {
|
||||
const sel = data[spec.id + 'Selection'];
|
||||
|
||||
if (!sel) {
|
||||
paramsObject[spec.id] = [];
|
||||
} else {
|
||||
paramsObject[spec.id] = sel.split(',').map(item => Number(item));
|
||||
}
|
||||
}
|
||||
|
||||
data.paramsObject = paramsObject;
|
||||
|
||||
callback(null, data);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = router;
|
|
@ -176,9 +176,14 @@ router.get('/subscribe/:cid', (req, res, next) => {
|
|||
|
||||
router.get('/:cid', passport.csrfProtection, (req, res, next) => {
|
||||
lists.getByCid(req.params.cid, (err, list) => {
|
||||
if (!err && !list) {
|
||||
err = new Error(_('Selected list not found'));
|
||||
err.status = 404;
|
||||
if (!err) {
|
||||
if (!list) {
|
||||
err = new Error(_('Selected list not found'));
|
||||
err.status = 404;
|
||||
} else if (!list.publicSubscribe) {
|
||||
err = new Error(_('The list does not allow public subscriptions.'));
|
||||
err.status = 403;
|
||||
}
|
||||
}
|
||||
|
||||
if (err) {
|
||||
|
@ -501,9 +506,14 @@ router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, r
|
|||
let testsPass = subTimeTest && addressTest;
|
||||
|
||||
lists.getByCid(req.params.cid, (err, list) => {
|
||||
if (!err && !list) {
|
||||
err = new Error(_('Selected list not found'));
|
||||
err.status = 404;
|
||||
if (!err) {
|
||||
if (!list) {
|
||||
err = new Error(_('Selected list not found'));
|
||||
err.status = 404;
|
||||
} else if (!list.publicSubscribe) {
|
||||
err = new Error(_('The list does not allow public subscriptions.'));
|
||||
err.status = 403;
|
||||
}
|
||||
}
|
||||
|
||||
if (err) {
|
||||
|
|
|
@ -8,6 +8,7 @@ let settings = require('../lib/models/settings');
|
|||
let tools = require('../lib/tools');
|
||||
let helpers = require('../lib/helpers');
|
||||
let striptags = require('striptags');
|
||||
let htmlescape = require('escape-html');
|
||||
let passport = require('../lib/passport');
|
||||
let mailer = require('../lib/mailer');
|
||||
let _ = require('../lib/translate')._;
|
||||
|
@ -22,23 +23,8 @@ router.all('/*', (req, res, next) => {
|
|||
});
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
let limit = 999999999;
|
||||
let start = 0;
|
||||
|
||||
templates.list(start, limit, (err, rows, total) => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
return res.redirect('/');
|
||||
}
|
||||
|
||||
res.render('templates/templates', {
|
||||
rows: rows.map((row, i) => {
|
||||
row.index = start + i + 1;
|
||||
row.description = striptags(row.description);
|
||||
return row;
|
||||
}),
|
||||
total
|
||||
});
|
||||
res.render('templates/templates', {
|
||||
title: _('Templates')
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -164,4 +150,27 @@ router.post('/delete', passport.parseForm, passport.csrfProtection, (req, res) =
|
|||
});
|
||||
});
|
||||
|
||||
router.post('/ajax', (req, res) => {
|
||||
templates.filter(req.body, Number(req.query.parent) || false, (err, data, total, filteredTotal) => {
|
||||
if (err) {
|
||||
return res.json({
|
||||
error: err.message || err,
|
||||
data: []
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
draw: req.body.draw,
|
||||
recordsTotal: total,
|
||||
recordsFiltered: filteredTotal,
|
||||
data: data.map((row, i) => [
|
||||
(Number(req.body.start) || 0) + 1 + i,
|
||||
'<span class="glyphicon glyphicon-file" aria-hidden="true"></span> ' + htmlescape(row.name || ''),
|
||||
htmlescape(striptags(row.description) || ''),
|
||||
'<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span><a href="/templates/edit/' + row.id + '">' + _('Edit') + '</a>' ]
|
||||
)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
131
services/executor.js
Normal file
131
services/executor.js
Normal file
|
@ -0,0 +1,131 @@
|
|||
'use strict';
|
||||
|
||||
/* Privileged executor. If Mailtrain is started as root, this process keeps the root privilege to be able to spawn workers
|
||||
that can chroot.
|
||||
*/
|
||||
|
||||
const fileHelpers = require('../lib/file-helpers');
|
||||
const fork = require('child_process').fork;
|
||||
const path = require('path');
|
||||
const log = require('npmlog');
|
||||
const fs = require('fs');
|
||||
const privilegeHelpers = require('../lib/privilege-helpers');
|
||||
|
||||
let processes = {};
|
||||
|
||||
function spawnProcess(tid, executable, args, outFile, errFile, cwd, uid, gid) {
|
||||
|
||||
function reportFail(msg) {
|
||||
process.send({
|
||||
type: 'process-failed',
|
||||
msg,
|
||||
tid
|
||||
});
|
||||
}
|
||||
|
||||
fs.open(outFile, 'w', (err, outFd) => {
|
||||
if (err) {
|
||||
log.error('Executor', err);
|
||||
reportFail('Cannot create standard output file.');
|
||||
return;
|
||||
}
|
||||
|
||||
fs.open(errFile, 'w', (err, errFd) => {
|
||||
if (err) {
|
||||
log.error('Executor', err);
|
||||
reportFail('Cannot create standard error file.');
|
||||
return;
|
||||
}
|
||||
|
||||
privilegeHelpers.ensureMailtrainOwner(outFile, (err) => {
|
||||
if (err) {
|
||||
log.warn('Executor', 'Cannot change owner of output file of process tid:%s.', tid)
|
||||
}
|
||||
|
||||
privilegeHelpers.ensureMailtrainOwner(errFile, (err) => {
|
||||
if (err) {
|
||||
log.warn('Executor', 'Cannot change owner of error output file of process tid:%s.', tid)
|
||||
}
|
||||
|
||||
const options = {
|
||||
stdio: ['ignore', outFd, errFd, 'ipc'],
|
||||
cwd,
|
||||
env: {NODE_ENV: process.env.NODE_ENV},
|
||||
uid,
|
||||
gid
|
||||
};
|
||||
|
||||
let child;
|
||||
|
||||
try {
|
||||
child = fork(executable, args, options);
|
||||
} catch (err) {
|
||||
log.error('Executor', 'Cannot start process with tid:%s.', tid);
|
||||
reportFail('Cannot start process.');
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = child.pid;
|
||||
processes[tid] = child;
|
||||
|
||||
log.info('Executor', 'Process started with tid:%s pid:%s.', tid, pid);
|
||||
process.send({
|
||||
type: 'process-started',
|
||||
tid
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
|
||||
delete processes[tid];
|
||||
log.info('Executor', 'Process tid:%s pid:%s exited with code %s signal %s.', tid, pid, code, signal);
|
||||
|
||||
fs.close(outFd, (err) => {
|
||||
if (err) {
|
||||
log.error('Executor', err);
|
||||
}
|
||||
|
||||
fs.close(errFd, (err) => {
|
||||
if (err) {
|
||||
log.error('Executor', err);
|
||||
}
|
||||
|
||||
process.send({
|
||||
type: 'process-finished',
|
||||
tid,
|
||||
code,
|
||||
signal
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
process.on('message', msg => {
|
||||
if (msg) {
|
||||
const type = msg.type;
|
||||
|
||||
if (type === 'start-report-processor-worker') {
|
||||
|
||||
const ids = privilegeHelpers.getConfigROUidGid();
|
||||
spawnProcess(msg.tid, path.join(__dirname, '..', 'workers', 'reports', 'report-processor.js'), [msg.data.id], fileHelpers.getReportContentFile(msg.data), fileHelpers.getReportOutputFile(msg.data), path.join(__dirname, '..', 'workers', 'reports'), ids.uid, ids.gid);
|
||||
|
||||
} else if (type === 'stop-process') {
|
||||
const child = processes[msg.tid];
|
||||
|
||||
if (child) {
|
||||
log.info('Executor', 'Killing process tid:%s pid:%s', msg.tid, child.pid);
|
||||
child.kill();
|
||||
} else {
|
||||
log.info('Executor', 'No running process found with tid:%s pid:%s', msg.tid, child.pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
process.send({
|
||||
type: 'executor-started'
|
||||
});
|
228
setup/install-centos7.sh
Executable file
228
setup/install-centos7.sh
Executable file
|
@ -0,0 +1,228 @@
|
|||
#!/bin/bash
|
||||
|
||||
# This installation script works on CentOS 7
|
||||
# Run as root!
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This script must be run as root" 1>&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
yum -y install epel-release
|
||||
|
||||
curl --silent --location https://rpm.nodesource.com/setup_6.x | bash -
|
||||
yum -y install mariadb-server nodejs ImageMagick git python redis pwgen bind-utils gcc-c++ make
|
||||
|
||||
systemctl start mariadb
|
||||
systemctl enable mariadb
|
||||
|
||||
systemctl start redis
|
||||
systemctl enable redis
|
||||
|
||||
|
||||
PUBLIC_IP=`curl -s https://api.ipify.org`
|
||||
if [ ! -z "$PUBLIC_IP" ]; then
|
||||
HOSTNAME=`dig +short -x $PUBLIC_IP | sed 's/\.$//'`
|
||||
HOSTNAME="${HOSTNAME:-$PUBLIC_IP}"
|
||||
fi
|
||||
HOSTNAME="${HOSTNAME:-`hostname`}"
|
||||
|
||||
MYSQL_PASSWORD=`pwgen 12 -1`
|
||||
MYSQL_RO_PASSWORD=`pwgen 12 -1`
|
||||
DKIM_API_KEY=`pwgen 12 -1`
|
||||
SMTP_PASS=`pwgen 12 -1`
|
||||
|
||||
# Setup MySQL user for Mailtrain
|
||||
mysql -u root -e "CREATE USER 'mailtrain'@'localhost' IDENTIFIED BY '$MYSQL_PASSWORD';"
|
||||
mysql -u root -e "GRANT ALL PRIVILEGES ON mailtrain.* TO 'mailtrain'@'localhost';"
|
||||
mysql -u root -e "CREATE USER 'mailtrain_ro'@'localhost' IDENTIFIED BY '$MYSQL_RO_PASSWORD';"
|
||||
mysql -u root -e "GRANT SELECT ON mailtrain.* TO 'mailtrain_ro'@'localhost';"
|
||||
mysql -u mailtrain --password="$MYSQL_PASSWORD" -e "CREATE database mailtrain;"
|
||||
|
||||
# Enable firewall, allow connections to SSH, HTTP, HTTPS and SMTP
|
||||
for port in 80/tcp 443/tcp 25/tcp; do firewall-cmd --add-port=$port --permanent; done
|
||||
firewall-cmd --reload
|
||||
|
||||
# Fetch Mailtrain files
|
||||
mkdir -p /opt/mailtrain
|
||||
cd /opt/mailtrain
|
||||
git clone git://github.com/Mailtrain-org/mailtrain.git .
|
||||
|
||||
# Normally we would let Mailtrain itself to import the initial SQL data but in this case
|
||||
# we need to modify it, before we start Mailtrain
|
||||
mysql -u mailtrain -p"$MYSQL_PASSWORD" mailtrain < setup/sql/mailtrain.sql
|
||||
|
||||
mysql -u mailtrain -p"$MYSQL_PASSWORD" mailtrain <<EOT
|
||||
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('admin_email','admin@$HOSTNAME') ON DUPLICATE KEY UPDATE \`value\`='admin@$HOSTNAME';
|
||||
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('default_address','admin@$HOSTNAME') ON DUPLICATE KEY UPDATE \`value\`='admin@$HOSTNAME';
|
||||
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('smtp_hostname','localhost') ON DUPLICATE KEY UPDATE \`value\`='localhost';
|
||||
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('smtp_disable_auth','') ON DUPLICATE KEY UPDATE \`value\`='';
|
||||
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('smtp_user','mailtrain') ON DUPLICATE KEY UPDATE \`value\`='mailtrain';
|
||||
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('smtp_pass','$SMTP_PASS') ON DUPLICATE KEY UPDATE \`value\`='$SMTP_PASS';
|
||||
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('smtp_encryption','NONE') ON DUPLICATE KEY UPDATE \`value\`='NONE';
|
||||
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('smtp_port','2525') ON DUPLICATE KEY UPDATE \`value\`='2525';
|
||||
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('default_homepage','http://$HOSTNAME/') ON DUPLICATE KEY UPDATE \`value\`='http://$HOSTNAME/';
|
||||
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('service_url','http://$HOSTNAME/') ON DUPLICATE KEY UPDATE \`value\`='http://$HOSTNAME/';
|
||||
INSERT INTO \`settings\` (\`key\`, \`value\`) VALUES ('dkim_api_key','$DKIM_API_KEY') ON DUPLICATE KEY UPDATE \`value\`='$DKIM_API_KEY';
|
||||
EOT
|
||||
|
||||
# Add new user for the mailtrain daemon to run as
|
||||
useradd mailtrain || true
|
||||
useradd zone-mta || true
|
||||
|
||||
# Setup installation configuration
|
||||
cat >> config/production.toml <<EOT
|
||||
user="mailtrain"
|
||||
group="mailtrain"
|
||||
rouser="nobody"
|
||||
rogroup="nobody"
|
||||
[log]
|
||||
level="error"
|
||||
[www]
|
||||
port=80
|
||||
secret="`pwgen -1`"
|
||||
[mysql]
|
||||
password="$MYSQL_PASSWORD"
|
||||
[redis]
|
||||
enabled=true
|
||||
[queue]
|
||||
processes=5
|
||||
[reports]
|
||||
enabled=true
|
||||
EOT
|
||||
|
||||
cat >> workers/reports/config/production.toml <<EOT
|
||||
[log]
|
||||
level="error"
|
||||
[mysql]
|
||||
user="mailtrain_ro"
|
||||
password="$MYSQL_RO_PASSWORD"
|
||||
EOT
|
||||
|
||||
# Install required node packages
|
||||
npm install --no-progress --production
|
||||
chown -R mailtrain:mailtrain .
|
||||
chmod o-rwx config
|
||||
|
||||
# Setup log rotation to not spend up entire storage on logs
|
||||
cat <<EOM > /etc/logrotate.d/mailtrain
|
||||
/var/log/mailtrain.log {
|
||||
daily
|
||||
rotate 12
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
copytruncate
|
||||
nomail
|
||||
}
|
||||
EOM
|
||||
|
||||
# Set up systemd service script
|
||||
cp setup/mailtrain-centos7.service /etc/systemd/system/mailtrain.service
|
||||
systemctl enable mailtrain.service
|
||||
|
||||
# Fetch ZoneMTA files
|
||||
mkdir -p /opt/zone-mta
|
||||
cd /opt/zone-mta
|
||||
git clone git://github.com/zone-eu/zone-mta.git .
|
||||
git checkout 6964091273
|
||||
|
||||
# Ensure queue folder
|
||||
mkdir -p /var/data/zone-mta/mailtrain
|
||||
|
||||
# Setup installation configuration
|
||||
cat >> config/production.json <<EOT
|
||||
{
|
||||
"name": "Mailtrain",
|
||||
"user": "zone-mta",
|
||||
"group": "zone-mta",
|
||||
"queue": {
|
||||
"db": "/var/data/zone-mta/mailtrain"
|
||||
},
|
||||
"smtpInterfaces": {
|
||||
"feeder": {
|
||||
"enabled": true,
|
||||
"port": 2525,
|
||||
"processes": 2,
|
||||
"authentication": true
|
||||
}
|
||||
},
|
||||
"api": {
|
||||
"maildrop": false,
|
||||
"user": "mailtrain",
|
||||
"pass": "$SMTP_PASS"
|
||||
},
|
||||
"log": {
|
||||
"level": "info",
|
||||
"syslog": true
|
||||
},
|
||||
"plugins": {
|
||||
"core/email-bounce": false,
|
||||
"core/http-bounce": {
|
||||
"enabled": "main",
|
||||
"url": "http://localhost/webhooks/zone-mta"
|
||||
},
|
||||
"core/http-auth": {
|
||||
"enabled": ["receiver", "main"],
|
||||
"url": "http://localhost:8080/test-auth"
|
||||
},
|
||||
"core/default-headers": {
|
||||
"enabled": ["receiver", "main", "sender"],
|
||||
"futureDate": false,
|
||||
"xOriginatingIP": false
|
||||
},
|
||||
"core/http-config": {
|
||||
"enabled": ["main", "receiver"],
|
||||
"url": "http://localhost/webhooks/zone-mta/sender-config?api_token=$DKIM_API_KEY"
|
||||
},
|
||||
"core/rcpt-mx": false
|
||||
},
|
||||
"pools": {
|
||||
"default": [{
|
||||
"address": "0.0.0.0",
|
||||
"name": "$HOSTNAME"
|
||||
}]
|
||||
},
|
||||
"zones": {
|
||||
"default": {
|
||||
"processes": 3,
|
||||
"connections": 5,
|
||||
"throttling": false,
|
||||
"pool": "default"
|
||||
},
|
||||
"transactional": {
|
||||
"processes": 1,
|
||||
"connections": 1,
|
||||
"pool": "default"
|
||||
}
|
||||
},
|
||||
"domainConfig": {
|
||||
"default": {
|
||||
"maxConnections": 4
|
||||
}
|
||||
}
|
||||
}
|
||||
EOT
|
||||
|
||||
# Install required node packages
|
||||
npm install --no-progress --production
|
||||
npm install leveldown
|
||||
|
||||
# Ensure queue folder is owned by MTA user
|
||||
chown -R zone-mta:zone-mta /var/data/zone-mta/mailtrain
|
||||
|
||||
# Set up systemd service script
|
||||
cp setup/zone-mta.service /etc/systemd/system/
|
||||
systemctl enable zone-mta.service
|
||||
|
||||
# Start the service
|
||||
systemctl daemon-reload
|
||||
|
||||
systemctl start zone-mta.service
|
||||
systemctl start mailtrain.service
|
||||
|
||||
echo "Success! Open http://$HOSTNAME/ and log in as admin:test";
|
||||
|
|
@ -28,12 +28,15 @@ fi
|
|||
HOSTNAME="${HOSTNAME:-`hostname`}"
|
||||
|
||||
MYSQL_PASSWORD=`pwgen 12 -1`
|
||||
MYSQL_RO_PASSWORD=`pwgen 12 -1`
|
||||
DKIM_API_KEY=`pwgen 12 -1`
|
||||
SMTP_PASS=`pwgen 12 -1`
|
||||
|
||||
# Setup MySQL user for Mailtrain
|
||||
mysql -u root -e "CREATE USER 'mailtrain'@'localhost' IDENTIFIED BY '$MYSQL_PASSWORD';"
|
||||
mysql -u root -e "GRANT ALL PRIVILEGES ON mailtrain.* TO 'mailtrain'@'localhost';"
|
||||
mysql -u root -e "CREATE USER 'mailtrain_ro'@'localhost' IDENTIFIED BY '$MYSQL_RO_PASSWORD';"
|
||||
mysql -u root -e "GRANT SELECT ON mailtrain.* TO 'mailtrain_ro'@'localhost';"
|
||||
mysql -u mailtrain --password="$MYSQL_PASSWORD" -e "CREATE database mailtrain;"
|
||||
|
||||
# Enable firewall, allow connections to SSH, HTTP, HTTPS and SMTP
|
||||
|
@ -87,9 +90,18 @@ enabled=true
|
|||
processes=5
|
||||
EOT
|
||||
|
||||
cat >> workers/reports/config/production.toml <<EOT
|
||||
[log]
|
||||
level="error"
|
||||
[mysql]
|
||||
user="mailtrain_ro"
|
||||
password="$MYSQL_RO_PASSWORD"
|
||||
EOT
|
||||
|
||||
# Install required node packages
|
||||
npm install --no-progress --production
|
||||
chown -R mailtrain:mailtrain .
|
||||
chmod o-rwx config
|
||||
|
||||
# Setup log rotation to not spend up entire storage on logs
|
||||
cat <<EOM > /etc/logrotate.d/mailtrain
|
||||
|
|
16
setup/mailtrain-centos7.service
Normal file
16
setup/mailtrain-centos7.service
Normal file
|
@ -0,0 +1,16 @@
|
|||
[Unit]
|
||||
Description=Mailtrain server
|
||||
Requires=mariadb.service
|
||||
After=syslog.target network.target
|
||||
|
||||
[Service]
|
||||
Environment="NODE_ENV=production"
|
||||
WorkingDirectory=/opt/mailtrain
|
||||
ExecStart=/usr/bin/node index.js
|
||||
Type=simple
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
# Alias=mailtrain.service
|
11
setup/sql/upgrade-00026.sql
Normal file
11
setup/sql/upgrade-00026.sql
Normal file
|
@ -0,0 +1,11 @@
|
|||
# Header section
|
||||
# Define incrementing schema version number
|
||||
SET @schema_version = '26';
|
||||
|
||||
# Add field
|
||||
ALTER TABLE `lists` ADD COLUMN `public_subscribe` tinyint(1) unsigned DEFAULT 1 NOT NULL AFTER `created`;
|
||||
|
||||
# Footer section
|
||||
LOCK TABLES `settings` WRITE;
|
||||
INSERT INTO `settings` (`key`, `value`) VALUES('db_schema_version', @schema_version) ON DUPLICATE KEY UPDATE `value`=@schema_version;
|
||||
UNLOCK TABLES;
|
37
setup/sql/upgrade-00027.sql
Normal file
37
setup/sql/upgrade-00027.sql
Normal file
|
@ -0,0 +1,37 @@
|
|||
# Header section
|
||||
# Define incrementing schema version number
|
||||
SET @schema_version = '27';
|
||||
|
||||
# Create table to report templates
|
||||
CREATE TABLE `report_templates` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(255) DEFAULT '',
|
||||
`mime_type` varchar(255) DEFAULT 'text/html' NOT NULL,
|
||||
`description` text,
|
||||
`user_fields` longtext,
|
||||
`js` longtext,
|
||||
`hbs` longtext,
|
||||
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
# Create table to store reports
|
||||
CREATE TABLE `reports` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(255) DEFAULT '',
|
||||
`description` text,
|
||||
`report_template` int(11) unsigned NOT NULL,
|
||||
`params` longtext,
|
||||
`state` int(11) unsigned NOT NULL DEFAULT 0,
|
||||
`last_run` DATETIME DEFAULT NULL,
|
||||
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `report_template` (`report_template`),
|
||||
CONSTRAINT `report_template_ibfk_1` FOREIGN KEY (`report_template`) REFERENCES `report_templates` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
|
||||
# Footer section
|
||||
LOCK TABLES `settings` WRITE;
|
||||
INSERT INTO `settings` (`key`, `value`) VALUES('db_schema_version', @schema_version) ON DUPLICATE KEY UPDATE `value`=@schema_version;
|
||||
UNLOCK TABLES;
|
|
@ -23,7 +23,7 @@
|
|||
<div class="table-responsive">
|
||||
<table data-topic-url="/campaigns" data-sort-column="4" data-sort-order="desc" class="table table-bordered table-hover data-table-ajax display nowrap" width="100%" data-row-sort="0,1,0,1,1,0">
|
||||
<thead>
|
||||
<th class="col-md-1">
|
||||
<th style="width: 1%">
|
||||
#
|
||||
</th>
|
||||
<th>
|
||||
|
@ -38,7 +38,7 @@
|
|||
<th>
|
||||
{{#translate}}Created{{/translate}}
|
||||
</th>
|
||||
<th class="col-md-1">
|
||||
<th style="width: 1%">
|
||||
|
||||
</th>
|
||||
</thead>
|
||||
|
|
|
@ -26,6 +26,16 @@
|
|||
|
||||
<hr />
|
||||
|
||||
<div class="col-sm-offset-2">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="public_subscribe" value="1" {{#if publicSubscribe}} checked {{/if}}> {{#translate}}Allow public users to subscribe themselves{{/translate}}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-plus"></i> {{#translate}}Create List{{/translate}}</button>
|
||||
|
|
|
@ -56,6 +56,16 @@
|
|||
|
||||
<hr />
|
||||
|
||||
<div class="col-sm-offset-2">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="public_subscribe" value="1" {{#if publicSubscribe}} checked {{/if}}> {{#translate}}Allow public users to subscribe themselves{{/translate}}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<div class="pull-right">
|
||||
|
|
|
@ -12,57 +12,26 @@
|
|||
<hr>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover data-table display nowrap" width="100%" data-row-sort="0,1,1,1,0,0">
|
||||
<table data-topic-url="/lists" data-sort-column="1" data-sort-order="asc" class="table table-bordered table-hover data-table-ajax display nowrap" width="100%" data-row-sort="0,1,1,1,0,0">
|
||||
<thead>
|
||||
<th>
|
||||
#
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}Name{{/translate}}
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}ID{{/translate}}
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}Subscribers{{/translate}}
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}Description{{/translate}}
|
||||
</th>
|
||||
<th>
|
||||
|
||||
</th>
|
||||
<th style="width: 1%">
|
||||
#
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}Name{{/translate}}
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}ID{{/translate}}
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}Subscribers{{/translate}}
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}Description{{/translate}}
|
||||
</th>
|
||||
<th style="width: 1%">
|
||||
|
||||
</th>
|
||||
</thead>
|
||||
{{#if rows}}
|
||||
<tbody>
|
||||
{{#each rows}}
|
||||
<tr>
|
||||
<th scope="row">
|
||||
{{index}}
|
||||
</th>
|
||||
<td style="width: 30%;">
|
||||
<span class="glyphicon glyphicon-list-alt" aria-hidden="true"></span>
|
||||
<a href="/lists/view/{{id}}">
|
||||
{{name}}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{cid}}</code>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{{subscribers}}
|
||||
</td>
|
||||
<td class="text-muted" style="width: 70%;">
|
||||
{{description}}
|
||||
</td>
|
||||
<td>
|
||||
<a href="/lists/edit/{{id}}">
|
||||
{{#translate}}Edit{{/translate}}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
{{/if}}
|
||||
</table>
|
||||
</div>
|
||||
|
|
24
views/report-templates/create.hbs
Normal file
24
views/report-templates/create.hbs
Normal file
|
@ -0,0 +1,24 @@
|
|||
<ol class="breadcrumb">
|
||||
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
|
||||
<li><a href="/reports">{{#translate}}Reports{{/translate}}</a></li>
|
||||
<li><a href="/report-templates">{{#translate}}Templates{{/translate}}</a></li>
|
||||
<li class="active">{{#translate}}Create Template{{/translate}}</li>
|
||||
</ol>
|
||||
|
||||
<h2>{{#translate}}Create Report Template{{/translate}}</h2>
|
||||
|
||||
<hr>
|
||||
|
||||
<form class="form-horizontal" method="post" action="/report-templates/create">
|
||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||
|
||||
{{> report_template_fields }}
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-plus"></i> {{#translate}}Create Template{{/translate}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
36
views/report-templates/edit.hbs
Normal file
36
views/report-templates/edit.hbs
Normal file
|
@ -0,0 +1,36 @@
|
|||
<ol class="breadcrumb">
|
||||
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
|
||||
<li><a href="/reports">{{#translate}}Reports{{/translate}}</a></li>
|
||||
<li><a href="/report-templates">{{#translate}}Templates{{/translate}}</a></li>
|
||||
<li class="active">{{#translate}}Edit Template{{/translate}}</li>
|
||||
</ol>
|
||||
|
||||
<h2>{{#translate}}Edit Report Template{{/translate}}</h2>
|
||||
|
||||
<hr>
|
||||
|
||||
<form method="post" class="delete-form" id="report-templates-delete" action="/report-templates/delete">
|
||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||
<input type="hidden" name="id" value="{{id}}" />
|
||||
</form>
|
||||
|
||||
|
||||
<form class="form-horizontal" method="post" action="/report-templates/edit">
|
||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||
<input type="hidden" name="id" value="{{id}}" />
|
||||
|
||||
{{> report_template_fields }}
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<div class="pull-right">
|
||||
<button type="submit" form="report-templates-delete" class="btn btn-danger"><i class="glyphicon glyphicon-remove"></i> {{#translate}}Delete Template{{/translate}}</button>
|
||||
</div>
|
||||
<button type="submit" name="submit" value="update-and-stay" class="btn btn-primary"><i class="glyphicon glyphicon-ok"></i> {{#translate}}Update and Stay{{/translate}}</button>
|
||||
<button type="submit" name="submit" value="update-and-leave" class="btn btn-primary"><i class="glyphicon glyphicon-ok"></i> {{#translate}}Update and Leave{{/translate}}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
59
views/report-templates/partials/report-template-fields.hbs
Normal file
59
views/report-templates/partials/report-template-fields.hbs
Normal file
|
@ -0,0 +1,59 @@
|
|||
<div class="form-group">
|
||||
<label for="name" class="col-sm-2 control-label">{{#translate}}Name{{/translate}}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control input-lg" name="name" id="name" value="{{name}}" placeholder="{{#translate}}Template Name{{/translate}}" autofocus required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="col-sm-2 control-label">{{#translate}}Description{{/translate}}</label>
|
||||
<div class="col-sm-10">
|
||||
<textarea class="form-control" rows="3" name="description" id="description">{{description}}</textarea>
|
||||
<span class="help-block">{{#translate}}HTML is allowed{{/translate}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="mimeType" class="col-sm-2 control-label">{{#translate}}Type{{/translate}}</label>
|
||||
<div class="col-sm-10">
|
||||
<select name="mimeType" class="form-control">
|
||||
{{#each mimeTypes}}
|
||||
<option value="{{key}}" {{#if selected}} selected {{/if}}>{{value}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label">{{#translate}}User selectable fields{{/translate}}</label>
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<div class="help-block" style="margin-top: -8px;">
|
||||
<small>JSON specification of user selectable fields.</small>
|
||||
</div>
|
||||
<div class="code-editor-json" style="height: 250px; border: 1px solid #ccc;"></div>
|
||||
<input type="hidden" name="userFields" value="{{userFields}}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label">{{#translate}}Data processing code{{/translate}}</label>
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<div class="help-block" style="margin-top: -8px;">
|
||||
<small>Write the body of the JavaScript function with signature <code>function(inputs, callback)</code> that returns an object to be rendered by the Handlebars template below.</small>
|
||||
</div>
|
||||
<div class="code-editor-javascript" style="height: 700px; border: 1px solid #ccc;"></div>
|
||||
<input type="hidden" name="js" value="{{js}}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label">{{#translate}}Rendering template{{/translate}}</label>
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<div class="help-block" style="margin-top: -8px;">
|
||||
<small>Use HTML with Handlebars syntax. See documentation <a href="http://handlebarsjs.com/">here</a>.</small>
|
||||
</div>
|
||||
<div class="code-editor-handlebars" style="height: 700px; border: 1px solid #ccc;"></div>
|
||||
<input type="hidden" name="hbs" value="{{hbs}}">
|
||||
</div>
|
||||
</div>
|
||||
|
45
views/report-templates/report-templates.hbs
Normal file
45
views/report-templates/report-templates.hbs
Normal file
|
@ -0,0 +1,45 @@
|
|||
<ol class="breadcrumb">
|
||||
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
|
||||
<li><a href="/reports">{{#translate}}Reports{{/translate}}</a></li>
|
||||
<li class="active">{{#translate}}Templates{{/translate}}</li>
|
||||
</ol>
|
||||
|
||||
<div class="pull-right">
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
{{#translate}}Create Template{{/translate}} <span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="/report-templates/create">{{#translate}}Blank{{/translate}}</a></li>
|
||||
<li><a href="/report-templates/create?type=subscribers-all">{{#translate}}All Subscribers{{/translate}}</a></li>
|
||||
<li><a href="/report-templates/create?type=subscribers-grouped">{{#translate}}Grouped Subscribers{{/translate}}</a></li>
|
||||
<li><a href="/report-templates/create?type=export-list-csv">{{#translate}}Export List as CSV{{/translate}}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>{{#translate}}Report Templates{{/translate}}</h2>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table data-topic-url="/report-templates" data-sort-column="2" data-sort-order="desc" class="table table-bordered table-hover data-table-ajax display nowrap" width="100%" data-row-sort="0,1,0,1,0">
|
||||
<thead>
|
||||
<th class="col-md-1">
|
||||
#
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}Name{{/translate}}
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}Description{{/translate}}
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}Created{{/translate}}
|
||||
</th>
|
||||
<th class="col-md-1">
|
||||
|
||||
</th>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
22
views/reports/create-select-template.hbs
Normal file
22
views/reports/create-select-template.hbs
Normal file
|
@ -0,0 +1,22 @@
|
|||
<ol class="breadcrumb">
|
||||
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
|
||||
<li><a href="/reports">{{#translate}}Reports{{/translate}}</a></li>
|
||||
<li class="active">{{#translate}}Create Report{{/translate}}</li>
|
||||
</ol>
|
||||
|
||||
<h2>{{#translate}}Create Report{{/translate}}</h2>
|
||||
|
||||
<hr>
|
||||
|
||||
<form class="form-horizontal" method="get" action="/reports/create">
|
||||
|
||||
{{> report_select_template }}
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-chevron-right"></i> {{#translate}}Next{{/translate}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
23
views/reports/create.hbs
Normal file
23
views/reports/create.hbs
Normal file
|
@ -0,0 +1,23 @@
|
|||
<ol class="breadcrumb">
|
||||
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
|
||||
<li><a href="/reports">{{#translate}}Reports{{/translate}}</a></li>
|
||||
<li class="active">{{#translate}}Create Report{{/translate}}</li>
|
||||
</ol>
|
||||
|
||||
<h2>{{#translate}}Create Report{{/translate}}</h2>
|
||||
|
||||
<hr>
|
||||
|
||||
<form class="form-horizontal" method="post" action="/reports/create">
|
||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||
|
||||
{{> report_fields }}
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-plus"></i> {{#translate}}Create Report{{/translate}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
34
views/reports/edit.hbs
Normal file
34
views/reports/edit.hbs
Normal file
|
@ -0,0 +1,34 @@
|
|||
<ol class="breadcrumb">
|
||||
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
|
||||
<li><a href="/reports">{{#translate}}Reports{{/translate}}</a></li>
|
||||
<li class="active">{{#translate}}Edit Report{{/translate}}</li>
|
||||
</ol>
|
||||
|
||||
<h2>{{#translate}}Edit Report{{/translate}}</h2>
|
||||
|
||||
<hr>
|
||||
|
||||
<form method="post" class="delete-form" id="reports-delete" action="/reports/delete">
|
||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||
<input type="hidden" name="id" value="{{id}}" />
|
||||
</form>
|
||||
|
||||
|
||||
<form class="form-horizontal" method="post" action="/reports/edit">
|
||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||
<input type="hidden" name="id" value="{{id}}" />
|
||||
|
||||
{{> report_fields }}
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<div class="pull-right">
|
||||
<button type="submit" form="reports-delete" class="btn btn-danger"><i class="glyphicon glyphicon-remove"></i> {{#translate}}Delete Report{{/translate}}</button>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-ok"></i> {{#translate}}Update{{/translate}}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
8
views/reports/output.hbs
Normal file
8
views/reports/output.hbs
Normal file
|
@ -0,0 +1,8 @@
|
|||
<ol class="breadcrumb">
|
||||
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
|
||||
<li><a href="/reports/">{{#translate}}Reports{{/translate}}</a></li>
|
||||
<li class="active">{{title}}</li>
|
||||
</ol>
|
||||
|
||||
<i>{{error}}</i>
|
||||
<pre>{{output}}</pre>
|
73
views/reports/partials/report-fields.hbs
Normal file
73
views/reports/partials/report-fields.hbs
Normal file
|
@ -0,0 +1,73 @@
|
|||
{{> report_select_template options="readonly" }}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name" class="col-sm-2 control-label">{{#translate}}Name{{/translate}}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control input-lg" name="name" id="name" value="{{name}}" placeholder="{{#translate}}Report Name{{/translate}}" autofocus required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="col-sm-2 control-label">{{#translate}}Description{{/translate}}</label>
|
||||
<div class="col-sm-10">
|
||||
<textarea class="form-control" rows="3" name="description" id="description">{{description}}</textarea>
|
||||
<span class="help-block">{{#translate}}HTML is allowed{{/translate}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#each userFields}}
|
||||
{{#switch type}}
|
||||
{{#case "campaign"}}
|
||||
<div class="form-group">
|
||||
<label for="description" class="col-sm-2 control-label">{{name}}</label>
|
||||
<div class="col-sm-10">
|
||||
<div class="table-responsive">
|
||||
<table data-topic-url="/campaigns/quicklist" data-sort-column="2" data-sort-order="desc" class="table table-bordered table-hover data-table-ajax data-table-{{#if isMulti}}multi{{/if}}selectable display nowrap" width="100%" data-row-sort="0,1,0,1">
|
||||
<thead>
|
||||
<th class="col-md-1">
|
||||
#
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}Name{{/translate}}
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}Description{{/translate}}
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}Created{{/translate}}
|
||||
</th>
|
||||
</thead>
|
||||
</table>
|
||||
<input type="hidden" name="{{id}}Selection" value="{{value}}" />
|
||||
</div>
|
||||
<span class="help-block">{{#translate}}Select a campaign in the table above by clicking on the respective row number.{{/translate}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{/case}}
|
||||
{{#case "list"}}
|
||||
<div class="form-group">
|
||||
<label for="description" class="col-sm-2 control-label">{{name}}</label>
|
||||
<div class="col-sm-10">
|
||||
<div class="table-responsive">
|
||||
<table data-topic-url="/lists/quicklist" data-sort-column="2" data-sort-order="asc" class="table table-bordered table-hover data-table-ajax data-table-{{#if isMulti}}multi{{/if}}selectable display nowrap" width="100%" data-row-sort="0,1,1">
|
||||
<thead>
|
||||
<th class="col-md-1">
|
||||
#
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}Name{{/translate}}
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}Subscribers{{/translate}}
|
||||
</th>
|
||||
</thead>
|
||||
</table>
|
||||
<input type="hidden" name="{{id}}Selection" value="{{value}}" />
|
||||
</div>
|
||||
<span class="help-block">{{#translate}}Select a campaign in the table above by clicking on the respective row number.{{/translate}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{/case}}
|
||||
{{/switch}}
|
||||
{{/each}}
|
||||
|
11
views/reports/partials/report-select-template.hbs
Normal file
11
views/reports/partials/report-select-template.hbs
Normal file
|
@ -0,0 +1,11 @@
|
|||
<div class="form-group">
|
||||
<label for="name" class="col-sm-2 control-label">{{#translate}}Report Template{{/translate}}</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control" id="reportTemplate" name="reportTemplate" required {{options}}>
|
||||
<option value=""> –– {{#translate}}Select{{/translate}} –– </option>
|
||||
{{#each reportTemplates}}
|
||||
<option value="{{id}}" {{#if selected}} selected {{/if}}>{{name}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
40
views/reports/reports.hbs
Normal file
40
views/reports/reports.hbs
Normal file
|
@ -0,0 +1,40 @@
|
|||
<ol class="breadcrumb">
|
||||
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
|
||||
<li class="active">{{#translate}}Reports{{/translate}}</li>
|
||||
</ol>
|
||||
|
||||
<div class="pull-right">
|
||||
<a class="btn btn-primary" href="/reports/create" role="button"><i class="glyphicon glyphicon-plus"></i> {{#translate}}Create Report{{/translate}}</a>
|
||||
|
||||
<a class="btn btn-primary" href="/report-templates" role="button">{{#translate}}Report Templates{{/translate}}</a>
|
||||
</div>
|
||||
|
||||
<h2>{{#translate}}Reports{{/translate}}</h2>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table data-topic-url="/reports" data-sort-column="2" data-sort-order="desc" class="table table-bordered table-hover data-table-ajax display nowrap" width="100%" data-row-sort="0,1,1,0,1,0">
|
||||
<thead>
|
||||
<th style="width: 1%">
|
||||
#
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}Name{{/translate}}
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}Template{{/translate}}
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}Description{{/translate}}
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}Created{{/translate}}
|
||||
</th>
|
||||
<th style="width: 1%">
|
||||
|
||||
</th>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
|
7
views/reports/view.hbs
Normal file
7
views/reports/view.hbs
Normal file
|
@ -0,0 +1,7 @@
|
|||
<ol class="breadcrumb">
|
||||
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
|
||||
<li><a href="/reports/">{{#translate}}Reports{{/translate}}</a></li>
|
||||
<li class="active">{{title}}</li>
|
||||
</ol>
|
||||
|
||||
{{report}}
|
|
@ -12,9 +12,9 @@
|
|||
<hr>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover data-table display nowrap" width="100%" data-row-sort="0,1,0,0">
|
||||
<table data-topic-url="/templates" data-sort-column="1" data-sort-order="asc" class="table table-bordered table-hover data-table-ajax display nowrap" width="100%" data-row-sort="0,1,0,0">
|
||||
<thead>
|
||||
<th class="col-md-1">
|
||||
<th style="width: 1%">
|
||||
#
|
||||
</th>
|
||||
<th>
|
||||
|
@ -23,32 +23,9 @@
|
|||
<th>
|
||||
{{#translate}}Description{{/translate}}
|
||||
</th>
|
||||
<th class="col-md-1">
|
||||
<th style="width: 1%">
|
||||
|
||||
</th>
|
||||
</thead>
|
||||
{{#if rows}}
|
||||
<tbody>
|
||||
{{#each rows}}
|
||||
<tr>
|
||||
<th scope="row">
|
||||
{{index}}
|
||||
</th>
|
||||
<td>
|
||||
<span class="glyphicon glyphicon-file" aria-hidden="true"></span> {{name}}
|
||||
</td>
|
||||
<td>
|
||||
<p class="text-muted">{{description}}</p>
|
||||
</td>
|
||||
<td>
|
||||
<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span>
|
||||
<a href="/templates/edit/{{id}}">
|
||||
{{#translate}}Edit{{/translate}}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
{{/if}}
|
||||
</table>
|
||||
</div>
|
||||
|
|
18
workers/reports/config/default.toml
Normal file
18
workers/reports/config/default.toml
Normal file
|
@ -0,0 +1,18 @@
|
|||
# Process title visible in monitoring logs and process listing
|
||||
title="mailtrain"
|
||||
|
||||
# Default language to use
|
||||
language="en"
|
||||
|
||||
[log]
|
||||
# silly|verbose|info|http|warn|error|silent
|
||||
level="verbose"
|
||||
|
||||
[mysql]
|
||||
host="localhost"
|
||||
user="mailtrain"
|
||||
password="mailtrain"
|
||||
database="mailtrain"
|
||||
port=3306
|
||||
charset="utf8mb4"
|
||||
timezone="local"
|
147
workers/reports/report-processor.js
Normal file
147
workers/reports/report-processor.js
Normal file
|
@ -0,0 +1,147 @@
|
|||
'use strict';
|
||||
|
||||
const reports = require('../../lib/models/reports');
|
||||
const reportTemplates = require('../../lib/models/report-templates');
|
||||
const lists = require('../../lib/models/lists');
|
||||
const subscriptions = require('../../lib/models/subscriptions');
|
||||
const campaigns = require('../../lib/models/campaigns');
|
||||
const handlebars = require('handlebars');
|
||||
const handlebarsHelpers = require('../../lib/handlebars-helpers');
|
||||
const _ = require('../../lib/translate')._;
|
||||
const hbs = require('hbs');
|
||||
const vm = require('vm');
|
||||
const log = require('npmlog');
|
||||
const fs = require('fs');
|
||||
|
||||
handlebarsHelpers.registerHelpers(handlebars);
|
||||
|
||||
let reportId = Number(process.argv[2]);
|
||||
let reportDir;
|
||||
|
||||
function resolveEntities(getter, ids, callback) {
|
||||
const idsRemaining = ids.slice();
|
||||
const resolved = [];
|
||||
|
||||
function doWork() {
|
||||
if (idsRemaining.length == 0) {
|
||||
return callback(null, resolved);
|
||||
}
|
||||
|
||||
getter(idsRemaining.shift(), (err, entity) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
resolved.push(entity);
|
||||
return doWork();
|
||||
});
|
||||
}
|
||||
|
||||
setImmediate(doWork);
|
||||
}
|
||||
|
||||
const userFieldTypeToGetter = {
|
||||
'campaign': (id, callback) => campaigns.get(id, false, callback),
|
||||
'list': lists.get
|
||||
};
|
||||
|
||||
function resolveUserFields(userFields, params, callback) {
|
||||
const userFieldsRemaining = userFields.slice();
|
||||
const resolved = {};
|
||||
|
||||
function doWork() {
|
||||
if (userFieldsRemaining.length == 0) {
|
||||
return callback(null, resolved);
|
||||
}
|
||||
|
||||
const spec = userFieldsRemaining.shift();
|
||||
const getter = userFieldTypeToGetter[spec.type];
|
||||
|
||||
if (getter) {
|
||||
return resolveEntities(getter, params[spec.id], (err, entities) => {
|
||||
if (spec.minOccurences == 1 && spec.maxOccurences == 1) {
|
||||
resolved[spec.id] = entities[0];
|
||||
} else {
|
||||
resolved[spec.id] = entities;
|
||||
}
|
||||
|
||||
doWork();
|
||||
});
|
||||
} else {
|
||||
return callback(new Error(_('Unknown user field type "' + spec.type + '".')));
|
||||
}
|
||||
}
|
||||
|
||||
setImmediate(doWork);
|
||||
}
|
||||
|
||||
function doneSuccess() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function doneFail() {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
|
||||
|
||||
reports.get(reportId, (err, report) => {
|
||||
if (err || !report) {
|
||||
log.error('reports', err && err.message || err || _('Could not find report with specified ID'));
|
||||
doneFail();
|
||||
}
|
||||
|
||||
reportTemplates.get(report.reportTemplate, (err, reportTemplate) => {
|
||||
if (err) {
|
||||
log.error('reports', err && err.message || err || _('Could not find report template'));
|
||||
doneFail();
|
||||
}
|
||||
|
||||
resolveUserFields(reportTemplate.userFieldsObject, report.paramsObject, (err, inputs) => {
|
||||
if (err) {
|
||||
log.error('reports', err.message || err);
|
||||
doneFail();
|
||||
}
|
||||
|
||||
const campaignsProxy = {
|
||||
results: reports.getCampaignResults,
|
||||
list: campaigns.list,
|
||||
get: campaigns.get
|
||||
};
|
||||
|
||||
const subscriptionsProxy = {
|
||||
list: subscriptions.list
|
||||
};
|
||||
|
||||
const sandbox = {
|
||||
console,
|
||||
campaigns: campaignsProxy,
|
||||
subscriptions: subscriptionsProxy,
|
||||
inputs,
|
||||
|
||||
callback: (err, outputs) => {
|
||||
if (err) {
|
||||
log.error('reports', err.message || err);
|
||||
doneFail();
|
||||
}
|
||||
|
||||
const hbsTmpl = handlebars.compile(reportTemplate.hbs);
|
||||
const reportText = hbsTmpl(outputs);
|
||||
|
||||
process.stdout.write(reportText);
|
||||
doneSuccess();
|
||||
}
|
||||
};
|
||||
|
||||
const script = new vm.Script(reportTemplate.js);
|
||||
|
||||
try {
|
||||
script.runInNewContext(sandbox, {displayErrors: true, timeout: 120000});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
doneFail();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in a new issue