refactor: organise API around resources (#798)

This commit is contained in:
Carlos Valente
2024-03-02 23:08:08 +01:00
committed by GitHub
parent d87ba12b2e
commit 088927bbbb
104 changed files with 1741 additions and 1718 deletions
@@ -0,0 +1,31 @@
import type { Alias, ErrorResponse } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failIsNotArray } from '../../utils/routerUtils.js';
export async function getAliases(_req: Request, res: Response<Alias[]>) {
const aliases = DataProvider.getAliases();
res.status(200).send(aliases);
}
export async function postAliases(req: Request, res: Response<Alias[] | ErrorResponse>) {
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: String(error) });
}
}
@@ -0,0 +1,8 @@
import express from 'express';
import { getAliases, postAliases } from './aliases.controller.js';
import { validateAliases } from './aliases.validation.js';
export const router = express.Router();
router.get('/', getAliases);
router.post('/', validateAliases, postAliases);
@@ -0,0 +1,18 @@
import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
/**
* @description Validates object for POST /ontime/aliases
*/
export const validateAliases = [
body().isArray(),
body('*.enabled').isBoolean(),
body('*.alias').isString().trim(),
body('*.pathAndParams').isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,49 @@
import { CustomField, CustomFields } from 'ontime-types';
import type { Request, Response } from 'express';
import {
createCustomField,
editCustomField,
getCustomFields as getCustomFieldsFromCache,
removeCustomField,
} from '../../services/rundown-service/rundownCache.js';
export async function getCustomFields(_req: Request, res: Response<CustomFields>) {
const customFields = getCustomFieldsFromCache();
res.json(customFields);
}
// Expects { label: <label> type: 'string | ..' }
export async function postCustomField(req: Request, res: Response) {
try {
const newField = req.body as CustomField;
const allFields = await createCustomField(newField);
res.status(201).send(allFields);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
}
// Expects { label: <oldLabel>, field: { label: <newLabel> type: 'string | ..' } }
export async function putCustomField(req: Request, res: Response) {
try {
const oldLabel = req.params.label;
const { colour, type, label } = req.body;
const newFields = await editCustomField(oldLabel, { label, colour, type });
res.status(200).send(newFields);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
}
// Expects { label: <label> }
export async function deleteCustomField(req: Request, res: Response) {
try {
const fieldToDelete = req.params.label;
await removeCustomField(fieldToDelete);
res.sendStatus(204);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
}
@@ -0,0 +1,14 @@
import express from 'express';
import { deleteCustomField, getCustomFields, postCustomField, putCustomField } from './customFields.controller.js';
import { validateCustomField, validateDeleteCustomField, validateEditCustomField } from './customFields.validation.js';
export const router = express.Router();
router.get('/', getCustomFields);
router.post('/', validateCustomField, postCustomField);
router.put('/:label', validateEditCustomField, putCustomField);
router.delete('/:label', validateDeleteCustomField, deleteCustomField);
@@ -0,0 +1,45 @@
import { isAlphanumeric } from 'ontime-utils';
import { Request, Response, NextFunction } from 'express';
import { body, param, validationResult } from 'express-validator';
export const validateCustomField = [
body('label')
.exists()
.isString()
.trim()
.custom((value) => {
return isAlphanumeric(value);
}),
body('type').exists().isString().trim(),
body('colour').exists().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateEditCustomField = [
param('label').exists().isString().trim(),
body('label').exists().isString().trim(),
body('type').exists().isString().trim(),
body('colour').exists().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateDeleteCustomField = [
param('label').exists().isString(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,285 @@
import {
DatabaseModel,
ErrorResponse,
GetInfo,
MessageResponse,
ProjectData,
ProjectFileListResponse,
} from 'ontime-types';
import type { Request, Response } from 'express';
import fs from 'fs';
import { join } from 'path';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { resolveDbPath, resolveProjectsDirectory } from '../../setup/index.js';
import * as projectService from '../../services/project-service/ProjectService.js';
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
import { setRundown } from '../../services/rundown-service/RundownService.js';
import { ensureJsonExtension } from '../../utils/fileManagement.js';
import { generateUniqueFileName } from '../../utils/generateUniqueFilename.js';
import { appStateService } from '../../services/app-state-service/AppStateService.js';
import { handleMaybeExcel } from '../../utils/parser.js';
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
// all fields are optional in validation
if (failEmptyObjects(req.body, res)) {
res.status(400).send({ message: 'No field found to patch' });
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,
customFields: req.body?.customFields,
};
const maybeRundown = req.body?.rundown;
await DataProvider.mergeIntoData(patchDb);
if (maybeRundown !== undefined) {
// it is likely cheaper to invalidate cache than to calculate diff
runtimeService.stop();
await setRundown(maybeRundown);
}
const newData = DataProvider.getData();
res.status(200).send(newData);
} catch (error) {
res.status(400).send({ message: String(error) });
}
}
/**
* Creates a new project file.
* Receives the project filename (`filename`) from the request body.
*
* @param {Request} req - The express request object. Expects `filename` in the request body.
* @param {Response} res - The express response object. Sends a 200 status with a success message upon successful creation,
* a 409 status if there are validation errors,
* or a 500 status with an error message in case of an exception.
*/
export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
try {
const originalFilename = ensureJsonExtension(req.body.title || 'Untitled');
const filename = generateUniqueFileName(resolveProjectsDirectory, originalFilename);
const errors = projectService.validateProjectFiles({ newFilename: filename });
if (errors.length) {
return res.status(409).send({ message: 'Project with title already exists' });
}
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 ?? '',
};
projectService.createProjectFile(filename, newProjectData);
res.status(200).send({
filename,
});
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
export async function projectDownload(_req: Request, res: Response) {
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}`,
});
}
});
}
/**
* uploads, parses and applies the data from a given file
*/
export async function postProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
if (!req.file) {
res.status(400).send({ message: 'File not found' });
return;
}
try {
const options = req.query;
const filePath = req.file.path;
await projectService.applyProjectFile(filePath, options);
res.status(201).send({ message: 'ok' });
} catch (error) {
res.status(400).send({ message: `Failed parsing ${error}` });
}
}
/**
* Retrieves and lists all project files from the uploads directory.
*/
export async function listProjects(_req: Request, res: Response<ProjectFileListResponse | ErrorResponse>) {
try {
const data = await projectService.getProjectList();
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
/**
* Receives a `filename` from the request body and loads the project file from the uploads directory.
*/
export async function loadProject(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
const filename = req.body.filename;
const filePath = join(resolveProjectsDirectory, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).send({ message: 'File not found' });
}
await projectService.applyProjectFile(filePath);
res.status(201).send({
message: `Loaded project ${filename}`,
});
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
/**
* Duplicates a project file.
* Receives the original project filename (`filename`) from the request parameters
* and the filename for the duplicate (`newFilename`) from the request body.
*
* @param {Request} req - The express request object. Expects `filename` in the request parameters and `newFilename` in the request body.
* @param {Response} res - The express response object. Sends a 201 status with a success message upon successful duplication,
* a 409 status if there are validation errors,
* or a 500 status with an error message in case of an exception.
*/
export async function duplicateProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
const { filename } = req.params;
const { newFilename } = req.body;
const errors = projectService.validateProjectFiles({ filename, newFilename });
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
}
await projectService.duplicateProjectFile(filename, newFilename);
res.status(201).send({
message: `Duplicated project ${filename} to ${newFilename}`,
});
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
/**
* Renames a project file.
* Receives the current filename (`filename`) from the request parameters
* and the new filename (`newFilename`) from the request body.
*
* @param {Request} req - The express request object. Expects `filename` in the request parameters and `newFilename` in the request body.
* @param {Response} res - The express response object. Sends a 201 status with a success message upon successful renaming,
* a 409 status if there are validation errors,
* or a 500 status with an error message in case of an exception.
*/
export async function renameProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
const { newFilename } = req.body;
const { filename } = req.params;
const errors = projectService.validateProjectFiles({ filename, newFilename });
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
}
// Rename the file
await projectService.renameProjectFile(filename, newFilename);
res.status(201).send({
message: `Renamed project ${filename} to ${newFilename}`,
});
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
/**
* Deletes an existing project file.
* Receives the project filename (`filename`) from the request parameters.
*
* @param {Request} req - The express request object. Expects `filename` in the request parameters.
* @param {Response} res - The express response object. Sends a 204 status with a success message upon successful deletion,
* a 403 status if attempting to delete the currently loaded project,
* a 409 status if there are validation errors,
* or a 500 status with an error message in case of an exception.
*/
export async function deleteProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
const { filename } = req.params;
const { lastLoadedProject } = await appStateService.get();
if (lastLoadedProject === filename) {
return res.status(403).send({ message: 'Cannot delete currently loaded project' });
}
const errors = projectService.validateProjectFiles({ filename });
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
}
await projectService.deleteProjectFile(filename);
res.status(204).send({
message: `Deleted project ${filename}`,
});
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
export async function getInfo(_req: Request, res: Response<GetInfo>) {
const info = await projectService.getInfo();
res.status(200).send(info);
}
/**
* uploads and parses an excel spreadsheet
* @returns parsed result
*/
export async function previewSpreadsheet(req: Request, res: Response) {
if (!req.file) {
res.status(400).send({ message: 'File not found' });
return;
}
try {
const filePath = req.file.path;
if (!fs.existsSync(filePath)) {
throw new Error('Upload failed');
}
const options = JSON.parse(req.body.options);
const data = handleMaybeExcel(filePath, options);
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
@@ -0,0 +1,32 @@
import { Request } from 'express';
import multer, { FileFilterCallback } from 'multer';
import { EXCEL_MIME, JSON_MIME } from '../../utils/parser.js';
import { storage } from '../../utils/upload.js';
const filterProjectFile = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes(JSON_MIME)) {
cb(null, true);
} else {
cb(null, false);
}
};
const filterSpreadsheet = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes(EXCEL_MIME)) {
cb(null, true);
} else {
cb(null, false);
}
};
// Build multer uploader for a single file
export const uploadProjectFile = multer({
storage,
fileFilter: filterProjectFile,
}).single('project');
export const uploadSpreadsheet = multer({
storage,
fileFilter: filterSpreadsheet,
}).single('spreadsheet');
+44
View File
@@ -0,0 +1,44 @@
import express from 'express';
import {
createProjectFile,
projectDownload,
deleteProjectFile,
getInfo,
listProjects,
patchPartialProjectFile,
previewSpreadsheet,
loadProject,
duplicateProjectFile,
renameProjectFile,
postProjectFile,
} from './db.controller.js';
import { uploadProjectFile, uploadSpreadsheet } from './db.middleware.js';
import {
projectSanitiser,
sanitizeProjectFilename,
validateLoadProjectFile,
validatePatchProjectFile,
validateProjectDuplicate,
validateProjectRename,
} from './db.validation.js';
export const router = express.Router();
router.get('/download', projectDownload);
router.post('/upload', uploadProjectFile, postProjectFile);
router.patch('/', validatePatchProjectFile, patchPartialProjectFile);
router.post('/new', projectSanitiser, createProjectFile);
router.get('/all', listProjects);
router.post('/load', validateLoadProjectFile, sanitizeProjectFilename, loadProject);
router.post('/:filename/duplicate', validateProjectDuplicate, sanitizeProjectFilename, duplicateProjectFile);
router.put('/:filename/rename', validateProjectRename, sanitizeProjectFilename, renameProjectFile);
router.delete('/:filename', sanitizeProjectFilename, deleteProjectFile);
router.get('/info', getInfo);
// TODO: validate import map
router.post('/spreadsheet/preview', uploadSpreadsheet, previewSpreadsheet);
@@ -0,0 +1,106 @@
import { Request, Response, NextFunction } from 'express';
import { body, validationResult } from 'express-validator';
import { ensureJsonExtension } from '../../utils/fileManagement.js';
export const projectSanitiser = [
body('title').optional().isString().trim(),
body('description').optional().isString().trim(),
body('publicUrl').optional().isString().trim(),
body('publicInfo').optional().isString().trim(),
body('backstageUrl').optional().isString().trim(),
body('backstageInfo').optional().isString().trim(),
body('endMessage').optional().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const sanitizeProjectFilename = (req: Request, _res: Response, next: NextFunction) => {
const { filename, newFilename } = req.body;
const { filename: projectName } = req.params;
req.body.filename = ensureJsonExtension(filename);
req.body.newFilename = ensureJsonExtension(newFilename);
req.params.filename = ensureJsonExtension(projectName);
next();
};
export const validatePatchProjectFile = [
body('rundown').isArray().optional({ nullable: false }),
body('project').isObject().optional({ nullable: false }),
body('settings').isObject().optional({ nullable: false }),
body('viewSettings').isObject().optional({ nullable: false }),
body('aliases').isArray().optional({ nullable: false }),
body('customFields').isObject().optional({ nullable: false }),
body('osc').isObject().optional({ nullable: false }),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
* @description Validates the filename for loading a project file.
*/
export const validateLoadProjectFile = [
body('filename').exists().withMessage('Filename is required').isString().withMessage('Filename must be a string'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
];
/**
* @description Validates the filenames for duplicating a project.
*/
export const validateProjectDuplicate = [
body('newFilename')
.exists()
.withMessage('New project filename is required')
.isString()
.withMessage('New project filename must be a string')
.isLength({ min: 1, max: 255 })
.withMessage('New project filename must be between 1 and 255 characters'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
];
/**
* @description Validates the filenames for renaming a project.
*/
export const validateProjectRename = [
body('newFilename')
.exists()
.withMessage('Duplicate project filename is required')
.isString()
.withMessage('Duplicate project filename must be a string')
.isLength({ min: 1, max: 255 })
.withMessage('Duplicate project filename must be between 1 and 255 characters'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
];
@@ -0,0 +1,29 @@
import type { ErrorResponse, HttpSettings } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { httpIntegration } from '../../services/integration-service/HttpIntegration.js';
export async function getHTTP(_req: Request, res: Response<HttpSettings>) {
const http = DataProvider.getHttp();
res.status(200).send(http);
}
export async function postHTTP(req: Request, res: Response<HttpSettings | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const httpSettings = req.body;
httpIntegration.init(httpSettings);
// we persist the data after init to avoid persisting invalid data
const result = await DataProvider.setHttp(httpSettings);
res.send(result).status(200);
} catch (error) {
res.status(400).send({ message: String(error) });
}
}
@@ -0,0 +1,9 @@
import express from 'express';
import { validateHTTP } from './http.validation.js';
import { getHTTP, postHTTP } from './http.controller.js';
export const router = express.Router();
router.get('/', getHTTP);
router.post('/', validateHTTP, postHTTP);
@@ -0,0 +1,21 @@
import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
import { sanitiseHttpSubscriptions } from '../../utils/parserFunctions.js';
/**
* @description Validates object for POST /ontime/http
*/
export const validateHTTP = [
body('enabledOut').exists().isBoolean(),
body('subscriptions')
.exists()
.isArray()
.custom((value) => sanitiseHttpSubscriptions(value)),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
+25
View File
@@ -0,0 +1,25 @@
import express from 'express';
import { router as aliasesRouter } from './aliases/aliases.router.js';
import { router as customFieldsRouter } from './custom-fields/customFields.router.js';
import { router as dbRouter } from './db/db.router.js';
import { router as httpRouter } from './http/http.router.js';
import { router as oscRouter } from './osc/osc.router.js';
import { router as projectRouter } from './project/project.router.js';
import { router as rundownRouter } from './rundown/rundown.router.js';
import { router as settingsRouter } from './settings/settings.router.js';
import { router as sheetsRouter } from './sheets/sheets.router.js';
import { router as viewSettingsRouter } from './view-settings/viewSettings.router.js';
export const appRouter = express.Router();
appRouter.use('/aliases', aliasesRouter);
appRouter.use('/custom-fields', customFieldsRouter);
appRouter.use('/db', dbRouter);
appRouter.use('/http', httpRouter);
appRouter.use('/osc', oscRouter);
appRouter.use('/project', projectRouter);
appRouter.use('/rundown', rundownRouter);
appRouter.use('/settings', settingsRouter);
appRouter.use('/sheets', sheetsRouter);
appRouter.use('/view-settings', viewSettingsRouter);
@@ -0,0 +1,29 @@
import type { ErrorResponse, OSCSettings } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { oscIntegration } from '../../services/integration-service/OscIntegration.js';
export async function getOSC(_req: Request, res: Response<OSCSettings>) {
const osc = DataProvider.getOsc();
res.status(200).send(osc);
}
export async function postOSC(req: Request, res: Response<OSCSettings | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const oscSettings = req.body;
oscIntegration.init(oscSettings);
// we persist the data after init to avoid persisting invalid data
const result = await DataProvider.setOsc(oscSettings);
res.send(result).status(200);
} catch (error) {
res.status(400).send({ message: String(error) });
}
}
@@ -0,0 +1,8 @@
import express from 'express';
import { getOSC, postOSC } from './osc.controller.js';
import { validateOSC } from './osc.validation.js';
export const router = express.Router();
router.get('/', getOSC);
router.post('/', validateOSC, postOSC);
@@ -0,0 +1,25 @@
import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
import { sanitiseOscSubscriptions } from '../../utils/parserFunctions.js';
/**
* @description Validates object for POST /ontime/osc
*/
export const validateOSC = [
body('portIn').exists().isPort(),
body('portOut').exists().isPort(),
body('targetIP').exists().isIP(),
body('enabledIn').exists().isBoolean(),
body('enabledOut').exists().isBoolean(),
body('subscriptions')
.exists()
.isArray()
.custom((value) => sanitiseOscSubscriptions(value)),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,32 @@
import { ErrorResponse, ProjectData } from 'ontime-types';
import type { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { removeUndefined } from '../../utils/parserUtils.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
export async function getProjectData(_req: Request, res: Response<ProjectData>) {
res.json(DataProvider.getProjectData());
}
export async function postProjectData(req: Request, res: Response<ProjectData | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const newEvent: Partial<ProjectData> = removeUndefined({
title: req.body?.title,
description: req.body?.description,
publicUrl: req.body?.publicUrl,
publicInfo: req.body?.publicInfo,
backstageUrl: req.body?.backstageUrl,
backstageInfo: req.body?.backstageInfo,
endMessage: req.body?.endMessage,
});
const newData = await DataProvider.setProjectData(newEvent);
res.status(200).send(newData);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
}
@@ -0,0 +1,9 @@
import express from 'express';
import { getProjectData, postProjectData } from './project.controller.js';
import { projectSanitiser } from './project.validation.js';
export const router = express.Router();
router.get('/', getProjectData);
router.post('/', projectSanitiser, postProjectData);
@@ -0,0 +1,18 @@
import { Request, Response, NextFunction } from 'express';
import { body, validationResult } from 'express-validator';
export const projectSanitiser = [
body('title').optional().isString().trim(),
body('description').optional().isString().trim(),
body('publicUrl').optional().isString().trim(),
body('publicInfo').optional().isString().trim(),
body('backstageUrl').optional().isString().trim(),
body('backstageInfo').optional().isString().trim(),
body('endMessage').optional().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,121 @@
import { ErrorResponse, MessageResponse, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { Request, Response } from 'express';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import {
addEvent,
applyDelay,
batchEditEvents,
deleteAllEvents,
deleteEvent,
editEvent,
reorderEvent,
swapEvents,
} from '../../services/rundown-service/RundownService.js';
import { getNormalisedRundown, getRundown } from '../../services/rundown-service/rundownUtils.js';
export async function rundownGetAll(_req: Request, res: Response<OntimeRundown>) {
const rundown = getRundown();
res.json(rundown);
}
export async function rundownGetNormalised(_req: Request, res: Response<RundownCached>) {
const cachedRundown = getNormalisedRundown();
res.json(cachedRundown);
}
export async function rundownPost(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const newEvent = await addEvent(req.body);
res.status(201).send(newEvent);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
}
export async function rundownPut(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const event = await editEvent(req.body);
res.status(200).send(event);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
}
export async function rundownBatchPut(req: Request, res: Response<MessageResponse | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return res.status(404);
}
try {
const { data, ids } = req.body;
await batchEditEvents(ids, data);
res.status(200).send({ message: 'Batch edit successful' });
} catch (error) {
res.status(400).send(error);
}
}
export async function rundownReorder(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const { eventId, from, to } = req.body;
const event = await reorderEvent(eventId, from, to);
res.status(200).send(event.newEvent);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
}
export async function rundownSwap(req: Request, res: Response<MessageResponse | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const { from, to } = req.body;
await swapEvents(from, to);
res.status(200).send({ message: 'Swap successful' });
} catch (error) {
res.status(400).send({ message: error.toString() });
}
}
export async function rundownApplyDelay(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
await applyDelay(req.params.eventId);
res.status(200).send({ message: 'Delay applied' });
} catch (error) {
res.status(400).send({ message: error.toString() });
}
}
export async function rundownDelete(_req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
await deleteAllEvents();
res.status(204).send({ message: 'All events deleted' });
} catch (error) {
res.status(400).send({ message: error.toString() });
}
}
export async function deleteEventById(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
await deleteEvent(req.params.eventId);
res.status(204).send({ message: 'Event deleted' });
} catch (error) {
res.status(400).send({ message: error.toString() });
}
}
@@ -0,0 +1,39 @@
import express from 'express';
import {
deleteEventById,
rundownApplyDelay,
rundownBatchPut,
rundownDelete,
rundownGetAll,
rundownGetNormalised,
rundownPost,
rundownPut,
rundownReorder,
rundownSwap,
} from './rundown.controller.js';
import {
paramsMustHaveEventId,
rundownBatchPutValidator,
rundownPostValidator,
rundownPutValidator,
rundownReorderValidator,
rundownSwapValidator,
} from './rundown.validation.js';
export const router = express.Router();
router.get('/', rundownGetAll); // not used in Ontime frontend
router.get('/normalised', rundownGetNormalised);
router.post('/', rundownPostValidator, rundownPost);
router.put('/', rundownPutValidator, rundownPut);
router.put('/batch', rundownBatchPutValidator, rundownBatchPut);
router.patch('/reorder/', rundownReorderValidator, rundownReorder);
router.patch('/swap', rundownSwapValidator, rundownSwap);
router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay);
router.delete('/all', rundownDelete);
router.delete('/:eventId', paramsMustHaveEventId, deleteEventById);
@@ -0,0 +1,66 @@
import { body, param, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
export const rundownPostValidator = [
body('type').isString().exists().isIn(['event', 'delay', 'block']),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const rundownPutValidator = [
body('id').isString().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(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const rundownReorderValidator = [
body('eventId').isString().exists(),
body('from').isNumeric().exists(),
body('to').isNumeric().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 rundownSwapValidator = [
body('from').isString().exists(),
body('to').isString().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 paramsMustHaveEventId = [
param('eventId').exists(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,54 @@
import { ErrorResponse, Settings } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { extractPin } from '../../services/project-service/ProjectService.js';
import { isDocker } from '../../setup/index.js';
export async function getSettings(_req: Request, res: Response<Settings>) {
const settings = DataProvider.getSettings();
res.status(200).send(settings);
}
export async function postSettings(req: Request, res: Response<Settings | ErrorResponse>) {
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({ message: `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: String(error) });
}
}
@@ -0,0 +1,8 @@
import express from 'express';
import { getSettings, postSettings } from './settings.controller.js';
import { validateSettings } from './settings.validation.js';
export const router = express.Router();
router.get('/', getSettings);
router.post('/', validateSettings, postSettings);
@@ -0,0 +1,19 @@
import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
/**
* @description Validates object for POST /ontime/settings
*/
export const validateSettings = [
body('editorKey').isString().isLength({ min: 0, max: 4 }).optional({ nullable: true }),
body('operatorKey').isString().isLength({ min: 0, max: 4 }).optional({ nullable: true }),
body('timeFormat').isString().isIn(['12', '24']),
body('language').isString(),
body('serverPort').isPort().optional(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,79 @@
/**
* This module encapsulates logic related to
* Google Sheets
*/
import { Request, Response } from 'express';
import { readFileSync } from 'fs';
import { deleteFile } from '../../utils/parserUtils.js';
import {
revoke,
handleClientSecret,
handleInitialConnection,
hasAuth,
download,
upload,
} from '../../services/sheet-service/SheetService.js';
export async function requestConnection(req: Request, res: Response) {
const { sheetId } = req.params;
const file = req.file.path;
try {
const client = readFileSync(file, 'utf-8');
const clientSecret = handleClientSecret(client);
const { verification_url, user_code } = await handleInitialConnection(clientSecret, sheetId);
res.status(200).send({ verification_url, user_code });
} catch (error) {
res.status(500).send({ message: String(error) });
}
// delete uploaded file after parsing
try {
deleteFile(file);
} catch (_error) {
/** we dont handle failure here */
}
}
export async function verifyAuthentication(_req: Request, res: Response) {
try {
const authenticated = hasAuth();
res.status(200).send(authenticated);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
export async function revokeAuthentication(_req: Request, res: Response) {
try {
const authenticated = revoke();
res.status(200).send(authenticated);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
export async function readFromSheet(req: Request, res: Response) {
try {
const { sheetId } = req.params;
const { options } = req.body;
const data = await download(sheetId, options);
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
export async function writeToSheet(req: Request, res: Response) {
try {
const { sheetId } = req.params;
const { options } = req.body;
await upload(sheetId, options);
res.status(200).send();
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
@@ -0,0 +1,18 @@
import { Request } from 'express';
import multer, { FileFilterCallback } from 'multer';
import { JSON_MIME } from '../../utils/parser.js';
import { storage } from '../../utils/upload.js';
const filterClientSecret = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes(JSON_MIME)) {
cb(null, true);
} else {
cb(null, false);
}
};
export const uploadClientSecret = multer({
storage,
fileFilter: filterClientSecret,
}).single('client_secret');
@@ -0,0 +1,25 @@
/**
* This is a feature specific router for integration with google sheets
*/
import express from 'express';
import {
readFromSheet,
requestConnection,
revokeAuthentication,
verifyAuthentication,
writeToSheet,
} from './sheets.controller.js';
import { uploadClientSecret } from './sheets.middleware.js';
import { validateRequestConnection, validateSheetOptions } from './sheets.validation.js';
export const router = express.Router();
router.get('/connect', verifyAuthentication);
router.post('/:sheetId/connect', uploadClientSecret, validateRequestConnection, requestConnection);
router.post('/revoke', revokeAuthentication);
router.post('/:sheetId/read', validateSheetOptions, readFromSheet);
router.post('/:sheetId/write', validateSheetOptions, writeToSheet);
@@ -0,0 +1,38 @@
import { isImportMap } from 'ontime-utils';
import { body, param, validationResult } from 'express-validator';
import { NextFunction, Request, Response } from 'express';
export const validateRequestConnection = [
param('sheetId')
.exists()
.isString()
.isLength({
min: 40,
max: 100,
})
.withMessage('Sheet ID is usually 44 characters long'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateSheetOptions = [
param('sheetId').exists().isString(),
body('options')
.exists()
.isObject()
.custom((content) => {
const isValid = isImportMap(content);
return isValid;
}),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,33 @@
import type { ErrorResponse, ViewSettings } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
export async function getViewSettings(_req: Request, res: Response<ViewSettings>) {
const views = DataProvider.getViewSettings();
res.status(200).send(views);
}
export async function postViewSettings(req: Request, res: Response<ViewSettings | ErrorResponse>) {
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: String(error) });
}
}
@@ -0,0 +1,9 @@
import express from 'express';
import { validateViewSettings } from './viewSettings.validation.js';
import { getViewSettings, postViewSettings } from './viewSettings.controller.js';
export const router = express.Router();
router.get('/', getViewSettings);
router.post('/', validateViewSettings, postViewSettings);
@@ -0,0 +1,20 @@
import { check, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
/**
* @description Validates object for POST /ontime/views
*/
export const validateViewSettings = [
check('overrideStyles').isBoolean().withMessage('overrideStyles value must be boolean'),
check('endMessage').isString().trim().withMessage('endMessage value must be string'),
check('normalColor').isString().trim().withMessage('normalColor value must be string'),
check('warningColor').isString().trim().withMessage('warningColor value must be string'),
check('dangerColor').isString().trim().withMessage('dangerColor value must be string'),
check('warningThreshold').isNumeric().withMessage('warningThreshold value must be a number'),
check('dangerThreshold').isNumeric().withMessage('dangerThreshold value must a number'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];