// Cronicle API Layer - Events // Copyright (c) 2015 Joseph Huckaby // Released under the MIT License var fs = require('fs'); var assert = require("assert"); var async = require('async'); var Class = require("pixl-class"); var Tools = require("pixl-tools"); module.exports = Class.create({ // // Events: // api_get_schedule: function(args, callback) { // get list of scheduled events (with pagination) var self = this; var params = Tools.mergeHashes( args.params, args.query ); if (!this.requireMaster(args, callback)) return; this.loadSession(args, function(err, session, user) { if (err) return self.doError('session', err.message, callback); if (!self.requireValidUser(session, user, callback)) return; self.storage.listGet( 'global/schedule', parseInt(params.offset || 0), parseInt(params.limit || 0), function(err, items, list) { if (err) { // no items found, not an error for this API return callback({ code: 0, rows: [], list: { length: 0 } }); } // success, return keys and list header callback({ code: 0, rows: items, list: list }); } ); // got event list } ); // loaded session }, api_get_event: function(args, callback) { // get single event for editing var self = this; var params = Tools.mergeHashes( args.params, args.query ); if (!this.requireMaster(args, callback)) return; var criteria = {}; if (params.id) criteria.id = params.id; else if (params.title) criteria.title = params.title; else return this.doError('event', "Failed to locate event: No criteria specified", callback); this.loadSession(args, function(err, session, user) { if (err) return self.doError('session', err.message, callback); if (!self.requireValidUser(session, user, callback)) return; self.storage.listFind( 'global/schedule', criteria, function(err, item) { if (err || !item) { return self.doError('event', "Failed to locate event: " + (params.id || params.title), callback); } // success, return event var resp = { code: 0, event: item }; if (item.queue) resp.queue = self.eventQueue[item.id] || 0; // if event has any active jobs, include those as well var all_jobs = self.getAllActiveJobs(true); var event_jobs = []; for (var key in all_jobs) { var job = all_jobs[key]; if (job.event == item.id) event_jobs.push(job); } if (event_jobs.length) resp.jobs = event_jobs; callback(resp); } ); // got event } ); // loaded session }, api_create_event: function(args, callback) { // add new event var self = this; var event = args.params; if (!this.requireMaster(args, callback)) return; if (!this.requireParams(event, { title: /\S/, enabled: /^(\d+|true|false)$/, category: /^\w+$/, target: /^[\w\-\.]+$/, plugin: /^\w+$/ }, callback)) return; // validate optional event data parameters if (!this.requireValidEventData(event, callback)) return; this.loadSession(args, function(err, session, user) { if (err) return self.doError('session', err.message, callback); if (!self.requireValidUser(session, user, callback)) return; if (!self.requirePrivilege(user, "create_events", callback)) return; if (!self.requireCategoryPrivilege(user, event.category, callback)) return; if (!self.requireGroupPrivilege(args, user, event.target, callback)) return; args.user = user; args.session = session; if (event.id) event.id = event.id.toString().toLowerCase().replace(/\W+/g, ''); if (!event.id) event.id = self.getUniqueID('e'); event.created = event.modified = Tools.timeNow(true); if (!event.max_children) event.max_children = 0; if (!event.timeout) event.timeout = 0; if (!event.timezone) event.timezone = self.tz; if (!event.params) event.params = {}; if (user.key) { // API Key event.api_key = user.key; } else { event.username = user.username; } // validate category self.storage.listFind( 'global/categories', { id: event.category }, function(err, item) { if (err || !item) { return self.doError('event', "Failed to create event: Category not found: " + event.category, callback); } self.logDebug(6, "Creating new event: " + event.title, event); self.storage.listUnshift( 'global/schedule', event, function(err) { if (err) { return self.doError('event', "Failed to create event: " + err, callback); } self.logDebug(6, "Successfully created event: " + event.title, event); self.logTransaction('event_create', event.title, self.getClientInfo(args, { event: event })); self.logActivity('event_create', { event: event }, args); callback({ code: 0, id: event.id }); // broadcast update to all websocket clients self.updateClientData( 'schedule' ); // create cursor for new event var now = Tools.normalizeTime( Tools.timeNow(), { sec: 0 } ); self.state.cursors[ event.id ] = now; // send new state data to all web clients self.authSocketEmit( 'update', { state: self.state } ); } ); // list insert } ); // find cat } ); // load session }, api_update_event: function(args, callback) { // update existing event var self = this; var params = args.params; if (!this.requireMaster(args, callback)) return; if (!this.requireParams(params, { id: /^\w+$/ }, callback)) return; // validate optional event data parameters if (!this.requireValidEventData(params, callback)) return; this.loadSession(args, function(err, session, user) { if (err) return self.doError('session', err.message, callback); if (!self.requireValidUser(session, user, callback)) return; if (!self.requirePrivilege(user, "edit_events", callback)) return; if (params.abort_jobs && !self.requirePrivilege(user, "abort_events", callback)) return; args.user = user; args.session = session; self.storage.listFind( 'global/schedule', { id: params.id }, function(err, event) { if (err || !event) { return self.doError('event', "Failed to locate event: " + params.id, callback); } // validate category self.storage.listFind('global/categories', { id: params.category || event.category }, function (err, item) { if (err || !item) { return self.doError('event', "Failed to update event: Category not found: " + params.category, callback); } if (!self.requireCategoryPrivilege(user, event.category, callback)) return; if (!self.requireGroupPrivilege(args, user, event.target, callback)) return; params.modified = Tools.timeNow(true); self.logDebug(6, "Updating event: " + event.title, params); // pull cursor reset out of event object, for use later var new_cursor = 0; if (params.reset_cursor) { new_cursor = Tools.normalizeTime(params.reset_cursor - 60, { sec: 0 }); delete params.reset_cursor; } // pull abort flag out of event object, for use later var abort_jobs = 0; if (params.abort_jobs) { abort_jobs = params.abort_jobs; delete params.abort_jobs; } self.storage.listFindUpdate( 'global/schedule', { id: params.id }, params, function(err) { if (err) { return self.doError('event', "Failed to update event: " + err, callback); } // merge params into event, just so we have the full updated record for (var key in params) event[key] = params[key]; // optionally reset cursor if (new_cursor) { var dargs = Tools.getDateArgs( new_cursor ); self.logDebug(6, "Resetting event cursor to: " + dargs.yyyy_mm_dd + ' ' + dargs.hh_mi_ss); self.state.cursors[ params.id ] = new_cursor; // send new state data to all web clients self.authSocketEmit( 'update', { state: self.state } ); } self.logDebug(6, "Successfully updated event: " + event.id + " (" + event.title + ")"); self.logTransaction('event_update', event.title, self.getClientInfo(args, { event: params })); self.logActivity('event_update', { event: params }, args); // send response to web client callback({ code: 0 }); // broadcast update to all websocket clients self.updateClientData( 'schedule' ); // if event is disabled, abort all applicable jobs if (!event.enabled && abort_jobs) { var all_jobs = self.getAllActiveJobs(true); for (var key in all_jobs) { var job = all_jobs[key]; if ((job.event == event.id) && !job.detached) { var msg = "Event '" + event.title + "' has been disabled."; self.logDebug(4, "Job " + job.id + " is being aborted: " + msg); self.abortJob({ id: job.id, reason: msg }); } // matches event } // foreach job } // event disabled // if this is a catch_up event and is being enabled, force scheduler to re-tick the minute var dargs = Tools.getDateArgs( new Date() ); if (params.enabled && event.catch_up && !self.schedulerGraceTimer && !self.schedulerTicking && (dargs.sec != 59)) { self.schedulerMinuteTick( null, true ); } // check event queue if (self.eventQueue[event.id]) { if (event.queue) self.checkEventQueues( event.id ); else self.deleteEventQueues( event.id ); } } ); // update event } ); // find cat } ); // find event } ); // load session }, api_delete_event: function(args, callback) { // delete existing event var self = this; var params = args.params; if (!this.requireMaster(args, callback)) return; if (!this.requireParams(params, { id: /^\w+$/ }, callback)) return; this.loadSession(args, function(err, session, user) { if (err) return self.doError('session', err.message, callback); if (!self.requireValidUser(session, user, callback)) return; if (!self.requirePrivilege(user, "delete_events", callback)) return; args.user = user; args.session = session; self.storage.listFind( 'global/schedule', { id: params.id }, function(err, event) { if (err || !event) { return self.doError('event', "Failed to locate event: " + params.id, callback); } if (!self.requireCategoryPrivilege(user, event.category, callback)) return; if (!self.requireGroupPrivilege(args, user, event.target, callback)) return; // Do not allow deleting event if any active jobs var all_jobs = self.getAllActiveJobs(true); for (var key in all_jobs) { var job = all_jobs[key]; if (job.event == params.id) { var err = "Still has running jobs"; return self.doError('event', "Failed to delete event: " + err, callback); } // matches event } // foreach job self.logDebug(6, "Deleting event: " + params.id); self.storage.listFindDelete( 'global/schedule', { id: params.id }, function(err) { if (err) { return self.doError('event', "Failed to delete event: " + err, callback); } self.logDebug(6, "Successfully deleted event: " + event.title, event); self.logTransaction('event_delete', event.title, self.getClientInfo(args, { event: event })); self.logActivity('event_delete', { event: event }, args); callback({ code: 0 }); // broadcast update to all websocket clients self.updateClientData( 'schedule' ); // schedule event's activity log to be deleted at next maint run self.storage.expire( 'logs/events/' + event.id, Tools.timeNow(true) + 86400 ); // delete state data delete self.state.cursors[ event.id ]; if (self.state.robins) delete self.state.robins[ event.id ]; // send new state data to all web clients self.authSocketEmit( 'update', { state: self.state } ); // check event queue if (self.eventQueue[event.id]) { self.deleteEventQueues( event.id ); } } ); // delete } ); // listFind } ); // load session }, api_run_event: function(args, callback) { // run event manually (via "Run" button in UI or by API Key) // can include any event overrides in params (such as 'now') var self = this; if (!this.requireMaster(args, callback)) return; // default behavor: merge post params and query together // alt behavior (post_data): store post params into post_data var params = Tools.copyHash( args.query, true ); if (args.query.post_data) params.post_data = args.params; else Tools.mergeHashInto( params, args.params ); var criteria = {}; if (params.id) criteria.id = params.id; else if (params.title) criteria.title = params.title; else return this.doError('event', "Failed to locate event: No criteria specified", callback); // validate optional event data parameters if (!this.requireValidEventData(params, callback)) return; this.loadSession(args, function(err, session, user) { if (err) return self.doError('session', err.message, callback); if (!self.requireValidUser(session, user, callback)) return; if (!self.requirePrivilege(user, "run_events", callback)) return; args.user = user; args.session = session; self.storage.listFind( 'global/schedule', criteria, function(err, event) { if (err || !event) { return self.doError('event', "Failed to locate event: " + (params.id || params.title), callback); } if (!self.requireCategoryPrivilege(user, event.category, callback)) return; if (!self.requireGroupPrivilege(args, user, event.target, callback)) return; delete params.id; delete params.title; delete params.catch_up; delete params.category; delete params.multiplex; delete params.stagger; delete params.detached; delete params.queue; delete params.queue_max; delete params.max_children; delete params.session_id; // allow for ¶ms/foo=bar and the like for (var key in params) { if (key.match(/^(\w+)\/(\w+)$/)) { var parent_key = RegExp.$1; var sub_key = RegExp.$2; if (!params[parent_key]) params[parent_key] = {}; params[parent_key][sub_key] = params[key]; delete params[key]; } } // allow sparsely populated event params in request if (params.params && event.params) { for (var key in event.params) { if (!(key in params.params)) params.params[key] = event.params[key]; } } var job = Tools.mergeHashes( Tools.copyHash(event, true), params ); if (user.key) { // API Key job.source = "API Key ("+user.title+")"; job.api_key = user.key; } else { job.source = "Manual ("+user.username+")"; job.username = user.username; } self.logDebug(6, "Running event manually: " + job.title, job); self.launchOrQueueJob( job, function(err, jobs_launched) { if (err) { return self.doError('event', "Failed to launch event: " + err.message, callback); } // multiple jobs may have been launched (multiplex) var ids = []; for (var idx = 0, len = jobs_launched.length; idx < len; idx++) { var job = jobs_launched[idx]; var stub = { id: job.id, event: job.event }; self.logTransaction('job_run', job.event_title, self.getClientInfo(args, stub)); if (self.server.config.get('track_manual_jobs')) self.logActivity('job_run', stub, args); ids.push( job.id ); } var resp = { code: 0, ids: ids }; if (!ids.length) resp.queue = self.eventQueue[event.id] || 1; callback(resp); } ); // launch job } ); // find event } ); // load session }, api_flush_event_queue: function(args, callback) { // flush event queue var self = this; var params = args.params; if (!this.requireMaster(args, callback)) return; if (!this.requireParams(params, { id: /^\w+$/ }, callback)) return; this.loadSession(args, function(err, session, user) { if (err) return self.doError('session', err.message, callback); if (!self.requireValidUser(session, user, callback)) return; if (!self.requirePrivilege(user, "abort_events", callback)) return; args.user = user; args.session = session; self.storage.listFind( 'global/schedule', { id: params.id }, function(err, event) { if (err || !event) { return self.doError('event', "Failed to locate event: " + params.id, callback); } if (!self.requireCategoryPrivilege(user, event.category, callback)) return; if (!self.requireGroupPrivilege(args, user, event.target, callback)) return; self.deleteEventQueues( params.id, function() { callback({ code: 0 }); } ); } ); // listFind } ); // loadSession }, api_get_event_history: function(args, callback) { // get event history var self = this; var params = Tools.mergeHashes( args.params, args.query ); if (!this.requireParams(params, { id: /^\w+$/ }, callback)) return; this.loadSession(args, function(err, session, user) { if (err) return self.doError('session', err.message, callback); if (!self.requireValidUser(session, user, callback)) return; args.user = user; args.session = session; self.storage.listFind( 'global/schedule', { id: params.id }, function(err, event) { if (err || !event) { return self.doError('event', "Failed to locate event: " + params.id, callback); } if (!self.requireCategoryPrivilege(user, event.category, callback)) return; if (!self.requireGroupPrivilege(args, user, event.target, callback)) return; self.storage.listGet( 'logs/events/' + params.id, parseInt(params.offset || 0), parseInt(params.limit || 100), function(err, items, list) { if (err) { // no rows found, not an error for this API return callback({ code: 0, rows: [], list: { length: 0 } }); } // success, return rows and list header callback({ code: 0, rows: items, list: list }); } ); // got data } ); // listFind } ); // load session }, api_get_history: function(args, callback) { // get list of completed jobs for ALL events (with pagination) var self = this; var params = Tools.mergeHashes( args.params, args.query ); this.loadSession(args, function(err, session, user) { if (err) return self.doError('session', err.message, callback); if (!self.requireValidUser(session, user, callback)) return; // user may be limited to certain categories // but in order to keep pagination sane, just mask out IDs var privs = user.privileges; var cat_limited = (!privs.admin && privs.cat_limit); self.storage.listGet( 'logs/completed', parseInt(params.offset || 0), parseInt(params.limit || 50), function(err, items, list) { if (err) { // no rows found, not an error for this API return callback({ code: 0, rows: [], list: { length: 0 } }); } // mask out IDs for cats that user shouldn't see if (cat_limited) items.forEach( function(item) { var priv_id = 'cat_' + item.category; if (!privs[priv_id]) item.id = ''; } ); // success, return rows and list header callback({ code: 0, rows: items, list: list }); } ); // got data } ); // loaded session } } );