diff --git a/apps/client/src/common/api/automation.ts b/apps/client/src/common/api/automation.ts index 5f37fa27a..d3d6a5a49 100644 --- a/apps/client/src/common/api/automation.ts +++ b/apps/client/src/common/api/automation.ts @@ -4,6 +4,7 @@ import type { AutomationDTO, AutomationOutput, AutomationSettings, + AutomationTriggerDTO, Trigger, TriggerDTO, } from 'ontime-types'; @@ -57,16 +58,20 @@ export function deleteTrigger(id: string): Promise { /** * HTTP request to create a new automation */ -export async function addAutomation(automation: AutomationDTO): Promise { - const res = await axios.post(`${automationsPath}/automation`, automation); +export async function addAutomation(automation: AutomationDTO, triggers: AutomationTriggerDTO[]): Promise { + const res = await axios.post(`${automationsPath}/automation`, { ...automation, triggers }); return res.data; } /** * HTTP request to update a automation */ -export async function editAutomation(id: string, automation: Automation): Promise { - const res = await axios.put(`${automationsPath}/automation/${id}`, automation); +export async function editAutomation( + id: string, + automation: Automation, + triggers?: AutomationTriggerDTO[], +): Promise { + const res = await axios.put(`${automationsPath}/automation/${id}`, { ...automation, triggers }); return res.data; } diff --git a/apps/server/src/api-data/automation/__tests__/automation.dao.test.ts b/apps/server/src/api-data/automation/__tests__/automation.dao.test.ts index ed5464a5d..901dee649 100644 --- a/apps/server/src/api-data/automation/__tests__/automation.dao.test.ts +++ b/apps/server/src/api-data/automation/__tests__/automation.dao.test.ts @@ -135,6 +135,27 @@ describe('addAutomation()', () => { const automations = getAutomations(); expect(automations[automation.id]).toMatchObject(testData); }); + + it('creates an automation and its global triggers together', async () => { + const automation = await addAutomation({ title: 'triggered', filterRule: 'all', filters: [], outputs: [] }, [ + { title: 'triggered — On Start', trigger: TimerLifeCycle.onStart }, + { title: 'triggered — On Finish', trigger: TimerLifeCycle.onFinish }, + ]); + + expect(getAutomations()[automation.id]).toEqual(automation); + expect(getAutomationTriggers()).toEqual([ + expect.objectContaining({ + title: 'triggered — On Start', + trigger: TimerLifeCycle.onStart, + automationId: automation.id, + }), + expect.objectContaining({ + title: 'triggered — On Finish', + trigger: TimerLifeCycle.onFinish, + automationId: automation.id, + }), + ]); + }); }); describe('editAutomation()', () => { @@ -182,6 +203,19 @@ describe('editAutomation()', () => { outputs: expect.any(Array), }); }); + + it('replaces lifecycle triggers with the automation update', async () => { + await addTrigger({ title: 'On Start', trigger: TimerLifeCycle.onStart, automationId: firstAutomation.id }); + await addTrigger({ title: 'On Finish', trigger: TimerLifeCycle.onFinish, automationId: firstAutomation.id }); + + await editAutomation(firstAutomation.id, { title: 'edited-title', filterRule: 'all', filters: [], outputs: [] }, [ + { title: 'edited-title — On Danger', trigger: TimerLifeCycle.onDanger }, + ]); + + expect(getAutomationTriggers()).toEqual([ + expect.objectContaining({ automationId: firstAutomation.id, trigger: TimerLifeCycle.onDanger }), + ]); + }); }); describe('deleteAutomation()', () => { @@ -225,4 +259,44 @@ describe('deleteAutomation()', () => { const removed = getAutomations(); expect(Object.keys(removed).length).toEqual(0); }); + + it('takes the automation global triggers with it, and leaves the others alone', async () => { + const doomed = Object.keys(getAutomations())[0]; + const survivor = await addAutomation({ title: 'survivor', filterRule: 'all', filters: [], outputs: [] }); + + await addTrigger({ title: 'on start', trigger: TimerLifeCycle.onStart, automationId: doomed }); + await addTrigger({ title: 'on finish', trigger: TimerLifeCycle.onFinish, automationId: doomed }); + await addTrigger({ title: 'keep me', trigger: TimerLifeCycle.onStart, automationId: survivor.id }); + + await deleteAutomation({}, doomed); + + // a trigger pointing at nothing never fires, so it must not outlive its automation + expect(getAutomationTriggers()).toEqual([expect.objectContaining({ title: 'keep me' })]); + expect(Object.keys(getAutomations())).toEqual([survivor.id]); + }); + + it('refuses an automation attached to an event, and keeps its triggers', async () => { + const automationId = Object.keys(getAutomations())[0]; + await addTrigger({ title: 'on start', trigger: TimerLifeCycle.onStart, automationId }); + + const projectRundowns: ProjectRundowns = { + 'rundown-1': { + id: 'rundown-1', + title: 'Rundown 1', + order: ['1'], + flatOrder: ['1'], + entries: { + '1': makeOntimeEvent({ + id: '1', + triggers: [{ id: 'trigger-1', title: 'Trigger 1', trigger: TimerLifeCycle.onClock, automationId }], + }), + }, + revision: 1, + }, + }; + + await expect(deleteAutomation(projectRundowns, automationId)).rejects.toThrow(/used in rundown/); + expect(getAutomationTriggers()).toHaveLength(1); + expect(Object.keys(getAutomations())).toEqual([automationId]); + }); }); diff --git a/apps/server/src/api-data/automation/__tests__/automation.utils.test.ts b/apps/server/src/api-data/automation/__tests__/automation.utils.test.ts index 06cb09487..a9b239171 100644 --- a/apps/server/src/api-data/automation/__tests__/automation.utils.test.ts +++ b/apps/server/src/api-data/automation/__tests__/automation.utils.test.ts @@ -44,6 +44,14 @@ describe('parseTemplateNested()', () => { const result = parseTemplateNested(testString, mockState); expect(result).toStrictEqual(expected); }); + + it('URL-encodes an explicitly marked variable', () => { + const result = parseTemplateNested('http://example.com/?title={{url:event.title}}', { + event: { title: 'Keynote & Roadmap #1' }, + }); + + expect(result).toBe('http://example.com/?title=Keynote%20%26%20Roadmap%20%231'); + }); }); describe('parseNestedTemplate() -> resolveAliasData()', () => { diff --git a/apps/server/src/api-data/automation/__tests__/automation.validation.test.ts b/apps/server/src/api-data/automation/__tests__/automation.validation.test.ts index a5e02c9bd..0d2fcdece 100644 --- a/apps/server/src/api-data/automation/__tests__/automation.validation.test.ts +++ b/apps/server/src/api-data/automation/__tests__/automation.validation.test.ts @@ -1,17 +1,30 @@ -import { parseOutput } from '../automation.validation.js'; +import { parseAutomationTriggers, parseOutput } from '../automation.validation.js'; + +describe('parseAutomationTriggers', () => { + it('accepts trigger descriptors without an automation ID', () => { + expect(parseAutomationTriggers([{ title: 'Start', trigger: 'onStart' }])).toEqual([ + { title: 'Start', trigger: 'onStart' }, + ]); + }); + + it('rejects incomplete or unknown trigger descriptors', () => { + expect(() => parseAutomationTriggers([{ trigger: 'onStart' }])).toThrow(); + expect(() => parseAutomationTriggers([{ title: 'Start', trigger: 'unknown' }])).toThrow(); + }); +}); describe('parseOutput', () => { describe('handles OSC outputs', () => { it('parses a valid payload', () => { const payload = { type: 'osc', - targetIP: 'localhost', + targetIP: ' qlab ', targetPort: 1234, address: '/test', args: 'test', }; const result = parseOutput(payload); - expect(result).toStrictEqual(payload); + expect(result).toStrictEqual({ ...payload, targetIP: 'qlab' }); }); it('throws on a invalid payload', () => { @@ -24,6 +37,33 @@ describe('parseOutput', () => { }; expect(() => parseOutput(payload)).toThrow('Unexpected payload type:'); }); + + it('rejects invalid targets and ports', () => { + expect(() => + parseOutput({ type: 'osc', targetIP: 'not a host', targetPort: 53000, address: '/test', args: '' }), + ).toThrow('Invalid OSC target'); + expect(() => + parseOutput({ type: 'osc', targetIP: '127.0.0.1', targetPort: 70000, address: '/test', args: '' }), + ).toThrow('Invalid OSC port'); + expect(() => + parseOutput({ type: 'osc', targetIP: '127.0.0.1', targetPort: 0, address: '/test', args: '' }), + ).toThrow('Invalid OSC port'); + expect(() => + parseOutput({ type: 'osc', targetIP: '::1', targetPort: 53000, address: '/test', args: '' }), + ).toThrow('Invalid OSC target'); + }); + + it('allows runtime templates in a target hostname', () => { + expect( + parseOutput({ + type: 'osc', + targetIP: '{{eventNow.custom.oscTarget}}', + targetPort: 53000, + address: '/test', + args: '', + }), + ).toMatchObject({ targetIP: '{{eventNow.custom.oscTarget}}' }); + }); }); describe('handles HTTP outputs', () => { it('parses a valid payload', () => { @@ -41,6 +81,18 @@ describe('parseOutput', () => { }; expect(() => parseOutput(payload)).toThrow('Unexpected payload type:'); }); + + it('rejects malformed and unsupported URLs', () => { + expect(() => parseOutput({ type: 'http', url: 'localhost:3000/hook' })).toThrow('Invalid HTTP URL'); + expect(() => parseOutput({ type: 'http', url: 'ftp://example.com/hook' })).toThrow('Invalid HTTP URL'); + }); + + it('allows runtime templates in HTTP URLs', () => { + expect(parseOutput({ type: 'http', url: 'http://{{eventNow.customFields.webhookHost}}/hook' })).toEqual({ + type: 'http', + url: 'http://{{eventNow.customFields.webhookHost}}/hook', + }); + }); }); describe('handles Ontime outputs', () => { it('parses a valid payload', () => { diff --git a/apps/server/src/api-data/automation/__tests__/osc.client.test.ts b/apps/server/src/api-data/automation/__tests__/osc.client.test.ts new file mode 100644 index 000000000..bdf7bda6f --- /dev/null +++ b/apps/server/src/api-data/automation/__tests__/osc.client.test.ts @@ -0,0 +1,26 @@ +const { send } = vi.hoisted(() => ({ send: vi.fn() })); + +vi.mock('node:dgram', () => ({ + createSocket: vi.fn(() => ({ send })), +})); + +import { emitOSC } from '../clients/osc.client.js'; + +describe('emitOSC()', () => { + it('resolves runtime templates in the target host', () => { + emitOSC( + { + type: 'osc', + targetIP: '{{eventNow.custom.oscTarget}}', + targetPort: 53000, + address: '/cue/start', + args: '', + }, + { + eventNow: { custom: { oscTarget: 'qlab' } }, + } as never, + ); + + expect(send).toHaveBeenCalledWith(expect.anything(), 0, expect.any(Number), 53000, 'qlab', expect.any(Function)); + }); +}); diff --git a/apps/server/src/api-data/automation/automation.controller.ts b/apps/server/src/api-data/automation/automation.controller.ts index 590232b1b..f700500c8 100644 --- a/apps/server/src/api-data/automation/automation.controller.ts +++ b/apps/server/src/api-data/automation/automation.controller.ts @@ -75,12 +75,18 @@ export async function deleteTrigger(req: Request, res: Response) { try { - const newAutomation = await automationDao.addAutomation({ - title: req.body.title, - filterRule: req.body.filterRule, - filters: req.body.filters, - outputs: req.body.outputs, - }); + const newAutomation = await automationDao.addAutomation( + { + title: req.body.title, + filterRule: req.body.filterRule, + filters: req.body.filters, + outputs: req.body.outputs, + }, + req.body.triggers?.map(({ title, trigger }: { title: string; trigger: Trigger['trigger'] }) => ({ + title: title.trim(), + trigger, + })), + ); res.status(201).send(newAutomation); } catch (error) { const message = getErrorMessage(error); @@ -90,12 +96,19 @@ export async function postAutomation(req: Request, res: Response) { try { - const newAutomation = await automationDao.editAutomation(req.params.id, { - title: req.body.title, - filterRule: req.body.filterRule, - filters: req.body.filters, - outputs: req.body.outputs, - }); + const newAutomation = await automationDao.editAutomation( + req.params.id, + { + title: req.body.title, + filterRule: req.body.filterRule, + filters: req.body.filters, + outputs: req.body.outputs, + }, + req.body.triggers?.map(({ title, trigger }: { title: string; trigger: Trigger['trigger'] }) => ({ + title: title.trim(), + trigger, + })), + ); res.status(200).send(newAutomation); } catch (error) { const message = getErrorMessage(error); @@ -110,7 +123,7 @@ export async function deleteAutomation(req: Request, res: Response { - const automations = getAutomations(); +export async function addAutomation( + newAutomation: AutomationDTO, + newTriggers: AutomationTriggerDTO[] = [], +): Promise { + const automations = { ...getAutomations() }; const id = getUniqueAutomationId(automations); automations[id] = { ...newAutomation, id }; - await saveChanges({ automations }); + + const triggers = [...getAutomationTriggers()]; + for (const newTrigger of newTriggers) { + triggers.push({ ...newTrigger, id: getUniqueTriggerId(triggers), automationId: id }); + } + + await saveChanges({ automations, triggers }); return automations[id]; } /** * Updates an existing automation with a new entry */ -export async function editAutomation(id: string, newAutomation: AutomationDTO): Promise { +export async function editAutomation( + id: string, + newAutomation: AutomationDTO, + requestedTriggers?: AutomationTriggerDTO[], +): Promise { const automations = getAutomations(); if (!Object.hasOwn(automations, id)) { throw new Error(`Automation with id ${id} not found`); } automations[id] = { ...newAutomation, id }; - await saveChanges({ automations }); + + if (requestedTriggers === undefined) { + await saveChanges({ automations }); + return automations[id]; + } + + const triggers = replaceAutomationTriggers(getAutomationTriggers(), id, requestedTriggers); + await saveChanges({ automations, triggers }); return automations[id]; } +function replaceAutomationTriggers( + triggers: Trigger[], + automationId: string, + requestedTriggers: AutomationTriggerDTO[], +): Trigger[] { + const requestedCycles = new Set(requestedTriggers.map((trigger) => trigger.trigger)); + const keptTriggers = triggers.filter( + (trigger) => trigger.automationId !== automationId || requestedCycles.has(trigger.trigger), + ); + const existingCycles = new Set( + keptTriggers.filter((trigger) => trigger.automationId === automationId).map((trigger) => trigger.trigger), + ); + + for (const trigger of requestedTriggers) { + if (!existingCycles.has(trigger.trigger)) { + keptTriggers.push({ ...trigger, id: getUniqueTriggerId(keptTriggers), automationId }); + existingCycles.add(trigger.trigger); + } + } + + return keptTriggers; +} + /** * Deletes a automation given its ID */ @@ -145,24 +189,18 @@ export async function deleteAutomation(projectRundowns: ProjectRundowns, automat return; } - // prevent deleting a automation that is in use in triggers - const triggers = getAutomationTriggers().filter((trigger) => trigger.automationId === automationId); - if (triggers.length) { - const firstTrigger = triggers[0]; - const triggerTitle = firstTrigger?.title ?? 'Unknown trigger'; - throw new Error( - `Unable to delete automation used in trigger ${triggerTitle}${triggers.length > 1 ? ` and ${triggers.length - 1} more` : ''}`, - ); - } - - // prevent deleting a automation that is in use in events + // prevent deleting an automation that is in use in events, the user has to unlink it there const isInUse = isAutomationUsed(projectRundowns, automationId); if (isInUse) { throw new Error(`Unable to delete automation used in rundown: ${isInUse[0]}, in event with ID: ${isInUse[1]}`); } + // a global trigger without its automation is dead data, so it goes with it. + // Both are written in a single patch, there is no state where one outlived the other + const triggers = getAutomationTriggers().filter((trigger) => trigger.automationId !== automationId); + delete automations[automationId]; - await saveChanges({ automations }); + await saveChanges({ automations, triggers }); } /** diff --git a/apps/server/src/api-data/automation/automation.utils.ts b/apps/server/src/api-data/automation/automation.utils.ts index 53546a044..f424d9ba5 100644 --- a/apps/server/src/api-data/automation/automation.utils.ts +++ b/apps/server/src/api-data/automation/automation.utils.ts @@ -72,6 +72,7 @@ const placeholderRegex = /{{(.*?)}}/g; /** * Parses a templated string to values in a nested object + * Prefix a variable with `url:` when its value must be safe inside a URL component. */ export function parseTemplateNested(template: string, state: object, humanReadable = quickAliases): string { let parsedTemplate = template; @@ -79,7 +80,9 @@ export function parseTemplateNested(template: string, state: object, humanReadab for (const match of matches) { const variableName = match[1]; - const variableParts = variableName.split('.'); + const shouldEncodeForUrl = variableName.startsWith('url:'); + const propertyName = shouldEncodeForUrl ? variableName.slice(4) : variableName; + const variableParts = propertyName.split('.'); let value: string | undefined = undefined; if (variableParts[0] === 'human') { @@ -93,10 +96,10 @@ export function parseTemplateNested(template: string, state: object, humanReadab } } else { // we cast to string since this will be used in a string context - value = getPropertyFromPath(variableName, state) as string; + value = getPropertyFromPath(propertyName, state) as string; } if (value !== undefined) { - parsedTemplate = parsedTemplate.replace(match[0], value); + parsedTemplate = parsedTemplate.replace(match[0], shouldEncodeForUrl ? encodeURIComponent(value) : value); } } diff --git a/apps/server/src/api-data/automation/automation.validation.ts b/apps/server/src/api-data/automation/automation.validation.ts index 4295d3aed..71fda8ca2 100644 --- a/apps/server/src/api-data/automation/automation.validation.ts +++ b/apps/server/src/api-data/automation/automation.validation.ts @@ -1,12 +1,16 @@ -import { body, oneOf, param } from 'express-validator'; +import { isIP } from 'node:net'; + +import { body, param } from 'express-validator'; import { Automation, AutomationFilter, AutomationOutput, + AutomationTriggerDTO, HTTPOutput, OSCOutput, OntimeAction, SecondarySource, + isTimerLifeCycle, timerLifecycleValues, } from 'ontime-types'; @@ -44,11 +48,16 @@ export const validateTriggerPatch = [ requestValidationFunction, ]; -export const validateAutomation = [body().custom(parseAutomation), requestValidationFunction]; +export const validateAutomation = [ + body().custom(parseAutomation), + body('triggers').optional().custom(parseAutomationTriggers), + requestValidationFunction, +]; export const validateAutomationPatch = [ param('id').isString().notEmpty(), body().custom(parseAutomation), + body('triggers').optional().custom(parseAutomationTriggers), requestValidationFunction, ]; @@ -75,6 +84,20 @@ export function parseAutomation(maybeAutomation: unknown): Automation { return maybeAutomation as Automation; } +export function parseAutomationTriggers(maybeTriggers: unknown): AutomationTriggerDTO[] { + assert.isArray(maybeTriggers); + return maybeTriggers.map((maybeTrigger) => { + assert.isObject(maybeTrigger); + assert.hasKeys(maybeTrigger, ['title', 'trigger']); + assert.isString(maybeTrigger.title); + assert.isString(maybeTrigger.trigger); + if (!maybeTrigger.title.trim() || !isTimerLifeCycle(maybeTrigger.trigger)) { + throw new Error('Invalid automation trigger'); + } + return { title: maybeTrigger.title.trim(), trigger: maybeTrigger.trigger }; + }); +} + function validateFilters(filters: Array): filters is AutomationFilter[] { filters.forEach((condition) => { assert.isObject(condition); @@ -103,33 +126,7 @@ function validateOutput(output: Array): output is AutomationOutput[] { } 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(), + body().custom(parseOutput), requestValidationFunction, ]; @@ -163,9 +160,25 @@ function parseOSCOutput(maybeOSCOutput: object): OSCOutput { assert.isString(maybeOSCOutput.address); assert.isString(maybeOSCOutput.args); + const targetIP = maybeOSCOutput.targetIP.trim(); + const target = replaceAutomationTemplates(targetIP, 'template.local'); + const isHostname = /^(?=.{1,253}$)[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?)*$/i.test( + target, + ); + if (isIP(target) !== 4 && !isHostname) { + throw new Error('Invalid OSC target'); + } + if ( + !Number.isInteger(maybeOSCOutput.targetPort) || + maybeOSCOutput.targetPort < 1 || + maybeOSCOutput.targetPort > 65535 + ) { + throw new Error('Invalid OSC port'); + } + return { type: 'osc', - targetIP: maybeOSCOutput.targetIP, + targetIP, targetPort: maybeOSCOutput.targetPort, address: maybeOSCOutput.address, args: maybeOSCOutput.args, @@ -176,12 +189,25 @@ function parseHTTPOutput(maybeHTTPOutput: object): HTTPOutput { assert.hasKeys(maybeHTTPOutput, ['url']); assert.isString(maybeHTTPOutput.url); + try { + const url = new URL(replaceAutomationTemplates(maybeHTTPOutput.url, 'template')); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !url.hostname) { + throw new Error('Invalid HTTP URL'); + } + } catch { + throw new Error('Invalid HTTP URL'); + } + return { type: 'http', url: maybeHTTPOutput.url, }; } +function replaceAutomationTemplates(value: string, replacement: string): string { + return value.replace(/{{.*?}}/g, replacement); +} + function parseOntimeAction(maybeOntimeAction: object): OntimeAction { assert.hasKeys(maybeOntimeAction, ['action']); assert.isString(maybeOntimeAction.action); diff --git a/apps/server/src/api-data/automation/clients/osc.client.ts b/apps/server/src/api-data/automation/clients/osc.client.ts index b2b0f9c72..72166d4ea 100644 --- a/apps/server/src/api-data/automation/clients/osc.client.ts +++ b/apps/server/src/api-data/automation/clients/osc.client.ts @@ -14,7 +14,8 @@ const udpClient = dgram.createSocket('udp4'); */ export function emitOSC(output: OSCOutput, store: DeepReadonly) { const message = preparePayload(output, store); - emit(output.targetIP, output.targetPort, message); + const targetIP = parseTemplateNested(output.targetIP, store); + emit(targetIP, output.targetPort, message); } /** Parses the state and prepares payload to be emitted */ diff --git a/packages/types/src/definitions/core/Automation.type.ts b/packages/types/src/definitions/core/Automation.type.ts index c67768b1b..1bb84a13a 100644 --- a/packages/types/src/definitions/core/Automation.type.ts +++ b/packages/types/src/definitions/core/Automation.type.ts @@ -33,6 +33,9 @@ export type Trigger = { export type TriggerDTO = Omit; +/** A global trigger whose automation ID is assigned during automation creation. */ +export type AutomationTriggerDTO = Omit; + export type AutomationFilter = { field: string; // this should be a key of a OntimeEvent + custom fields operator: 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains'; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 66ea56090..1bcd4f39c 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -34,6 +34,7 @@ export type { AutomationDTO, AutomationFilter, AutomationSettings, + AutomationTriggerDTO, AutomationOutput, FilterRule, HTTPOutput,