Files
ontime/apps/server/src/controllers/ontimeController.ts
T
2023-12-16 23:56:15 +01:00

534 lines
14 KiB
TypeScript

import { LogOrigin } from 'ontime-types';
import type { Alias, DatabaseModel, GetInfo, HttpSettings, ProjectData } from 'ontime-types';
import { RequestHandler, Request, Response } from 'express';
import fs from 'fs';
import { networkInterfaces } from 'os';
import { fileHandler } from '../utils/parser.js';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js';
import { PlaybackService } from '../services/PlaybackService.js';
import { eventStore } from '../stores/EventStore.js';
import { isDocker, pathToStartStyles, resolveDbPath } from '../setup.js';
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
import { httpIntegration } from '../services/integration-service/HttpIntegration.js';
import { logger } from '../classes/Logger.js';
import { deleteAllEvents, notifyChanges } from '../services/rundown-service/RundownService.js';
import { deepmerge } from 'ontime-utils';
import { runtimeCacheStore } from '../stores/cachingStore.js';
import { delayedRundownCacheKey } from '../services/rundown-service/delayedRundown.utils.js';
import { integrationService } from '../services/integration-service/IntegrationService.js';
import { Sheet } from '../utils/sheetsAuth.js';
// Create controller for GET request to '/ontime/poll'
// Returns data for current state
export const poll = async (req, res) => {
try {
const s = eventStore.poll();
res.status(200).send(s);
} catch (error) {
res.status(500).send({
message: `Could not get sync data: ${error}`,
});
}
};
// Create controller for GET request to '/ontime/db'
// Returns -
export const dbDownload = async (req, res) => {
const { title } = DataProvider.getProjectData();
const fileTitle = title || 'ontime data';
res.download(resolveDbPath, `${fileTitle}.json`, (err) => {
if (err) {
res.status(500).send({
message: `Could not download the file: ${err}`,
});
}
});
};
/**
* Parses a file and returns the result objects
* @param file
* @param _req
* @param _res
* @param options
*/
async function parseFile(file, _req, _res, options) {
if (!fs.existsSync(file)) {
throw new Error('Upload failed');
}
const result = await fileHandler(file, options);
return result.data;
}
/**
* parse an uploaded file and apply its parsed objects
* @param file
* @param req
* @param res
* @param [options]
* @returns {Promise<void>}
*/
const parseAndApply = async (file, _req, res, options) => {
const result = await parseFile(file, _req, res, options);
PlaybackService.stop();
const newRundown = result.rundown || [];
if (options?.onlyRundown === 'true') {
await DataProvider.setRundown(newRundown);
} else {
await DataProvider.mergeIntoData(result);
}
notifyChanges({ timer: true, external: true, reset: true });
};
/**
* @description Gets information on IPV4 non-internal interfaces
* @returns {array} - Array of objects {name: ip}
*/
const getNetworkInterfaces = () => {
const nets = networkInterfaces();
const results = [];
for (const name of Object.keys(nets)) {
for (const net of nets[name]) {
// Skip over non-IPv4 and internal (i.e. 127.0.0.1) addresses
if (net.family === 'IPv4' && !net.internal) {
results.push({
name: name,
address: net.address,
});
}
}
}
return results;
};
// Create controller for GET request to '/ontime/info'
// Returns -
export const getInfo = async (req: Request, res: Response<GetInfo>) => {
const { version, serverPort } = DataProvider.getSettings();
const osc = DataProvider.getOsc();
// get nif and inject localhost
const ni = getNetworkInterfaces();
ni.unshift({ name: 'localhost', address: '127.0.0.1' });
const cssOverride = pathToStartStyles;
// send object with network information
res.status(200).send({
networkInterfaces: ni,
version,
serverPort,
osc,
cssOverride,
});
};
// Create controller for POST request to '/ontime/aliases'
// Returns -
export const getAliases = async (req, res) => {
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 (failIsNotArray(req.body, res)) {
return;
}
try {
const newAliases: Alias[] = [];
req.body.forEach((a) => {
newAliases.push({
enabled: a.enabled,
alias: a.alias,
pathAndParams: a.pathAndParams,
});
});
await DataProvider.setAliases(newAliases);
res.status(200).send(newAliases);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
};
// Create controller for GET request to '/ontime/userfields'
// Returns -
export const getUserFields = async (req, res) => {
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 (failEmptyObjects(req.body, res)) {
return;
}
try {
const persistedData = DataProvider.getUserFields();
const newData = deepmerge(persistedData, req.body);
await DataProvider.setUserFields(newData);
res.status(200).send(newData);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
};
// Create controller for POST request to '/ontime/settings'
// Returns -
export const getSettings = async (req, res) => {
const settings = DataProvider.getSettings();
res.status(200).send(settings);
};
function extractPin(value: string | undefined | null, fallback: string | null): string | null {
if (value === null) {
return value;
}
if (typeof value === 'undefined') {
return fallback;
}
if (value.length === 0) {
return null;
}
return value;
}
// Create controller for POST request to '/ontime/settings'
// Returns ACK message
export const postSettings = async (req, res) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const settings = DataProvider.getSettings();
const editorKey = extractPin(req.body?.editorKey, settings.editorKey);
const operatorKey = extractPin(req.body?.operatorKey, settings.operatorKey);
const serverPort = Number(req.body?.serverPort);
if (isNaN(serverPort)) {
return res.status(400).send(`Invalid value found for server port: ${req.body?.serverPort}`);
}
const hasChangedPort = settings.serverPort !== serverPort;
if (isDocker && hasChangedPort) {
return res.status(403).json({ message: 'Can`t change port when running inside docker' });
}
let timeFormat = settings.timeFormat;
if (req.body?.timeFormat === '12' || req.body?.timeFormat === '24') {
timeFormat = req.body.timeFormat;
}
const language = req.body?.language || 'en';
const newData = {
...settings,
editorKey,
operatorKey,
timeFormat,
language,
serverPort,
};
await DataProvider.setSettings(newData);
res.status(200).send(newData);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
};
/**
* @description Get view Settings
* @method GET
*/
export const getViewSettings = async (req, res) => {
const views = DataProvider.getViewSettings();
res.status(200).send(views);
};
/**
* @description Change view Settings
* @method POST
*/
export const postViewSettings = async (req, res) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const newData = {
overrideStyles: req.body.overrideStyles,
endMessage: req.body?.endMessage || '',
normalColor: req.body.normalColor,
warningColor: req.body.warningColor,
warningThreshold: req.body.warningThreshold,
dangerColor: req.body.dangerColor,
dangerThreshold: req.body.dangerThreshold,
};
await DataProvider.setViewSettings(newData);
res.status(200).send(newData);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
};
// Create controller for GET request to '/ontime/osc'
// Returns -
export const getOSC = async (req, res) => {
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 (failEmptyObjects(req.body, res)) {
return;
}
try {
const oscSettings = req.body;
await DataProvider.setOsc(oscSettings);
integrationService.unregister(oscIntegration);
// TODO: this update could be more granular, checking that relevant data was changed
const { success, message } = oscIntegration.init(oscSettings);
logger.info(LogOrigin.Tx, message);
if (success) {
integrationService.register(oscIntegration);
}
res.send(oscSettings).status(200);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
};
export const postOscSubscriptions = async (req, res) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const subscriptions = req.body;
const oscSettings = DataProvider.getOsc();
oscSettings.subscriptions = subscriptions;
await DataProvider.setOsc(oscSettings);
// TODO: this update could be more granular, checking that relevant data was changed
const { message } = oscIntegration.init(oscSettings);
logger.info(LogOrigin.Tx, message);
res.send(oscSettings).status(200);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
};
// Create controller for GET request to '/ontime/http'
export const getHTTP = async (_req, res: Response<HttpSettings>) => {
const http = DataProvider.getHttp();
res.status(200).send(http);
};
// Create controller for POST request to '/ontime/http'
export const postHTTP = async (req, res) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const httpSettings = req.body;
await DataProvider.setHttp(httpSettings);
integrationService.unregister(httpIntegration);
// TODO: this update could be more granular, checking that relevant data was changed
const { success, message } = httpIntegration.init(httpSettings);
logger.info(LogOrigin.Tx, message);
if (success) {
integrationService.register(httpIntegration);
}
res.send(httpSettings).status(200);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
};
export async function patchPartialProjectFile(req, res) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const patchDb: Partial<DatabaseModel> = {
project: req.body?.project,
settings: req.body?.settings,
viewSettings: req.body?.viewSettings,
osc: req.body?.osc,
aliases: req.body?.aliases,
userFields: req.body?.userFields,
rundown: req.body?.rundown,
};
await DataProvider.mergeIntoData(patchDb);
if (patchDb.rundown !== undefined) {
// it is likely cheaper to invalidate cache than to calculate diff
PlaybackService.stop();
runtimeCacheStore.invalidate(delayedRundownCacheKey);
notifyChanges({ external: true, reset: true });
}
res.status(200).send();
} catch (error) {
res.status(400).send({ message: error.toString() });
}
}
/**
* uploads, parses and applies the data from a given file
*/
export const dbUpload = async (req, res) => {
if (!req.file) {
res.status(400).send({ message: 'File not found' });
return;
}
const options = req.query;
const file = req.file.path;
try {
await parseAndApply(file, req, res, options);
res.status(200).send();
} catch (error) {
res.status(400).send({ message: `Failed parsing ${error}` });
}
};
/**
* uploads and parses an excel file
* @returns parsed result
*/
export async function previewExcel(req, res) {
if (!req.file) {
res.status(400).send({ message: 'File not found' });
return;
}
try {
const options = JSON.parse(req.body.options);
const file = req.file.path;
const data = await parseFile(file, req, res, options);
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: error.toString() });
}
}
/**
* Meant to create a new project file, it will clear only fields which are specific to a project
* @param req
* @param res
*/
export const postNew: RequestHandler = async (req, res) => {
try {
const newProjectData: ProjectData = {
title: req.body?.title ?? '',
description: req.body?.description ?? '',
publicUrl: req.body?.publicUrl ?? '',
publicInfo: req.body?.publicInfo ?? '',
backstageUrl: req.body?.backstageUrl ?? '',
backstageInfo: req.body?.backstageInfo ?? '',
};
const newData = await DataProvider.setProjectData(newProjectData);
await deleteAllEvents();
res.status(201).send(newData);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
};
/**
* downloads and parses an sheet
* @returns parsed result
*/
export async function previewSheet(req, res) {
try {
const { id, worksheet } = req.data;
const data = await Sheet.pull(id, worksheet);
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: error.toString() });
}
}
/**
* downloads and parses an sheet
* @returns parsed result
* @method POST
*/
export async function pushSheet(req, res) {
try {
const { id, worksheet } = req.data;
await Sheet.push(id, worksheet);
res.status(200).send('ok');
} catch (error) {
res.status(500).send({ message: error.toString() });
}
}
/**
* uploads Client secrets file
* @returns parsed result
* @method POST
*/
export async function uploadSheetClientFile(req, res) {
if (!req.file.path) {
res.status(400).send({ message: 'File not found' });
return;
}
try {
const client = JSON.parse(fs.readFileSync(req.file.path as string, 'utf-8'));
await Sheet.saveClientSecrets(client);
res.status(200).send('OK');
} catch (error) {
res.status(500).send({ message: error.toString() });
}
fs.unlink(req.file.path, (err) => {
if (err) logger.error(LogOrigin.Server, err.message);
});
}
/**
* @returns link to sheet auth url
* @method GET
*/
export async function sheetAuthUrl(req, res) {
const authUrl = await Sheet.openAuthServer();
if (!authUrl) {
res.status(500).send('Unable to start auth server');
} else {
res.status(200).send(authUrl);
}
}
/**
* @description Get sheet state
* @method GET
*/
export const getSheetState = async (req, res) => {
const { id, worksheet } = req.data;
res.status(200).send(await Sheet.getSheetState(id, worksheet));
};