From c2ed9d7634cd20729bb87ec74457dcba70445e34 Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Sun, 9 Oct 2022 19:10:48 +0200 Subject: [PATCH] Refactor/socket controller (#212) * refactor(socket): dictionary cleanup * refactor(socket): detangle socket from EventTimer * refactor(socket): poll object * refactor(socket): wip, extract data responsibilities to provider * fix: issue with onAir * refactor: create data provider and validation utils * refactor: remove deprecated endpoint * refactor: detangle and validate ontime controller * refactor: detangle and validate events controller * refactor: validate routers * refactor: handle post failure in modals --- client/src/common/api/ontimeApi.js | 6 - .../src/features/modals/AppSettingsModal.jsx | 12 +- .../features/modals/EventSettingsModal.jsx | 17 +- .../modals/IntegrationSettingsModal.jsx | 4 +- .../src/features/modals/OscSettingsModal.jsx | 12 +- .../src/features/modals/TableOptionsModal.jsx | 12 +- .../features/modals/ViewsSettingsModal.jsx | 17 +- server/electron.config.js | 12 + server/main.js | 29 +- server/src/app.js | 39 +- .../src/classes/data-provider/DataProvider.js | 163 +++++- server/src/classes/socket/SocketController.js | 400 ++++++++++++++ server/src/classes/timer/EventTimer.js | 509 +++--------------- server/src/classes/timer/Timer.js | 47 +- server/src/controllers/eventController.js | 26 +- .../controllers/eventController.validate.js | 14 + server/src/controllers/eventsController.js | 172 ++---- .../controllers/eventsController.validate.js | 19 + server/src/controllers/ontimeController.js | 144 ++--- .../controllers/ontimeController.validate.js | 77 +++ server/src/routes/eventRouter.js | 3 +- server/src/routes/eventsRouter.js | 10 +- server/src/routes/ontimeRouter.js | 20 +- .../src/utils/__tests__/parserUtils.tests.js | 92 ++++ server/src/utils/getRandomName.js | 99 +--- server/src/utils/parserUtils.js | 36 ++ server/src/utils/routerUtils.js | 39 ++ server/src/validation/ontimeValidator.js | 13 - 28 files changed, 1145 insertions(+), 898 deletions(-) create mode 100644 server/electron.config.js create mode 100644 server/src/classes/socket/SocketController.js create mode 100644 server/src/controllers/eventController.validate.js create mode 100644 server/src/controllers/eventsController.validate.js create mode 100644 server/src/controllers/ontimeController.validate.js create mode 100644 server/src/utils/__tests__/parserUtils.tests.js create mode 100644 server/src/utils/routerUtils.js delete mode 100644 server/src/validation/ontimeValidator.js diff --git a/client/src/common/api/ontimeApi.js b/client/src/common/api/ontimeApi.js index 2009ab56b..9e626e1c1 100644 --- a/client/src/common/api/ontimeApi.js +++ b/client/src/common/api/ontimeApi.js @@ -165,12 +165,6 @@ export const getInfo = async () => { return res.data; }; -/** - * @description HTTP request to mutate application info - * @return {Promise} - */ -export const postInfo = async (data) => axios.post(`${ontimeURL}/info`, data); - /** * @description HTTP request to retrieve view settings * @return {Promise} diff --git a/client/src/features/modals/AppSettingsModal.jsx b/client/src/features/modals/AppSettingsModal.jsx index 2e2382532..6af85f9af 100644 --- a/client/src/features/modals/AppSettingsModal.jsx +++ b/client/src/features/modals/AppSettingsModal.jsx @@ -58,6 +58,7 @@ export default function AppSettingsModal() { setSubmitting(true); // set context + // TODO: add fast-equals here and check if event settings have changed saveEventSettings(formSettings); const validation = { isValid: false }; @@ -83,12 +84,17 @@ export default function AppSettingsModal() { if (!validation.isValid) { emitError(`Invalid Input: ${validation.message}`); } else { - await postSettings(formData); - await refetch(); + try { + await postSettings(formData); + } catch (error) { + emitError(`Error saving settings: ${error}`) + } finally { + await refetch(); + setChanged(false); + } validation?.message && emitWarning(validation.message); } setSubmitting(false); - setChanged(false); }; /** diff --git a/client/src/features/modals/EventSettingsModal.jsx b/client/src/features/modals/EventSettingsModal.jsx index c4a34ca12..a51dca70c 100644 --- a/client/src/features/modals/EventSettingsModal.jsx +++ b/client/src/features/modals/EventSettingsModal.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useContext, useEffect, useState } from 'react'; import { ModalBody } from '@chakra-ui/modal'; import { FormLabel, Input, Textarea } from '@chakra-ui/react'; import { EVENT_TABLE } from 'common/api/apiConstants'; @@ -6,6 +6,7 @@ import { fetchEvent, postEvent } from 'common/api/eventApi'; import { useFetch } from 'common/hooks/useFetch'; import { eventPlaceholderSettings } from '../../common/api/ontimeApi'; +import { LoggingContext } from '../../common/context/LoggingContext'; import { inputProps } from './modalHelper'; import SubmitContainer from './SubmitContainer'; @@ -14,6 +15,7 @@ import style from './Modals.module.scss'; export default function SettingsModal() { const { data, status, refetch } = useFetch(EVENT_TABLE, fetchEvent); + const { emitError } = useContext(LoggingContext); const [formData, setFormData] = useState(eventPlaceholderSettings); const [changed, setChanged] = useState(false); const [submitting, setSubmitting] = useState(false); @@ -42,13 +44,18 @@ export default function SettingsModal() { event.preventDefault(); setSubmitting(true); - await postEvent(formData); - await refetch(); + try { + await postEvent(formData); + } catch (error) { + emitError(`Error saving event settings: ${error}`) + } finally { + await refetch(); + setChanged(false); + } - setChanged(false); setSubmitting(false); }, - [formData, refetch] + [emitError, formData, refetch] ); /** diff --git a/client/src/features/modals/IntegrationSettingsModal.jsx b/client/src/features/modals/IntegrationSettingsModal.jsx index 8b10014f0..f7cd243c3 100644 --- a/client/src/features/modals/IntegrationSettingsModal.jsx +++ b/client/src/features/modals/IntegrationSettingsModal.jsx @@ -3,7 +3,7 @@ import { ModalBody } from '@chakra-ui/modal'; import { FormControl, FormLabel, Input, Switch } from '@chakra-ui/react'; import { FiInfo } from '@react-icons/all-files/fi/FiInfo'; import { APP_TABLE } from 'common/api/apiConstants'; -import { getInfo, httpPlaceholder, ontimeVars, postInfo } from 'common/api/ontimeApi'; +import { getInfo, httpPlaceholder, ontimeVars } from 'common/api/ontimeApi'; import { useFetch } from 'common/hooks/useFetch'; import { LoggingContext } from '../../common/context/LoggingContext'; @@ -50,7 +50,7 @@ export default function IntegrationSettingsModal() { if (e.status) { emitError(`Invalid Input: ${e.message}`); } else { - await postInfo(f); + // call API endpoint here with value of f setChanged(false); setSubmitting(false); } diff --git a/client/src/features/modals/OscSettingsModal.jsx b/client/src/features/modals/OscSettingsModal.jsx index 3286b77f2..0e9c2730a 100644 --- a/client/src/features/modals/OscSettingsModal.jsx +++ b/client/src/features/modals/OscSettingsModal.jsx @@ -122,10 +122,14 @@ export default function OscSettingsModal() { if (e.status) { emitError(`Invalid Input: ${e.message}`); } else { - // Post here - await postOSC(formData); - await refetch(); - setChanged(false); + try { + await postOSC(formData); + } catch (error){ + emitError(`Error setting OSC: ${error}`) + } finally { + await refetch(); + setChanged(false); + } } setSubmitting(false); }, diff --git a/client/src/features/modals/TableOptionsModal.jsx b/client/src/features/modals/TableOptionsModal.jsx index d42327778..a68f454e2 100644 --- a/client/src/features/modals/TableOptionsModal.jsx +++ b/client/src/features/modals/TableOptionsModal.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useContext, useEffect, useState } from 'react'; import { ModalBody } from '@chakra-ui/modal'; import { Input } from '@chakra-ui/react'; import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInformationCircleOutline'; @@ -6,6 +6,7 @@ import { USERFIELDS } from 'common/api/apiConstants'; import { useFetch } from 'common/hooks/useFetch'; import { getUserFields, postUserFields, userFieldsPlaceholder } from '../../common/api/ontimeApi'; +import { LoggingContext } from '../../common/context/LoggingContext'; import { handleLinks, host } from '../../common/utils/linkUtils'; import SubmitContainer from './SubmitContainer'; @@ -14,6 +15,7 @@ import style from './Modals.module.scss'; export default function TableOptionsModal() { const { data, status, refetch } = useFetch(USERFIELDS, getUserFields); + const { emitError } = useContext(LoggingContext); const [changed, setChanged] = useState(false); const [submitting, setSubmitting] = useState(false); const [userFields, setUserFields] = useState(userFieldsPlaceholder); @@ -43,13 +45,17 @@ export default function TableOptionsModal() { } if (!errors) { - await postUserFields(validatedFields); + try { + await postUserFields(validatedFields); + } catch (error) { + emitError(`Error saving table options: ${error}`) + } await refetch(); setChanged(false); } setSubmitting(false); - },[refetch, userFields]); + },[emitError, refetch, userFields]); /** * Reverts local state equals to server state diff --git a/client/src/features/modals/ViewsSettingsModal.jsx b/client/src/features/modals/ViewsSettingsModal.jsx index 615958cff..12ed0a3f5 100644 --- a/client/src/features/modals/ViewsSettingsModal.jsx +++ b/client/src/features/modals/ViewsSettingsModal.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useContext, useEffect, useState } from 'react'; import { ModalBody } from '@chakra-ui/modal'; import { FormControl, FormLabel } from '@chakra-ui/react'; import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInformationCircleOutline'; @@ -6,6 +6,7 @@ import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInforma import { VIEW_SETTINGS } from '../../common/api/apiConstants'; import { getView, postView, viewsPlaceholder } from '../../common/api/ontimeApi'; import EnableBtn from '../../common/components/buttons/EnableBtn'; +import { LoggingContext } from '../../common/context/LoggingContext'; import { useFetch } from '../../common/hooks/useFetch'; import { openLink } from '../../common/utils/linkUtils'; @@ -15,6 +16,7 @@ import style from './Modals.module.scss'; export default function ViewsSettingsModal() { const { data, status, refetch } = useFetch(VIEW_SETTINGS, getView); + const { emitError } = useContext(LoggingContext); const [formData, setFormData] = useState(viewsPlaceholder); const [changed, setChanged] = useState(false); const [submitting, setSubmitting] = useState(false); @@ -35,12 +37,17 @@ export default function ViewsSettingsModal() { async (event) => { event.preventDefault(); setSubmitting(true); - await postView(formData); - await refetch(); - setChanged(false); + try { + await postView(formData); + } catch (error) { + emitError(`Error view settings: ${error}`) + } finally{ + await refetch(); + setChanged(false); + } setSubmitting(false); }, - [formData, refetch] + [emitError, formData, refetch] ); /** diff --git a/server/electron.config.js b/server/electron.config.js new file mode 100644 index 000000000..339f1b0e2 --- /dev/null +++ b/server/electron.config.js @@ -0,0 +1,12 @@ +module.exports = { + appIni: { + mainWindowWait: 2000, + }, + reactAppUrl: { + development: 'http://localhost:3000/editor', + production: 'http://localhost:4001/editor', + }, + externalUrls: { + help: 'https://cpvalente.gitbook.io/ontime/', + }, +}; diff --git a/server/main.js b/server/main.js index 438d985df..c81cce4c9 100644 --- a/server/main.js +++ b/server/main.js @@ -10,19 +10,18 @@ const { Notification, } = require('electron'); const path = require('path'); - +const electronConfig = require('./electron.config'); if (process.env.NODE_ENV === undefined) { process.env.NODE_ENV = 'production'; } -const env = process.env.NODE_ENV; +const isProduction = process.env.NODE_ENV === 'production'; let loaded = 'Nothing loaded'; let isQuitting = false; -const nodePath = - env !== 'production' - ? path.join('file://', __dirname, 'src/app.js') - : path.join('file://', __dirname, '../', 'extraResources', 'src/app.js'); +const nodePath = isProduction + ? path.join('file://', __dirname, '../', 'extraResources', 'src/app.js') + : path.join('file://', __dirname, 'src/app.js'); (async () => { try { @@ -144,22 +143,25 @@ app.whenReady().then(() => { // give the nodejs server some time setTimeout(() => { // Load page served by node - const reactApp = - env === 'development' ? 'http://localhost:3000/editor' : 'http://localhost:4001/editor'; + const reactApp = isProduction + ? electronConfig.reactAppUrl.production + : electronConfig.reactAppUrl.development; win.loadURL(reactApp).then(() => { win.webContents.setBackgroundThrottling(false); - // window stuff win.show(); win.focus(); splash.destroy(); - // tray stuff - tray.setToolTip(loaded); + if (typeof loaded === 'string') { + tray.setToolTip(loaded); + } else { + tray.setToolTip('Initialising error: please restart ontime'); + } }); - }, 2000); + }, electronConfig.appIni.mainWindowWait); // Hide on close win.on('close', function (event) { @@ -173,7 +175,6 @@ app.whenReady().then(() => { }); // create tray - // TODO: Design better icon tray = new Tray(trayIcon); // Define context menu @@ -269,7 +270,7 @@ ipcMain.on('send-to-link', (event, arg) => { // send to help URL if (arg === 'help') { - shell.openExternal('https://cpvalente.gitbook.io/ontime/'); + shell.openExternal(electronConfig.externalUrls.help); } else { shell.openExternal(arg); } diff --git a/server/src/app.js b/server/src/app.js index 5a6db6a0c..b4b8fb829 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -21,9 +21,11 @@ import { router as playbackRouter } from './routes/playbackRouter.js'; // Global Objects import { EventTimer } from './classes/timer/EventTimer.js'; +import { socketProvider } from './classes/socket/SocketController.js'; // Start OSC server import { initiateOSC, shutdownOSCServer } from './controllers/OscController.js'; import { fileURLToPath } from 'url'; +import { DataProvider } from './classes/data-provider/DataProvider.js'; // get environment const env = process.env.NODE_ENV || 'production'; @@ -31,9 +33,11 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); export const { db, data } = await loadDb(__dirname); - console.log(`Starting ontime version ${process.env.npm_package_version}`); +// import socket provider +const socket = socketProvider; + // Create express APP const app = express(); app.disable('x-powered-by'); @@ -80,22 +84,22 @@ app.use((err, req, res, next) => { * */ -const osc = data.osc; +const { osc, settings } = DataProvider.getData(); const oscIP = osc?.targetIP || config.osc.targetIP; const oscOutPort = osc?.portOut || config.osc.portOut; const oscInPort = osc?.port || config.osc.port; const oscInEnabled = osc?.enabled !== undefined ? osc.enabled : config.osc.inputEnabled; - -const serverPort = data.settings.serverPort || config.server.port; +const serverPort = settings.serverPort || config.server.port; /** + * @description starts OSC server * @description starts OSC server * @param overrideConfig * @return {Promise} */ export const startOSCServer = async (overrideConfig = null) => { if (!oscInEnabled) { - global.timer.info('RX', 'OSC Input Disabled'); + socket.info('RX', 'OSC Input Disabled'); return; } @@ -105,7 +109,7 @@ export const startOSCServer = async (overrideConfig = null) => { }; // Start OSC Server - global.timer.info('RX', `Starting OSC Server on port: ${oscInPort}`); + socket.info('RX', `Starting OSC Server on port: ${oscInPort}`); initiateOSC(oscSettings); }; @@ -119,11 +123,16 @@ const server = http.createServer(app); */ export const startServer = async (overrideConfig = null) => { const port = 4001; // port hardcoded + const { events, http } = DataProvider.getData(); // Start server const returnMessage = `Ontime is listening on port ${port}`; server.listen(port, '0.0.0.0'); + // init socket controller + await socket.initServer(server); + socket.info('SERVER', 'Socket initialised'); + // OSC Config const oscConfig = { ip: oscIP, @@ -131,9 +140,11 @@ export const startServer = async (overrideConfig = null) => { }; // init timer - global.timer = new EventTimer(server, config.timer, oscConfig, data.http); - global.timer.setupWithEventList(data.events); - global.timer.info('SERVER', returnMessage); + global.timer = new EventTimer(socket, config.timer, oscConfig, http); + global.timer.setupWithEventList(events); + + socket.info('SERVER', returnMessage); + socket.startListener(); return returnMessage; }; @@ -146,16 +157,14 @@ export const shutdown = async () => { // shutdown express server server.close(); - // shutdown OSC Server shutdownOSCServer(); - - // shutdown timer global.timer.shutdown(); + socket.shutdown(); }; // register shutdown signals -process.once('SIGHUP', shutdown) -process.once('SIGINT', shutdown) -process.once('SIGTERM', shutdown) +process.once('SIGHUP', shutdown); +process.once('SIGINT', shutdown); +process.once('SIGTERM', shutdown); export { server, app }; diff --git a/server/src/classes/data-provider/DataProvider.js b/server/src/classes/data-provider/DataProvider.js index df1796d57..e8d29920c 100644 --- a/server/src/classes/data-provider/DataProvider.js +++ b/server/src/classes/data-provider/DataProvider.js @@ -1,7 +1,168 @@ /** - * Class Event Provider adds functions specific for handling event data + * Class Event Provider is a mediator for handling the local db + * and adds logic specific to ontime data */ +import { data, db } from '../../app.js'; + export class DataProvider { + static getData() { + return data; + } + + static async setEventData(newData) { + data.event = { ...data.event, ...newData }; + await this.persist(); + return data.event; + } + + static getEventData() { + return data.event; + } + + static async setEvents(newData) { + data.events = [...newData]; + await this.persist(); + } + + static getEventById(eventId) { + return data.events.find((e) => e.id === eventId); + } + + static async updateEventById(eventId, newData) { + const eventIndex = data.events.findIndex((e) => e.id === eventId); + const e = data.events[eventIndex]; + data.events[eventIndex] = { ...e, ...newData }; + data.events[eventIndex].revision++; + await this.persist(); + return data.events[eventIndex]; + } + + static async deleteEvent(eventId) { + data.events = Array.from(data.events).filter((e) => e.id !== eventId); + await this.persist(); + } + + static getNumEvents() { + return data.events.length; + } + + static async deleteAllEvents() { + data.events = []; + await db.write(); + } + + /** + * Insets an event after a given index + * @param entry + * @param index + * @return {Promise} + * @private + */ + static async insertEventAt(entry, index) { + // get events + const events = DataProvider.getEvents(); + const count = events.length; + const order = entry.order; + + // Remove order field from object + delete entry.order; + + // Insert at beginning + if (order === 0) { + events.unshift(entry); + } + + // insert at end + else if (order >= count) { + events.push(entry); + } + + // insert in the middle + else { + events.splice(index, 0, entry); + } + + // save events + await DataProvider.setEvents(events); + } + + /** + * @description Inserts an entry after an element with given ID + * @param entry + * @param id + * @return {Promise} + * @private + */ + static async insertEventAfterId(entry, id) { + const index = [...data.events].findIndex((event) => event.id === id); + await DataProvider.insertEventAt(entry, index + 1); + } + + static getSettings() { + return data.settings; + } + + static async setSettings(newData) { + data.settings = { ...newData }; + await this.persist(); + } + + static getOsc() { + return data.osc; + } + + static getAliases() { + return data.aliases; + } + + static async setAliases(newData) { + data.aliases = newData; + await this.persist(); + } + + static getUserFields() { + return { ...data.userFields }; + } + + static getViews() { + return { ...data.views }; + } + + static async setViews(newData) { + data.views = { ...newData }; + await this.persist(); + } + + static async setUserFields(newData) { + data.userFields = { ...newData }; + await this.persist(); + } + + static async setOsc(newData) { + data.osc = { ...newData }; + await this.persist(); + } + + static getEvents() { + return [...data.events]; + } + + static async persist() { + await db.write(); + } + + static async mergeIntoData(newData) { + const mergedData = DataProvider.safeMerge(data, newData); + data.event = mergedData.event; + data.settings = mergedData.settings; + data.osc = mergedData.osc; + data.http = mergedData.http; + data.aliases = mergedData.aliases; + data.userFields = mergedData.userFields; + data.events = mergedData.events; + await this.persist(); + } + /** * Merges two data objects * @param {object} existing diff --git a/server/src/classes/socket/SocketController.js b/server/src/classes/socket/SocketController.js new file mode 100644 index 000000000..8778592e6 --- /dev/null +++ b/server/src/classes/socket/SocketController.js @@ -0,0 +1,400 @@ +import { Server } from 'socket.io'; +import getRandomName from '../../utils/getRandomName.js'; +import { generateId } from '../../utils/generate_id.js'; +import { stringFromMillis } from '../../utils/time.js'; +import { Timer } from '../timer/Timer.js'; + +class SocketController { + constructor() { + this.numClients = 0; + this.messageStack = []; + this._MAX_MESSAGES = 100; + this._clientNames = {}; + this.socket = null; + } + + initServer(httpServer) { + this.socket = new Server(httpServer, { + cors: { + origin: '*', + methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', + preflightContinue: false, + optionsSuccessStatus: 204, + }, + }); + } + + startListener() { + this._socketMessageHandler(); + } + + shutdown() { + this.info('SERVER', 'Shutting down ontime'); + if (this.socket) { + this.info('TX', '... Closing socket server'); + this.socket.close(); + } + } + + /** + * Handle socket io connections + * @private + */ + _socketMessageHandler() { + this.socket.on('connection', (socket) => { + /*******************************/ + /*** HANDLE NEW CONNECTION ***/ + /*** --------------------- ***/ + /*******************************/ + // keep track of connections + this.numClients++; + this._clientNames[socket.id] = getRandomName(); + const message = `${this.numClients} Clients with new connection: ${ + this._clientNames[socket.id] + }`; + this.info('CLIENT', message); + + // send state + socket.emit('timer', global.timer.getTimeObject()); + socket.emit('playstate', global.timer.state); + socket.emit('selected-id', global.timer.selectedEventId); + socket.emit('next-id', global.timer.nextEventId); + socket.emit('publicselected-id', global.timer.selectedPublicEventId); + socket.emit('publicnext-id', global.timer.nextPublicEventId); + + /** + * @description handle disconnecting a user + */ + socket.on('disconnect', () => { + this.numClients--; + const message = `${this.numClients} Clients with disconnection: ${ + this._clientNames[socket.id] + }`; + delete this._clientNames[socket.id]; + this.info('CLIENT', message); + }); + + /** + * @description utility for renaming a user + */ + socket.on('rename-client', (newName) => { + if (newName) { + const previousName = this._clientNames[socket.id]; + this._clientNames[socket.id] = newName; + this.info('CLIENT', `Client ${previousName} renamed to ${newName}`); + } + }); + + /***************************************/ + /*** TIMER STATE GETTERS / SETTERS ***/ + /*** ------- WEBSOCKET API ------- ***/ + /*** ----------------------------- ***/ + /***************************************/ + + /*******************************************/ + socket.on('ontime-test', () => { + socket.emit('hello', socket.id); + }); + + socket.on('set-start', () => { + global.timer.trigger('start'); + socket.emit('playstate', global.timer.state); + }); + + socket.on('set-startid', (data) => { + global.timer.trigger('startById', data); + socket.emit('playstate', global.timer.state); + }); + + socket.on('set-startindex', (data) => { + const eventIndex = Number(data); + if (isNaN(eventIndex)) { + return; + } + global.timer.trigger('startByIndex', data); + socket.emit('playstate', global.timer.state); + }); + + socket.on('set-loadid', (data) => { + global.timer.trigger('loadById', data); + socket.emit('playstate', global.timer.state); + }); + + socket.on('set-loadindex', (data) => { + const eventIndex = Number(data); + if (isNaN(eventIndex)) { + return; + } + global.timer.trigger('loadByIndex', data); + socket.emit('playstate', global.timer.state); + }); + + socket.on('set-pause', () => { + global.timer.trigger('pause'); + socket.emit('playstate', global.timer.state); + }); + + socket.on('set-stop', () => { + global.timer.trigger('stop'); + socket.emit('playstate', global.timer.state); + }); + + socket.on('set-reload', () => { + global.timer.trigger('reload'); + socket.emit('playstate', global.timer.state); + }); + + socket.on('set-previous', () => { + global.timer.trigger('previous'); + socket.emit('playstate', global.timer.state); + }); + + socket.on('set-next', () => { + global.timer.trigger('next'); + socket.emit('playstate', global.timer.state); + }); + + socket.on('set-roll', () => { + global.timer.trigger('roll'); + socket.emit('playstate', global.timer.state); + }); + + socket.on('set-delay', (data) => { + const delayTime = Number(data); + if (isNaN(delayTime)) { + return; + } + global.timer.increment(delayTime * 1000 * 60); + }); + + socket.on('set-onAir', (data) => { + try { + const d = JSON.parse(data); + d ? global.timer.trigger('onAir') : global.timer.trigger('offAir'); + } catch (error) { + this.error('RX', `Failed to parse message ${data}`); + } + this.send('onAir', global.timer.onAir); + global.timer._broadcastFeatureMessageControl(); + }); + + /*******************************************/ + // general playback state, useful for external sync + socket.on('ontime-poll', () => { + const timerPoll = global.timer.poll(); + const isDelayed = false; + const colour = ''; + socket.emit('ontime-poll', { isDelayed, colour, ...timerPoll }); + }); + + /*******************************************/ + + // ** TO BE DEPRECATED ** // + socket.on('get-timer', () => { + socket.emit('timer', global.timer.getTimeObject()); + }); + + // ** TO BE DEPRECATED IN FAVOR OF DELAY ** // + socket.on('increment-timer', (data) => { + if (isNaN(parseInt(data, 10))) return; + if (data < -5 || data > 5) return; + global.timer.increment(data * 1000 * 60); + }); + + /*******************************************/ + // playstate + socket.on('set-playstate', (data) => { + global.timer.trigger(data); + global.timer._broadcastFeaturePlaybackControl(); + global.timer._broadcastFeatureInfo(); + }); + + socket.on('get-playstate', () => { + socket.emit('playstate', global.timer.state); + }); + + socket.on('get-onAir', () => { + socket.emit('onAir', global.timer.onAir); + }); + + /*******************************************/ + // selection data + socket.on('get-selected', () => { + socket.emit('selected', { + id: global.timer.selectedEventId, + index: global.timer.selectedEventIndex, + total: global.timer._eventlist.length, + }); + }); + + socket.on('get-selected-id', () => { + socket.emit('selected-id', global.timer.selectedEventId); + }); + + socket.on('get-next-id', () => { + socket.emit('next-id', global.timer.nextEventId); + }); + + // title data + socket.on('get-titles', () => { + socket.emit('titles', global.timer.titles); + }); + + socket.on('get-publictitles', () => { + socket.emit('publictitles', global.timer.titlesPublic); + }); + + /***********************************/ + /*** MESSAGE GETTERS / SETTERS ***/ + /*** ------------------------- ***/ + /***********************************/ + + /*******************************************/ + + // Presenter message + socket.on('set-timer-message-text', (data) => { + global.timer._setTitles('set-timer-text', data); + global.timer._broadcastFeatureMessageControl(); + }); + + socket.on('set-timer-message-visible', (data) => { + global.timer._setTitles('set-timer-visible', data); + global.timer._broadcastFeatureMessageControl(); + }); + + /*******************************************/ + // Public message + socket.on('set-public-message-text', (data) => { + global.timer._setTitles('set-public-text', data); + global.timer._broadcastFeatureMessageControl(); + }); + + socket.on('set-public-message-visible', (data) => { + global.timer._setTitles('set-public-visible', data); + global.timer._broadcastFeatureMessageControl(); + }); + + /*******************************************/ + // Lower third message + socket.on('set-lower-message-text', (data) => { + global.timer._setTitles('set-lower-text', data); + global.timer._broadcastFeatureMessageControl(); + }); + + socket.on('set-lower-message-visible', (data) => { + global.timer._setTitles('set-lower-visible', data); + global.timer._broadcastFeatureMessageControl(); + }); + + /* MOLECULAR ENDPOINTS + * ===================== + * 1. EVENT LIST + * 2. MESSAGE CONTROL + * 3. PLAYBACK CONTROL + * 4. INFO + * 5. CUE SHEET + * 6. TIMER OBJECT + * */ + + // 1. EVENT LIST + socket.on('get-ontime-feat-eventlist', () => { + global.timer._broadcastFeatureEventList(); + }); + + // 2. MESSAGE CONTROL + socket.on('get-ontime-feat-messagecontrol', () => { + global.timer._broadcastFeatureMessageControl(); + }); + + // 3. PLAYBACK CONTROL + socket.on('get-ontime-feat-playbackcontrol', () => { + global.timer._broadcastFeaturePlaybackControl(); + }); + + // 4. INFO + socket.on('get-ontime-feat-info', () => { + global.timer._broadcastFeatureInfo(); + }); + + // 5. CUE SHEET + socket.on('get-ontime-feat-cuesheet', () => { + global.timer._broadcastFeatureCuesheet(); + }); + + // 6. TIMER + socket.on('get-ontime-timer', () => { + global.timer._broadcastFeatureTimer(); + }); + }); + } + + send(topic, payload) { + this.socket.emit(topic, payload); + } + + /****************************************************************************/ + + /** + * Logger logic + * ------------- + * + * This should be separate of event timer, left here for convenience + * + */ + + /** + * Utility method, sends message and pushes into stack + * @param {string} level + * @param {string} origin + * @param {string} text + */ + _push(level, origin, text) { + const logMessage = { + id: generateId(), + level, + origin, + text, + time: stringFromMillis(Timer.getCurrentTime() || 0), + }; + + this.messageStack.unshift(logMessage); + this.socket.emit('logger', logMessage); + + if (process.env.NODE_ENV !== 'production') { + console.log(`[${logMessage.level}] \t ${logMessage.origin} \t ${logMessage.text}`); + } + + if (this.messageStack.length > this._MAX_MESSAGES) { + this.messageStack.pop(); + } + } + + /** + * Sends a message with level LOG + * @param {string} origin + * @param {string} text + */ + info(origin, text) { + this._push('INFO', origin, text); + } + + /** + * Sends a message with level WARN + * @param {string} origin + * @param {string} text + */ + warning(origin, text) { + this._push('WARN', origin, text); + } + + /** + * Sends a message with level ERROR + * @param {string} origin + * @param {string} text + */ + error(origin, text) { + this._push('ERROR', origin, text); + } +} + +export const socketProvider = new SocketController(); diff --git a/server/src/classes/timer/EventTimer.js b/server/src/classes/timer/EventTimer.js index c9c336c4f..056675111 100644 --- a/server/src/classes/timer/EventTimer.js +++ b/server/src/classes/timer/EventTimer.js @@ -1,12 +1,8 @@ import { Timer } from './Timer.js'; -import { Server } from 'socket.io'; import { DAY_TO_MS, getSelectionByRoll, replacePlaceholder, updateRoll } from './classUtils.js'; import { OSCIntegration } from './integrations/Osc.js'; import { HTTPIntegration } from './integrations/Http.js'; import { cleanURL } from '../../utils/url.js'; -import getRandomName from '../../utils/getRandomName.js'; -import { generateId } from '../../utils/generate_id.js'; -import { stringFromMillis } from '../../utils/time.js'; /* * Class EventTimer adds functions specific to APP @@ -16,12 +12,12 @@ import { stringFromMillis } from '../../utils/time.js'; export class EventTimer extends Timer { /** * Instantiates an event timer object - * @param {object} httpServer + * @param {object} socket * @param {object} timerConfig * @param {object} [oscConfig] * @param {object} [httpConfig] */ - constructor(httpServer, timerConfig, oscConfig, httpConfig) { + constructor(socket, timerConfig, oscConfig, httpConfig) { // call super constructor super(); @@ -42,13 +38,15 @@ export class EventTimer extends Timer { this.ontimeCycle = 'idle'; this.prevCycle = null; + // Socket Object + this.socket = socket; + // OSC Object this.osc = null; // HTTP Client Object this.http = null; - this._numClients = 0; this._interval = null; this.presenter = { @@ -69,26 +67,9 @@ export class EventTimer extends Timer { this._eventlist = []; this.onAir = false; - - // initialise socketIO server - this.messageStack = []; - this.MAX_MESSAGES = 100; - this._clientNames = {}; - this.io = new Server(httpServer, { - cors: { - origin: '*', - methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', - preflightContinue: false, - optionsSuccessStatus: 204, - }, - }); - // set recurrent emits this._interval = setInterval(() => this.runCycle(), timerConfig?.refresh || 1000); - // listen to new connections - this._listenToConnections(); - if (oscConfig != null) { this._initOscClient(oscConfig); } @@ -103,17 +84,12 @@ export class EventTimer extends Timer { */ shutdown() { clearInterval(this._interval); - this.info('SERVER', 'Shutting down ontime'); - if (this.io != null) { - this.info('TX', '... Closing socket server'); - this.io.close(); - } if (this.osc != null) { - this.info('TX', '... Closing OSC Client'); + this.socket.info('TX', '... Closing OSC Client'); this.osc.shutdown(); } if (this.http != null) { - this.info('TX', '... Closing HTTP Client'); + this.socket.info('TX', '... Closing HTTP Client'); this.http.shutdown(); } } @@ -126,7 +102,7 @@ export class EventTimer extends Timer { _initOscClient(oscConfig) { this.osc = new OSCIntegration(); const r = this.osc.init(oscConfig); - r.success ? this.info('TX', r.message) : this.error('TX', r.message); + r.success ? this.socket.info('TX', r.message) : this.socket.error('TX', r.message); } /** @@ -135,7 +111,7 @@ export class EventTimer extends Timer { * @private */ _initHTTPClient(httpConfig) { - this.info('TX', `Initialise HTTP Client on port`); + this.socket.info('TX', `Initialise HTTP Client on port`); this.http = new HTTPIntegration(); this.http.init(httpConfig); this.httpMessages = httpConfig.messages; @@ -146,7 +122,7 @@ export class EventTimer extends Timer { */ broadcastTimer() { // through websockets - this.io.emit('timer', this.getTimeObject()); + this.socket.send('timer', this.getTimeObject()); } /** @@ -162,7 +138,7 @@ export class EventTimer extends Timer { expectedFinish: this._getExpectedFinish(), startedAt: this._startedAt, }; - this.io.emit('ontime-timer', featureData); + this.socket.send('ontime-timer', featureData); } /** @@ -174,7 +150,7 @@ export class EventTimer extends Timer { selectedEventId: this.selectedEventId, nextEventId: this.nextEventId, }; - this.io.emit('ontime-feat-eventlist', featureData); + this.socket.send('ontime-feat-eventlist', featureData); } /** @@ -188,7 +164,7 @@ export class EventTimer extends Timer { lower: this.lower, onAir: this.onAir, }; - this.io.emit('ontime-feat-messagecontrol', featureData); + this.socket.send('ontime-feat-messagecontrol', featureData); } /** @@ -201,7 +177,7 @@ export class EventTimer extends Timer { selectedEventId: this.selectedEventId, numEvents: this._eventlist.length, }; - this.io.emit('ontime-feat-playbackcontrol', featureData); + this.socket.send('ontime-feat-playbackcontrol', featureData); } /** @@ -216,7 +192,7 @@ export class EventTimer extends Timer { selectedEventIndex: this.selectedEventIndex, numEvents: this._eventlist.length, }; - this.io.emit('ontime-feat-info', featureData); + this.socket.send('ontime-feat-info', featureData); } _broadcastFeatureCuesheet() { @@ -227,7 +203,7 @@ export class EventTimer extends Timer { numEvents: this._eventlist.length, titleNow: this.titles.titleNow, }; - this.io.emit('ontime-feat-cuesheet', featureData); + this.socket.send('ontime-feat-cuesheet', featureData); } /** @@ -244,29 +220,20 @@ export class EventTimer extends Timer { const numEvents = this._eventlist.length; this.broadcastTimer(); - this.io.emit('playstate', this.state); - this.io.emit('selected', { + this.socket.send('playstate', this.state); + this.socket.send('selected', { id: this.selectedEventId, index: this.selectedEventIndex, total: numEvents, }); - this.io.emit('selected-id', this.selectedEventId); - this.io.emit('next-id', this.nextEventId); - this.io.emit('numevents', numEvents); - this.io.emit('publicselected-id', this.selectedPublicEventId); - this.io.emit('publicnext-id', this.nextPublicEventId); - this.io.emit('titles', this.titles); - this.io.emit('publictitles', this.titlesPublic); - this.io.emit('onAir', this.onAir); - } - - /** - * Broadcast given message - * @param {string} address - socket io address - * @param {any} payload - message body - */ - broadcastThis(address, payload) { - this.io.emit(address, payload); + this.socket.send('selected-id', this.selectedEventId); + this.socket.send('next-id', this.nextEventId); + this.socket.send('numevents', numEvents); + this.socket.send('publicselected-id', this.selectedPublicEventId); + this.socket.send('publicnext-id', this.nextPublicEventId); + this.socket.send('titles', this.titles); + this.socket.send('publictitles', this.titlesPublic); + this.socket.send('onAir', this.onAir); } /** @@ -282,7 +249,7 @@ export class EventTimer extends Timer { case 'start': { if (!numEvents) return false; // Call action and force update - this.info('PLAYBACK', 'Play Mode Start'); + this.socket.info('PLAYBACK', 'Play Mode Start'); this.start(); break; } @@ -290,8 +257,8 @@ export class EventTimer extends Timer { if (!numEvents) return false; const loaded = this.loadEventById(payload); if (loaded) { - this.info('PLAYBACK', `Loaded event with ID ${payload}`); - this.info('PLAYBACK', 'Play Mode Start'); + this.socket.info('PLAYBACK', `Loaded event with ID ${payload}`); + this.socket.info('PLAYBACK', 'Play Mode Start'); this.start(); } else { return false; @@ -302,8 +269,8 @@ export class EventTimer extends Timer { if (!numEvents) return false; const loaded = this.loadEventByIndex(payload); if (loaded) { - this.info('PLAYBACK', `Loaded event with index ${payload}`); - this.info('PLAYBACK', 'Play Mode Start'); + this.socket.info('PLAYBACK', `Loaded event with index ${payload}`); + this.socket.info('PLAYBACK', 'Play Mode Start'); this.start(); } else { return false; @@ -313,35 +280,35 @@ export class EventTimer extends Timer { case 'pause': { if (!numEvents) return false; // Call action and force update - this.info('PLAYBACK', 'Play Mode Pause'); + this.socket.info('PLAYBACK', 'Play Mode Pause'); this.pause(); break; } case 'stop': { if (!numEvents) return false; // Call action and force update - this.info('PLAYBACK', 'Play Mode Stop'); + this.socket.info('PLAYBACK', 'Play Mode Stop'); this.stop(); break; } case 'roll': { if (!numEvents) return false; // Call action and force update - this.info('PLAYBACK', 'Play Mode Roll'); + this.socket.info('PLAYBACK', 'Play Mode Roll'); this.roll(); break; } case 'previous': { if (!numEvents) return false; // Call action and force update - this.info('PLAYBACK', 'Play Mode Previous'); + this.socket.info('PLAYBACK', 'Play Mode Previous'); this.previous(); break; } case 'next': { if (!numEvents) return false; // Call action and force update - this.info('PLAYBACK', 'Play Mode Next'); + this.socket.info('PLAYBACK', 'Play Mode Next'); this.next(); break; } @@ -349,7 +316,7 @@ export class EventTimer extends Timer { if (!numEvents) return false; const loaded = this.loadEventById(payload); if (loaded) { - this.info('PLAYBACK', `Loaded event with ID ${payload}`); + this.socket.info('PLAYBACK', `Loaded event with ID ${payload}`); } else { return false; } @@ -359,7 +326,7 @@ export class EventTimer extends Timer { if (!numEvents) return false; const loaded = this.loadEventByIndex(payload); if (loaded) { - this.info('PLAYBACK', `Loaded event with index ${payload}`); + this.socket.info('PLAYBACK', `Loaded event with index ${payload}`); } else { return false; } @@ -368,32 +335,32 @@ export class EventTimer extends Timer { case 'unload': { if (!numEvents) return false; // Call action and force update - this.info('PLAYBACK', 'Events unloaded'); + this.socket.info('PLAYBACK', 'Events unloaded'); this.unload(); break; } case 'reload': { if (!numEvents) return false; // Call action and force update - this.info('PLAYBACK', 'Reloaded event'); + this.socket.info('PLAYBACK', 'Reloaded event'); this.reload(); break; } case 'onAir': { // Call action - this.info('PLAYBACK', 'Going On Air'); + this.socket.info('PLAYBACK', 'Going On Air'); this.setonAir(true); break; } case 'offAir': { // Call action and force update - this.info('PLAYBACK', 'Going Off Air'); + this.socket.info('PLAYBACK', 'Going Off Air'); this.setonAir(false); break; } default: { // Error, disable flag - this.error('RX', `Unhandled action triggered ${action}`); + this.socket.error('RX', `Unhandled action triggered ${action}`); success = false; break; } @@ -518,7 +485,7 @@ export class EventTimer extends Timer { this.ontimeCycle = this.cycleState.onUpdate; break; default: - this.error('SERVER', `Unhandled cycle: ${this.ontimeCycle}`); + this.socket.error('SERVER', `Unhandled cycle: ${this.ontimeCycle}`); } // send http message if any @@ -546,13 +513,13 @@ export class EventTimer extends Timer { update() { // if there is nothing selected, update clock - this.clock = this._getCurrentTime(); + this.clock = Timer.getCurrentTime(); this._broadcastFeatureTimer(); this.broadcastTimer(); // if we are not updating, send the timers if (this.ontimeCycle !== this.cycleState.onUpdate) { - this.io.emit('timer', this.getTimeObject()); + this.socket.send('timer', this.getTimeObject()); } // Have we skipped onStart? @@ -616,33 +583,33 @@ export class EventTimer extends Timer { // Presenter message case 'set-timer-text': this.presenter.text = payload; - this.broadcastThis('messages-timer', this.presenter); + this.socket.send('messages-timer', this.presenter); break; case 'set-timer-visible': this.presenter.visible = payload; - this.broadcastThis('messages-timer', this.presenter); + this.socket.send('messages-timer', this.presenter); break; /*******************************************/ // Public message case 'set-public-text': this.public.text = payload; - this.broadcastThis('messages-public', this.public); + this.socket.send('messages-public', this.public); break; case 'set-public-visible': this.public.visible = payload; - this.broadcastThis('messages-public', this.public); + this.socket.send('messages-public', this.public); break; /*******************************************/ // Lower third message case 'set-lower-text': this.lower.text = payload; - this.broadcastThis('messages-lower', this.lower); + this.socket.send('messages-lower', this.lower); break; case 'set-lower-visible': this.lower.visible = payload; - this.broadcastThis('messages-lower', this.lower); + this.socket.send('messages-lower', this.lower); break; default: @@ -650,286 +617,6 @@ export class EventTimer extends Timer { } } - /** - * Handle socket io connections - * @private - */ - _listenToConnections() { - this.io.on('connection', (socket) => { - /*******************************/ - /*** HANDLE NEW CONNECTION ***/ - /*** --------------------- ***/ - /*******************************/ - // keep track of connections - this._numClients++; - this._clientNames[socket.id] = getRandomName(); - const m = `${this._numClients} Clients with new connection: ${this._clientNames[socket.id]}`; - this.info('CLIENT', m); - - // send state - socket.emit('timer', this.getTimeObject()); - socket.emit('playstate', this.state); - socket.emit('selected-id', this.selectedEventId); - socket.emit('next-id', this.nextEventId); - socket.emit('publicselected-id', this.selectedPublicEventId); - socket.emit('publicnext-id', this.nextPublicEventId); - - /********************************/ - /*** HANDLE DISCONNECT USER ***/ - /*** ---------------------- ***/ - /********************************/ - socket.on('disconnect', () => { - this._numClients--; - const m = `${this._numClients} Clients with disconnection: ${this._clientNames[socket.id]}`; - delete this._clientNames[socket.id]; - this.info('CLIENT', m); - }); - - /***************************************/ - /*** TIMER STATE GETTERS / SETTERS ***/ - /*** ------- WEBSOCKET API ------- ***/ - /*** ----------------------------- ***/ - /***************************************/ - - /*******************************************/ - socket.on('ontime-test', () => { - socket.emit('hello'); - }); - - socket.on('set-start', () => { - this.trigger('start'); - socket.emit('playstate', this.state); - }); - - socket.on('set-startid', (data) => { - this.trigger('startById', data); - socket.emit('playstate', this.state); - }); - - socket.on('set-startindex', (data) => { - const eventIndex = Number(data); - if (isNaN(eventIndex)) { - return; - } - this.trigger('startByIndex', data); - socket.emit('playstate', this.state); - }); - - socket.on('set-loadid', (data) => { - this.trigger('loadById', data); - socket.emit('playstate', this.state); - }); - - socket.on('set-loadindex', (data) => { - const eventIndex = Number(data); - if (isNaN(eventIndex)) { - return; - } - this.trigger('loadByIndex', data); - socket.emit('playstate', this.state); - }); - - socket.on('set-pause', () => { - this.trigger('pause'); - socket.emit('playstate', this.state); - }); - - socket.on('set-stop', () => { - this.trigger('stop'); - socket.emit('playstate', this.state); - }); - - socket.on('set-reload', () => { - this.trigger('reload'); - socket.emit('playstate', this.state); - }); - - socket.on('set-previous', () => { - this.trigger('previous'); - socket.emit('playstate', this.state); - }); - - socket.on('set-next', () => { - this.trigger('next'); - socket.emit('playstate', this.state); - }); - - socket.on('set-roll', () => { - this.trigger('roll'); - socket.emit('playstate', this.state); - }); - - socket.on('set-delay', (data) => { - const delayTime = Number(data); - if (isNaN(delayTime)) { - return; - } - this.increment(delayTime * 1000 * 60); - }); - - socket.on('set-onAir', (data) => { - try { - const d = JSON.parse(data); - this.onAir = !!d; - } catch (error) { - this.error('RX', `Failed to parse message ${data}`); - } - this.broadcastThis('onAir', this.onAir); - this._broadcastFeatureMessageControl(); - }); - - /*******************************************/ - // general playback state - socket.on('get-state', () => { - socket.emit('timer', this.getTimeObject()); - socket.emit('playstate', this.state); - socket.emit('selected-id', this.selectedEventId); - socket.emit('next-id', this.nextEventId); - socket.emit('publicselected-id', this.selectedPublicEventId); - socket.emit('publicnext-id', this.this.nextPublicEventId); - }); - - /*******************************************/ - - // ** TO BE DEPRECATED ** // - socket.on('get-timer', () => { - socket.emit('timer', this.getTimeObject()); - }); - - // ** TO BE DEPRECATED IN FAVOR OF DELAY ** // - socket.on('increment-timer', (data) => { - if (isNaN(parseInt(data, 10))) return; - if (data < -5 || data > 5) return; - this.increment(data * 1000 * 60); - }); - - /*******************************************/ - // playstate - socket.on('set-playstate', (data) => { - this.trigger(data); - this._broadcastFeaturePlaybackControl(); - this._broadcastFeatureInfo(); - }); - - socket.on('get-playstate', () => { - socket.emit('playstate', this.state); - }); - - socket.on('get-onAir', () => { - socket.emit('onAir', this.onAir); - }); - - /*******************************************/ - // selection data - socket.on('get-selected', () => { - socket.emit('selected', { - id: this.selectedEventId, - index: this.selectedEventIndex, - total: this._eventlist.length, - }); - }); - - socket.on('get-selected-id', () => { - socket.emit('selected-id', this.selectedEventId); - }); - - socket.on('get-next-id', () => { - socket.emit('next-id', this.nextEventId); - }); - - // title data - socket.on('get-titles', () => { - socket.emit('titles', this.titles); - }); - - socket.on('get-publictitles', () => { - socket.emit('publictitles', this.titlesPublic); - }); - - /***********************************/ - /*** MESSAGE GETTERS / SETTERS ***/ - /*** ------------------------- ***/ - /***********************************/ - - /*******************************************/ - - // Presenter message - socket.on('set-timer-message-text', (data) => { - this._setTitles('set-timer-text', data); - this._broadcastFeatureMessageControl(); - }); - - socket.on('set-timer-message-visible', (data) => { - this._setTitles('set-timer-visible', data); - this._broadcastFeatureMessageControl(); - }); - - /*******************************************/ - // Public message - socket.on('set-public-message-text', (data) => { - this._setTitles('set-public-text', data); - this._broadcastFeatureMessageControl(); - }); - - socket.on('set-public-message-visible', (data) => { - this._setTitles('set-public-visible', data); - this._broadcastFeatureMessageControl(); - }); - - /*******************************************/ - // Lower third message - socket.on('set-lower-message-text', (data) => { - this._setTitles('set-lower-text', data); - this._broadcastFeatureMessageControl(); - }); - - socket.on('set-lower-message-visible', (data) => { - this._setTitles('set-lower-visible', data); - this._broadcastFeatureMessageControl(); - }); - - /* MOLECULAR ENDPOINTS - * ===================== - * 1. EVENT LIST - * 2. MESSAGE CONTROL - * 3. PLAYBACK CONTROL - * 4. INFO - * 5. CUESHEET - * 6. TIMER OBJECT - * */ - - // 1. EVENT LIST - socket.on('get-ontime-feat-eventlist', () => { - this._broadcastFeatureEventList(); - }); - - // 2. MESSAGE CONTROL - socket.on('get-ontime-feat-messagecontrol', () => { - this._broadcastFeatureMessageControl(); - }); - - // 3. PLAYBACK CONTROL - socket.on('get-ontime-feat-playbackcontrol', () => { - this._broadcastFeaturePlaybackControl(); - }); - - // 4. INFO - socket.on('get-ontime-feat-info', () => { - this._broadcastFeatureInfo(); - }); - - // 5. CUE SHEET - socket.on('get-ontime-feat-cuesheet', () => { - this._broadcastFeatureCuesheet(); - }); - - // 6. TIMER - socket.on('get-ontime-timer', () => { - this._broadcastFeatureTimer(); - }); - }); - } - /** * Deletes running event list from object */ @@ -944,7 +631,7 @@ export class EventTimer extends Timer { this.ontimeCycle = this.cycleState.onStop; // update clients - this.broadcastThis('numevents', this._eventlist.length); + this.socket.send('numevents', this._eventlist.length); } /** @@ -1030,7 +717,7 @@ export class EventTimer extends Timer { // find object in events const eventIndex = this._eventlist.findIndex((e) => e.id === id); if (eventIndex === -1) { - throw 'Event not found'; + throw new Error('Event not found'); } // check if event is set to be skipped @@ -1068,7 +755,7 @@ export class EventTimer extends Timer { this._loadTitlesNow(); } } catch (error) { - this.error('SERVER', error); + this.socket.error('SERVER', error); } // update clients @@ -1117,7 +804,7 @@ export class EventTimer extends Timer { this._loadTitlesNow(); } } catch (error) { - this.error('SERVER', error); + this.socket.error('SERVER', error); } // update clients @@ -1228,7 +915,7 @@ export class EventTimer extends Timer { this.selectedEventIndex = eventIndex; this.selectedEventId = e.id; } else if (type === 'reload') { - const now = this._getCurrentTime(); + const now = Timer.getCurrentTime(); const elapsed = this.getElapsed(); this.duration = end - start; @@ -1441,7 +1128,7 @@ export class EventTimer extends Timer { setonAir(onAir) { this.onAir = onAir; // broadcast change - this.broadcastThis('onAir', onAir); + this.socket.send('onAir', onAir); } /** @@ -1509,7 +1196,7 @@ export class EventTimer extends Timer { * @description Look for current event considering local clock */ rollLoad() { - const now = this._getCurrentTime(); + const now = Timer.getCurrentTime(); const prevLoaded = this.selectedEventId; // maybe roll has already been loaded @@ -1524,7 +1211,7 @@ export class EventTimer extends Timer { // nothing to play, unload if (nowIndex === null && nextIndex === null) { this.unload(); - this.warning('SERVER', 'Roll: no events found'); + this.socket.warning('SERVER', 'Roll: no events found'); return; } @@ -1551,7 +1238,7 @@ export class EventTimer extends Timer { if (nowIndex === null) { // only warn the first time if (this.secondaryTimer === null) { - this.info('SERVER', 'Roll: waiting for event start'); + this.socket.info('SERVER', 'Roll: waiting for event start'); } // reset running timer @@ -1685,70 +1372,6 @@ export class EventTimer extends Timer { /****************************************************************************/ - /** - * Logger logic - * ------------- - * - * This should be separate of event timer, left here for convenience - * - */ - - /** - * Utility method, sends message and pushes into stack - * @param {string} level - * @param {string} origin - * @param {string} text - */ - _push(level, origin, text) { - const m = { - id: generateId(), - level, - origin, - text, - time: stringFromMillis(this._getCurrentTime()), - }; - - this.messageStack.unshift(m); - this.io.emit('logger', m); - - if (process.env.NODE_ENV !== 'production') { - console.log(`[${m.level}] \t ${m.origin} \t ${m.text}`); - } - - if (this.messageStack.length > this.MAX_MESSAGES) { - this.messageStack.pop(); - } - } - - /** - * Sends a message with level LOG - * @param {string} origin - * @param {string} text - */ - info(origin, text) { - this._push('INFO', origin, text); - } - - /** - * Sends a message with level WARN - * @param {string} origin - * @param {string} text - */ - warning(origin, text) { - this._push('WARN', origin, text); - } - - /** - * Sends a message with level ERROR - * @param {string} origin - * @param {string} text - */ - error(origin, text) { - this._push('ERROR', origin, text); - } - - /****************************************************************************/ - /** * Integrations * ------------- @@ -1765,20 +1388,20 @@ export class EventTimer extends Timer { async sendOsc(message, payload) { const reply = await this.osc.send(message, payload); if (!reply.success) { - this.error('TX', reply.message); + this.socket.error('TX', reply.message); } } /** - * Builds sync object - * @returns {{running: number, timer: (null|string|*), presenter: null, playback: string, clock: null, title: null}} + * @description Builds sync object */ poll() { return { - clock: this.clock, - running: Timer.toSeconds(this.current), + currentId: this.selectedEventId, timer: this.timeTag, + clock: this.clock, playback: this.state, + currentColour: null, title: this.titles.titleNow, presenter: this.titles.presenterNow, }; diff --git a/server/src/classes/timer/Timer.js b/server/src/classes/timer/Timer.js index 43df4f08a..40ed0a187 100644 --- a/server/src/classes/timer/Timer.js +++ b/server/src/classes/timer/Timer.js @@ -11,39 +11,12 @@ export class Timer { this.state = 'stop'; } - /** - * @description initiates a timer with given seconds - * @param seconds - * @param autoStart - */ - setupWithSeconds(seconds, autoStart = false) { - // aux - const now = this._getCurrentTime(); - this.clock = now; - - // populate targets - this.duration = seconds * 1000; - this._finishAt = now + seconds * 1000; - - // start counting - this._startedAt = now; - - if (autoStart) { - this.state = 'start'; - } else { - this._pausedAt = now; - this._pausedInterval = 0; - } - this._pausedTotal = 0; - this.update(); - } - /** * @description updates the running timer */ update() { // get current time - const now = this._getCurrentTime(); + const now = Timer.getCurrentTime(); this.clock = now; let checkFinish = false; @@ -106,7 +79,7 @@ export class Timer { * @return {number} * @private */ - _getCurrentTime() { + static getCurrentTime() { const now = new Date(); // extract milliseconds since midnight @@ -178,16 +151,6 @@ export class Timer { }; } - /** - * @description get current time in seconds - * @return {number|number} - */ - getCurrentInSeconds() { - // update timeStamp - this.update(); - return Timer.toSeconds(this.current); - } - // playback /** * @description start current time @@ -197,7 +160,7 @@ export class Timer { if (this.state === 'start') return; else if (this._startedAt == null) { // it hasn't started yet - const now = this._getCurrentTime(); + const now = Timer.getCurrentTime(); // set start time as now this._startedAt = now; // calculate expected finish time @@ -230,7 +193,7 @@ export class Timer { } // set pause time - this._pausedAt = this._getCurrentTime(); + this._pausedAt = Timer.getCurrentTime(); // change state this.state = 'pause'; @@ -259,7 +222,7 @@ export class Timer { if (amount < 0 && Math.abs(amount) > this.current) { // if we will make the clock negative - if (this._finishedAt == null) this._finishedAt = this._getCurrentTime(); + if (this._finishedAt == null) this._finishedAt = Timer.getCurrentTime(); } else if (this.current < 0 && this.current + amount > 0) { // clock will go from negative to positive this._finishedAt = null; diff --git a/server/src/controllers/eventController.js b/server/src/controllers/eventController.js index b40eb5f79..63e9eecb8 100644 --- a/server/src/controllers/eventController.js +++ b/server/src/controllers/eventController.js @@ -1,24 +1,28 @@ -// get database -import { db, data } from '../app.js'; +import { removeUndefined } from '../utils/parserUtils.js'; +import { failEmptyObjects } from '../utils/routerUtils.js'; +import { DataProvider } from '../classes/data-provider/DataProvider.js'; // Create controller for GET request to 'event' -// Returns ACK message export const getEvent = async (req, res) => { - res.json(data.event); + res.json(DataProvider.getEventData()); }; // Create controller for POST request to 'event' -// Returns ACK message export const postEvent = async (req, res) => { - if (!req.body) { - res.status(400).send('No object found in request'); + if (failEmptyObjects(req.body, res)) { return; } - // TODO: validate data + try { - data.event = { ...data.event, ...req.body }; - await db.write(); - res.sendStatus(200); + const newEvent = removeUndefined({ + title: req.body?.title, + url: req.body?.url, + publicInfo: req.body?.publicInfo, + backstageInfo: req.body?.backstageInfo, + endMessage: req.body?.endMessage, + }); + const newData = await DataProvider.setEventData(newEvent); + res.status(200).send(newData); } catch (error) { res.status(400).send(error); } diff --git a/server/src/controllers/eventController.validate.js b/server/src/controllers/eventController.validate.js new file mode 100644 index 000000000..49861cae3 --- /dev/null +++ b/server/src/controllers/eventController.validate.js @@ -0,0 +1,14 @@ +import { body, validationResult } from 'express-validator'; + +export const eventSanitizer = [ + body('title').optional().isString().trim().escape(), + body('url').optional().isString().trim().escape(), + body('publicInfo').optional().isString().trim().escape(), + body('backstageInfo').optional().isString().trim().escape(), + body('endMessage').optional().isString().trim().escape(), + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; diff --git a/server/src/controllers/eventsController.js b/server/src/controllers/eventsController.js index 4d2ab3898..edf6e1378 100644 --- a/server/src/controllers/eventsController.js +++ b/server/src/controllers/eventsController.js @@ -1,7 +1,3 @@ -// get database -import { data, db } from '../app.js'; - -// utils import { block as blockDef, delay as delayDef, @@ -10,87 +6,36 @@ import { import { generateId } from '../utils/generate_id.js'; import { MAX_EVENTS } from '../settings.js'; import { getPreviousPlayable } from '../utils/eventUtils.js'; +import { DataProvider } from '../classes/data-provider/DataProvider.js'; +import { failEmptyObjects } from '../utils/routerUtils.js'; +import { socketProvider } from '../classes/socket/SocketController.js'; + +// import socket provider +const socket = socketProvider; async function _insertAndSync(newEvent) { if (newEvent.order) { - const events = data.events; - await _insertAt(newEvent, newEvent.order); + const events = DataProvider.getEvents(); + await DataProvider.insertEventAt(newEvent, newEvent.order); const previousId = events?.[newEvent.order - 1]?.id; _insertEventInTimerAfterId(newEvent, previousId); } else if (newEvent.after) { - await _insertAfterId(newEvent, newEvent.after); + await DataProvider.insertEventAfterId(newEvent, newEvent.after); _insertEventInTimerAfterId(newEvent, newEvent.after); } else { - await _insertAt(newEvent, 0); + await DataProvider.insertEventAt(newEvent, 0); _insertEventInTimerAfterId(newEvent); } } -/** - * Insets an event after a given index - * @param entry - * @param index - * @return {Promise} - * @private - */ -async function _insertAt(entry, index) { - // get events - const events = data.events; - const count = events.length; - const order = entry.order; - - // Remove order field from object - delete entry.order; - - // Insert at beginning - if (order === 0) { - events.unshift(entry); - } - - // insert at end - else if (order >= count) { - events.push(entry); - } - - // insert in the middle - else { - events.splice(index, 0, entry); - } - - // save events - data.events = events; - await db.write(); -} - -/** - * @description Inserts an entry after an element with given Id - * @param entry - * @param id - * @return {Promise} - * @private - */ -async function _insertAfterId(entry, id) { - const index = [...data.events].findIndex((event) => event.id === id); - await _insertAt(entry, index + 1); -} - -/** - * @description deletes an event from the db given its id - * @param eventId - * @return {Promise} - */ -async function _removeById(eventId) { - data.events = Array.from(data.events).filter((e) => e.id !== eventId); - await db.write(); -} - /** * @description returns all events of type event * @return {unknown[]} */ function getEventEvents() { // return data.events.filter((e) => e.type === 'event'); - return Array.from(data.events).filter((e) => e.type === 'event'); + const events = DataProvider.getEvents(); + return Array.from(events).filter((e) => e.type === 'event'); } // Updates timer object @@ -112,7 +57,7 @@ function _insertEventInTimerAfterId(event, previousId) { try { global.timer.insertEventAfterId(event, previousId); } catch (error) { - global.timer.error('SERVER', `Unable to update object: ${error}`); + socket.error('SERVER', `Unable to update object: ${error}`); } } } @@ -135,7 +80,7 @@ function _deleteTimerId(entryId) { // Create controller for GET request to '/events' // Returns - export const eventsGetAll = async (req, res) => { - res.json(data.events); + res.json(DataProvider.getEvents()); }; // Create controller for GET request to '/events/:eventId' @@ -146,7 +91,7 @@ export const eventsGetById = async (req, res) => { if (id == null) { res.status(400).send(`No eventId found in request`); } else { - const event = data.events.find((e) => e.id === id); + const event = DataProvider.getEventById(id); res.json(event); } }; @@ -154,13 +99,12 @@ export const eventsGetById = async (req, res) => { // Create controller for POST request to '/events/' // Returns - export const eventsPost = async (req, res) => { - // TODO: Validate event - if (!req.body) { - res.status(400).send(`No object found in request`); + if (failEmptyObjects(req.body, res)) { return; } - if (data.events.length > MAX_EVENTS) { + const numEvents = DataProvider.getNumEvents(); + if (numEvents > MAX_EVENTS) { const error = `ERROR: Reached limit number of ${MAX_EVENTS} events`; res.status(400).send(error); return; @@ -187,7 +131,7 @@ export const eventsPost = async (req, res) => { } try { - _insertAndSync(newEvent); + await _insertAndSync(newEvent); res.sendStatus(201); } catch (error) { res.status(400).send(error); @@ -197,41 +141,33 @@ export const eventsPost = async (req, res) => { // Create controller for PUT request to '/events/' // Returns - export const eventsPut = async (req, res) => { - // no valid params - if (!req.body) { - res.status(400).send(`No object found`); + if (failEmptyObjects(req.body, res)) { return; } - const eventId = req.body.id; - if (!eventId) { - res.status(400).send(`Object malformed: id missing`); - return; - } - - const eventIndex = data.events.findIndex((e) => e.id === eventId); - if (eventIndex === -1) { - res.status(400).send(`No Id found found`); + const eventDataFromRequest = req.body; + const eventId = eventDataFromRequest.id; + const event = DataProvider.getEventById(eventId); + if (typeof event === 'undefined') { + res.status(400).send(`No event with ID found`); return; } try { - const e = data.events[eventIndex]; - data.events[eventIndex] = { ...e, ...req.body }; - data.events[eventIndex].revision++; - await db.write(); + const newData = DataProvider.updateEventById(eventId, eventDataFromRequest); - if (data.events[eventIndex].skip) { + if (newData.skip) { _deleteTimerId(eventId); - // if it is a skip, i make sure it is deleted from timer + // if it is a skip, make sure it is deleted from timer // event id might already not exist } else { try { - _updateTimersSingle(eventId, req.body); + _updateTimersSingle(newData.id, eventDataFromRequest); } catch (error) { if (error === 'Event not found') { - const { id: previousId } = getPreviousPlayable(data.events, e.id); - _insertEventInTimerAfterId(data.events[eventIndex], previousId); + const events = DataProvider.getEvents(); + const { id: previousId } = getPreviousPlayable(events, newData.id); + _insertEventInTimerAfterId(newData, previousId); } } } @@ -250,16 +186,14 @@ export const eventsPatch = async (req, res) => { }; export const eventsReorder = async (req, res) => { - // TODO: Validate event - if (!req.body) { - res.status(400).send(`No object found in request`); + if (failEmptyObjects(req.body, res)) { return; } const { index, from, to } = req.body; // get events - const events = data.events; + const events = DataProvider.getEvents(); const idx = events.findIndex((e) => e.id === index, from); // Check if item is at given index @@ -276,8 +210,7 @@ export const eventsReorder = async (req, res) => { events.splice(to, 0, reorderedItem); // save events - data.events = events; - await db.write(); + await DataProvider.setEventData(events); // update timer _updateTimers(); @@ -291,15 +224,9 @@ export const eventsReorder = async (req, res) => { // Create controller for PATCH request to '/events/applydelay/:eventId' // Returns - export const eventsApplyDelay = async (req, res) => { - // no valid params - if (!req.params.eventId) { - res.status(400).send(`No id found in request`); - return; - } - try { // get events - const events = data.events; + const events = DataProvider.getEvents(); // AUX let delayIndex = null; @@ -340,8 +267,7 @@ export const eventsApplyDelay = async (req, res) => { if (blockIndex) events.splice(blockIndex - 1, 1); // update events - data.events = events; - await db.write(); + await DataProvider.setEvents(events); // update timer _updateTimers(); @@ -355,20 +281,15 @@ export const eventsApplyDelay = async (req, res) => { // Create controller for DELETE request to '/events/:eventId' // Returns - export const eventsDelete = async (req, res) => { - // no valid params - if (!req.params.eventId) { - res.status(400).send(`No id found in request`); - return; - } - try { + const eventId = req.params.eventId; + // remove new event - await _removeById(req.params.eventId); - + await DataProvider.deleteEvent(eventId); // update timer - _deleteTimerId(req.params.eventId); + _deleteTimerId(eventId); - res.sendStatus(201); + res.sendStatus(204); } catch (error) { res.status(400).send(error); } @@ -378,14 +299,9 @@ export const eventsDelete = async (req, res) => { // Returns - export const eventsDeleteAll = async (req, res) => { try { - // set with nothing - data.events = []; - await db.write(); - - // update timer object + await DataProvider.deleteAllEvents(); global.timer.clearEventList(); - - res.sendStatus(201); + res.sendStatus(204); } catch (error) { res.status(400).send(error); } diff --git a/server/src/controllers/eventsController.validate.js b/server/src/controllers/eventsController.validate.js new file mode 100644 index 000000000..cbf559ba3 --- /dev/null +++ b/server/src/controllers/eventsController.validate.js @@ -0,0 +1,19 @@ +import { body, param, validationResult } from 'express-validator'; + +export const eventsPutValidator = [ + body('id').isString().exists(), + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; + +export const paramsMustHaveEventId = [ + param('eventId').exists(), + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; diff --git a/server/src/controllers/ontimeController.js b/server/src/controllers/ontimeController.js index 5daa7d334..1b66663dc 100644 --- a/server/src/controllers/ontimeController.js +++ b/server/src/controllers/ontimeController.js @@ -1,10 +1,11 @@ import fs from 'fs'; -import { data, db } from '../app.js'; import { networkInterfaces } from 'os'; import { fileHandler } from '../utils/parser.js'; import { generateId } from '../utils/generate_id.js'; import { resolveDbPath } from '../modules/loadDb.js'; import { DataProvider } from '../classes/data-provider/DataProvider.js'; +import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js'; +import { mergeObject } from '../utils/parserUtils.js'; // Create controller for GET request to '/ontime/poll' // Returns data for current state @@ -22,7 +23,8 @@ export const poll = async (req, res) => { // Create controller for GET request to '/ontime/db' // Returns - export const dbDownload = async (req, res) => { - const fileTitle = data?.event?.title || 'ontime events'; + const { title } = DataProvider.getEventData(); + const fileTitle = title || 'ontime events'; const dbInDisk = resolveDbPath(); res.download(dbInDisk, `${fileTitle}.json`, (err) => { @@ -48,18 +50,13 @@ const uploadAndParse = async (file, req, res, options) => { } else if (result.message === 'success') { // explicitly write objects if (typeof result !== 'undefined') { - if (!options.onlyEvents) { - const mergedData = DataProvider.safeMerge(data, result.data); - data.event = mergedData.event; - data.settings = mergedData.settings; - data.osc = mergedData.osc; - data.http = mergedData.http; - data.aliases = mergedData.aliases; - data.userFields = mergedData.userFields; + const newEvents = result.data.events || []; + if (options.onlyEvents) { + await DataProvider.setEvents(newEvents); + } else { + await DataProvider.mergeIntoData(result.data); } - data.events = result.data.events || []; - global.timer.setupWithEventList(result.data.events || []); - await db.write(); + global.timer.setupWithEventList(newEvents); } res.sendStatus(200); } else { @@ -71,7 +68,7 @@ const uploadAndParse = async (file, req, res, options) => { }; /** - * @description Gets information on IPV4 non internal interfaces + * @description Gets information on IPV4 non-internal interfaces * @returns {array} - Array of objects {name: ip} */ const getNetworkInterfaces = () => { @@ -96,15 +93,8 @@ const getNetworkInterfaces = () => { // Create controller for POST request to '/ontime/info' // Returns - export const getInfo = async (req, res) => { - const version = data.settings.version; - const serverPort = data.settings.serverPort; - - const osc = { - port: data.osc.port, - portOut: data.osc.portOut, - targetIP: data.osc.targetIP, - enabled: data.osc.enabled, - }; + const { version, serverPort } = DataProvider.getSettings(); + const osc = DataProvider.getOsc(); // get nif and inject localhost const ni = getNetworkInterfaces(); @@ -122,18 +112,16 @@ export const getInfo = async (req, res) => { // Create controller for POST request to '/ontime/aliases' // Returns - export const getAliases = async (req, res) => { - // send aliases array - res.status(200).send(data.aliases); + const aliases = DataProvider.getAliases(); + res.status(200).send(aliases); }; // Create controller for POST request to '/ontime/aliases' // Returns ACK message export const postAliases = async (req, res) => { - if (!req.body) { - res.status(400).send('No object found in request'); + if (failIsNotArray()) { return; } - // TODO: validate data try { const newAliases = []; req.body.forEach((a) => { @@ -144,9 +132,8 @@ export const postAliases = async (req, res) => { pathAndParams: a.pathAndParams, }); }); - data.aliases = newAliases; - await db.write(); - res.sendStatus(200); + await DataProvider.setAliases(newAliases); + res.status(200).send(newAliases); } catch (error) { res.status(400).send(error); } @@ -155,27 +142,21 @@ export const postAliases = async (req, res) => { // Create controller for GET request to '/ontime/userfields' // Returns - export const getUserFields = async (req, res) => { - // send userFields array - res.status(200).send(data.userFields); + const userFields = DataProvider.getUserFields(); + res.status(200).send(userFields); }; // Create controller for POST request to '/ontime/userfields' // Returns ACK message export const postUserFields = async (req, res) => { - if (!req.body) { - res.status(400).send('No object found in request'); + if (failEmptyObjects(req.body, res)) { return; } try { - const newUserFields = { ...data.userFields }; - for (const field in newUserFields) { - if (typeof req.body[field] !== 'undefined') { - newUserFields[field] = req.body[field]; - } - } - data.userFields = newUserFields; - await db.write(); - res.sendStatus(200); + const persistedData = DataProvider.getUserFields(); + const newData = mergeObject(persistedData, req.body); + await DataProvider.setUserFields(newData); + res.status(200).send(newData); } catch (error) { res.status(400).send(error); } @@ -184,12 +165,8 @@ export const postUserFields = async (req, res) => { // Create controller for POST request to '/ontime/settings' // Returns - export const getSettings = async (req, res) => { - const version = data.settings.version; - const serverPort = data.settings.serverPort; - const pinCode = data.settings.pinCode; - const timeFormat = data.settings.timeFormat; + const { version, serverPort, pinCode, timeFormat } = DataProvider.getSettings(); - // send object with network information res.status(200).send({ version, serverPort, @@ -201,12 +178,12 @@ export const getSettings = async (req, res) => { // Create controller for POST request to '/ontime/settings' // Returns ACK message export const postSettings = async (req, res) => { - if (!req.body) { - res.status(400).send('No object found in request'); + if (failEmptyObjects(req.body, res)) { return; } try { - let pin = data.settings.pinCode; + const settings = DataProvider.getSettings(); + let pin = settings.pinCode; if (typeof req.body?.pinCode === 'string') { if (req.body?.pinCode.length === 0) { pin = null; @@ -215,20 +192,20 @@ export const postSettings = async (req, res) => { } } - let timeFormat = data.settings.timeFormat; + let format = settings.timeFormat; if (typeof req.body?.timeFormat === 'string') { if (req.body?.timeFormat === '12' || req.body?.timeFormat === '24') { - timeFormat = req.body.timeFormat; + format = req.body.timeFormat; } } - data.settings = { - ...data.settings, + const newData = { + ...settings, pinCode: pin, - timeFormat: timeFormat, + timeFormat: format, }; - await db.write(); - res.sendStatus(200); + DataProvider.setSettings(newData); + res.status(200).send(newData); } catch (error) { res.status(400).send(error); } @@ -239,7 +216,8 @@ export const postSettings = async (req, res) => { * @method GET */ export const getViewSettings = async (req, res) => { - res.status(200).send({ ...data.views }); + const views = DataProvider.getViews(); + res.status(200).send(views); }; /** @@ -247,33 +225,14 @@ export const getViewSettings = async (req, res) => { * @method POST */ export const postViewSettings = async (req, res) => { - if (!req.body) { - res.status(400).send('No object found in request'); + if (failEmptyObjects(req.body, res)) { return; } - try { - data.views = { - overrideStyles: req.body?.overrideStyles ?? data.views.overrideStyles, - }; - await db.write(); - res.sendStatus(200); - } catch (error) { - res.status(400).send(error); - } -}; -// Create controller for POST request to '/ontime/info' -// Returns ACK message -export const postInfo = async (req, res) => { - if (!req.body) { - res.status(400).send('No object found in request'); - return; - } - // TODO: validate data try { - data.settings = { ...data.settings, ...req.body }; - await db.write(); - res.sendStatus(200); + const newData = { overrideStyles: req.body.overrideStyles }; + DataProvider.setViews(newData); + res.status(200).send(newData); } catch (error) { res.status(400).send(error); } @@ -282,25 +241,22 @@ export const postInfo = async (req, res) => { // Create controller for POST request to '/ontime/osc' // Returns - export const getOSC = async (req, res) => { - // send object with network information - res.status(200).send(data.osc); + const osc = DataProvider.getOsc(); + res.status(200).send(osc); }; // Create controller for POST request to '/ontime/osc' // Returns ACK message export const postOSC = async (req, res) => { - if (!req.body) { - res.status(400).send('No object found in request'); + if (failEmptyObjects(req.body, res)) { return; } - // TODO: validate data + try { - data.osc = { ...data.osc, ...req.body }; - await db.write(); - res.sendStatus(200); + await DataProvider.setOsc(req.body); + res.send(req.body).status(200); } catch (error) { res.status(400).send(error); - console.log(error); } }; @@ -313,7 +269,7 @@ export const dbUpload = async (req, res) => { } const options = req.query; const file = req.file.path; - uploadAndParse(file, req, res, options); + await uploadAndParse(file, req, res, options); }; // Create controller for POST request to '/ontime/dbpath' @@ -323,5 +279,5 @@ export const dbPathToUpload = async (req, res) => { res.status(400).send({ message: 'Path to file not found' }); return; } - uploadAndParse(req.body.path, req, res); + await uploadAndParse(req.body.path, req, res); }; diff --git a/server/src/controllers/ontimeController.validate.js b/server/src/controllers/ontimeController.validate.js new file mode 100644 index 000000000..717391b2b --- /dev/null +++ b/server/src/controllers/ontimeController.validate.js @@ -0,0 +1,77 @@ +import { body, check, validationResult } from 'express-validator'; + +/** + * @description Validates object for POST /ontime/views + */ +export const viewValidator = [ + check('overrideStyles').isBoolean().withMessage('overrideStyles value must be boolean'), + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; + +/** + * @description Validates object for POST /ontime/aliases + */ +export const validateAliases = [ + body().isArray(), + body('*.enabled').isBoolean(), + body('*.alias').isString().trim(), + body('*.pathAndParams').isString().trim(), + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; + +/** + * @description Validates object for POST /ontime/userfields + */ +export const validateUserFields = [ + body('user0').exists().isString().trim(), + body('user1').exists().isString().trim(), + body('user2').exists().isString().trim(), + body('user3').exists().isString().trim(), + body('user4').exists().isString().trim(), + body('user5').exists().isString().trim(), + body('user6').exists().isString().trim(), + body('user7').exists().isString().trim(), + body('user8').exists().isString().trim(), + body('user9').exists().isString().trim(), + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; + +/** + * @description Validates object for POST /ontime/settings + */ +export const validateSettings = [ + body('pinCode').isString().isLength({ min: 0, max: 4 }).optional({ nullable: true }), + body('timeFormat').isString().isIn(['12', '24']), + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; + +/** + * @description Validates object for POST /ontime/osc + */ +export const validateOSC = [ + body('port').exists().isInt({ min: 0, max: 65353 }), + body('portOut').exists().isInt({ min: 0, max: 65353 }), + body('targetIP').exists().isIP(), + body('enabled').exists().isBoolean(), + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; diff --git a/server/src/routes/eventRouter.js b/server/src/routes/eventRouter.js index 9b929f95d..1f53016df 100644 --- a/server/src/routes/eventRouter.js +++ b/server/src/routes/eventRouter.js @@ -3,9 +3,10 @@ export const router = express.Router(); // import event controller import { getEvent, postEvent } from '../controllers/eventController.js'; +import { eventSanitizer } from '../controllers/eventController.validate.js'; // create route between controller and 'GET /event' endpoint router.get('/', getEvent); // create route between controller and 'POST /event' endpoint -router.post('/', postEvent); +router.post('/', eventSanitizer, postEvent); diff --git a/server/src/routes/eventsRouter.js b/server/src/routes/eventsRouter.js index 837a1e95b..58a2ce53b 100644 --- a/server/src/routes/eventsRouter.js +++ b/server/src/routes/eventsRouter.js @@ -13,6 +13,10 @@ import { eventsDeleteAll, eventsDelete, } from '../controllers/eventsController.js'; +import { + eventsPutValidator, + paramsMustHaveEventId, +} from '../controllers/eventsController.validate.js'; // create route between controller and '/events/' endpoint router.get('/', eventsGetAll); @@ -24,7 +28,7 @@ router.get('/:eventId', eventsGetById); router.post('/', eventsPost); // create route between controller and '/events/' endpoint -router.put('/', eventsPut); +router.put('/', eventsPutValidator, eventsPut); // create route between controller and '/events/' endpoint // DEPRECATED @@ -34,10 +38,10 @@ router.patch('/', eventsPatch); router.patch('/reorder/', eventsReorder); // create route between controller and '/events/applydelay/:eventId' endpoint -router.patch('/applydelay/:eventId', eventsApplyDelay); +router.patch('/applydelay/:eventId', paramsMustHaveEventId, eventsApplyDelay); // create route between controller and '/events/all' endpoint router.delete('/all', eventsDeleteAll); // create route between controller and '/events/:eventId' endpoint -router.delete('/:eventId', eventsDelete); +router.delete('/:eventId', paramsMustHaveEventId, eventsDelete); diff --git a/server/src/routes/ontimeRouter.js b/server/src/routes/ontimeRouter.js index 987103132..105eb9686 100644 --- a/server/src/routes/ontimeRouter.js +++ b/server/src/routes/ontimeRouter.js @@ -12,14 +12,19 @@ import { getViewSettings, poll, postAliases, - postInfo, postOSC, postSettings, postUserFields, postViewSettings, } from '../controllers/ontimeController.js'; -import { viewValidator } from '../validation/ontimeValidator.js'; +import { + viewValidator, + validateAliases, + validateUserFields, + validateSettings, + validateOSC, +} from '../controllers/ontimeController.validate.js'; export const router = express.Router(); @@ -36,7 +41,7 @@ router.post('/db', uploadFile, dbUpload); router.get('/settings', getSettings); // create route between controller and '/ontime/settings' endpoint -router.post('/settings', postSettings); +router.post('/settings', validateSettings, postSettings); // create route between controller and '/ontime/views' endpoint router.get('/views', getViewSettings); @@ -48,25 +53,22 @@ router.post('/views', viewValidator, postViewSettings); router.get('/aliases', getAliases); // create route between controller and '/ontime/aliases' endpoint -router.post('/aliases', postAliases); +router.post('/aliases', validateAliases, postAliases); // create route between controller and '/ontime/aliases' endpoint router.get('/userfields', getUserFields); // create route between controller and '/ontime/aliases' endpoint -router.post('/userfields', postUserFields); +router.post('/userfields', validateUserFields, postUserFields); // create route between controller and '/ontime/info' endpoint router.get('/info', getInfo); -// create route between controller and '/ontime/info' endpoint -router.post('/info', postInfo); - // create route between controller and '/ontime/osc' endpoint router.get('/osc', getOSC); // create route between controller and '/ontime/osc' endpoint -router.post('/osc', postOSC); +router.post('/osc', validateOSC, postOSC); // create route between controller and '/ontime/dbpath' endpoint router.post('/dbpath', dbPathToUpload); diff --git a/server/src/utils/__tests__/parserUtils.tests.js b/server/src/utils/__tests__/parserUtils.tests.js new file mode 100644 index 000000000..6bef1ca22 --- /dev/null +++ b/server/src/utils/__tests__/parserUtils.tests.js @@ -0,0 +1,92 @@ +import { isEmptyObject, mergeObject, removeUndefined } from '../parserUtils.js'; + +describe('isEmptyObject()', () => { + test('finds an empty object', () => { + const isEmpty = isEmptyObject({}); + expect(isEmpty).toBe(true); + }); + test('throws on other types', () => { + expect(() => isEmptyObject(12)).toThrow(); + }); + test('resolves an object with methods', () => { + const isEmpty = isEmptyObject({ test: 'yes' }); + expect(isEmpty).toBe(false); + }); +}); + +describe('mergeObject()', () => { + test('it suppresses undefined keys', () => { + const a = { + first: 'yes', + second: 'yes', + }; + const b = { + first: undefined, + second: 'no', + }; + const merged = mergeObject(a, b); + expect(merged).toStrictEqual({ + first: 'yes', + second: 'no', + }); + }); + test('it handles falsy values', () => { + const a = { + first: 'yes', + second: 'yes', + third: 'yes', + }; + const b = { + first: 0, + second: null, + third: '', + }; + const merged = mergeObject(a, b); + expect(merged).toStrictEqual({ + first: 0, + second: null, + third: '', + }); + }); + test.skip('it only merges fields of the first object', () => { + const a = { + first: 'yes', + second: 'yes', + third: 'yes', + }; + const b = { + first: 0, + second: null, + third: '', + forth: 'not-this', + }; + const merged = mergeObject(a, b); + expect(merged).toStrictEqual({ + first: 0, + second: null, + third: '', + }); + }); +}); + +describe('removeUndefined()', () => { + test('it removes undefined keys from object', () => { + const obj = { + first: 'yes', + second: undefined, + third: 'yes', + }; + expect(removeUndefined(obj)).toStrictEqual({ + first: 'yes', + third: 'yes', + }); + }); + test('it handles falsy values object', () => { + const obj = { + first: '', + second: 0, + third: 'null', + }; + expect(removeUndefined(obj)).toStrictEqual(obj); + }); +}); diff --git a/server/src/utils/getRandomName.js b/server/src/utils/getRandomName.js index f51b8fda3..dfee4efa0 100644 --- a/server/src/utils/getRandomName.js +++ b/server/src/utils/getRandomName.js @@ -1,19 +1,13 @@ const adjective = [ - 'abandoned', - 'able', - 'absolute', - 'adorable', - 'adventurous', 'academic', - 'acceptable', 'acclaimed', 'accomplished', 'accurate', - 'aching', - 'acidic', 'acrobatic', 'active', - 'actual', + 'adaptable', + 'adorable', + 'adventurous', 'adept', 'admirable', 'admired', @@ -23,16 +17,11 @@ const adjective = [ 'advanced', 'afraid', 'affectionate', - 'aged', - 'aggravating', - 'aggressive', 'agile', 'agitated', - 'agonizing', 'agreeable', 'ajar', 'alarmed', - 'alarming', 'alert', 'alienated', 'alive', @@ -52,13 +41,11 @@ const adjective = [ 'annual', 'another', 'antique', - 'anxious', 'any', 'apprehensive', 'appropriate', 'apt', 'arctic', - 'arid', 'aromatic', 'artistic', 'ashamed', @@ -73,18 +60,12 @@ const adjective = [ 'authorized', 'automatic', 'avaricious', - 'average', 'aware', 'awesome', - 'awful', - 'awkward', - 'babyish', 'bad', 'back', - 'baggy', 'bare', 'barren', - 'basic', 'beautiful', 'belated', 'beloved', @@ -112,7 +93,6 @@ const adjective = [ 'boiling', 'bold', 'bony', - 'boring', 'bossy', 'both', 'bouncy', @@ -127,12 +107,10 @@ const adjective = [ 'broken', 'bronze', 'brown', - 'bruised', 'bubbly', 'bulky', 'bumpy', 'buoyant', - 'burdensome', 'burly', 'bustling', 'busy', @@ -195,7 +173,6 @@ const adjective = [ 'cooperative', 'coordinated', 'corny', - 'corrupt', 'costly', 'courageous', 'courteous', @@ -203,8 +180,6 @@ const adjective = [ 'crazy', 'creamy', 'creative', - 'creepy', - 'criminal', 'crisp', 'critical', 'crooked', @@ -219,7 +194,6 @@ const adjective = [ 'curvy', 'cute', 'cylindrical', - 'damaged', 'damp', 'dangerous', 'dapper', @@ -236,10 +210,8 @@ const adjective = [ 'decimal', 'decisive', 'deep', - 'defenseless', 'defensive', 'defiant', - 'deficient', 'definite', 'definitive', 'delayed', @@ -249,7 +221,6 @@ const adjective = [ 'delirious', 'demanding', 'dense', - 'dental', 'dependable', 'dependent', 'descriptive', @@ -258,29 +229,19 @@ const adjective = [ 'determined', 'devoted', 'different', - 'difficult', 'digital', 'diligent', 'dim', 'dimpled', - 'dimwitted', - 'direct', - 'disastrous', 'discrete', - 'disfigured', - 'disgusting', - 'disloyal', 'dismal', 'distant', 'downright', 'dreary', - 'dirty', 'disguised', - 'dishonest', 'dismal', 'distant', 'distinct', - 'distorted', 'dizzy', 'dopey', 'doting', @@ -293,7 +254,6 @@ const adjective = [ 'droopy', 'dry', 'dual', - 'dull', 'dutiful', 'each', 'eager', @@ -474,20 +434,16 @@ const adjective = [ 'great', 'greedy', 'green', - 'gregarious', 'grim', 'grimy', 'gripping', 'grizzled', - 'gross', - 'grotesque', 'grouchy', 'grounded', 'growing', 'growling', 'grown', 'grubby', - 'gruesome', 'grumpy', 'guilty', 'gullible', @@ -506,7 +462,6 @@ const adjective = [ 'harmonious', 'harsh', 'hasty', - 'hateful', 'haunting', 'healthy', 'heartfelt', @@ -528,16 +483,13 @@ const adjective = [ 'honorable', 'honored', 'hopeful', - 'horrible', 'hospitable', 'hot', 'huge', 'humble', - 'humiliating', 'humming', 'humongous', 'hungry', - 'hurtful', 'husky', 'icky', 'icy', @@ -548,10 +500,7 @@ const adjective = [ 'idiotic', 'idolized', 'ignorant', - 'ill', 'illegal', - 'ill-fated', - 'ill-informed', 'illiterate', 'illustrious', 'imaginary', @@ -566,15 +515,11 @@ const adjective = [ 'imperfect', 'imperturbable', 'impish', - 'impolite', 'important', 'impossible', - 'impractical', 'impressionable', 'impressive', 'improbable', - 'impure', - 'inborn', 'incomparable', 'incompatible', 'incomplete', @@ -592,7 +537,6 @@ const adjective = [ 'innocent', 'insecure', 'insidious', - 'insignificant', 'insistent', 'instructive', 'insubstantial', @@ -639,7 +583,6 @@ const adjective = [ 'known', 'kooky', 'kosher', - 'lame', 'lanky', 'large', 'last', @@ -722,9 +665,7 @@ const adjective = [ 'miniature', 'minor', 'minty', - 'miserable', 'miserly', - 'misguided', 'misty', 'mixed', 'modern', @@ -777,18 +718,15 @@ const adjective = [ 'noteworthy', 'novel', 'noxious', - 'numb', 'nutritious', 'nutty', 'obedient', - 'obese', 'oblong', 'oily', 'oblong', 'obvious', 'occasional', 'odd', - 'oddball', 'offbeat', 'offensive', 'official', @@ -836,7 +774,6 @@ const adjective = [ 'personal', 'pertinent', 'pesky', - 'pessimistic', 'petty', 'phony', 'physical', @@ -858,7 +795,6 @@ const adjective = [ 'pointed', 'pointless', 'poised', - 'poor', 'popular', 'portly', 'posh', @@ -903,7 +839,6 @@ const adjective = [ 'quarterly', 'queasy', 'querulous', - 'questionable', 'quick', 'quick-witted', 'quiet', @@ -931,7 +866,6 @@ const adjective = [ 'reliable', 'relieved', 'remarkable', - 'remorseful', 'remote', 'repentant', 'required', @@ -949,7 +883,6 @@ const adjective = [ 'robust', 'rosy', 'rotating', - 'rotten', 'rough', 'round', 'rowdy', @@ -963,7 +896,6 @@ const adjective = [ 'rusty', 'sad', 'safe', - 'salty', 'same', 'sandy', 'sane', @@ -998,7 +930,6 @@ const adjective = [ 'shadowy', 'shady', 'shallow', - 'shameful', 'shameless', 'sharp', 'shimmering', @@ -1011,7 +942,6 @@ const adjective = [ 'showy', 'shrill', 'shy', - 'sick', 'silent', 'silky', 'silly', @@ -1027,7 +957,6 @@ const adjective = [ 'sleepy', 'slight', 'slim', - 'slimy', 'slippery', 'slow', 'slushy', @@ -1136,7 +1065,6 @@ const adjective = [ 'tender', 'tense', 'tepid', - 'terrible', 'terrific', 'testy', 'thankful', @@ -1183,7 +1111,6 @@ const adjective = [ 'tubby', 'turbulent', 'twin', - 'ugly', 'ultimate', 'unacceptable', 'unaware', @@ -1198,14 +1125,12 @@ const adjective = [ 'unfolded', 'unfortunate', 'unhappy', - 'unhealthy', 'uniform', 'unimportant', 'unique', 'united', 'unkempt', 'unknown', - 'unlawful', 'unlined', 'unlucky', 'unnatural', @@ -1235,7 +1160,6 @@ const adjective = [ 'usable', 'used', 'useful', - 'useless', 'utilized', 'utter', 'vacant', @@ -1271,7 +1195,6 @@ const adjective = [ 'warmhearted', 'warped', 'wary', - 'wasteful', 'watchful', 'waterlogged', 'watery', @@ -1611,7 +1534,6 @@ const object = [ 'classroom', 'delivery', 'device', - 'difficulty', 'drama', 'election', 'engine', @@ -1625,7 +1547,6 @@ const object = [ 'suggestion', 'tension', 'variation', - 'anxiety', 'atmosphere', 'awareness', 'bread', @@ -1761,7 +1682,6 @@ const object = [ 'drawer', 'establishment', 'examination', - 'garbage', 'grocery', 'honey', 'impression', @@ -1886,7 +1806,6 @@ const object = [ 'group', 'risk', 'word', - 'fat', 'force', 'key', 'light', @@ -1934,7 +1853,6 @@ const object = [ 'coast', 'action', 'age', - 'bad', 'boat', 'record', 'result', @@ -2223,7 +2141,6 @@ const object = [ 'pleasure', 'score', 'screw', - 'sex', 'shop', 'shower', 'suit', @@ -2416,7 +2333,6 @@ const object = [ 'counter', 'flower', 'grandfather', - 'harm', 'knee', 'lawyer', 'leather', @@ -2747,7 +2663,6 @@ const object = [ 'crazy', 'escape', 'gather', - 'hate', 'prior', 'repair', 'rough', @@ -2757,19 +2672,15 @@ const object = [ 'strike', 'employ', 'external', - 'hurt', - 'illegal', 'laugh', 'lay', 'mobile', - 'nasty', 'ordinary', 'respond', 'royal', 'senior', 'split', 'strain', - 'struggle', 'swim', 'train', 'upper', @@ -2808,7 +2719,6 @@ const object = [ 'prompt', 'quiet', 'refuse', - 'regret', 'reveal', 'rush', 'shake', @@ -2848,9 +2758,6 @@ const object = [ 'wake', 'wrap', 'yesterday', - 'Thomas', - 'Tom', - 'Lieuwe', ]; /** diff --git a/server/src/utils/parserUtils.js b/server/src/utils/parserUtils.js index 461aae5fa..a66d17916 100644 --- a/server/src/utils/parserUtils.js +++ b/server/src/utils/parserUtils.js @@ -49,3 +49,39 @@ export const validateFile = (file) => { return false; } }; + +/** + * @description Verifies if object is empty + * @param {object} obj + */ +export const isEmptyObject = (obj) => { + if (typeof obj === 'object' && obj !== null && !Array.isArray(obj)) { + return Object.keys(obj).length === 0; + } + throw new Error('Variable is not an object'); +}; + +/** + * @description Merges two objects, suppressing undefined keys + * @param {object} a + * @param {object} b + */ +export const mergeObject = (a, b) => { + const merged = {}; + Object.keys({ ...a, ...b }).map((key) => { + merged[key] = typeof b[key] === 'undefined' ? a[key] : b[key]; + }); + return merged; +}; + +/** + * @description Removes undefined + * @param {object} obj + */ +export const removeUndefined = (obj) => { + const patched = {}; + Object.keys({ ...obj }) + .filter((key) => typeof obj[key] !== 'undefined') + .map((key) => (patched[key] = obj[key])); + return patched; +}; diff --git a/server/src/utils/routerUtils.js b/server/src/utils/routerUtils.js new file mode 100644 index 000000000..b8ba6b147 --- /dev/null +++ b/server/src/utils/routerUtils.js @@ -0,0 +1,39 @@ +import { isEmptyObject } from './parserUtils.js'; + +/** + * @description initial checks for an empty of malformed request object + * @param obj + * @param res + */ +export const failEmptyObjects = (obj, res) => { + let failed = false; + try { + if (isEmptyObject(obj)) { + res.status(400).send('No object found in request'); + failed = true; + } + } catch (error) { + res.status(400).send(error); + failed = true; + } + return failed; +}; + +/** + * @description initial checks for an empty of malformed request object + * @param obj + * @param res + */ +export const failIsNotArray = (obj, res) => { + let failed = false; + try { + if (!Array.isArray(obj)) { + res.status(400).send('No array found in request'); + failed = true; + } + } catch (error) { + res.status(400).send(error); + failed = true; + } + return failed; +}; diff --git a/server/src/validation/ontimeValidator.js b/server/src/validation/ontimeValidator.js deleted file mode 100644 index 55a4dc44e..000000000 --- a/server/src/validation/ontimeValidator.js +++ /dev/null @@ -1,13 +0,0 @@ -import { check, validationResult } from 'express-validator'; - -/** - * @description Validates object for POST /ontime/views - */ -export const viewValidator = [ - check('overrideStyles').isBoolean().withMessage('overrideStyles value must be boolean'), - (req, res, next) => { - const errors = validationResult(req); - if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); - next(); - }, -];