diff --git a/apps/server/package.json b/apps/server/package.json index cf27f361e..6f50b0606 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -26,6 +26,7 @@ "xlsx": "^0.18.5" }, "devDependencies": { + "@types/cookie-parser": "^1.4.8", "@types/cors": "^2.8.17", "@types/express": "^4.17.17", "@types/multer": "^1.4.11", diff --git a/apps/server/src/adapters/WebsocketAdapter.ts b/apps/server/src/adapters/WebsocketAdapter.ts index 849c0884a..fd8a91e1d 100644 --- a/apps/server/src/adapters/WebsocketAdapter.ts +++ b/apps/server/src/adapters/WebsocketAdapter.ts @@ -102,7 +102,7 @@ class SocketServer implements IAdapter { ws.on('message', (data) => { try { - // @ts-expect-error -- ?? + // @ts-expect-error -- this works fine const message = JSON.parse(data); const { type, payload } = message; @@ -120,7 +120,7 @@ class SocketServer implements IAdapter { ws.send( JSON.stringify({ type: 'client-name', - payload: this.clients.get(clientId).name, + payload: this.getOrCreateClient(clientId), }), ); return; @@ -136,7 +136,7 @@ class SocketServer implements IAdapter { if (type === 'set-client-type') { if (payload && typeof payload == 'string') { - const previousData = this.clients.get(clientId); + const previousData = this.getOrCreateClient(clientId); this.clients.set(clientId, { ...previousData, type: payload }); } this.sendClientList(); @@ -145,7 +145,7 @@ class SocketServer implements IAdapter { if (type === 'set-client-path') { if (payload && typeof payload == 'string') { - const previousData = this.clients.get(clientId); + const previousData = this.getOrCreateClient(clientId); previousData.path = payload; this.clients.set(clientId, previousData); @@ -166,13 +166,13 @@ class SocketServer implements IAdapter { if (type === 'set-client-name') { if (payload) { - const previousData = this.clients.get(clientId); + const previousData = this.getOrCreateClient(clientId); logger.info(LogOrigin.Client, `Client ${previousData.name} renamed to ${payload}`); this.clients.set(clientId, { ...previousData, name: payload }); ws.send( JSON.stringify({ type: 'client-name', - payload: this.clients.get(clientId).name, + payload: this.getOrCreateClient(clientId).name, }), ); } @@ -215,6 +215,19 @@ class SocketServer implements IAdapter { }; } + private getOrCreateClient(clientId: string): Client { + if (!this.clients.has(clientId)) { + this.clients.set(clientId, { + type: 'unknown', + identify: false, + name: getRandomName(), + origin: '', + path: '', + }); + } + return this.clients.get(clientId) as Client; + } + private sendClientList(): void { const payload = Object.fromEntries(this.clients.entries()); this.sendAsJson({ type: 'client-list', payload }); diff --git a/apps/server/src/adapters/utils/parse.ts b/apps/server/src/adapters/utils/parse.ts index 6bf40aae0..5e278f46c 100644 --- a/apps/server/src/adapters/utils/parse.ts +++ b/apps/server/src/adapters/utils/parse.ts @@ -4,7 +4,7 @@ * @param {string} value - value to assign * @returns {object | string | null} nested object or null if no object was created */ -export const integrationPayloadFromPath = (path: string[], value?: unknown): object | string | null => { +export function integrationPayloadFromPath(path: string[], value?: unknown): object | string | null { if (path.length === 1) { const key = path[0]; return value === undefined ? key : { [key]: value }; @@ -16,4 +16,4 @@ export const integrationPayloadFromPath = (path: string[], value?: unknown): obj const obj = shortenedPath.reduceRight((result, key) => ({ [key]: result }), parsedValue); return typeof obj === 'object' ? obj : null; -}; +} diff --git a/apps/server/src/api-data/db/db.controller.ts b/apps/server/src/api-data/db/db.controller.ts index 8601000ce..a56370628 100644 --- a/apps/server/src/api-data/db/db.controller.ts +++ b/apps/server/src/api-data/db/db.controller.ts @@ -91,7 +91,7 @@ export async function quickProjectFile(req: Request, res: Response<{ filename: s */ export async function currentProjectDownload(_req: Request, res: Response) { const { filename, pathToFile } = await projectService.getCurrentProject(); - res.download(pathToFile, filename, (error) => { + res.download(pathToFile, filename, (error: Error | null) => { if (error) { const message = getErrorMessage(error); res.status(500).send({ message }); diff --git a/apps/server/src/api-data/excel/excel.controller.ts b/apps/server/src/api-data/excel/excel.controller.ts index 4d1436fe4..16d2e5072 100644 --- a/apps/server/src/api-data/excel/excel.controller.ts +++ b/apps/server/src/api-data/excel/excel.controller.ts @@ -9,7 +9,8 @@ import { CustomFields, Rundown } from 'ontime-types'; export async function postExcel(req: Request, res: Response) { try { - const filePath = req.file.path; + // file has been validated by middleware + const filePath = (req.file as Express.Multer.File).path; await saveExcelFile(filePath); res.status(200).send(); } catch (error) { diff --git a/apps/server/src/api-data/report/report.service.ts b/apps/server/src/api-data/report/report.service.ts index 8cdd75f55..c453d9509 100644 --- a/apps/server/src/api-data/report/report.service.ts +++ b/apps/server/src/api-data/report/report.service.ts @@ -60,6 +60,5 @@ export function triggerReportEntry( sendRefetch({ target: 'REPORT', }); - return; } } diff --git a/apps/server/src/api-data/session/session.service.ts b/apps/server/src/api-data/session/session.service.ts index 168121fc9..dde25cc21 100644 --- a/apps/server/src/api-data/session/session.service.ts +++ b/apps/server/src/api-data/session/session.service.ts @@ -4,7 +4,7 @@ import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { publicDir } from '../../setup/index.js'; import { socket } from '../../adapters/WebsocketAdapter.js'; import { getLastRequest } from '../../api-integration/integration.controller.js'; -import { getLastLoadedProject } from '../../services/app-state-service/AppStateService.js'; +import { getCurrentProject } from '../../services/project-service/ProjectService.js'; import { runtimeService } from '../../services/runtime-service/RuntimeService.js'; import { getNetworkInterfaces } from '../../utils/network.js'; import { getTimezoneLabel } from '../../utils/time.js'; @@ -17,7 +17,7 @@ const startedAt = new Date(); export async function getSessionStats(): Promise { const { connectedClients, lastConnection } = socket.getStats(); const lastRequest = getLastRequest(); - const projectName = await getLastLoadedProject(); + const { filename } = await getCurrentProject(); const { playback } = runtimeService.getRuntimeState(); return { @@ -25,7 +25,7 @@ export async function getSessionStats(): Promise { connectedClients, lastConnection: lastConnection !== null ? lastConnection.toISOString() : null, lastRequest: lastRequest !== null ? lastRequest.toISOString() : null, - projectName, + projectName: filename, playback, timezone: getTimezoneLabel(startedAt), }; diff --git a/apps/server/src/api-data/sheets/sheets.controller.ts b/apps/server/src/api-data/sheets/sheets.controller.ts index b82935b51..59a86636e 100644 --- a/apps/server/src/api-data/sheets/sheets.controller.ts +++ b/apps/server/src/api-data/sheets/sheets.controller.ts @@ -25,10 +25,11 @@ export async function requestConnection( res: Response<{ verification_url: string; user_code: string } | ErrorResponse>, ) { const { sheetId } = req.params; - const file = req.file.path; + // the check for the file is done in the validation middleware + const filePath = (req.file as Express.Multer.File).path; try { - const client = readFileSync(file, 'utf-8'); + const client = readFileSync(filePath, 'utf-8'); const clientSecret = handleClientSecret(client); const { verification_url, user_code } = await handleInitialConnection(clientSecret, sheetId); @@ -40,7 +41,7 @@ export async function requestConnection( // delete uploaded file after parsing try { - deleteFile(file); + await deleteFile(filePath); } catch (_error) { /** we dont handle failure here */ } diff --git a/apps/server/src/api-data/sheets/sheets.validation.ts b/apps/server/src/api-data/sheets/sheets.validation.ts index 3f15b0224..d1016efca 100644 --- a/apps/server/src/api-data/sheets/sheets.validation.ts +++ b/apps/server/src/api-data/sheets/sheets.validation.ts @@ -16,6 +16,10 @@ export const validateRequestConnection = [ (req: Request, res: Response, next: NextFunction) => { const errors = validationResult(req); if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + // check that the file exists + if (!req.file) { + return res.status(422).json({ errors: 'File not found' }); + } next(); }, ]; diff --git a/apps/server/src/api-data/url-presets/urlPresets.controller.ts b/apps/server/src/api-data/url-presets/urlPresets.controller.ts index c07c24b3c..fb763c29a 100644 --- a/apps/server/src/api-data/url-presets/urlPresets.controller.ts +++ b/apps/server/src/api-data/url-presets/urlPresets.controller.ts @@ -16,7 +16,7 @@ export async function postUrlPresets(req: Request, res: Response ({ + const newPresets: URLPreset[] = req.body.map((preset: URLPreset) => ({ enabled: preset.enabled, alias: preset.alias, pathAndParams: preset.pathAndParams, diff --git a/apps/server/src/api-integration/integration.router.ts b/apps/server/src/api-integration/integration.router.ts index c652d49a2..a403794bb 100644 --- a/apps/server/src/api-integration/integration.router.ts +++ b/apps/server/src/api-integration/integration.router.ts @@ -36,8 +36,9 @@ integrationRouter.get('/*', (req: Request, res: Response) => { try { const actionArray = action.split('/'); const query = isEmptyObject(req.query) ? undefined : (req.query as object); - let payload = {}; + let payload: unknown = {}; if (actionArray.length > 1) { + // @ts-expect-error -- we decide to give up on typing here action = actionArray.shift(); payload = integrationPayloadFromPath(actionArray, query); } else { diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 700498ee0..ae167c41f 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -44,7 +44,7 @@ import { oscServer } from './adapters/OscAdapter.js'; // Utilities import { clearUploadfolder } from './utils/upload.js'; import { generateCrashReport } from './utils/generateCrashReport.js'; -import { timerConfig } from './config/config.js'; +import { timerConfig } from './setup/config.js'; import { serverTryDesiredPort, getNetworkInterfaces } from './utils/network.js'; console.log('\n'); diff --git a/apps/server/src/classes/simple-timer/SimpleTimer.ts b/apps/server/src/classes/simple-timer/SimpleTimer.ts index 8ae23cb46..15df69b69 100644 --- a/apps/server/src/classes/simple-timer/SimpleTimer.ts +++ b/apps/server/src/classes/simple-timer/SimpleTimer.ts @@ -86,6 +86,12 @@ export class SimpleTimer { public update(timeNow: number): SimpleTimerState { if (this.state.playback === SimplePlayback.Start) { // we know startedAt is not null since we are in play mode + // eslint-disable-next-line no-unused-labels -- dev code path + DEV: { + if (this.startedAt === null) { + throw new Error('SimpleTimer.update: invalid state received'); + } + } const elapsed = timeNow - this.startedAt; if (this.state.direction === SimpleDirection.CountDown) { this.state.current = this.state.duration - elapsed; diff --git a/apps/server/src/config/config.ts b/apps/server/src/config/config.ts deleted file mode 100644 index 80778d468..000000000 --- a/apps/server/src/config/config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { MILLIS_PER_MINUTE } from 'ontime-utils'; - -export const timerConfig = { - 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 - auxTimerDefault: 5 * MILLIS_PER_MINUTE, // default aux timer duration -}; diff --git a/apps/server/src/middleware/authenticate.ts b/apps/server/src/middleware/authenticate.ts index 30e81012f..c070a607d 100644 --- a/apps/server/src/middleware/authenticate.ts +++ b/apps/server/src/middleware/authenticate.ts @@ -81,7 +81,9 @@ export function makeAuthenticateMiddleware(prefix: string) { // we use query params for generating authenticated URLs and for clients like the companion module // if the user gives is a token in the query params, we set the cookie to be used in further requests if (req.query.token === hashedPassword) { - setSessionCookie(res, hashedPassword); + if (hashedPassword !== undefined) { + setSessionCookie(res, hashedPassword); + } return next(); } diff --git a/apps/server/src/services/EventTimer.ts b/apps/server/src/services/EventTimer.ts index e40aa67a6..98eac3107 100644 --- a/apps/server/src/services/EventTimer.ts +++ b/apps/server/src/services/EventTimer.ts @@ -1,11 +1,11 @@ +import { timerConfig } from '../setup/config.js'; import * as runtimeState from '../stores/runtimeState.js'; import type { UpdateResult } from '../stores/runtimeState.js'; -import { timerConfig } from '../config/config.js'; type UpdateCallbackFn = (updateResult: UpdateResult) => void; /** - * Service manages Ontime's main timer + * Manages Ontime's main timer */ export class EventTimer { private readonly _interval: NodeJS.Timeout; @@ -43,6 +43,14 @@ export class EventTimer { } const state = runtimeState.getState(); + + // eslint-disable-next-line no-unused-labels -- dev code path + DEV: { + if (state.timer.current === null) { + throw new Error('EventTimer.start: invalid state received'); + } + } + const endTime = state.timer.current - timerConfig.triggerAhead; this.endCallback = setTimeout(() => this.update(), endTime); return true; diff --git a/apps/server/src/services/app-state-service/AppStateService.ts b/apps/server/src/services/app-state-service/AppStateService.ts index eff624979..2c565b5e9 100644 --- a/apps/server/src/services/app-state-service/AppStateService.ts +++ b/apps/server/src/services/app-state-service/AppStateService.ts @@ -45,8 +45,6 @@ export async function getShowWelcomeDialog(): Promise { } export async function setShowWelcomeDialog(show: boolean): Promise { - if (isTest) return; - config.data.showWelcomeDialog = show; await config.write(); return show; diff --git a/apps/server/src/services/aux-timer-service/AuxTimerService.ts b/apps/server/src/services/aux-timer-service/AuxTimerService.ts index 7b04ae4f7..7e8fe90cb 100644 --- a/apps/server/src/services/aux-timer-service/AuxTimerService.ts +++ b/apps/server/src/services/aux-timer-service/AuxTimerService.ts @@ -2,7 +2,7 @@ import { SimpleDirection, SimplePlayback, SimpleTimerState } from 'ontime-types' import { SimpleTimer } from '../../classes/simple-timer/SimpleTimer.js'; import { eventStore } from '../../stores/EventStore.js'; -import { timerConfig } from '../../config/config.js'; +import { timerConfig } from '../../setup/config.js'; type EmitFn = (state: SimpleTimerState) => void; type GetTimeFn = () => number; @@ -77,7 +77,8 @@ function broadcastReturn(_target: any, _propertyKey: string, descriptor: Propert descriptor.value = function (...args: any[]) { const result = originalMethod.apply(this, args); - this.emit(result); + // @ts-expect-error -- we can access private properties from the decorator + (this as AuxTimerService).emit(result); return result; }; diff --git a/apps/server/src/services/message-service/MessageService.ts b/apps/server/src/services/message-service/MessageService.ts index eec0c3266..c943cb92b 100644 --- a/apps/server/src/services/message-service/MessageService.ts +++ b/apps/server/src/services/message-service/MessageService.ts @@ -31,7 +31,8 @@ export function clear() { * Exposes the internal state of the message service */ export function getState(): MessageState { - return storeGet('message'); + // we know this exists at runtime + return storeGet('message') as MessageState; } /** diff --git a/apps/server/src/services/message-service/__tests__/MessageService.test.ts b/apps/server/src/services/message-service/__tests__/MessageService.test.ts index 0ef7ef153..e37fa6910 100644 --- a/apps/server/src/services/message-service/__tests__/MessageService.test.ts +++ b/apps/server/src/services/message-service/__tests__/MessageService.test.ts @@ -1,12 +1,16 @@ +import { RuntimeStore } from 'ontime-types'; + import * as messageService from '../MessageService.js'; describe('MessageService', () => { + let store: Partial; beforeEach(() => { // at runtime, the store is instantiated before the message service - const store = {}; - const storeSetter = (key, value) => (store[key] = value); - const storeGetter = (key) => store[key]; - messageService.init(storeSetter, storeGetter); + store = {}; + messageService.init( + (key, value) => (store[key] = value), + (key) => store[key], + ); messageService.clear(); }); diff --git a/apps/server/src/services/project-service/ProjectService.ts b/apps/server/src/services/project-service/ProjectService.ts index 36b2217c3..56ad536a9 100644 --- a/apps/server/src/services/project-service/ProjectService.ts +++ b/apps/server/src/services/project-service/ProjectService.ts @@ -42,6 +42,21 @@ import { } from './projectServiceUtils.js'; import { getFirstRundown } from '../rundown-service/rundownUtils.js'; +type ProjectState = + | { + status: 'PENDING'; + currentProjectName: undefined; + } + | { + status: 'INITIALIZED'; + currentProjectName: string; + }; + +let currentProjectState: ProjectState = { + status: 'PENDING', + currentProjectName: undefined, +}; + // init dependencies init(); @@ -53,11 +68,17 @@ function init() { ensureDirectory(publicDir.corruptDir); } -export async function getCurrentProject() { - const filename = await getLastLoadedProject(); - const pathToFile = getPathToProject(filename); +export async function getCurrentProject(): Promise<{ filename: string; pathToFile: string }> { + if (currentProjectState.status === 'PENDING') { + const lastLoadedProject = await initialiseProject(); + currentProjectState = { + status: 'INITIALIZED', + currentProjectName: lastLoadedProject, + }; + } + const pathToFile = getPathToProject(currentProjectState.currentProjectName); - return { filename, pathToFile }; + return { filename: currentProjectState.currentProjectName, pathToFile }; } /** @@ -276,6 +297,9 @@ export async function createProject(filename: string, initialData: Partial ({ * controller depend on these to send the right responses */ describe('deleteProjectFile', () => { - it('throws an error if trying to delete the currently loaded project', async () => { - (isLastLoadedProject as Mock).mockResolvedValue(true); - await expect(deleteProjectFile('loadedProject')).rejects.toThrow('Cannot delete currently loaded project'); - }); - it('throws an error if the project file does not exist', async () => { (isLastLoadedProject as Mock).mockResolvedValue(false); (doesProjectExist as Mock).mockReturnValue(null); diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index 2883f260a..58153b221 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -18,10 +18,10 @@ import { deepEqual } from 'fast-equals'; import { logger } from '../../classes/Logger.js'; import * as runtimeState from '../../stores/runtimeState.js'; import type { RuntimeState } from '../../stores/runtimeState.js'; -import { timerConfig } from '../../config/config.js'; import { eventStore } from '../../stores/EventStore.js'; - import { triggerReportEntry } from '../../api-data/report/report.service.js'; +import { timerConfig } from '../../setup/config.js'; +import { triggerAutomations } from '../../api-data/automation/automation.service.js'; import { EventTimer } from '../EventTimer.js'; import { RestorePoint, restoreService } from '../RestoreService.js'; @@ -35,11 +35,10 @@ import { getTimedEvents, getRundownData, } from '../rundown-service/rundownUtils.js'; +import { skippedOutOfEvent } from '../timerUtils.js'; +import { getEventOrder } from '../rundown-service/rundownCache.js'; import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './rundownService.utils.js'; -import { skippedOutOfEvent } from '../timerUtils.js'; -import { triggerAutomations } from '../../api-data/automation/automation.service.js'; -import { getEventOrder } from '../rundown-service/rundownCache.js'; type RuntimeStateEventKeys = keyof Pick; @@ -48,7 +47,7 @@ type RuntimeStateEventKeys = keyof Pick { /** * Parses and validates a client secret string - * @param clientSecret - * @returns */ export function handleClientSecret(clientSecret: string): ClientSecret { const clientSecretObject = JSON.parse(clientSecret); - try { - validateClientSecret(clientSecretObject); - } catch (error) { - throw new Error(`Client secret is invalid: ${error}`); + if (!isClientSecret(clientSecretObject)) { + throw new Error('Client secret is invalid'); } return clientSecretObject; @@ -94,21 +91,26 @@ type CodesResponse = { }; /** - * Establishes connection with Google Auth server - * and retrieves device codes - * @param clientSecret - * @returns + * Establishes connection with Google Auth server and retrieves device codes */ async function getDeviceCodes(clientSecret: ClientSecret): Promise { - const deviceCodes: CodesResponse = await got - .post(codesUrl, { - json: { - client_id: clientSecret.installed.client_id, - scope: sheetScope, - }, - }) - .json(); + const response = await fetch(codesUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: clientSecret.installed.client_id, + scope: sheetScope, + }), + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to fetch device codes: ${response.status} ${response.statusText} - ${errorText}`); + } + + const deviceCodes: CodesResponse = await response.json(); return deviceCodes; } @@ -186,6 +188,9 @@ function verifyConnection( } export function hasAuth(): { authenticated: AuthenticationStatus; sheetId: string } { + if (!currentSheetId) { + throw new Error('No sheet ID'); + } if (cleanupTimeout) { return { authenticated: 'pending', sheetId: currentSheetId }; } @@ -196,22 +201,28 @@ async function verifySheet( sheetId = currentSheetId, authClient = currentAuthClient, ): Promise<{ worksheetOptions: string[] }> { + if (!sheetId || !authClient) { + throw new Error('Missing sheet ID or authentication'); + } + try { const spreadsheets = await sheets({ version: 'v4', auth: authClient }).spreadsheets.get({ spreadsheetId: sheetId, includeGridData: false, }); - return { worksheetOptions: spreadsheets.data.sheets.map((i) => i.properties.title) }; + const worksheets = spreadsheets.data.sheets?.forEach((sheet) => { + if (sheet.properties?.title) { + return sheet.properties.title; + } + }); + + if (!worksheets) { + throw new Error('No worksheets found'); + } + return worksheets; } catch (error) { // attempt to catch errors caused by importing xlsx - if ( - error.code === 400 && - Array.isArray(error.errors) && - error.errors[0].reason === 'failedPrecondition' && - error.errors[0].message === 'This operation is not supported for this document' - ) { - throw new Error('Cannot read the linked file as a Google Sheet. It may be an .xlsx file instead.'); - } + catchCommonImportXlsxError(error); const errorMessage = getErrorMessage(error); throw new Error(`Failed to verify sheet: ${errorMessage}`); } @@ -227,6 +238,9 @@ export async function handleInitialConnection( // we know there is an ongoing process if there is a timeout for cleanup // if there is an ongoing process, we return its data if (cleanupTimeout) { + if (!currentAuthUrl || !currentAuthCode) { + throw new Error('No ongoing connection'); + } return { verification_url: currentAuthUrl, user_code: currentAuthCode }; } @@ -255,6 +269,10 @@ export async function getWorksheetOptions(sheetId: string): ReturnType { + if (!currentAuthClient) { + throw new Error('Not authenticated'); + } + const spreadsheets = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.get({ spreadsheetId: sheetId, }); @@ -263,22 +281,33 @@ async function verifyWorksheet(sheetId: string, worksheet: string): Promise<{ wo throw new Error(`Request failed: ${spreadsheets.status} ${spreadsheets.statusText}`); } + if (!spreadsheets.data.sheets) { + throw new Error('No worksheets found'); + } + const selectedWorksheet = spreadsheets.data.sheets.find( - (n) => n.properties.title.toLowerCase() === worksheet.toLowerCase(), + (sheet) => sheet.properties?.title && sheet.properties.title.toLowerCase() === worksheet.toLowerCase(), ); if (!selectedWorksheet) { throw new Error('Could not find worksheet'); } + if (!selectedWorksheet.properties || !selectedWorksheet.properties.sheetId) { + throw new Error('Got invalid data from worksheet'); + } const endCell = getA1Notation( - selectedWorksheet.properties.gridProperties.rowCount, - selectedWorksheet.properties.gridProperties.columnCount, + selectedWorksheet.properties?.gridProperties?.rowCount ?? -1, + selectedWorksheet.properties?.gridProperties?.columnCount ?? -1, ); return { worksheetId: selectedWorksheet.properties.sheetId, range: `${worksheet}!A1:${endCell}` }; } export async function upload(sheetId: string, options: ImportMap) { + if (!currentAuthClient) { + throw new Error('Not authenticated'); + } + const { worksheetId, range } = await verifyWorksheet(sheetId, options.worksheet); const readResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.values.get({ @@ -288,7 +317,7 @@ export async function upload(sheetId: string, options: ImportMap) { range, }); - if (readResponse.status !== 200) { + if (readResponse.status !== 200 || !readResponse.data.values) { throw new Error(`Sheet read failed: ${readResponse.statusText}`); } @@ -357,6 +386,10 @@ export async function download( rundown: Rundown; customFields: CustomFields; }> { + if (!currentAuthClient) { + throw new Error('Not authenticated'); + } + const { range } = await verifyWorksheet(sheetId, options.worksheet); const googleResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.values.get({ @@ -370,6 +403,10 @@ export async function download( throw new Error(`Sheet read failed: ${googleResponse.statusText}`); } + if (!googleResponse.data.values) { + throw new Error('Sheet: No data found in the worksheet'); + } + const dataFromSheet = parseExcel(googleResponse.data.values, getCustomFields(), 'Rundown', options); const rundownId = dataFromSheet.rundown.id; diff --git a/apps/server/src/services/sheet-service/googleApi.utils.ts b/apps/server/src/services/sheet-service/googleApi.utils.ts new file mode 100644 index 000000000..f4d3ff846 --- /dev/null +++ b/apps/server/src/services/sheet-service/googleApi.utils.ts @@ -0,0 +1,39 @@ +// https://developers.google.com/calendar/api/guides/errors +interface GoogleApiError { + code: number; + message: string; + errors?: { + message: string; + domain: string; + reason: string; + }[]; +} + +/** + * Checks whether an error is a Google API error + */ +function isGoogleApiError(error: any): error is GoogleApiError { + return ( + typeof error === 'object' && + error !== null && + typeof error.code === 'number' && + Array.isArray(error.errors) && + typeof error.errors[0]?.reason === 'string' && + typeof error.errors[0]?.message === 'string' + ); +} + +/** + * Extract utility to handle a common error where a user imports an xlsx file instead of a Google Sheet + */ +export function catchCommonImportXlsxError(error: any) { + if ( + isGoogleApiError(error) && + error.code === 400 && + Array.isArray(error.errors) && + error.errors[0].reason === 'failedPrecondition' && + error.errors[0].message === 'This operation is not supported for this document' + ) { + throw new Error('Cannot read the linked file as a Google Sheet. It may be an .xlsx file instead.'); + } +} diff --git a/apps/server/src/services/sheet-service/sheetUtils.ts b/apps/server/src/services/sheet-service/sheetUtils.ts index e796318a5..f6fb59e30 100644 --- a/apps/server/src/services/sheet-service/sheetUtils.ts +++ b/apps/server/src/services/sheet-service/sheetUtils.ts @@ -2,17 +2,7 @@ import { isOntimeBlock, isOntimeEvent, OntimeEvent, OntimeEntry } from 'ontime-t import { millisToString } from 'ontime-utils'; import type { sheets_v4 } from '@googleapis/sheets'; -import { isObject } from '../../utils/assert.js'; - -// we expect client secret file to contain the following keys -const requiredClientKeys = [ - 'client_id', - 'auth_uri', - 'token_uri', - 'token_uri', - 'auth_provider_x509_cert_url', - 'client_secret', -]; +import { is } from '../../utils/is.js'; export type ClientSecret = { installed: { @@ -29,19 +19,25 @@ export type ClientSecret = { * @param clientSecret * @throws */ -export function validateClientSecret(clientSecret: object): clientSecret is ClientSecret { +export function isClientSecret(clientSecret: object): clientSecret is ClientSecret { if (!('installed' in clientSecret)) { - throw new Error('Missing "installed" object'); + return false; } const { installed } = clientSecret; - isObject(installed); - - if (requiredClientKeys.every((key) => Object.keys(installed).includes(key))) { - return; + if (!is.object(installed)) { + return false; } - throw new Error('Missing keys in "installed" object'); + // we expect client secret file to contain the following keys + return is.objectWithKeys(installed, [ + 'client_id', + 'auth_uri', + 'token_uri', + 'token_uri', + 'auth_provider_x509_cert_url', + 'client_secret', + ]); } /** diff --git a/apps/server/src/setup/config.ts b/apps/server/src/setup/config.ts index 2d5384260..b5583708d 100644 --- a/apps/server/src/setup/config.ts +++ b/apps/server/src/setup/config.ts @@ -1,3 +1,13 @@ +import { MILLIS_PER_MINUTE } from 'ontime-utils'; + +export const timerConfig = { + 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 + auxTimerDefault: 5 * MILLIS_PER_MINUTE, // default aux timer duration +}; + export const config = { appState: 'app-state.json', corrupt: 'corrupt files', diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index a095a71a9..54ab4d17c 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -25,9 +25,9 @@ import { getRuntimeOffset, getTimerPhase, } from '../services/timerUtils.js'; -import { timerConfig } from '../config/config.js'; import { loadRoll, normaliseRollStart } from '../services/rollUtils.js'; import { filterTimedEvents } from '../services/rundown-service/rundownUtils.js'; +import { timerConfig } from '../setup/config.js'; export type RuntimeState = { clock: number; // realtime clock @@ -141,10 +141,12 @@ export function clearState() { function patchTimer(newState: Partial) { for (const key in newState) { if (key in runtimeState.timer) { + // @ts-expect-error -- not sure how to type this in a sane way runtimeState.timer[key] = newState[key]; } else if (key in runtimeState._timer) { // in case of a RestorePoint we will receive a pausedAt value - // wiche is needed to resume a paused timer + // which is needed to resume a paused timer + // @ts-expect-error -- not sure how to type this in a sane way runtimeState._timer[key] = newState[key]; } } @@ -423,6 +425,15 @@ export function start(state: RuntimeState = runtimeState): boolean { const { absoluteOffset, relativeOffset } = getRuntimeOffset(runtimeState); runtimeState.runtime.offset = absoluteOffset; runtimeState.runtime.relativeOffset = relativeOffset; + + // as long as there is a timer, we need an planned end + // eslint-disable-next-line no-unused-labels -- dev code path + DEV: { + if (state.runtime.plannedEnd === null) { + throw new Error('runtimeState.start: invalid state received'); + } + } + state.runtime.expectedEnd = state.runtime.plannedEnd - state.runtime.offset; return true; } diff --git a/apps/server/src/utils/__tests__/assert.test.ts b/apps/server/src/utils/__tests__/assert.test.ts new file mode 100644 index 000000000..5d7db5a03 --- /dev/null +++ b/apps/server/src/utils/__tests__/assert.test.ts @@ -0,0 +1,34 @@ +import { hasKeys, isArray, isDefined, isNumber, isObject, isString } from '../assert.js'; + +describe('assert utilities', () => { + it('should assert strings', () => { + expect(() => isString('hello')).not.toThrow(); + expect(() => isString(123)).toThrow('Unexpected payload type: 123'); + }); + + it('should assert numbers', () => { + expect(() => isNumber(123)).not.toThrow(); + expect(() => isNumber('123')).toThrow('Unexpected payload type: 123'); + }); + + it('should assert defined values', () => { + expect(() => isDefined('value')).not.toThrow(); + expect(() => isDefined(undefined)).toThrow('Payload not found'); + }); + + it('should assert objects', () => { + expect(() => isObject({})).not.toThrow(); + expect(() => isObject(null)).toThrow('Unexpected payload type: null'); + expect(() => isObject([])).toThrow('Unexpected payload type: '); + }); + + it('should assert objects with specific keys', () => { + expect(() => hasKeys({ a: 1, b: 2 }, ['a', 'b'])).not.toThrow(); + expect(() => hasKeys({ a: 1 }, ['a', 'b'])).toThrow('Unexpected payload type: [object Object]'); + }); + + it('should assert arrays', () => { + expect(() => isArray([1, 2, 3])).not.toThrow(); + expect(() => isArray('not an array')).toThrow('Unexpected payload type: not an array'); + }); +}); diff --git a/apps/server/src/utils/__tests__/parser.test.ts b/apps/server/src/utils/__tests__/parser.test.ts index 8b6d77973..3c619ec03 100644 --- a/apps/server/src/utils/__tests__/parser.test.ts +++ b/apps/server/src/utils/__tests__/parser.test.ts @@ -31,15 +31,16 @@ describe('test parseDatabaseModel() with demo project (valid)', () => { const filteredDemoProject = structuredClone(demoDb); const { data } = parseDatabaseModel(filteredDemoProject); - delete filteredDemoProject.settings.version; - delete data.settings.version; - it('has 16 events', () => { expect(data.rundowns.demo.order.length).toBe(16); expect(Object.keys(data.rundowns.demo.entries).length).toBe(16); }); it('is the same as the demo project since all data is valid', () => { + // @ts-expect-error -- its ok + delete filteredDemoProject.settings.version; + // @ts-expect-error -- its ok + delete data.settings.version; expect(data).toMatchObject(filteredDemoProject); }); }); diff --git a/apps/server/src/utils/__tests__/parserUtils.test.ts b/apps/server/src/utils/__tests__/parserUtils.test.ts index d5d1430d4..d9d414181 100644 --- a/apps/server/src/utils/__tests__/parserUtils.test.ts +++ b/apps/server/src/utils/__tests__/parserUtils.test.ts @@ -1,4 +1,4 @@ -import { isEmptyObject, mergeObject, removeUndefined } from '../parserUtils.js'; +import { isEmptyObject, removeUndefined } from '../parserUtils.js'; describe('isEmptyObject()', () => { test('finds an empty object', () => { @@ -11,92 +11,6 @@ describe('isEmptyObject()', () => { }); }); -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' as string | null, - third: 'yes', - }; - const b = { - first: 'no', - second: null, - third: '', - }; - const merged = mergeObject(a, b); - expect(merged).toStrictEqual({ - first: 'no', - second: null, - third: '', - }); - }); - test('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', - }; - // @ts-expect-error -- testing changing type - const merged = mergeObject(a, b); - expect(merged).toStrictEqual({ - first: 0, - second: null, - third: '', - }); - }); - test('merges nested objects', () => { - // Define a sample object with nested properties - const a = { - name: 'John', - address: { - city: 'New York', - postalCode: '10001', - }, - }; - - // Define a partial object with nested properties for merging - const b = { - name: 'Doe', - address: { - city: 'San Francisco', - state: 'CA', - }, - }; - - // @ts-expect-error -- testing missing property - const merged = mergeObject(a, b); - - expect(merged.name).toBe('Doe'); - expect(merged.address.city).toBe('San Francisco'); - // @ts-expect-error -- its ok, just checking - expect(merged.address.state).toBe('CA'); - expect(merged.address.postalCode).toBe('10001'); - expect(merged.address).not.toBe(a.address); - expect(merged.address).not.toBe(b.address); - }); -}); - describe('removeUndefined()', () => { test('it removes undefined keys from object', () => { const obj = { diff --git a/apps/server/src/utils/assert.ts b/apps/server/src/utils/assert.ts index 1a9b9fb2e..19b7457ef 100644 --- a/apps/server/src/utils/assert.ts +++ b/apps/server/src/utils/assert.ts @@ -1,29 +1,31 @@ +import { is } from './is.js'; + export function isString(value: unknown): asserts value is string { - if (typeof value !== 'string') { + if (!is.string(value)) { throw new Error(`Unexpected payload type: ${String(value)}`); } } export function isDefined(value: T | undefined): asserts value is T { - if (value === undefined) { + if (!is.defined(value)) { throw new Error('Payload not found'); } } export function isNumber(value: unknown): asserts value is number { - if (typeof value !== 'number') { + if (!is.number(value)) { throw new Error(`Unexpected payload type: ${String(value)}`); } } export function isObject(value: unknown): asserts value is object { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { + if (!is.object(value)) { throw new Error(`Unexpected payload type: ${String(value)}`); } } export function isArray(value: unknown): asserts value is unknown[] { - if (!Array.isArray(value)) { + if (!is.array(value)) { throw new Error(`Unexpected payload type: ${String(value)}`); } } @@ -32,9 +34,7 @@ export function hasKeys( value: T, keys: K[], ): asserts value is T & Record { - for (const key of keys) { - if (!(key in value)) { - throw new Error(`Key not found: ${String(key)}`); - } + if (!is.objectWithKeys(value, keys)) { + throw new Error(`Unexpected payload type: ${String(value)}`); } } diff --git a/apps/server/src/utils/fileManagement.ts b/apps/server/src/utils/fileManagement.ts index d4da7c441..1a7c17a13 100644 --- a/apps/server/src/utils/fileManagement.ts +++ b/apps/server/src/utils/fileManagement.ts @@ -19,7 +19,7 @@ export function ensureDirectory(directory: string): void { /** * Ensures that a filename ends with .json extension */ -export function ensureJsonExtension(filename: string | undefined): string | undefined { +export function ensureJsonExtension(filename: string): string { if (!filename) return filename; return filename.endsWith('.json') ? filename : `${filename}.json`; } diff --git a/apps/server/src/utils/is.ts b/apps/server/src/utils/is.ts new file mode 100644 index 000000000..2c243f1c3 --- /dev/null +++ b/apps/server/src/utils/is.ts @@ -0,0 +1,10 @@ +export const is = { + string: (value: unknown): value is string => typeof value === 'string', + number: (value: unknown): value is number => typeof value === 'number', + defined: (value: T | undefined): value is T => value !== undefined, + object: (value: unknown): value is object => typeof value === 'object' && value !== null && !Array.isArray(value), + objectWithKeys: (value: T, keys: K[]): value is T & Record => { + return keys.every((key) => key in value); + }, + array: (value: unknown): value is unknown[] => Array.isArray(value), +}; diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index bf7a79c4f..abd4de87b 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -32,6 +32,7 @@ import { makeString } from './parserUtils.js'; import { parseProject, parseRundowns, parseSettings, parseUrlPresets, parseViewSettings } from './parserFunctions.js'; import { parseExcelDate } from './time.js'; import { Merge } from 'ts-essentials'; +import { is } from './is.js'; export type ErrorEmitter = (message: string) => void; export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; @@ -57,14 +58,19 @@ export function getCustomFieldData( customFieldImportKeys: Record; } { const customFields = {}; - const customFieldImportKeys = {}; + const customFieldImportKeys: Record = {}; + for (const ontimeLabel in importMap.custom) { const ontimeKey = customKeyFromLabel(ontimeLabel, existingCustomFields) ?? customFieldLabelToKey(ontimeLabel); + if (!ontimeKey) { + continue; + } const importLabel = importMap.custom[ontimeLabel].toLowerCase(); - const colour = ontimeKey in existingCustomFields ? existingCustomFields[ontimeKey].colour : ''; + + // @ts-expect-error -- we are sure that the key exists customFields[ontimeKey] = { type: 'string', - colour, + colour: ontimeKey in existingCustomFields ? existingCustomFields[ontimeKey].colour : '', label: ontimeLabel, }; customFieldImportKeys[importLabel] = ontimeKey; @@ -92,8 +98,9 @@ export const parseExcel = ( const importMap: ImportMap = { ...defaultImportMap, ...options }; for (const [key, value] of Object.entries(importMap)) { - if (typeof value === 'string') { - importMap[key] = value.toLocaleLowerCase().trim(); + if (is.string(value)) { + // @ts-expect-error -- we are sure that the key exists + importMap[key] = value.toLowerCase().trim(); } } @@ -278,6 +285,7 @@ export const parseExcel = ( // check if it is an ontime column if (handlers[columnText]) { + // @ts-expect-error -- its ok handlers[columnText](rowIndex, j, undefined, undefined); } diff --git a/apps/server/src/utils/parserUtils.ts b/apps/server/src/utils/parserUtils.ts index 52fa2c5ad..e6829d477 100644 --- a/apps/server/src/utils/parserUtils.ts +++ b/apps/server/src/utils/parserUtils.ts @@ -1,5 +1,4 @@ import { unlink } from 'fs'; -import { deepmerge } from 'ontime-utils'; /** * @description Ensures variable is string, it skips object types @@ -35,33 +34,6 @@ export const isEmptyObject = (obj: object) => { throw new Error('Variable is not an object'); }; -/** - * @description Merges two objects, suppressing undefined keys - * @param {object} a - any object - * @param {object} b - a potential partial object of same time as a - */ -export function mergeObject(a: T, b: Partial): T { - const merged = { ...a }; - - for (const key in b) { - const aValue = a[key]; - const bValue = b[key]; - - // ignore keys that do not exist in original object - if (!Object.hasOwn(merged, key)) { - continue; - } - - if (typeof bValue === 'object' && bValue !== null && typeof aValue === 'object' && aValue !== null) { - // @ts-expect-error -- not sure how to type this - merged[key] = deepmerge(aValue, bValue); - } else if (bValue !== undefined) { - merged[key] = bValue; - } - } - return merged; -} - /** * @description Removes undefined * @param {object} obj diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 6daff1a8c..f331d534d 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "strict": false, + "strict": true, "target": "ESNext", "module": "Node16", "moduleResolution": "Node16", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f09b40844..eedbdee82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -343,6 +343,9 @@ importers: specifier: ^0.18.5 version: 0.18.5 devDependencies: + '@types/cookie-parser': + specifier: ^1.4.8 + version: 1.4.8(@types/express@4.17.17) '@types/cors': specifier: ^2.8.17 version: 2.8.17 @@ -2204,6 +2207,11 @@ packages: '@types/connect@3.4.35': resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==} + '@types/cookie-parser@1.4.8': + resolution: {integrity: sha512-l37JqFrOJ9yQfRQkljb41l0xVphc7kg5JTjjr+pLRZ0IyZ49V4BQ8vbF4Ut2C2e+WH4al3xD3ZwYwIUfnbT4NQ==} + peerDependencies: + '@types/express': '*' + '@types/cors@2.8.17': resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} @@ -7600,6 +7608,10 @@ snapshots: dependencies: '@types/node': 22.15.26 + '@types/cookie-parser@1.4.8(@types/express@4.17.17)': + dependencies: + '@types/express': 4.17.17 + '@types/cors@2.8.17': dependencies: '@types/node': 22.15.26