diff --git a/index.js b/index.js index 1ef2340f..57b9d482 100644 --- a/index.js +++ b/index.js @@ -18,6 +18,7 @@ let tzupdate = require('./services/tzupdate'); let feedcheck = require('./services/feedcheck'); let dbcheck = require('./lib/dbcheck'); let tools = require('./lib/tools'); +let reportProcessor = require('./services/report-processor'); let port = config.www.port; let host = config.www.host; @@ -120,23 +121,25 @@ server.on('listening', () => { 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); + reportProcessor.init(() => { + 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); + 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); + } } - } + }); }); }); }); diff --git a/lib/fs-tools.js b/lib/fs-tools.js deleted file mode 100644 index 59f126e6..00000000 --- a/lib/fs-tools.js +++ /dev/null @@ -1,14 +0,0 @@ -'use strict'; - -module.exports = { - nameToFileName, -}; - -function nameToFileName(name) { - return name. - trim(). - toLowerCase(). - replace(/[ .+/]/g, '-'). - replace(/[^a-z0-9\-_]/gi, ''). - replace(/--*/g, '-'); -} diff --git a/lib/models/campaigns.js b/lib/models/campaigns.js index e9007dcf..1291cd78 100644 --- a/lib/models/campaigns.js +++ b/lib/models/campaigns.js @@ -19,7 +19,7 @@ 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) => { - tableHelpers.list('campaigns', ['*'], 'scheduled', start, limit, callback); + tableHelpers.list('campaigns', ['*'], 'scheduled', null, start, limit, callback); }; module.exports.filter = (request, parent, callback) => { diff --git a/lib/models/lists.js b/lib/models/lists.js index 772a5820..28304722 100644 --- a/lib/models/lists.js +++ b/lib/models/lists.js @@ -10,7 +10,7 @@ let tableHelpers = require('../table-helpers'); let allowedKeys = ['description', 'default_form', 'public_subscribe']; module.exports.list = (start, limit, callback) => { - tableHelpers.list('lists', ['*'], 'name', start, limit, callback); + tableHelpers.list('lists', ['*'], 'name', null, start, limit, callback); }; module.exports.filter = (request, parent, callback) => { diff --git a/lib/models/report-templates.js b/lib/models/report-templates.js index 195472a3..e43bd7d8 100644 --- a/lib/models/report-templates.js +++ b/lib/models/report-templates.js @@ -8,7 +8,7 @@ const _ = require('../translate')._; const allowedKeys = ['name', 'description', 'mime_type', 'user_fields', 'js', 'hbs']; module.exports.list = (start, limit, callback) => { - tableHelpers.list('report_templates', ['*'], 'name', start, limit, callback); + tableHelpers.list('report_templates', ['*'], 'name', null, start, limit, callback); }; module.exports.quicklist = callback => { diff --git a/lib/models/reports.js b/lib/models/reports.js index 2d972f36..31a53c4f 100644 --- a/lib/models/reports.js +++ b/lib/models/reports.js @@ -8,16 +8,29 @@ const tools = require('../tools'); const _ = require('../translate')._; const log = require('npmlog'); -const allowedKeys = ['name', 'description', 'report_template', 'params', 'filename']; +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', 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.filename AS filename', 'reports.report_template AS report_template', 'reports.params AS params', 'reports.created AS created', 'report_templates.name AS report_template_name', 'report_templates.mime_type AS mime_type' ], - request, ['#', 'name', 'report_templates.name', 'description', 'created'], ['name'], 'created DESC', null, callback); + ['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) => { @@ -60,29 +73,56 @@ module.exports.get = (id, callback) => { }); }; -module.exports.createOrUpdate = (createMode, data, callback) => { - data = data || {}; +// 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 next(err); + } - const id = 'id' in data ? Number(data.id) : 0; + 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 template = tools.convertKeys(data); - const name = (template.name || '').toString().trim(); + const name = (report.name || '').toString().trim(); if (!name) { return callback(new Error(_('Report name must be set'))); } - const reportTemplateId = Number(template.reportTemplate); + const reportTemplateId = Number(report.reportTemplate); reportTemplates.get(reportTemplateId, (err, reportTemplate) => { if (err) { callback(err); } - const params = data.paramsObject; + 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.'))); @@ -97,8 +137,8 @@ module.exports.createOrUpdate = (createMode, data, callback) => { const values = [name, JSON.stringify(params)]; - Object.keys(template).forEach(key => { - let value = typeof template[key] === 'number' ? template[key] : (template[key] || '').toString().trim(); + Object.keys(report).forEach(key => { + let value = typeof report[key] === 'number' ? report[key] : (report[key] || '').toString().trim(); key = tools.toDbKey(key); if (key === 'description') { diff --git a/lib/models/subscriptions.js b/lib/models/subscriptions.js index f5fb5f84..99d32be0 100644 --- a/lib/models/subscriptions.js +++ b/lib/models/subscriptions.js @@ -21,7 +21,7 @@ module.exports.list = (listId, start, limit, callback) => { return callback(new Error('Missing List ID')); } - tableHelpers.list('subscription__' + listId, ['*'], 'email', start, limit, (err, rows, total) => { + tableHelpers.list('subscription__' + listId, ['*'], 'email', null, start, limit, (err, rows, total) => { if (!err) { rows = rows.map(row => tools.convertKeys(row)); } diff --git a/lib/models/templates.js b/lib/models/templates.js index 09f0abb3..a6723eef 100644 --- a/lib/models/templates.js +++ b/lib/models/templates.js @@ -8,7 +8,7 @@ let tableHelpers = require('../table-helpers'); let allowedKeys = ['description', 'editor_name', 'editor_data', 'html', 'text']; module.exports.list = (start, limit, callback) => { - tableHelpers.list('templates', ['*'], 'name', start, limit, callback); + tableHelpers.list('templates', ['*'], 'name', null, start, limit, callback); }; module.exports.filter = (request, parent, callback) => { diff --git a/lib/table-helpers.js b/lib/table-helpers.js index f5c2e6bc..2c082c2c 100644 --- a/lib/table-helpers.js +++ b/lib/table-helpers.js @@ -4,7 +4,7 @@ let db = require('./db'); let tools = require('./tools'); let log = require('npmlog'); -module.exports.list = (source, fields, orderBy, start, limit, callback) => { +module.exports.list = (source, fields, orderBy, queryData, start, limit, callback) => { db.getConnection((err, connection) => { if (err) { return callback(err); @@ -22,7 +22,15 @@ module.exports.list = (source, fields, orderBy, start, limit, callback) => { } } - connection.query('SELECT SQL_CALC_FOUND_ROWS ' + fields.join(', ') + ' FROM ' + source + ' ORDER BY ' + orderBy + ' DESC' + limitQuery, limitValues, (err, rows) => { + 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); diff --git a/lib/tools.js b/lib/tools.js index 080afdcb..7b3074a2 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -28,6 +28,7 @@ module.exports = { prepareHtml, purifyHTML, mergeTemplateIntoLayout, + nameToFileName, workers: new Set() }; @@ -300,3 +301,12 @@ function mergeTemplateIntoLayout(template, layout, callback) { return done(template, layout); } } + +function nameToFileName(name) { + return name. + trim(). + toLowerCase(). + replace(/[ .+/]/g, '-'). + replace(/[^a-z0-9\-_]/gi, ''). + replace(/--*/g, '-'); +} diff --git a/public/css/mailtrain.css b/public/css/mailtrain.css index f8a82266..f4bf6f2f 100644 --- a/public/css/mailtrain.css +++ b/public/css/mailtrain.css @@ -38,4 +38,12 @@ tbody>tr.selected { .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; } \ No newline at end of file diff --git a/public/javascript/tables.js b/public/javascript/tables.js index 3660e2db..be91079e 100644 --- a/public/javascript/tables.js +++ b/public/javascript/tables.js @@ -4,7 +4,62 @@ 'use strict'; -(function(){ +(function() { + function refreshTargets(data) { + for (var target in data) { + var newContent = $(data[target]); + + $(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; + }); + } + + function setupDatestring() { + var self = $(this); + self.html(moment(self.data('date')).fromNow()); + } + + function getDataTableOptions(elem) { var rowSort = $(elem).data('rowSort') || false; @@ -92,6 +147,15 @@ 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); @@ -112,131 +176,135 @@ opts.serverSide = true; opts.processing = true; + opts.createdRow = function( row, data, dataIndex ) { + installHandlers($(row)); + } + $(this).DataTable(opts).on('draw', function () { - $('.datestring').each(function () { - $(this).html(moment($(this).data('date')).fromNow()); - }); + $('.datestring').each(setupDatestring); }); }); + $('.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'); + }); + + }); + } + })(); -$('.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'); - }); - - }); -} diff --git a/routes/report-templates.js b/routes/report-templates.js index 638502fd..5393ccb4 100644 --- a/routes/report-templates.js +++ b/routes/report-templates.js @@ -93,7 +93,7 @@ router.get('/create', passport.csrfProtection, (req, res) => { '