UNPKG

@fontoxml/fontoxml-development-tools

Version:

Development tools for Fonto.

812 lines (711 loc) 21.6 kB
import path from 'path'; import { v4 as uuid } from 'uuid'; import FileSystemStore from './FileSystemStore.js'; import MemoryStore from './MemoryStore.js'; import mergeItems from './mergeItems.js'; import getFileHash from '../getFileHash.js'; import unmergeableRevisionList from './unmergeableRevisionList.js'; /** @typedef {import('../../../src/getAppConfig.js').DevCmsConfig} DevCmsConfig */ /** * @typedef {Object} DevCmsRevision * * @property {string} id * @property {Object} author * @property {string} lastModifiedTimestamp * @property {string} _editSessionToken */ /** * File extensions to use for specific fileType requests. * This is used to construct the query string in the browse endpoint to retrieve the correct files from Google Drive. * * @type {Object} */ const ASSET_TYPE_BY_EXTENSION = { xml: 'document', dita: 'document', ditamap: 'document', template: 'document-template', bmp: 'image', gif: 'image', jpeg: 'image', jpg: 'image', png: 'image', svg: 'image', mp3: 'audio', wav: 'audio', aac: 'audio', ogg: 'audio', }; export class DevCmsFileLock { constructor() { /** @type {() => void} */ this._resolve = null; /** @type {Promise<void>} */ this._promise = new Promise((resolve) => { this._resolve = resolve; }); } release() { if (this._resolve) { this._resolve(); this._resolve = null; } } waitForRelease() { if (this._resolve) { return this._promise; } return Promise.resolve(); } } /** * A file path object pointing to the `history.json` file. */ export class DevCmsHistoryFilePath { /** * @param {string} filePath */ constructor(filePath) { /** @type {string} */ this._filePath = filePath; } getFilePath() { return this._filePath; } /** * @returns {string} */ getHistoryFilePath() { return path.join('.history', this._filePath, 'history.json'); } } /** * A file path object pointing to a specific history revision file. */ export class DevCmsHistoryRevisionFilePath extends DevCmsHistoryFilePath { /** * @param {string} filePath * @param {string} revisionId */ constructor(filePath, revisionId) { super(filePath); /** @type {string} */ this._revisionId = revisionId; } /** * @returns {string} */ getHistoryFilePath() { return path.join('.history', this._filePath, this._revisionId); } } export default class DevelopmentCms { /** * @param {DevCmsConfig} config */ constructor(config) { /** @type {{ [extension: string]: string}} */ this._assetTypeByExtension = { ...ASSET_TYPE_BY_EXTENSION, ...config.assetTypeByExtension, }; /** @type {Map<string, MemoryStore> }} */ this._memoryStoreById = new Map(); /** @type {Map<string, NodeJS.Timeout>} */ this._memoryStoreCleanupById = new Map(); /** @type {FileSystemStore} */ this._fileSystem = new FileSystemStore(config, this._assetTypeByExtension); /** @type {DevCmsConfig['memoryStoreTtl']} */ this._memoryStoreTtl = config.memoryStoreTtl; /** @type {DevCmsConfig['saveMode']} */ this._saveMode = config.saveMode; /** @type {Map<string, DevCmsFileLock> }} */ this._locksByFilePath = new Map(); } /** * @param {string} editSessionToken * * @return {MemoryStore} */ _getOrCreateMemoryStore(editSessionToken) { const memoryStoreId = this._saveMode === 'shared-memory' ? 'shared-memory-store-id' : editSessionToken; let memoryStore = this._memoryStoreById.get(memoryStoreId); if (!memoryStore) { memoryStore = new MemoryStore(this._assetTypeByExtension); this._memoryStoreById.set(memoryStoreId, memoryStore); } if (this._memoryStoreTtl < 0) { return memoryStore; } const memoryStoreCleanup = this._memoryStoreCleanupById.get(memoryStoreId); if (memoryStoreCleanup) { clearTimeout(memoryStoreCleanup); } this._memoryStoreCleanupById.set( memoryStoreId, setTimeout(() => { this._memoryStoreCleanupById.delete(memoryStoreId); this._memoryStoreById.delete(memoryStoreId); }, this._memoryStoreTtl), ); return memoryStore; } /** * Get a lock for a file. * * @description * When performing a read-update-write on a file, be sure to use a single lock for the file, to * prevent any other process from modifying the file in the meantime, and the write potentially * overriding those changes. * * WARNING: When handling files in parallel, and you (might) process the same file multiple * times, you need to acquire the lock per parallel process, and not once per file. Otherwise * race conditions might still occur, potentially leading to data loss. * * TIP: When using in a route handler, be sure to use `asyncRouteWithLockCleanupHandler`, which * makes sure the locks are also released on error. * * @example * ``` * const fileLock = await devCms.acquireLock(filePath); * const file = await devCms.getRevisionsAndLatestContent(filePath, editSessionToken, fileLock); * const updatedContent = file.content.replace('foo', 'bar'); * await devCms.updateFile(filePath, updatedContent, currentSession, fileLock); * fileLock.release(); * ``` * * @example * ``` * return asyncRouteWithLockCleanupHandler(async (acquireLock, req, res) => { * // Note using `await acquireLock()` instead of `req.cms.acquireLock()`. * const fileLock = await acquireLock(filePath); * const file = await devCms.getRevisionsAndLatestContent(filePath, editSessionToken, fileLock); * // Note that the lock is also cleaned up on error at the end of the route handler. * fileLock.release(); * res.status(200).send(file.content); * }); * ``` * * @param {string} filePath * * @return {Promise<DevCmsFileLock>} */ async acquireLock(filePath) { if (!filePath) { throw new Error('No path specified for lock.'); } if (typeof filePath !== 'string') { throw new Error('Path must be a string.'); } if (!path.relative('.history/', filePath).startsWith('..')) { // Having the lock on a file, also means you have a lock on the history file(s). throw new Error('Cannot get lock for history file.'); } // Wait until any earlier lock is released. while (this._locksByFilePath.has(filePath)) { const fileLock = this._locksByFilePath.get(filePath); await fileLock?.waitForRelease(); } const fileLock = new DevCmsFileLock(); this._locksByFilePath.set(filePath, fileLock); void fileLock.waitForRelease().then(() => { if (this._locksByFilePath.get(filePath) === fileLock) { // If the lock is still the active lock, remove it. this._locksByFilePath.delete(filePath); } }); return fileLock; } /** * @param {string | DevCmsHistoryFilePath} filePath * @param {DevCmsFileLock} fileLock * * @return {Promise<void>} */ async ensureValidLock(filePath, fileLock) { if (!filePath) { throw new Error('No path specified for lock.'); } const normalizedFilePath = typeof filePath === 'string' ? filePath : filePath.getFilePath(); if (!normalizedFilePath) { throw new Error('No path specified for lock.'); } if (!fileLock) { throw new Error('No lock specified.'); } if (this._locksByFilePath.get(normalizedFilePath) !== fileLock) { throw new Error('Lock not acquired.'); } } /** * @param {string | DevCmsHistoryFilePath} filePath * @param {string} editSessionToken * @param {DevCmsFileLock} fileLock * * @return {Promise<string | null>} */ async _getFile(filePath, editSessionToken, fileLock) { await this.ensureValidLock(filePath, fileLock); const storeFilePath = typeof filePath === 'string' ? filePath : filePath.getHistoryFilePath(); const memoryStore = this._getOrCreateMemoryStore(editSessionToken); const file = memoryStore.existsSync(storeFilePath) ? await memoryStore.load(storeFilePath) : await this._fileSystem.load(storeFilePath); return file; } /** * @param {string | DevCmsHistoryFilePath} filePath path of the file relative to dev-cms/files * @param {*} content * @param {string} editSessionToken * @param {DevCmsFileLock} fileLock * * @return {Promise<void>} */ async _updateFile(filePath, content, editSessionToken, fileLock) { await this.ensureValidLock(filePath, fileLock); const storeFilePath = typeof filePath === 'string' ? filePath : filePath.getHistoryFilePath(); switch (this._saveMode) { case 'session-memory': case 'shared-memory': await this._getOrCreateMemoryStore(editSessionToken).save( storeFilePath, content, ); break; default: await this._fileSystem.save(storeFilePath, content); } } /** * @param {string | DevCmsHistoryFilePath} filePath * @param {*} content * @param {string} editSessionToken * @param {DevCmsFileLock} fileLock * * @return {Promise<void>} */ async _createFile(filePath, content, editSessionToken, fileLock) { await this.ensureValidLock(filePath, fileLock); const storeFilePath = typeof filePath === 'string' ? filePath : filePath.getHistoryFilePath(); if ( this._getOrCreateMemoryStore(editSessionToken).existsSync( storeFilePath, ) || this._fileSystem.existsSync(storeFilePath) ) { // TODO: Decide what we want to do here. Maybe return false? Or just throw and error? const error = new Error('File already exists.'); error.status = 409; throw error; } await this._updateFile(filePath, content, editSessionToken, fileLock); } /** * @param {string | DevCmsHistoryFilePath} filePath * @param {string} editSessionToken * @param {DevCmsFileLock} fileLock * * @return {Promise<void>} */ async _removeFile(filePath, editSessionToken, fileLock) { await this.ensureValidLock(filePath, fileLock); const storeFilePath = typeof filePath === 'string' ? filePath : filePath.getHistoryFilePath(); this._getOrCreateMemoryStore(editSessionToken).removeSync(storeFilePath); this._fileSystem.removeSync(storeFilePath); } /** * @param {string} filePath * @param {DevCmsRevision[]} revisions * @param {string} editSessionToken * @param {DevCmsFileLock} fileLock * * @return {Promise<void>} */ async _updateRevisions(filePath, revisions, editSessionToken, fileLock) { await this.ensureValidLock(filePath, fileLock); if (typeof filePath !== 'string') { throw new Error('Path must be a string.'); } const historyFilePath = new DevCmsHistoryFilePath(filePath); const json = JSON.stringify(revisions, null, '\t'); await this._updateFile(historyFilePath, json, editSessionToken, fileLock); } /** * @param {string} filePath * @param {*} content * @param {Object} author * @param {string} editSessionToken * @param {boolean} isFromSameSession * @param {DevCmsFileLock} fileLock * * @return {Promise<DevCmsRevision[]>} */ async _initRevisions( filePath, content, author, editSessionToken, isFromSameSession, fileLock, ) { if (typeof filePath !== 'string') { throw new Error('Path must be a string.'); } const initialRevisionId = uuid(); const initialRevisionFilePath = new DevCmsHistoryRevisionFilePath( filePath, initialRevisionId, ); await this._createFile( initialRevisionFilePath, content, editSessionToken, fileLock, ); const revisions = [ { id: initialRevisionId, hash: getFileHash(content), author, lastModifiedTimestamp: new Date().toISOString(), // Non-standard, used to combine saves per session _editSessionToken: isFromSameSession ? editSessionToken : uuid(), }, ]; // Force save to memory store, or revision ids between loadHistory call of editor and // document history service will never match. await this._updateRevisions( filePath, revisions, editSessionToken, fileLock, ); return revisions; } /** * @param {string} filePath * @param {string} editSessionToken * @param {DevCmsFileLock} fileLock * * @return {Promise<{ revisions: DevCmsRevision[], content: string } | null>} */ async getRevisionsAndLatestContent(filePath, editSessionToken, fileLock) { await this.ensureValidLock(filePath, fileLock); const content = await this._getFile(filePath, editSessionToken, fileLock); if (!content) { return null; } const historyFilePath = new DevCmsHistoryFilePath(filePath); const historyFileJson = await this._getFile( historyFilePath, editSessionToken, fileLock, ); if (historyFileJson) { // If there is a history file, load it and check if there is a revision matching the // current file content. const revisions = JSON.parse(historyFileJson); // Either return revision, or create a new revision on the fly and use that as latest. const hashInHistory = revisions[0]?.hash; const currentContentHash = getFileHash(content); if (revisions[0] && hashInHistory === currentContentHash) { return { content, revisions }; } const session = { editSessionToken: uuid(), user: { displayName: 'Unknown', id: uuid(), }, }; const revisionId = uuid(); revisions.unshift({ id: revisionId, hash: currentContentHash, author: session.user, lastModifiedTimestamp: new Date().toISOString(), // Non-standard, used to combine saves per session _editSessionToken: session.editSessionToken, }); const revisionFilePath = new DevCmsHistoryRevisionFilePath( filePath, revisionId, ); await this._updateFile( revisionFilePath, content, editSessionToken, fileLock, ); await this._updateRevisions( filePath, revisions, editSessionToken, fileLock, ); return { content, revisions }; } // No history for this file found, create one on the fly with only the initial revision const author = { displayName: 'Unknown', id: uuid(), }; const revisions = await this._initRevisions( filePath, content, author, editSessionToken, false, fileLock, ); return { content, revisions }; } /** * @param {string} filePath * @param {string} editSessionToken * @param {DevCmsFileLock} fileLock * * @return {Promise<DevCmsRevision | null>} */ async getLatestRevision(filePath, editSessionToken, fileLock) { await this.ensureValidLock(filePath, fileLock); const revisionsAndLatestContent = await this.getRevisionsAndLatestContent( filePath, editSessionToken, fileLock, ); if (!revisionsAndLatestContent) { return null; } return revisionsAndLatestContent.revisions[0]; } /** * @param {string} filePath * @param {string} revisionId * @param {string} editSessionToken * @param {DevCmsFileLock} fileLock * * @return {Promise<string | null>} */ async getFileByRevisionId(filePath, revisionId, editSessionToken, fileLock) { await this.ensureValidLock(filePath, fileLock); if (!this.existsSync(filePath, editSessionToken)) { return null; } // TODO: Should we check if the revision id exists in the history file? // Having the lock on a file, also means you have a lock on the revision file. const revisionFilePath = new DevCmsHistoryRevisionFilePath( filePath, revisionId, ); return this._getFile(revisionFilePath, editSessionToken, fileLock); } /** * @param {string} filePath * @param {string} editSessionToken * @param {DevCmsFileLock} fileLock * * @return {Promise<{ content: string, revisionId: string } | null>} */ async getFileAndLatestRevisionId(filePath, editSessionToken, fileLock) { await this.ensureValidLock(filePath, fileLock); const revisionsAndLatestContent = await this.getRevisionsAndLatestContent( filePath, editSessionToken, fileLock, ); if (!revisionsAndLatestContent) { return null; } return { content: revisionsAndLatestContent.content, revisionId: revisionsAndLatestContent.revisions[0].id, }; } /** * @param {string} filePath * @param {*} content * @param {Object} author * @param {string} editSessionToken * @param {DevCmsFileLock} fileLock * * @return {Promise<string>} The revision id */ async updateFile(filePath, content, author, editSessionToken, fileLock) { await this.ensureValidLock(filePath, fileLock); const revisionsAndLatestContent = await this.getRevisionsAndLatestContent( filePath, editSessionToken, fileLock, ); if (!revisionsAndLatestContent) { // TODO: Should we throw a generic error here? Or return something else? const error = new Error('File does not exist.'); error.status = 404; throw error; } await this._updateFile(filePath, content, editSessionToken, fileLock); // "Update" latest revision if this is the same session, unless it's in the unmergeable revision list. const revisions = revisionsAndLatestContent.revisions; const previousRevision = revisions[0]; if ( previousRevision && // TODO: This list should be pre-filled with all created context revisions of existing annotations. !unmergeableRevisionList.has(previousRevision.id) && previousRevision._editSessionToken === editSessionToken ) { await this._removeFile( new DevCmsHistoryRevisionFilePath(filePath, previousRevision.id), editSessionToken, fileLock, ); revisions.shift(); } const revisionId = uuid(); revisions.unshift({ id: revisionId, hash: getFileHash(content), author, lastModifiedTimestamp: new Date().toISOString(), // Non-standard, used to combine saves per session _editSessionToken: editSessionToken, }); const revisionFilePath = new DevCmsHistoryRevisionFilePath( filePath, revisionId, ); await this._updateFile( revisionFilePath, content, editSessionToken, fileLock, ); await this._updateRevisions( filePath, revisions, editSessionToken, fileLock, ); return revisionId; } /** * @param {string} filePath * @param {*} content * @param {Object} author * @param {string} editSessionToken * @param {DevCmsFileLock} fileLock * * @returns {Promise<string>} The revision id */ async createFile(filePath, content, author, editSessionToken, fileLock) { await this.ensureValidLock(filePath, fileLock); await this._createFile(filePath, content, editSessionToken, fileLock); const revisions = await this._initRevisions( filePath, content, author, editSessionToken, true, fileLock, ); return revisions[0].id; } /** * NOTE: Only use this for non document files. * * @param {string} filePath * @param {string} editSessionToken * @param {DevCmsFileLock} fileLock * * @return {Promise<string | null>} */ async getFileWithoutHistory(filePath, editSessionToken, fileLock) { await this.ensureValidLock(filePath, fileLock); const content = await this._getFile(filePath, editSessionToken, fileLock); if (!content) { return null; } return content; } /** * NOTE: Only use this for non document files. * * @param {string} filePath * @param {*} content * @param {string} editSessionToken * @param {DevCmsFileLock} fileLock * * @return {Promise<void>} */ async updateOrCreateFileWithoutHistory( filePath, content, editSessionToken, fileLock, ) { await this.ensureValidLock(filePath, fileLock); // TODO: Maybe split into separate update and create methods? await this._updateFile(filePath, content, editSessionToken, fileLock); } /** * @param {string} filePath path of the file relative to dev-cms/files * @param {string} editSessionToken * * @return {boolean} */ // TODO: Remove this method, and use load instead, checking for error.status === 404 / null?? existsSync(filePath, editSessionToken) { return ( this._getOrCreateMemoryStore(editSessionToken).existsSync(filePath) || this._fileSystem.existsSync(filePath) ); } /** * @param {string} filePath path of the file relative to dev-cms/files * @param {string} editSessionToken * * @return {string | null} the resolved absolute path of the file on disk. Does not work for files in memory. */ getPathInFilesystemSync(filePath, _editSessionToken) { return this._fileSystem.getPathSync(filePath); } /** * @param {string | undefined} folderPath path of the folder relative to dev-cms/files * @param {string} editSessionToken * * @return {Object[] | null} items in folder, combined from all stores, or null if the folder does not exist. */ listSync(folderPath, editSessionToken) { folderPath = folderPath || ''; const fileSystemItems = this._fileSystem.listSync(folderPath); const memoryItems = this._getOrCreateMemoryStore(editSessionToken).listSync(folderPath); if (!fileSystemItems && !memoryItems) { return null; } return mergeItems(fileSystemItems, memoryItems); } /** * @description * For unit testing only. */ destroy() { for (const memoryStoreId of this._memoryStoreById.keys()) { this._memoryStoreById.delete(memoryStoreId); const memoryStoreCleanup = this._memoryStoreCleanupById.get(memoryStoreId); if (memoryStoreCleanup) { clearTimeout(memoryStoreCleanup); this._memoryStoreCleanupById.delete(memoryStoreId); } } } }