import { body, oneOf, param } from 'express-validator'; import { Automation, AutomationFilter, AutomationOutput, HTTPOutput, OSCOutput, OntimeAction, SecondarySource, timerLifecycleValues, } from 'ontime-types'; import * as assert from '../../utils/assert.js'; import { requestValidationFunction } from '../validation-utils/validationFunction.js'; import { isFilterOperator, isFilterRule, isOntimeActionAction } from './automation.utils.js'; export const validateAutomationSettings = [ body('enabledAutomations').isBoolean(), body('enabledOscIn').isBoolean(), body('oscPortIn').isPort(), body('triggers').optional().isArray(), body('triggers.*.title').optional().isString().trim(), body('triggers.*.trigger').optional().isIn(timerLifecycleValues), body('triggers.*.automationId').optional().isString().trim(), body('automations').optional().custom(parseAutomation), requestValidationFunction, ]; export const validateTrigger = [ body('title').isString().trim().notEmpty(), body('trigger').isIn(timerLifecycleValues), body('automationId').isString().trim().notEmpty(), requestValidationFunction, ]; export const validateTriggerPatch = [ param('id').isString().notEmpty(), body('title').optional().isString().trim().notEmpty(), body('trigger').optional().isIn(timerLifecycleValues), body('automationId').optional().isString().trim().notEmpty(), requestValidationFunction, ]; export const validateAutomation = [body().custom(parseAutomation), requestValidationFunction]; export const validateAutomationPatch = [ param('id').isString().notEmpty(), body().custom(parseAutomation), requestValidationFunction, ]; /** * Parses and validates a use given automation */ export function parseAutomation(maybeAutomation: unknown): Automation { assert.isObject(maybeAutomation); assert.hasKeys(maybeAutomation, ['title', 'filterRule', 'filters', 'outputs']); const { title, filterRule, filters, outputs } = maybeAutomation; assert.isString(title); assert.isString(filterRule); if (!isFilterRule(filterRule)) { throw new Error(`Invalid automation: unknown filter rule ${filterRule}`); } assert.isArray(filters); validateFilters(filters); assert.isArray(outputs); validateOutput(outputs); return maybeAutomation as Automation; } function validateFilters(filters: Array): filters is AutomationFilter[] { filters.forEach((condition) => { assert.isObject(condition); assert.hasKeys(condition, ['field', 'operator', 'value']); const { field, operator, value } = condition; assert.isString(field); assert.isString(operator); assert.isString(value); if (!isFilterOperator(operator)) { throw new Error(`Invalid automation: unknown filter operator ${operator}`); } if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { throw new Error(`Invalid automation: unhandled filter type ${typeof value}`); } }); return true; } function validateOutput(output: Array): output is AutomationOutput[] { output.forEach((payload) => { parseOutput(payload); }); return true; } export const validateTestPayload = [ body('type').isIn(['osc', 'http', 'ontime']), // validation for OSC message oneOf([ body('targetIP').if(body('type').equals('osc')).isIP(), body('targetIP').if(body('type').equals('osc')).isFQDN(), body('targetIP').if(body('type').equals('osc')).equals('localhost'), ]), body('targetPort').if(body('type').equals('osc')).isPort(), body('address').if(body('type').equals('osc')).isString().trim(), body('args').if(body('type').equals('osc')).isString().trim(), // validation for HTTP message body('url').if(body('type').equals('http')).isURL({ require_tld: false }).trim(), // validation for Ontime actions body('action').if(body('type').equals('ontime')).isString().trim(), body('text').if(body('type').equals('ontime')).optional().isString().trim(), body('time').if(body('type').equals('ontime')).optional().isString().trim(), body('visible').if(body('type').equals('ontime')).optional().isBoolean(), // secondary source can be a enum case or null to clear it body('secondarySource') .if(body('type').equals('ontime')) .optional({ nullable: true }) .if((value) => value !== null) .isString() .trim(), requestValidationFunction, ]; /** * Sanitises an output object * @Throws if the output is invalid */ export function parseOutput(maybeOutput: unknown): AutomationOutput { assert.isObject(maybeOutput); assert.hasKeys(maybeOutput, ['type']); const { type } = maybeOutput; assert.isString(type); if (type === 'osc') { return parseOSCOutput(maybeOutput); } else if (type === 'http') { return parseHTTPOutput(maybeOutput); } else if (type === 'ontime') { return parseOntimeAction(maybeOutput); } else { throw new Error('Invalid automation output'); } } function parseOSCOutput(maybeOSCOutput: object): OSCOutput { assert.hasKeys(maybeOSCOutput, ['targetIP', 'targetPort', 'address', 'args']); assert.isString(maybeOSCOutput.targetIP); assert.isNumber(maybeOSCOutput.targetPort); assert.isString(maybeOSCOutput.address); assert.isString(maybeOSCOutput.args); return { type: 'osc', targetIP: maybeOSCOutput.targetIP, targetPort: maybeOSCOutput.targetPort, address: maybeOSCOutput.address, args: maybeOSCOutput.args, }; } function parseHTTPOutput(maybeHTTPOutput: object): HTTPOutput { assert.hasKeys(maybeHTTPOutput, ['url']); assert.isString(maybeHTTPOutput.url); return { type: 'http', url: maybeHTTPOutput.url, }; } function parseOntimeAction(maybeOntimeAction: object): OntimeAction { assert.hasKeys(maybeOntimeAction, ['action']); assert.isString(maybeOntimeAction.action); if (!isOntimeActionAction(maybeOntimeAction.action)) { throw new Error('Invalid Ontime action'); } // we know we have a valid action, deal with special cases if ( maybeOntimeAction.action === 'aux1-set' || maybeOntimeAction.action === 'aux2-set' || maybeOntimeAction.action === 'aux3-set' ) { assert.hasKeys(maybeOntimeAction, ['time']); assert.isString(maybeOntimeAction.time); return { type: 'ontime', action: maybeOntimeAction.action, time: maybeOntimeAction.time, }; } if (maybeOntimeAction.action === 'message-set') { assert.hasKeys(maybeOntimeAction, ['text']); assert.isString(maybeOntimeAction.text); let visible: boolean | undefined = undefined; if ('visible' in maybeOntimeAction) { assert.isBoolean(maybeOntimeAction.visible); visible = maybeOntimeAction.visible; } return { type: 'ontime', action: 'message-set', text: indeterminateText(maybeOntimeAction.text), visible, }; } if (maybeOntimeAction.action === 'message-secondary') { // the secondary text is optional, an empty string is treated as no change let text: string | undefined = undefined; if ('text' in maybeOntimeAction) { assert.isString(maybeOntimeAction.text); text = indeterminateText(maybeOntimeAction.text); } if (!('secondarySource' in maybeOntimeAction) || maybeOntimeAction.secondarySource === undefined) { return { type: 'ontime', action: 'message-secondary', text, }; } // null is used to clear the secondary source if (maybeOntimeAction.secondarySource === null) { return { type: 'ontime', action: 'message-secondary', secondarySource: null, text, }; } assert.isString(maybeOntimeAction.secondarySource); return { type: 'ontime', action: 'message-secondary', secondarySource: chooseSecondarySource(maybeOntimeAction.secondarySource), text, }; } return { type: 'ontime', action: maybeOntimeAction.action, }; } /** * Helper function to parse a text which may be indeterminate * "some text" -> string * "" -> undefined */ function indeterminateText(value: string): string | undefined { return value === '' ? undefined : value; } /** * Helper function to validate the secondary source */ function chooseSecondarySource(value: string): SecondarySource { if (value === 'aux1') return 'aux1'; if (value === 'aux2') return 'aux2'; if (value === 'aux3') return 'aux3'; if (value === 'secondary') return 'secondary'; return null; }