diff --git a/apps/server/src/controllers/ontimeController.ts b/apps/server/src/controllers/ontimeController.ts index 53d0197bc..798bc86ac 100644 --- a/apps/server/src/controllers/ontimeController.ts +++ b/apps/server/src/controllers/ontimeController.ts @@ -13,13 +13,21 @@ import { RequestHandler, Request, Response } from 'express'; import fs from 'fs'; import { networkInterfaces } from 'os'; import { join } from 'path'; +import { copyFile, rename, writeFile } from 'fs/promises'; 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 { getAppDataPath, isDocker, lastLoadedProjectConfigPath, resolveDbPath, resolveStylesPath } from '../setup.js'; +import { + getAppDataPath, + isDocker, + lastLoadedProjectConfigPath, + resolveDbPath, + resolveStylesPath, + uploadsFolderPath, +} 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'; @@ -29,6 +37,10 @@ import { runtimeCacheStore } from '../stores/cachingStore.js'; import { delayedRundownCacheKey } from '../services/rundown-service/delayedRundown.utils.js'; import { integrationService } from '../services/integration-service/IntegrationService.js'; import { getProjectFiles } from '../utils/getFileListFromFolder.js'; +import { configService } from '../services/ConfigService.js'; +import { deleteFile } from '../utils/parserUtils.js'; +import { validateProjectFiles } from './ontimeController.validate.js'; +import { dbModel } from '../models/dataModel.js'; // Create controller for GET request to '/ontime/poll' // Returns data for current state @@ -511,3 +523,148 @@ export const loadProject: RequestHandler = async (req, res) => { res.status(500).send({ message: error.toString() }); } }; + +/** + * 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 200 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 const duplicateProjectFile: RequestHandler = async (req, res) => { + try { + const { filename } = req.params; + const { newFilename } = req.body; + + const projectFilePath = join(uploadsFolderPath, filename); + const duplicateProjectFilePath = join(uploadsFolderPath, newFilename); + + const errors = validateProjectFiles({ filename, newFilename }); + + if (errors.length) { + return res.status(409).send({ message: errors.join(', ') }); + } + + await copyFile(projectFilePath, duplicateProjectFilePath); + + res.status(200).send({ + message: `Duplicated project ${filename} to ${newFilename}`, + }); + } catch (error) { + res.status(500).send({ message: error.toString() }); + } +}; + +/** + * 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 200 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 const renameProjectFile: RequestHandler = async (req, res) => { + try { + const { newFilename } = req.body; + const { filename } = req.params; + + const projectFilePath = join(uploadsFolderPath, filename); + const newProjectFilePath = join(uploadsFolderPath, newFilename); + + const errors = validateProjectFiles({ filename, newFilename }); + + if (errors.length) { + return res.status(409).send({ message: errors.join(', ') }); + } + + // Rename the file + await rename(projectFilePath, newProjectFilePath); + + // Update the last loaded project config if current loaded project is the one being renamed + const { lastLoadedProject } = await configService.getConfig(); + + if (lastLoadedProject === filename) { + await configService.updateDatabaseConfig(newFilename); + } + + res.status(200).send({ + message: `Renamed project ${filename} to ${newFilename}`, + }); + } catch (error) { + res.status(500).send({ message: error.toString() }); + } +}; + +/** + * 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 const createProjectFile: RequestHandler = async (req, res) => { + try { + const { filename } = req.body; + + const projectFilePath = join(uploadsFolderPath, filename); + + const errors = validateProjectFiles({ newFilename: filename }); + + if (errors.length) { + return res.status(409).send({ message: errors.join(', ') }); + } + + await writeFile(projectFilePath, JSON.stringify(dbModel)); + + res.status(200).send({ + message: `Created project ${filename}`, + }); + } catch (error) { + res.status(500).send({ message: error.toString() }); + } +}; + +/** + * 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 200 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 const deleteProjectFile: RequestHandler = async (req, res) => { + try { + const { filename } = req.params; + + const { lastLoadedProject } = await configService.getConfig(); + + if (lastLoadedProject === filename) { + return res.status(403).send({ message: 'Cannot delete currently loaded project' }); + } + + const projectFilePath = join(uploadsFolderPath, filename); + + const errors = validateProjectFiles({ filename: filename }); + + if (errors.length) { + return res.status(409).send({ message: errors.join(', ') }); + } + + await deleteFile(projectFilePath); + + res.status(200).send({ + message: `Deleted project ${filename}`, + }); + } catch (error) { + res.status(500).send({ message: error.toString() }); + } +}; diff --git a/apps/server/src/controllers/ontimeController.validate.ts b/apps/server/src/controllers/ontimeController.validate.ts index 31da2b0f7..b331627cf 100644 --- a/apps/server/src/controllers/ontimeController.validate.ts +++ b/apps/server/src/controllers/ontimeController.validate.ts @@ -1,9 +1,13 @@ import { body, check, validationResult } from 'express-validator'; +import { join } from 'path'; +import { existsSync } from 'fs'; + import { validateHttpSubscriptionObject, validateOscSubscriptionObject, validateOscSubscriptionCycle, } from '../utils/parserFunctions.js'; +import { uploadsFolderPath } from '../setup.js'; /** * @description Validates object for POST /ontime/views @@ -166,3 +170,99 @@ export const validateLoadProjectFile = [ 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, res, next) => { + 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, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + + next(); + }, +]; + +/** + * @description Validates the filename for creating a project file. + */ +export const validateProjectCreate = [ + body('filename') + .exists() + .withMessage('Filename is required') + .isString() + .withMessage('Filename must be a string') + .isLength({ min: 1, max: 255 }) + .withMessage('Filename must be between 1 and 255 characters'), + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + + next(); + }, +]; + +/** + * @description Validates the existence of project files. + * @param {object} projectFiles + * @param {string} projectFiles.projectFilename + * @param {string} projectFiles.newFilename + * + * @returns {Promise>} Array of errors + * + */ +export const validateProjectFiles = (projectFiles: { filename?: string; newFilename?: string }): Array => { + const errors = []; + + if (projectFiles.filename) { + const projectFilePath = join(uploadsFolderPath, projectFiles.filename); + + if (!existsSync(projectFilePath)) { + errors.push('Project file does not exist'); + } + } + + if (projectFiles.newFilename) { + const projectFilePath = join(uploadsFolderPath, projectFiles.newFilename); + + if (existsSync(projectFilePath)) { + errors.push('New project file already exists'); + } + } + + return errors; +}; diff --git a/apps/server/src/routes/ontimeRouter.ts b/apps/server/src/routes/ontimeRouter.ts index b7de0aa46..72a527395 100644 --- a/apps/server/src/routes/ontimeRouter.ts +++ b/apps/server/src/routes/ontimeRouter.ts @@ -21,8 +21,12 @@ import { postViewSettings, previewExcel, postHTTP, + duplicateProjectFile, listProjects, loadProject, + renameProjectFile, + createProjectFile, + deleteProjectFile, } from '../controllers/ontimeController.js'; import { @@ -34,9 +38,13 @@ import { viewValidator, validateHTTP, validateOscSubscription, + validateProjectDuplicate, validateLoadProjectFile, + validateProjectRename, + validateProjectCreate, } from '../controllers/ontimeController.validate.js'; import { projectSanitiser } from '../controllers/projectController.validate.js'; +import { sanitizeProjectFilename } from '../utils/sanitizeProjectFilename.js'; export const router = express.Router(); @@ -105,3 +113,15 @@ router.get('/projects', listProjects); // create route between controller and '/ontime/load-project' endpoint router.post('/load-project', validateLoadProjectFile, loadProject); + +// create route between controller and '/ontime/project/:filename/duplicate' endpoint +router.post('/project/:filename/duplicate', validateProjectDuplicate, sanitizeProjectFilename, duplicateProjectFile); + +// create route between controller and '/ontime/project/:filename/rename' endpoint +router.put('/project/:filename/rename', validateProjectRename, sanitizeProjectFilename, renameProjectFile); + +// create route between controller and '/ontime/project' endpoint +router.post('/project', validateProjectCreate, sanitizeProjectFilename, createProjectFile); + +// create route between controller and '/ontime/project/:filename' endpoint +router.delete('/project/:filename', sanitizeProjectFilename, deleteProjectFile); diff --git a/apps/server/src/setup.ts b/apps/server/src/setup.ts index 4eff3143b..56348df33 100644 --- a/apps/server/src/setup.ts +++ b/apps/server/src/setup.ts @@ -73,6 +73,7 @@ const testDbStartDirectory = isTest ? '../' : getAppDataPath(); export const externalsStartDirectory = isProduction ? getAppDataPath() : join(currentDirectory, 'external'); export const lastLoadedProjectConfigPath = join(getAppDataPath(), 'config.json'); +export const uploadsFolderPath = join(getAppDataPath(), 'uploads'); let lastLoadedProject; diff --git a/apps/server/src/utils/__tests__/ensureJsonExtension.test.ts b/apps/server/src/utils/__tests__/ensureJsonExtension.test.ts new file mode 100644 index 000000000..f652e898e --- /dev/null +++ b/apps/server/src/utils/__tests__/ensureJsonExtension.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { ensureJsonExtension } from '../ensureJsonExtension.js'; + +describe('ensureJsonExtension', () => { + it('should add .json to a filename without an extension', () => { + const filename = 'testfile'; + const result = ensureJsonExtension(filename); + expect(result).toBe('testfile.json'); + }); + + it('should not add .json to a filename that already has .json', () => { + const filename = 'testfile.json'; + const result = ensureJsonExtension(filename); + expect(result).toBe('testfile.json'); + }); + + it('should add .json to a filename with a different extension', () => { + const filename = 'testfile.txt'; + const result = ensureJsonExtension(filename); + expect(result).toBe('testfile.txt.json'); + }); + + it('should handle filenames with multiple dots', () => { + const filename = 'my.test.file'; + const result = ensureJsonExtension(filename); + expect(result).toBe('my.test.file.json'); + }); +}); diff --git a/apps/server/src/utils/ensureJsonExtension.ts b/apps/server/src/utils/ensureJsonExtension.ts new file mode 100644 index 000000000..0280e5427 --- /dev/null +++ b/apps/server/src/utils/ensureJsonExtension.ts @@ -0,0 +1,5 @@ +export const ensureJsonExtension = (filename: string) => { + if (!filename) return filename; + + return filename.includes('.json') ? filename : `${filename}.json`; +}; diff --git a/apps/server/src/utils/sanitizeProjectFilename.ts b/apps/server/src/utils/sanitizeProjectFilename.ts new file mode 100644 index 000000000..2b8972052 --- /dev/null +++ b/apps/server/src/utils/sanitizeProjectFilename.ts @@ -0,0 +1,12 @@ +import { ensureJsonExtension } from '../utils/ensureJsonExtension.js'; + +export const sanitizeProjectFilename = (req, res, next) => { + 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(); +};