Merge branch 'master' into port-colision

This commit is contained in:
arc-alex
2024-12-16 22:00:23 +01:00
106 changed files with 1352 additions and 1767 deletions
+2 -2
View File
@@ -47,8 +47,8 @@ export class SocketServer implements IAdapter {
this.wss = null;
}
init(server: Server) {
this.wss = new WebSocketServer({ path: '/ws', server, maxPayload: this.MAX_PAYLOAD });
init(server: Server, prefix?: string) {
this.wss = new WebSocketServer({ path: `${prefix}/ws`, server, maxPayload: this.MAX_PAYLOAD });
this.wss.on('connection', (ws) => {
const clientId = generateId();
@@ -19,7 +19,6 @@ import {
deleteEvent,
editEvent,
reorderEvent,
setFrozenState,
swapEvents,
} from '../../services/rundown-service/RundownService.js';
import {
@@ -127,17 +126,6 @@ export async function rundownBatchPut(req: Request, res: Response<MessageRespons
}
}
export async function rundownFrozenPost(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
const { frozen } = req.body;
setFrozenState(frozen);
res.status(200).send({ message: 'Rundown frozen state updated.' });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownReorder(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
@@ -1,9 +0,0 @@
import { eventStore } from '../../stores/EventStore.js';
export const preventIfFrozen = function (req, res, next) {
if (eventStore.get('frozen')) {
res.status(403).send({ message: 'Rundown is frozen' });
} else {
next();
}
};
@@ -5,7 +5,6 @@ import {
rundownApplyDelay,
rundownBatchPut,
rundownDelete,
rundownFrozenPost,
rundownGetAll,
rundownGetById,
rundownGetNormalised,
@@ -19,14 +18,12 @@ import {
paramsMustHaveEventId,
rundownArrayOfIds,
rundownBatchPutValidator,
rundownFrozenPostValidator,
rundownGetPaginatedQueryParams,
rundownPostValidator,
rundownPutValidator,
rundownReorderValidator,
rundownSwapValidator,
} from './rundown.validation.js';
import { preventIfFrozen } from './rundown.middleware.js';
export const router = express.Router();
@@ -36,14 +33,13 @@ router.get('/normalised', rundownGetNormalised);
router.get('/:eventId', paramsMustHaveEventId, rundownGetById); // not used in Ontime frontend
router.post('/', rundownPostValidator, rundownPost);
router.post('/frozen', rundownFrozenPostValidator, rundownFrozenPost);
router.put('/', rundownPutValidator, rundownPut);
router.put('/batch', rundownBatchPutValidator, rundownBatchPut);
router.patch('/reorder/', rundownReorderValidator, preventIfFrozen, rundownReorder);
router.patch('/swap', rundownSwapValidator, preventIfFrozen, rundownSwap);
router.patch('/reorder/', rundownReorderValidator, rundownReorder);
router.patch('/swap', rundownSwapValidator, rundownSwap);
router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay);
router.delete('/', rundownArrayOfIds, preventIfFrozen, deletesEventById);
router.delete('/all', preventIfFrozen, rundownDelete);
router.delete('/', rundownArrayOfIds, deletesEventById);
router.delete('/all', rundownDelete);
@@ -21,15 +21,6 @@ export const rundownPutValidator = [
},
];
export const rundownFrozenPostValidator = [
body('frozen').isBoolean().exists(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
]
export const rundownBatchPutValidator = [
body('data').isObject().exists(),
body('ids').isArray().exists(),
@@ -241,6 +241,11 @@ const actionHandlers: Record<string, ActionHandler> = {
const timeInMs = numberOrError(command.duration) * 1000;
reply.payload = auxTimerService.setTime(timeInMs);
}
if ('addtime' in command) {
// convert addTime in seconds to ms
const timeInMs = numberOrError(command.addtime) * 1000;
reply.payload = auxTimerService.addTime(timeInMs);
}
if ('direction' in command) {
if (command.direction === SimpleDirection.CountUp || command.direction === SimpleDirection.CountDown) {
reply.payload = auxTimerService.setDirection(command.direction);
+38 -28
View File
@@ -6,11 +6,11 @@ import expressStaticGzip from 'express-static-gzip';
import http, { Server } from 'http';
import cors from 'cors';
import serverTiming from 'server-timing';
import { extname, resolve } from 'path';
import { extname } from 'node:path';
// import utils
import { publicDir, srcDir } from './setup/index.js';
import { environment, isProduction, updateRouterPrefix } from './externals.js';
import { publicDir, srcDir, srcFiles } from './setup/index.js';
import { environment, isOntimeCloud, isProduction, updateRouterPrefix } from './externals.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js';
@@ -53,8 +53,14 @@ if (!canLog) {
console.log(`Ontime public directory at ${publicDir.root} `);
}
// calls an update to the client router prefix
updateRouterPrefix();
/**
* When running in Ontime cloud, the client is not at the root segment
* ie: https://cloud.getontime.com/client-hash/timer
* This means:
* - changing the base path in the index.html file
* - prepending all express routes with the given prefix
*/
const prefix = updateRouterPrefix();
// Create express APP
const app = express();
@@ -75,20 +81,20 @@ app.use(express.urlencoded({ extended: true }));
app.use(express.json({ limit: '1mb' }));
// Implement route endpoints
app.use('/data', appRouter); // router for application data
app.use('/api', integrationRouter); // router for integrations
app.use(`${prefix}/data`, appRouter); // router for application data
app.use(`${prefix}/api`, integrationRouter); // router for integrations
// serve static external files
app.use('/external', express.static(publicDir.externalDir));
app.use('/user', express.static(publicDir.userDir));
// if the user reaches to the root, we show a 404
app.use('/external', (req, res) => {
app.use(`${prefix}/external`, express.static(publicDir.externalDir));
app.use(`${prefix}/external`, (req, res) => {
// if the user reaches to the root, we show a 404
res.status(404).send(`${req.originalUrl} not found`);
});
app.use(`${prefix}/user`, express.static(publicDir.userDir));
// serve static - react, in dev/test mode we fetch the React app from module
app.use(
prefix,
expressStaticGzip(srcDir.clientDir, {
enableBrotli: true,
orderPreference: ['br'],
@@ -111,8 +117,8 @@ app.use(
}),
);
app.get('*', (_req, res) => {
res.sendFile(resolve(srcDir.clientDir, 'index.html'));
app.get(`${prefix}/*`, (_req, res) => {
res.sendFile(srcFiles.clientIndexHtml);
});
// Implement catch all
@@ -183,7 +189,7 @@ export const startServer = async (
const portError = resultPort !== desiredPort;
await getDataProvider().setSettings({ ...settings, serverPort: resultPort });
socket.init(expressServer);
socket.init(expressServer, prefix);
/**
* Module initialises the services and provides initial payload for the store
@@ -209,7 +215,6 @@ export const startServer = async (
playback: SimplePlayback.Stop,
direction: SimpleDirection.CountDown,
},
frozen: false,
ping: -1,
});
@@ -228,10 +233,10 @@ export const startServer = async (
runtimeService.init(maybeRestorePoint);
const nif = getNetworkInterfaces();
consoleSuccess(`Local: http://localhost:${resultPort}/editor`);
consoleSuccess(`Local: http://localhost:${resultPort}${prefix}/editor`);
for (const key in nif) {
const address = nif[key].address;
consoleSuccess(`Network: http://${address}:${resultPort}/editor`);
consoleSuccess(`Network: http://${address}:${resultPort}${prefix}/editor`);
}
const returnMessage = `Ontime is listening on port ${resultPort}`;
@@ -253,16 +258,6 @@ export const startIntegrations = async () => {
// if a config is not provided, we use the persisted one
const { osc, http } = getDataProvider().getData();
if (osc) {
logger.info(LogOrigin.Tx, 'Initialising OSC Integration...');
try {
oscIntegration.init(osc);
integrationService.register(oscIntegration);
} catch (error) {
logger.error(LogOrigin.Tx, 'OSC Integration initialisation failed');
}
}
if (http) {
logger.info(LogOrigin.Tx, 'Initialising HTTP Integration...');
try {
@@ -272,6 +267,21 @@ export const startIntegrations = async () => {
logger.error(LogOrigin.Tx, `HTTP Integration initialisation failed: ${error}`);
}
}
if (isOntimeCloud) {
logger.info(LogOrigin.Tx, 'Skipping OSC in Cloud environment...');
return;
}
if (osc) {
logger.info(LogOrigin.Tx, 'Initialising OSC Integration...');
try {
oscIntegration.init(osc);
integrationService.register(oscIntegration);
} catch (error) {
logger.error(LogOrigin.Tx, 'OSC Integration initialisation failed');
}
}
};
/**
@@ -37,6 +37,14 @@ export class SimpleTimer {
return this.state;
}
public addTime(millis: number): SimpleTimerState {
this.state.duration += millis;
// the value of current will be overridden when update is called,
// but if we are in pause or stop state it will not be changed so we do it here
this.state.current += millis;
return this.state;
}
public setDirection(direction: SimpleDirection, timeNow: number): SimpleTimerState {
// if we are playing, we need to reset the targets
if (this.state.playback === SimplePlayback.Start) {
@@ -177,5 +177,60 @@ describe('SimpleTimer count-down', () => {
playback: SimplePlayback.Start,
});
});
test('adding time affects final result', () => {
timer.reset();
timer.setTime(1000);
timer.start(0);
timer.update(100);
expect(timer.state).toMatchObject({ current: 900, duration: 1000 });
timer.addTime(1000);
timer.update(200);
expect(timer.state).toMatchObject({ current: 1800, duration: 2000 });
timer.update(300);
expect(timer.state).toMatchObject({ current: 1700, duration: 2000 });
timer.stop();
expect(timer.state).toMatchObject({ current: 1000, duration: 1000 });
});
test('adding time affects paused timer', () => {
timer.reset();
timer.setTime(1000);
timer.start(0);
timer.update(100);
expect(timer.state).toMatchObject({ current: 900, duration: 1000 });
timer.pause(200);
expect(timer.state).toMatchObject({ current: 900, duration: 1000 });
timer.addTime(1000);
timer.update(200);
expect(timer.state).toMatchObject({ current: 1900, duration: 2000 });
timer.start(300);
expect(timer.state).toMatchObject({ current: 1800, duration: 2000 });
});
test('adding time affects stopped timer, but returns to initial valuses when stopped again', () => {
timer.reset();
timer.setTime(1000);
expect(timer.state).toMatchObject({ current: 1000, duration: 1000 });
timer.addTime(1000);
expect(timer.state).toMatchObject({ current: 2000, duration: 2000 });
timer.start(0);
timer.update(100);
expect(timer.state).toMatchObject({ current: 1900, duration: 2000 });
timer.stop();
expect(timer.state).toMatchObject({ current: 1000, duration: 1000 });
});
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
import { MILLIS_PER_MINUTE } from 'ontime-utils';
export const timerConfig = {
skipLimit: 1000, // threshold of skip for recalculating
skipLimit: 1000, // threshold of skip for recalculating, values lower than updateRate can cause issues with rolling over midnight
updateRate: 32, // how often do we update the timer
notificationRate: 1000, // how often do we notify clients and integrations
triggerAhead: 10, // how far ahead do we trigger the end event
+11 -9
View File
@@ -3,7 +3,8 @@
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { srcFiles } from './setup/index.js';
// =================================================
// resolve running environment
@@ -13,25 +14,26 @@ export const isTest = Boolean(process.env.IS_TEST);
export const environment = isTest ? 'test' : env;
export const isDocker = env === 'docker';
export const isProduction = isDocker || (env === 'production' && !isTest);
export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
/**
* Updates the router prefix in the index.html file
* This is only needed in the cloud environment where the client is not at the root segment
* ie: https://cloud.getontime.com/client-hash/timer
*/
export function updateRouterPrefix(prefix: string | undefined = process.env.ROUTER_PREFIX) {
export function updateRouterPrefix(prefix: string | undefined = process.env.ROUTER_PREFIX): string {
if (!prefix) {
return;
return '';
}
const indexFile = resolve('.', 'client', 'index.html');
try {
const data = readFileSync(indexFile, { encoding: 'utf-8', flag: 'r' }).replace(
/<base href="[^"]*">/g,
`<base href="${prefix}>"`,
const data = readFileSync(srcFiles.clientIndexHtml, { encoding: 'utf-8', flag: 'r' }).replace(
'<base href="/" />',
`<base href="/${prefix}/" />`,
);
writeFileSync(indexFile, data, { encoding: 'utf-8', flag: 'w' });
writeFileSync(srcFiles.clientIndexHtml, data, { encoding: 'utf-8', flag: 'w' });
} catch (_error) {
/** unhandled */
}
return `/${prefix}`;
}
@@ -1,4 +1,4 @@
import { SimpleDirection, SimpleTimerState } from 'ontime-types';
import { SimpleDirection, SimplePlayback, SimpleTimerState } from 'ontime-types';
import { SimpleTimer } from '../../classes/simple-timer/SimpleTimer.js';
import { eventStore } from '../../stores/EventStore.js';
@@ -57,6 +57,15 @@ export class AuxTimerService {
return this.timer.setTime(duration);
}
@broadcastReturn
addTime(millis: number) {
if (this.timer.state.playback === SimplePlayback.Start) {
this.timer.addTime(millis);
return this.timer.update(this.getTime());
}
return this.timer.addTime(millis);
}
@broadcastReturn
private update() {
return this.timer.update(this.getTime());
@@ -128,18 +128,18 @@ export class OscIntegration implements IIntegration<OscSubscription, OSCSettings
}
private shutdownTX() {
logger.info(LogOrigin.Rx, 'Shutting down OSC integration');
if (this.oscServer) {
this.oscServer?.shutdown();
this.oscServer = null;
if (this.oscClient) {
logger.info(LogOrigin.Tx, 'Shutting down OSC integration');
this.oscClient?.close();
this.oscClient = null;
}
}
private shutdownRX() {
logger.info(LogOrigin.Tx, 'Shutting down OSC integration');
if (this.oscClient) {
this.oscClient?.close();
this.oscClient = null;
if (this.oscServer) {
logger.info(LogOrigin.Rx, 'Shutting down OSC integration');
this.oscServer?.shutdown();
this.oscServer = null;
}
}
}
@@ -1,12 +1,13 @@
import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { copyFile, rename } from 'fs/promises';
import { copyFile } from 'fs/promises';
import { logger } from '../../classes/Logger.js';
import { publicDir } from '../../setup/index.js';
import {
appendToName,
dockerSafeRename,
ensureDirectory,
generateUniqueFileName,
getFileNameFromPath,
@@ -93,7 +94,7 @@ async function handleCorruptedFile(filePath: string, fileName: string): Promise<
// and make a new file with the recovered data
const newPath = appendToName(filePath, '(recovered)');
await rename(filePath, newPath);
await dockerSafeRename(filePath, newPath);
return getFileNameFromPath(newPath);
}
@@ -231,7 +232,7 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
}
const pathToRenamed = getPathToProject(newFilename);
await rename(projectFilePath, pathToRenamed);
await dockerSafeRename(projectFilePath, pathToRenamed);
// Update the last loaded project config if current loaded project is the one being renamed
const isLoaded = await isLastLoadedProject(originalFile);
@@ -1,11 +1,16 @@
import { DatabaseModel, MaybeString, ProjectFile } from 'ontime-types';
import { existsSync } from 'fs';
import { copyFile, readFile, rename, stat } from 'fs/promises';
import { copyFile, readFile, stat } from 'fs/promises';
import { extname, join } from 'path';
import { publicDir } from '../../setup/index.js';
import { ensureDirectory, getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js';
import {
dockerSafeRename,
ensureDirectory,
getFilesFromFolder,
removeFileExtension,
} from '../../utils/fileManagement.js';
/**
* Handles the upload of a new project file
@@ -14,13 +19,13 @@ import { ensureDirectory, getFilesFromFolder, removeFileExtension } from '../../
*/
export async function handleUploaded(filePath: string, name: string) {
const newFilePath = join(publicDir.projectsDir, name);
await rename(filePath, newFilePath);
await dockerSafeRename(filePath, newFilePath);
}
export async function handleImageUpload(filePath: string, name: string): Promise<string> {
ensureDirectory(publicDir.logoDir);
const newFilePath = join(publicDir.logoDir, name);
await rename(filePath, newFilePath);
await dockerSafeRename(filePath, newFilePath);
return name;
}
@@ -86,7 +91,7 @@ export async function copyCorruptFile(filePath: string, name: string): Promise<v
*/
export async function moveCorruptFile(filePath: string, name: string): Promise<void> {
const newPath = join(publicDir.corruptDir, name);
return rename(filePath, newPath);
return dockerSafeRename(filePath, newPath);
}
/**
@@ -21,7 +21,6 @@ import { runtimeService } from '../runtime-service/RuntimeService.js';
import * as cache from './rundownCache.js';
import { getPlayableEvents, getTimedEvents } from './rundownUtils.js';
import { eventStore } from '../../stores/EventStore.js';
type PatchWithId = (Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) & { id: string };
@@ -275,7 +274,3 @@ export async function initRundown(rundown: Readonly<OntimeRundown>, customFields
// notify timer of change
notifyChanges({ timer: true, external: true, reload: true });
}
export async function setFrozenState(state: boolean) {
eventStore.set('frozen', state);
}
@@ -99,10 +99,15 @@ class RuntimeService {
});
this.handleLoadNext();
this.rollLoaded(keepOffset);
} else if (skippedOutOfEvent(newState, this.lastIntegrationClockUpdate, timerConfig.skipLimit)) {
} else if (
// if there is no previous clock, we could not have skipped
RuntimeService.previousState?.clock &&
skippedOutOfEvent(newState, RuntimeService.previousState.clock, timerConfig.skipLimit)
) {
// if we have skipped out of the event, we will recall roll
// to push the playback to the right place
// this comes with the caveat that we will lose our runtime data
logger.warning(LogOrigin.Playback, 'Time skip detected, reloading roll');
this.roll(true);
}
}
+2
View File
@@ -77,6 +77,8 @@ export const srcDir = {
} as const;
export const srcFiles = {
/** Path to start index.html */
clientIndexHtml: join(srcDir.clientDir, 'index.html'),
/** Path to bundled CSS */
cssOverride: join(srcDir.root, config.user, config.styles.directory, config.styles.filename),
/** Path to bundled external readme */
@@ -1,33 +0,0 @@
import { cleanURL } from '../url.js';
describe('url is correctly formatted', () => {
it('has no leading spaces', () => {
const test = ' http://testing';
const expected = 'http://testing';
expect(cleanURL(test)).toBe(expected);
});
it('has no trailing spaces', () => {
const test = 'http://testing ';
const expected = 'http://testing';
expect(cleanURL(test)).toBe(expected);
});
it('doesnt contain spaces', () => {
const test = 'http://t e s t i n g';
const expected = 'http://t%20e%20s%20t%20i%20n%20g';
expect(cleanURL(test)).toBe(expected);
});
it('only contains allowed characters', () => {
const test = 'http://<>[]{}|^';
const expected = 'http://';
expect(cleanURL(test)).toBe(expected);
});
it('begins with http://', () => {
const test = 'ontime.com';
const expected = 'http://ontime.com';
expect(cleanURL(test)).toBe(expected);
});
});
+11 -2
View File
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync } from 'fs';
import { readdir, copyFile } from 'fs/promises';
import { existsSync, mkdirSync, PathLike } from 'fs';
import { readdir, copyFile, unlink } from 'fs/promises';
import { basename, extname, join, parse } from 'path';
/**
@@ -105,3 +105,12 @@ export async function copyDirectory(src: string, dest: string) {
}
}
}
/**
* workaround avoids origin errors in docker deployments
* EXDEV cross-device link not permitted
*/
export async function dockerSafeRename(oldPath: PathLike, newPath: PathLike) {
await copyFile(oldPath, newPath);
await unlink(oldPath);
}
-20
View File
@@ -1,20 +0,0 @@
/**
* @description Cleans given url
* @param {string} url - URL to be checked
* @returns {string} Sanitized url
*/
export const cleanURL = (url: string): string => {
// trim whitespaces
let sanitised = url.trim();
// clear any whitespaces
sanitised = sanitised.split(' ').join('%20');
// contain only allowed characters
sanitised = sanitised.replace(/([@\s<>[\]{}|\\^])+/g, '');
// starts with http://
if (!sanitised.startsWith('http://')) sanitised = `http://${sanitised}`;
return sanitised;
};