@fontoxml/fontoxml-development-tools
Version:
Development tools for Fonto.
229 lines (206 loc) • 6.18 kB
JavaScript
import asyncRouteWithLockCleanupHandler from '../asyncRouteWithLockCleanupHandler.js';
/** @typedef {import('../../src/getAppConfig.js').DevCmsConfig} DevCmsConfig */
/** @typedef {import('./stores/DevelopmentCms')} DevelopmentCms */
/**
* @typedef DocumentToSave
*
* @property {boolean} [autosave]
* @property {DocumentContext} [documentContext]
* @property {string} documentId
* @property {string} [revisionId]
* @property {string} content
* @property {{ [key: string]: unknown }} [metadata]
*/
/**
* @typedef Lock
*
* @property {boolean} isLockAcquired
* @property {boolean} isLockAvailable
*/
/**
* @typedef DocumentContext
*
* @property {{ [key: string]: unknown }} [documentMetadata]
* @property {boolean} [isLockAcquired]
*/
/**
* @typedef ResultBody
*
* @property {DocumentContext} [documentContext]
* @property {string} [revisionId]
* @property {Lock} [lock]
*/
/**
* @typedef ErrorResultBody
*
* @property {string} [revisionId]
* @property {Lock} [lock]
*/
/**
* @typedef DocumentErrorResult
*
* @property {string} documentId
* @property {number} status
* @property {ErrorResultBody} [body]
*/
/**
* @typedef DocumentResult
*
* @property {string} documentId
* @property {200} status
* @property {ResultBody} body
*/
/**
* @param {DevCmsConfig} config
*/
export default function configureDocumentPutPostRouteHandler(config) {
/**
* @param {DocumentToSave} documentToSave
* @param {{ editSessionToken: string, user: {}}} currentSession
* @param {DevelopmentCms} cms
* @param {(filePath: string) => Promise<DevCmsFileLock>} acquireLock
*
* @return {Promise<DocumentResult | DocumentErrorResult>}
*/
async function saveDocument(
documentToSave,
currentSession,
cms,
acquireLock,
) {
const documentId = documentToSave.documentId;
let fileLock;
try {
if (documentId && documentId.indexOf('..') !== -1) {
return {
documentId,
status: 403,
};
}
// Make sure the existing content can be loaded.
fileLock = await acquireLock(documentId);
const contentAndLatestRevisionId = await cms.getFileAndLatestRevisionId(
documentId,
currentSession.editSessionToken,
fileLock,
);
if (!contentAndLatestRevisionId) {
return {
documentId,
status: 404,
};
}
const documentLoadLock = {
...config.documentLoadLock,
...config.documentLoadLockOverrides[documentId],
};
/** @type {DocumentContext} */
const documentContext = documentToSave.documentContext || {};
const currentState = {
lock: {
isLockAcquired:
documentContext.isLockAcquired !== undefined
? documentLoadLock.isLockAvailable &&
documentContext.isLockAcquired
: documentLoadLock.isLockAcquired,
isLockAvailable: documentLoadLock.isLockAvailable,
reason: !documentLoadLock.isLockAvailable
? documentLoadLock.lockReason
: undefined,
},
revisionId: contentAndLatestRevisionId.revisionId,
};
if (!currentState.lock.isLockAvailable) {
// Not available has to mean not acquired, so 412 is the correct response.
// Sending 403 here would cause the document to be considered inaccessible
// instead of merely having an unavailable lock.
return {
documentId,
status: 412,
body: currentState,
};
}
if (
documentToSave.revisionId &&
documentToSave.revisionId !== currentState.revisionId
) {
return {
documentId,
status: 412,
body: currentState,
};
}
if (!currentState.lock.isLockAcquired) {
// Saving without having a lock is allowed by the batch save endpoint.
}
const newContent = documentToSave.content;
const newRevisionId = await cms.updateFile(
documentId,
newContent,
currentSession.user,
currentSession.editSessionToken,
fileLock,
);
documentContext.isLockAcquired = currentState.lock.isLockAcquired;
documentContext.documentMetadata = documentToSave.metadata;
return {
documentId,
status: 200,
body: {
revisionId: newRevisionId,
documentContext,
// lock: currentState.lock,
},
};
} catch (error) {
if (error.statusCode === 404) {
// Should not happen, as we already checked the existence of the document.
return {
documentId,
status: 404,
};
}
} finally {
fileLock?.release();
}
}
return asyncRouteWithLockCleanupHandler(async (acquireLock, req, res) => {
const currentSession = req.getFontoSession(
req.body?.context?.editSessionToken,
);
// Sanity check. A batch may never contain the same document twice. It is both a problem for
// parallel processing, and it needs logic to only return one of them in the results array.
// Even though this can be solved on the backend, it is not the kind of complexity we want
// each implementation of this endpoints to be required to solve.
/** @type {DocumentToSave[]} */
const documentsToSave = [];
const documentIds = new Set();
for (const documentToSave of req.body.documents) {
if (
typeof config.documentSaveBatchResultsLimit === 'number' &&
documentsToSave.length >= config.documentSaveBatchResultsLimit
) {
break;
}
if (documentIds.has(documentToSave.documentId)) {
// Technically this is a 400 due tue the request being incorrect, however we only
// have status code 500 in the API spec, and not status code 400. So throw, which
// is handled as a 500.
throw new Error(
'Error while processing request, due to the request containing duplicate documents.',
);
}
documentsToSave.push(documentToSave);
documentIds.add(documentToSave.documentId);
}
const results = await Promise.all(
documentsToSave.map((documentToSave) =>
saveDocument(documentToSave, currentSession, req.cms, acquireLock),
),
);
res
.status(200)
.set('content-type', 'application/json; charset=utf-8')
.json({ results });
});
}