diff --git a/apps/client/src/common/api/db.ts b/apps/client/src/common/api/db.ts index 168072cb7..7e004327f 100644 --- a/apps/client/src/common/api/db.ts +++ b/apps/client/src/common/api/db.ts @@ -11,8 +11,8 @@ const dbPath = `${apiEntryUrl}/db`; /** * HTTP request to the current DB */ -async function getDb(fileName?: string): Promise> { - return axios.post(`${dbPath}/download/`, { fileName }); +async function getDb(filename: string): Promise> { + return axios.post(`${dbPath}/download/`, { filename }); } /** @@ -123,7 +123,7 @@ export async function renameProject(filename: string, newFilename: string): Prom const url = `${dbPath}/${filename}/rename`; const decodedUrl = decodeURIComponent(url); const res = await axios.put(decodedUrl, { - newFilename, + filename: newFilename, }); return res.data; } diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx index 09a1fdb3a..466956177 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx @@ -50,7 +50,8 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) { const handleSubmitCreate = async (values: ProjectCreateFormValues) => { try { setError(null); - const filename = values.title?.trim(); + + const filename = values.title ?? 'untitled'; await createProject({ ...values, diff --git a/apps/server/package.json b/apps/server/package.json index 65b02a207..b42b333b2 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -19,6 +19,7 @@ "node-osc": "^9.0.2", "node-xlsx": "^0.23.0", "ontime-utils": "workspace:*", + "sanitize-filename": "^1.6.3", "steno": "^3.1.0", "ts-essentials": "^9.4.1", "ws": "^8.13.0" diff --git a/apps/server/src/api-data/db/db.controller.ts b/apps/server/src/api-data/db/db.controller.ts index 7efe6aaa1..6b2a5fbb3 100644 --- a/apps/server/src/api-data/db/db.controller.ts +++ b/apps/server/src/api-data/db/db.controller.ts @@ -16,7 +16,6 @@ import { failEmptyObjects } from '../../utils/routerUtils.js'; import { resolveDbDirectory, resolveProjectsDirectory } from '../../setup/index.js'; import * as projectService from '../../services/project-service/ProjectService.js'; -import { ensureJsonExtension } from '../../utils/fileManagement.js'; import { generateUniqueFileName } from '../../utils/generateUniqueFilename.js'; import { appStateService } from '../../services/app-state-service/AppStateService.js'; import { oscIntegration } from '../../services/integration-service/OscIntegration.js'; @@ -54,8 +53,7 @@ export async function patchPartialProjectFile(req: Request, res: Response) { try { - const originalFilename = ensureJsonExtension(req.body.title || 'Untitled'); - const filename = generateUniqueFileName(resolveProjectsDirectory, originalFilename); + const filename = generateUniqueFileName(resolveProjectsDirectory, req.body.filename); const errors = projectService.validateProjectFiles({ newFilename: filename }); if (errors.length) { @@ -71,7 +69,7 @@ export async function createProjectFile(req: Request, res: Response<{ filename: backstageInfo: req.body?.backstageInfo ?? '', }; - projectService.createProjectFile(filename, newProjectData); + await projectService.createProjectFile(filename, newProjectData); res.status(200).send({ filename, @@ -83,29 +81,18 @@ export async function createProjectFile(req: Request, res: Response<{ filename: } /** - * Utility function finds the correct project file to download - */ -function selectProjectFile(fileName?: string) { - const projectsDirectory = resolveDbDirectory; - const fileToDownload = fileName ? ensureJsonExtension(fileName) : projectService.getProjectTitle(); - const pathToFile = join(projectsDirectory, fileToDownload); - - return { pathToFile, name: fileToDownload }; -} - -/** - * Allows downloading of a optionally given project files - * If no {filename} is provided, loaded file will be served + * Allows downloading of project files */ export async function projectDownload(req: Request, res: Response) { - const { pathToFile, name } = selectProjectFile(req.body?.fileName); + const { filename } = req.body; + const pathToFile = join(resolveDbDirectory, filename); // Check if the file exists before attempting to download if (!existsSync(pathToFile)) { - return res.status(404).send({ message: `Project ${name} not found.` }); + return res.status(404).send({ message: `Project ${filename} not found.` }); } - res.download(pathToFile, name, (error) => { + res.download(pathToFile, filename, (error) => { if (error) { const message = getErrorMessage(error); res.status(500).send({ message }); @@ -228,7 +215,7 @@ export async function duplicateProjectFile(req: Request, res: Response) { try { - const { newFilename } = req.body; + const { filename: newFilename } = req.body; const { filename } = req.params; const errors = projectService.validateProjectFiles({ filename, newFilename }); diff --git a/apps/server/src/api-data/db/db.router.ts b/apps/server/src/api-data/db/db.router.ts index eb660d49d..4b5ee0d9a 100644 --- a/apps/server/src/api-data/db/db.router.ts +++ b/apps/server/src/api-data/db/db.router.ts @@ -14,28 +14,25 @@ import { } from './db.controller.js'; import { uploadProjectFile } from './db.middleware.js'; import { - projectSanitiser, - sanitizeProjectFilename, - validateDownloadProject, - validateLoadProjectFile, - validatePatchProjectFile, - validateProjectDuplicate, - validateProjectRename, + validateNewProject, + validatePatchProject, + validateFilenameBody, + validateFilenameParam, } from './db.validation.js'; export const router = express.Router(); -router.post('/download', validateDownloadProject, projectDownload); +router.post('/download', validateFilenameBody, projectDownload); router.post('/upload', uploadProjectFile, postProjectFile); -router.patch('/', validatePatchProjectFile, patchPartialProjectFile); -router.post('/new', projectSanitiser, createProjectFile); +router.patch('/', validatePatchProject, patchPartialProjectFile); +router.post('/new', validateFilenameBody, validateNewProject, 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.post('/load', validateFilenameBody, loadProject); +router.post('/:filename/duplicate', validateFilenameParam, validateFilenameBody, duplicateProjectFile); +router.put('/:filename/rename', validateFilenameParam, validateFilenameBody, renameProjectFile); +router.delete('/:filename', validateFilenameParam, deleteProjectFile); router.get('/info', getInfo); diff --git a/apps/server/src/api-data/db/db.validation.ts b/apps/server/src/api-data/db/db.validation.ts index 1dedfd32c..18cf4fb80 100644 --- a/apps/server/src/api-data/db/db.validation.ts +++ b/apps/server/src/api-data/db/db.validation.ts @@ -1,9 +1,12 @@ import { Request, Response, NextFunction } from 'express'; -import { body, validationResult } from 'express-validator'; - +import { body, param, validationResult } from 'express-validator'; import { ensureJsonExtension } from '../../utils/fileManagement.js'; +import sanitize from 'sanitize-filename'; -export const projectSanitiser = [ +/** + * @description Validates request for a new project. + */ +export const validateNewProject = [ body('title').optional().isString().trim(), body('description').optional().isString().trim(), body('publicUrl').optional().isString().trim(), @@ -19,18 +22,10 @@ export const projectSanitiser = [ }, ]; -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 = [ +/** + * @description Validates request for pathing data in the project. + */ +export const validatePatchProject = [ body('rundown').isArray().optional({ nullable: false }), body('project').isObject().optional({ nullable: false }), body('settings').isObject().optional({ nullable: false }), @@ -47,31 +42,18 @@ export const validatePatchProjectFile = [ ]; /** - * @description Validates the filename for loading a project file. + * @description Validates request with filename in the body. */ -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') +export const validateFilenameBody = [ + body('filename') .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'), + .trim() + .customSanitizer((input: string) => sanitize(input)) + .withMessage('Failed to sanitize the filename') + .notEmpty() + .withMessage('Filename was empty or contained only invalid characters') + .customSanitizer((input: string) => ensureJsonExtension(input)), (req: Request, res: Response, next: NextFunction) => { const errors = validationResult(req); @@ -84,32 +66,18 @@ export const validateProjectDuplicate = [ ]; /** - * @description Validates the filenames for renaming a project. + * @description Validates request with filename in the params. */ -export const validateProjectRename = [ - body('newFilename') +export const validateFilenameParam = [ + param('filename') .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(); - }, -]; - -/** - * @description Validates a download request which can include an optional project name. - */ -export const validateDownloadProject = [ - body('fileName').isString().optional(), + .trim() + .customSanitizer((input: string) => sanitize(input)) + .withMessage('Failed to sanitize the filename') + .notEmpty() + .withMessage('Filename was empty or contained only invalid characters') + .customSanitizer((input: string) => ensureJsonExtension(input)), (req: Request, res: Response, next: NextFunction) => { const errors = validationResult(req); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b34f0462..cb70f6a8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,7 +212,7 @@ importers: version: 5.4.3 vite: specifier: ^5.2.11 - version: 5.2.11(@types/node@18.11.18)(sass@1.57.1) + version: 5.2.11(sass@1.57.1) vite-plugin-compression2: specifier: ^0.12.0 version: 0.12.0 @@ -224,7 +224,7 @@ importers: version: 4.3.1(typescript@5.4.3)(vite@5.2.11) vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@18.11.18)(jsdom@21.1.0)(sass@1.57.1) + version: 1.6.0(jsdom@21.1.0)(sass@1.57.1) apps/electron: devDependencies: @@ -288,6 +288,9 @@ importers: ontime-utils: specifier: workspace:* version: link:../../packages/utils + sanitize-filename: + specifier: ^1.6.3 + version: 1.6.3 steno: specifier: ^3.1.0 version: 3.2.0 @@ -3872,7 +3875,7 @@ packages: '@babel/plugin-transform-react-jsx-source': 7.23.3(@babel/core@7.23.6) '@types/babel__core': 7.20.5 react-refresh: 0.14.0 - vite: 5.2.11(@types/node@18.11.18)(sass@1.57.1) + vite: 5.2.11(sass@1.57.1) transitivePeerDependencies: - supports-color dev: true @@ -8560,7 +8563,6 @@ packages: resolution: {integrity: sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==} dependencies: truncate-utf8-bytes: 1.0.2 - dev: true /sass@1.57.1: resolution: {integrity: sha512-O2+LwLS79op7GI0xZ8fqzF7X2m/m8WFfI02dHOdsK5R2ECeS5F62zrwg/relM1rjSLy7Vd/DiMNIvPrQGsA0jw==} @@ -9146,7 +9148,6 @@ packages: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} dependencies: utf8-byte-length: 1.0.4 - dev: true /ts-api-utils@1.0.3(typescript@5.4.3): resolution: {integrity: sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==} @@ -9488,7 +9489,6 @@ packages: /utf8-byte-length@1.0.4: resolution: {integrity: sha512-4+wkEYLBbWxqTahEsWrhxepcoVOJ+1z5PGIjPZxRkytcdSUaNjIjBM7Xn8E+pdSuV7SzvWovBFA54FO0JSoqhA==} - dev: true /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -9549,6 +9549,27 @@ packages: - terser dev: true + /vite-node@1.6.0(sass@1.57.1): + resolution: {integrity: sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + dependencies: + cac: 6.7.14 + debug: 4.3.4 + pathe: 1.1.1 + picocolors: 1.0.0 + vite: 5.2.11(sass@1.57.1) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + dev: true + /vite-plugin-compression2@0.12.0: resolution: {integrity: sha512-9zdEF9xKVezETSF1l1bHoOk8LNoKIHB+DZVgSIGuGWaYupwFmsAGh0uwRcmK6rVHacxQRBECVYdtfc65DPDRfg==} dependencies: @@ -9566,7 +9587,7 @@ packages: '@rollup/pluginutils': 5.1.0 '@svgr/core': 8.1.0(typescript@5.4.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0) - vite: 5.2.11(@types/node@18.11.18)(sass@1.57.1) + vite: 5.2.11(sass@1.57.1) transitivePeerDependencies: - rollup - supports-color @@ -9584,7 +9605,7 @@ packages: debug: 4.3.4 globrex: 0.1.2 tsconfck: 3.0.2(typescript@5.4.3) - vite: 5.2.11(@types/node@18.11.18)(sass@1.57.1) + vite: 5.2.11(sass@1.57.1) transitivePeerDependencies: - supports-color - typescript @@ -9627,6 +9648,42 @@ packages: fsevents: 2.3.3 dev: true + /vite@5.2.11(sass@1.57.1): + resolution: {integrity: sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + esbuild: 0.20.2 + postcss: 8.4.38 + rollup: 4.17.2 + sass: 1.57.1 + optionalDependencies: + fsevents: 2.3.3 + dev: true + /vitest@1.6.0(@types/node@18.11.18)(jsdom@21.1.0)(sass@1.57.1): resolution: {integrity: sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -9684,6 +9741,62 @@ packages: - terser dev: true + /vitest@1.6.0(jsdom@21.1.0)(sass@1.57.1): + resolution: {integrity: sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.6.0 + '@vitest/ui': 1.6.0 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + dependencies: + '@vitest/expect': 1.6.0 + '@vitest/runner': 1.6.0 + '@vitest/snapshot': 1.6.0 + '@vitest/spy': 1.6.0 + '@vitest/utils': 1.6.0 + acorn-walk: 8.3.2 + chai: 4.3.10 + debug: 4.3.4 + execa: 8.0.1 + jsdom: 21.1.0 + local-pkg: 0.5.0 + magic-string: 0.30.5 + pathe: 1.1.1 + picocolors: 1.0.0 + std-env: 3.6.0 + strip-literal: 2.1.0 + tinybench: 2.5.1 + tinypool: 0.8.4 + vite: 5.2.11(sass@1.57.1) + vite-node: 1.6.0(sass@1.57.1) + why-is-node-running: 2.2.2 + transitivePeerDependencies: + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + dev: true + /w3c-xmlserializer@4.0.0: resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} engines: {node: '>=14'}