diff --git a/apps/server/src/api-data/db/db.controller.ts b/apps/server/src/api-data/db/db.controller.ts index c712302a9..344709d26 100644 --- a/apps/server/src/api-data/db/db.controller.ts +++ b/apps/server/src/api-data/db/db.controller.ts @@ -14,11 +14,7 @@ import type { Request, Response } from 'express'; import { failEmptyObjects } from '../../utils/routerUtils.js'; import { resolveDbDirectory } from '../../setup/index.js'; - -import { doesProjectExist, upload } from '../../services/project-service/projectServiceUtils.js'; -import { oscIntegration } from '../../services/integration-service/OscIntegration.js'; -import { httpIntegration } from '../../services/integration-service/HttpIntegration.js'; -import { DataProvider } from '../../classes/data-provider/DataProvider.js'; +import { handleUploaded } from '../../services/project-service/projectServiceUtils.js'; import * as projectService from '../../services/project-service/ProjectService.js'; export async function patchPartialProjectFile(req: Request, res: Response) { @@ -94,6 +90,7 @@ export async function projectDownload(req: Request, res: Response) { /** * uploads, parses and applies the data from a given file + * Pretty much loadProject but with the extra upload step */ export async function postProjectFile(req: Request, res: Response) { if (!req.file) { @@ -102,24 +99,18 @@ export async function postProjectFile(req: Request, res: Response) { try { const name = req.body.filename; - if (!doesProjectExist(name)) { - return res.status(404).send({ message: 'File not found' }); - } - - await projectService.applyProjectFile(name); - - const oscSettings = await DataProvider.getOsc(); - const httpSettings = await DataProvider.getHttp(); - - oscIntegration.init(oscSettings); - httpIntegration.init(httpSettings); + await projectService.loadProjectFile(name); res.status(201).send({ message: `Loaded project ${name}`, }); } catch (error) { const message = getErrorMessage(error); + if (message.startsWith('Project file')) { + return res.status(403).send({ message }); + } res.status(500).send({ message }); } } diff --git a/apps/server/src/classes/data-provider/DataProvider.ts b/apps/server/src/classes/data-provider/DataProvider.ts index 978e38116..e3287a169 100644 --- a/apps/server/src/classes/data-provider/DataProvider.ts +++ b/apps/server/src/classes/data-provider/DataProvider.ts @@ -106,6 +106,7 @@ export class DataProvider { } private static async persist() { + // TODO: this is already handled by lowDb if (isTest) { return; } diff --git a/apps/server/src/services/project-service/ProjectService.ts b/apps/server/src/services/project-service/ProjectService.ts index 6e94e6c63..5c9894b0b 100644 --- a/apps/server/src/services/project-service/ProjectService.ts +++ b/apps/server/src/services/project-service/ProjectService.ts @@ -1,20 +1,27 @@ -import { DatabaseModel, GetInfo, ProjectData, ProjectFileListResponse } from 'ontime-types'; +import { DatabaseModel, GetInfo, LogOrigin, ProjectData, ProjectFileListResponse } from 'ontime-types'; import { copyFile, rename } from 'fs/promises'; +import { join } from 'path'; -import { initRundown } from '../rundown-service/RundownService.js'; import { DataProvider } from '../../classes/data-provider/DataProvider.js'; -import { runtimeService } from '../runtime-service/RuntimeService.js'; +import { logger } from '../../classes/Logger.js'; import { getNetworkInterfaces } from '../../utils/networkInterfaces.js'; -import { resolveProjectsDirectory, resolveStylesPath } from '../../setup/index.js'; -import { parseProjectFile } from './projectFileUtils.js'; -import { appStateProvider } from '../app-state-service/AppStateService.js'; -import { ensureDirectory, removeFileExtension } from '../../utils/fileManagement.js'; +import { resolveCorruptDirectory, resolveProjectsDirectory, resolveStylesPath } from '../../setup/index.js'; +import { appendToName, ensureDirectory, removeFileExtension } from '../../utils/fileManagement.js'; import { dbModel } from '../../models/dataModel.js'; import { deleteFile } from '../../utils/parserUtils.js'; import { switchDb } from '../../setup/loadDb.js'; -import { doesProjectExist, getPathToProject, getProjectFiles } from './projectServiceUtils.js'; import { generateUniqueFileName } from '../../utils/generateUniqueFilename.js'; +import { parseJson } from '../../utils/parser.js'; + +import { initRundown } from '../rundown-service/RundownService.js'; +import { appStateProvider } from '../app-state-service/AppStateService.js'; +import { runtimeService } from '../runtime-service/RuntimeService.js'; +import { oscIntegration } from '../integration-service/OscIntegration.js'; +import { httpIntegration } from '../integration-service/HttpIntegration.js'; + +import { parseProjectFile } from './projectFileUtils.js'; +import { doesProjectExist, getPathToProject, getProjectFiles } from './projectServiceUtils.js'; // init dependencies init(); @@ -26,25 +33,50 @@ function init() { ensureDirectory(resolveProjectsDirectory); } -type Options = { - onlyRundown?: 'true' | 'false'; -}; - /** - * Handles a file from the upload folder and applies its data + * Loads a data from a file into the runtime */ -export async function applyProjectFile(name: string, options?: Options) { +export async function loadProjectFile(name: string) { + if (!(await doesProjectExist(name))) { + throw new Error('Project file not found'); + } + const filePath = getPathToProject(name); - const data = parseProjectFile(filePath); + + // when loading a project file, we allow parsing to fail and interrupt the process + const fileData = await parseProjectFile(filePath); + const result = parseJson(fileData); + + if (result.errors.length > 0) { + logger.warning(LogOrigin.Server, 'Project loaded with errors'); + + // move original file to corrupted + ensureDirectory(resolveCorruptDirectory); + copyFile(filePath, join(resolveCorruptDirectory, name)); + + // rename file to indicate recovery + const newName = appendToName(filePath, '(recovered)'); + await rename(filePath, newName); + } // change LowDB to point to new file - await switchDb(filePath); - - // apply data model - await applyDataModel(data, options); + await switchDb(filePath, result.data); + logger.info(LogOrigin.Server, `Loaded project ${name}`); // persist the project selection await appStateProvider.setLastLoadedProject(name); + + // apply data model + runtimeService.stop(); + + const { rundown, customFields, osc, http } = result.data; + + // apply the rundown + initRundown(rundown, customFields); + + // apply integrations + oscIntegration.init(osc); + httpIntegration.init(http); } /** @@ -64,11 +96,11 @@ export async function getProjectList(): Promise { * Duplicates an existing project file */ export async function duplicateProjectFile(originalFile: string, newFilename: string) { - if (!doesProjectExist(originalFile)) { + if (!(await doesProjectExist(originalFile))) { throw new Error('Project file not found'); } - if (doesProjectExist(newFilename)) { + if (await doesProjectExist(newFilename)) { throw new Error(`Project file with name ${newFilename} already exists`); } @@ -82,11 +114,11 @@ export async function duplicateProjectFile(originalFile: string, newFilename: st * Renames an existing project file */ export async function renameProjectFile(originalFile: string, newFilename: string) { - if (!doesProjectExist(originalFile)) { + if (!(await doesProjectExist(originalFile))) { throw new Error('Project file not found'); } - if (doesProjectExist(newFilename)) { + if (await doesProjectExist(newFilename)) { throw new Error(`Project file with name ${newFilename} already exists`); } @@ -98,7 +130,27 @@ export async function renameProjectFile(originalFile: string, newFilename: strin // Update the last loaded project config if current loaded project is the one being renamed const isLoaded = await appStateProvider.isLastLoadedProject(originalFile); if (isLoaded) { - await applyProjectFile(newFilename); + const fileData = await parseProjectFile(newProjectFilePath); + const result = parseJson(fileData); + + // change LowDB to point to new file + await switchDb(newProjectFilePath, result.data); + logger.info(LogOrigin.Server, `Loaded project ${newFilename}`); + + // persist the project selection + await appStateProvider.setLastLoadedProject(newFilename); + + // apply data model + runtimeService.stop(); + + const { rundown, customFields, osc, http } = result.data; + + // apply the rundown + initRundown(rundown, customFields); + + // apply integrations + oscIntegration.init(osc); + httpIntegration.init(http); } } @@ -139,7 +191,7 @@ export async function deleteProjectFile(filename: string) { throw new Error('Cannot delete currently loaded project'); } - if (!doesProjectExist(filename)) { + if (!(await doesProjectExist(filename))) { throw new Error('Project file not found'); } @@ -172,7 +224,7 @@ export async function getInfo(): Promise { * applies a partial database model */ // TODO: should be private as part of a load -export async function applyDataModel(data: Partial, _options?: Options) { +export async function applyDataModel(data: Partial) { runtimeService.stop(); // TODO: allow partial project merge from options diff --git a/apps/server/src/services/project-service/projectFileUtils.ts b/apps/server/src/services/project-service/projectFileUtils.ts index 5864bc13a..9a8eb8b0a 100644 --- a/apps/server/src/services/project-service/projectFileUtils.ts +++ b/apps/server/src/services/project-service/projectFileUtils.ts @@ -1,6 +1,9 @@ -import { readFileSync } from 'fs'; +import { readFile } from 'fs/promises'; +import { DatabaseModel } from 'ontime-types'; import { extname } from 'path'; +// TODO: move to projectServiceUtils + /** * Given an array of file names, filters out any files that do not have a '.json' extension. * We assume these are project files @@ -14,18 +17,18 @@ export function filterProjectFiles(files: Array): Array { }); } -export function parseProjectFile(filePath: string): object { +export async function parseProjectFile(filePath: string): Promise> { if (!filePath.endsWith('.json')) { throw new Error('Invalid file type'); } - const rawdata = readFileSync(filePath, 'utf-8'); + const rawdata = await readFile(filePath, 'utf-8'); const uploadedJson = JSON.parse(rawdata); // at this point, we think this is a DatabaseModel // verify by looking for the required fields if (uploadedJson?.settings?.app !== 'ontime') { - throw new Error('Not a ontime project file'); + throw new Error('Not an Ontime project file'); } return uploadedJson; } diff --git a/apps/server/src/services/project-service/projectServiceUtils.ts b/apps/server/src/services/project-service/projectServiceUtils.ts index 5ad7315f8..61938336e 100644 --- a/apps/server/src/services/project-service/projectServiceUtils.ts +++ b/apps/server/src/services/project-service/projectServiceUtils.ts @@ -1,24 +1,21 @@ import { ProjectFile } from 'ontime-types'; -import { stat } from 'fs/promises'; -import { existsSync } from 'fs'; +import { access, rename, stat } from 'fs/promises'; import { join } from 'path'; import { resolveProjectsDirectory } from '../../setup/index.js'; -import { filterProjectFiles } from './projectFileUtils.js'; import { getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js'; -import { moveUploadedFile } from '../../utils/upload.js'; + +import { filterProjectFiles } from './projectFileUtils.js'; /** * Handles the upload of a new project file * @param filePath * @param name - * @returns */ -export async function upload(filePath: string, name: string) { +export async function handleUploaded(filePath: string, name: string) { const newFilePath = join(resolveProjectsDirectory, name); - await moveUploadedFile(filePath, newFilePath); - return name; + await rename(filePath, newFilePath); } /** @@ -54,9 +51,14 @@ export async function getProjectFiles(): Promise { * Checks whether a project of a given name exists * @param name */ -export function doesProjectExist(name: string): boolean { - const projectFilePath = join(resolveProjectsDirectory, name); - return existsSync(projectFilePath); +export async function doesProjectExist(name: string): Promise { + try { + const projectFilePath = join(resolveProjectsDirectory, name); + await access(projectFilePath); + return true; + } catch (_) { + return false; + } } /** diff --git a/apps/server/src/setup/config.ts b/apps/server/src/setup/config.ts index fd96b470c..9c5c04852 100644 --- a/apps/server/src/setup/config.ts +++ b/apps/server/src/setup/config.ts @@ -1,5 +1,6 @@ export const config = { appState: 'app-state.json', + corrupt: 'corrupt files', crash: 'crash logs', database: { testdb: 'test-db', diff --git a/apps/server/src/setup/index.ts b/apps/server/src/setup/index.ts index 8faf1bbf9..1dc04c86a 100644 --- a/apps/server/src/setup/index.ts +++ b/apps/server/src/setup/index.ts @@ -56,10 +56,10 @@ const currentDir = dirname(__dirname); // locally we are in src/setup, in the production build, this is a single file at src export const srcDirectory = isProduction ? currentDir : join(currentDir, '../'); -// resolve path to external +// TODO: simplify logic +// resolve path to client const productionPath = join(srcDirectory, 'client/'); const devPath = join(srcDirectory, '../../client/build/'); - export const resolvedPath = (): string => { if (isTest) { return devPath; @@ -136,7 +136,10 @@ export const resolveRestoreFile = join(getAppDataPath(), config.restoreFile); export const resolveSheetsDirectory = join(getAppDataPath(), config.sheets.directory); // path to crash reports -export const resolveCrashReportDirectory = getAppDataPath(); +export const resolveCrashReportDirectory = join(getAppDataPath(), config.crash); // path to projects export const resolveProjectsDirectory = join(getAppDataPath(), config.projects); + +// path to corrupt files +export const resolveCorruptDirectory = join(getAppDataPath(), config.corrupt); diff --git a/apps/server/src/setup/loadDb.ts b/apps/server/src/setup/loadDb.ts index d2d26ee1d..84b0091be 100644 --- a/apps/server/src/setup/loadDb.ts +++ b/apps/server/src/setup/loadDb.ts @@ -56,7 +56,7 @@ async function loadDb(directory: string, filename: string) { let newData: DatabaseModel = dbModel; try { - const maybeProjectFile = parseProjectFile(dbInDisk); + const maybeProjectFile = await parseProjectFile(dbInDisk); const result = parseJson(maybeProjectFile); await appStateProvider.setLastLoadedProject(filename); diff --git a/apps/server/src/utils/__tests__/fileManagement.test.ts b/apps/server/src/utils/__tests__/fileManagement.test.ts index 36db325cb..675ff42a8 100644 --- a/apps/server/src/utils/__tests__/fileManagement.test.ts +++ b/apps/server/src/utils/__tests__/fileManagement.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { ensureJsonExtension } from '../fileManagement.js'; +import { appendToName, ensureJsonExtension } from '../fileManagement.js'; describe('ensureJsonExtension', () => { it('should add .json to a filename without an extension', () => { @@ -26,3 +26,26 @@ describe('ensureJsonExtension', () => { expect(result).toBe('my.test.file.json'); }); }); + +describe('appendToName', () => { + it('appends a given string to a file name', () => { + const filename = 'file.json'; + const append = '(recovered)'; + const result = appendToName(filename, append); + expect(result).toBe('file (recovered).json'); + }); + + it('handles paths', () => { + const path = '/Users/carlos/Library/Application Support/Ontime/projects/file.json'; + const append = '(recovered)'; + const result = appendToName(path, append); + expect(result).toBe('/Users/carlos/Library/Application Support/Ontime/projects/file (recovered).json'); + }); + + it('handles multiple . in string', () => { + const path = 'strange.file.name.json'; + const append = '(recovered)'; + const result = appendToName(path, append); + expect(result).toBe('strange.file.name (recovered).json'); + }); +}); diff --git a/apps/server/src/utils/fileManagement.ts b/apps/server/src/utils/fileManagement.ts index 6ec11e32a..097b110ac 100644 --- a/apps/server/src/utils/fileManagement.ts +++ b/apps/server/src/utils/fileManagement.ts @@ -28,7 +28,7 @@ export function ensureJsonExtension(filename: string | undefined): string | unde * Lists all files in a directory */ export async function getFilesFromFolder(folderPath: string): Promise { - return await readdir(folderPath); + return readdir(folderPath); } /** @@ -38,3 +38,12 @@ export async function getFilesFromFolder(folderPath: string): Promise export const removeFileExtension = (filename: string): string => { return parse(filename).name; }; + +/** + * Appends a given string to a file name or path + * @example appendToName('file.json', '(recovered)') => 'file (recovered).json' + */ +export function appendToName(filePath: string, append: string): string { + const extension = filePath.split('.').pop(); + return filePath.replace(`.${extension}`, ` ${append}.${extension}`); +} diff --git a/apps/server/src/utils/upload.ts b/apps/server/src/utils/upload.ts index a22b79de4..194b48d14 100644 --- a/apps/server/src/utils/upload.ts +++ b/apps/server/src/utils/upload.ts @@ -1,11 +1,10 @@ import multer from 'multer'; import path from 'path'; import fs from 'fs'; -import { rename, rm } from 'fs/promises'; +import { rm } from 'fs/promises'; import { ensureDirectory } from './fileManagement.js'; import { getAppDataPath, uploadsFolderPath } from '../setup/index.js'; -import { deleteFile } from './parserUtils.js'; function generateNewFileName(filePath: string, callback: (newName: string) => void) { const baseName = path.basename(filePath, path.extname(filePath)); @@ -69,14 +68,3 @@ export async function clearUploadfolder() { // we dont care that there was no folder } } - -/** - * Copies a file from upload folder to a destination - * @param filePath - * @param name - * @returns - */ -export async function moveUploadedFile(fromUpload: string, toDestination: string) { - await rename(fromUpload, toDestination); - await deleteFile(fromUpload); -}