diff --git a/services/web/app/src/Features/History/HistoryController.js b/services/web/app/src/Features/History/HistoryController.js index cc1bc67847..a0f0183f44 100644 --- a/services/web/app/src/Features/History/HistoryController.js +++ b/services/web/app/src/Features/History/HistoryController.js @@ -457,6 +457,19 @@ async function _pipeHistoryZipToResponse(v1ProjectId, version, name, req, res) { } } +async function getLatestHistory(req, res, next) { + const projectId = req.params.project_id + const history = await HistoryManager.promises.getLatestHistory(projectId) + res.json(history) +} + +async function getChanges(req, res, next) { + const projectId = req.params.project_id + const since = req.query.since + const changes = await HistoryManager.promises.getChanges(projectId, { since }) + res.json(changes) +} + function isPrematureClose(err) { return ( err instanceof Error && @@ -480,6 +493,8 @@ module.exports = { createLabel: expressify(createLabel), deleteLabel: expressify(deleteLabel), downloadZipOfVersion: expressify(downloadZipOfVersion), + getLatestHistory: expressify(getLatestHistory), + getChanges: expressify(getChanges), _displayNameForUser, promises: { _pipeHistoryZipToResponse, diff --git a/services/web/app/src/Features/History/HistoryManager.js b/services/web/app/src/Features/History/HistoryManager.js index 78730c4782..6e40907d1b 100644 --- a/services/web/app/src/Features/History/HistoryManager.js +++ b/services/web/app/src/Features/History/HistoryManager.js @@ -21,6 +21,12 @@ const projectKey = require('./project_key') const GLOBAL_BLOBS = new Set() // CHANGE FROM SOURCE: only store hashes. +const HISTORY_V1_URL = settings.apis.v1_history.url +const HISTORY_V1_BASIC_AUTH = { + user: settings.apis.v1_history.user, + password: settings.apis.v1_history.pass, +} + function makeGlobalKey(hash) { return `${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4)}` } @@ -144,16 +150,10 @@ async function _deleteProjectInProjectHistory(projectId) { async function _deleteProjectInFullProjectHistory(historyId) { try { - await fetchNothing( - `${settings.apis.v1_history.url}/projects/${historyId}`, - { - method: 'DELETE', - basicAuth: { - user: settings.apis.v1_history.user, - password: settings.apis.v1_history.pass, - }, - } - ) + await fetchNothing(`${HISTORY_V1_URL}/projects/${historyId}`, { + method: 'DELETE', + basicAuth: HISTORY_V1_BASIC_AUTH, + }) } catch (err) { throw OError.tag(err, 'failed to clear project history', { historyId }) } @@ -162,29 +162,23 @@ async function _deleteProjectInFullProjectHistory(historyId) { async function uploadBlobFromDisk(historyId, hash, byteLength, fsPath) { const outStream = fs.createReadStream(fsPath) - const url = `${settings.apis.v1_history.url}/projects/${historyId}/blobs/${hash}` + const url = `${HISTORY_V1_URL}/projects/${historyId}/blobs/${hash}` await fetchNothing(url, { method: 'PUT', body: outStream, headers: { 'Content-Length': byteLength }, // add the content length to work around problems with chunked encoding in node 18 signal: AbortSignal.timeout(60 * 1000), - basicAuth: { - user: settings.apis.v1_history.user, - password: settings.apis.v1_history.pass, - }, + basicAuth: HISTORY_V1_BASIC_AUTH, }) } async function copyBlob(sourceHistoryId, targetHistoryId, hash) { - const url = `${settings.apis.v1_history.url}/projects/${targetHistoryId}/blobs/${hash}` + const url = `${HISTORY_V1_URL}/projects/${targetHistoryId}/blobs/${hash}` await fetchNothing( `${url}?${new URLSearchParams({ copyFrom: sourceHistoryId })}`, { method: 'POST', - basicAuth: { - user: settings.apis.v1_history.user, - password: settings.apis.v1_history.pass, - }, + basicAuth: HISTORY_V1_BASIC_AUTH, } ) } @@ -200,7 +194,7 @@ async function requestBlobWithFallback( 'overleaf.history.id': true, }) // Talk to history-v1 directly to avoid streaming via project-history. - let url = new URL(settings.apis.v1_history.url) + let url = new URL(HISTORY_V1_URL) url.pathname += `/projects/${project.overleaf.history.id}/blobs/${hash}` const opts = { method, headers: { Range: range } } @@ -255,22 +249,14 @@ async function requestBlobWithFallback( * @returns Promise */ async function getCurrentContent(projectId) { - const project = await ProjectGetter.promises.getProject(projectId, { - overleaf: true, - }) - const historyId = project?.overleaf?.history?.id - if (!historyId) { - throw new OError('project does not have a history id', { projectId }) - } + const historyId = await getHistoryId(projectId) + try { return await fetchJson( - `${settings.apis.v1_history.url}/projects/${historyId}/latest/content`, + `${HISTORY_V1_URL}/projects/${historyId}/latest/content`, { method: 'GET', - basicAuth: { - user: settings.apis.v1_history.user, - password: settings.apis.v1_history.pass, - }, + basicAuth: HISTORY_V1_BASIC_AUTH, } ) } catch (err) { @@ -287,22 +273,14 @@ async function getCurrentContent(projectId) { * @returns Promise */ async function getContentAtVersion(projectId, version) { - const project = await ProjectGetter.promises.getProject(projectId, { - overleaf: true, - }) - const historyId = project?.overleaf?.history?.id - if (!historyId) { - throw new OError('project does not have a history id', { projectId }) - } + const historyId = await getHistoryId(projectId) + try { return await fetchJson( - `${settings.apis.v1_history.url}/projects/${historyId}/versions/${version}/content`, + `${HISTORY_V1_URL}/projects/${historyId}/versions/${version}/content`, { method: 'GET', - basicAuth: { - user: settings.apis.v1_history.user, - password: settings.apis.v1_history.pass, - }, + basicAuth: HISTORY_V1_BASIC_AUTH, } ) } catch (err) { @@ -314,6 +292,53 @@ async function getContentAtVersion(projectId, version) { } } +/** + * Get the latest chunk from history + * + * @param {string} projectId + */ +async function getLatestHistory(projectId) { + const historyId = await getHistoryId(projectId) + + return await fetchJson( + `${HISTORY_V1_URL}/projects/${historyId}/latest/history`, + { + basicAuth: HISTORY_V1_BASIC_AUTH, + } + ) +} + +/** + * Get history changes since a given version + * + * @param {string} projectId + * @param {object} opts + * @param {number} opts.since - The start version of changes to get + */ +async function getChanges(projectId, opts = {}) { + const historyId = await getHistoryId(projectId) + + const url = new URL(`${HISTORY_V1_URL}/projects/${historyId}/changes`) + if (opts.since) { + url.searchParams.set('since', opts.since) + } + + return await fetchJson(url, { + basicAuth: HISTORY_V1_BASIC_AUTH, + }) +} + +async function getHistoryId(projectId) { + const project = await ProjectGetter.promises.getProject(projectId, { + overleaf: true, + }) + const historyId = project?.overleaf?.history?.id + if (!historyId) { + throw new OError('project does not have a history id', { projectId }) + } + return historyId +} + async function injectUserDetails(data) { // data can be either: // { @@ -404,6 +429,8 @@ module.exports = { uploadBlobFromDisk: callbackify(uploadBlobFromDisk), copyBlob: callbackify(copyBlob), requestBlobWithFallback: callbackify(requestBlobWithFallback), + getLatestHistory: callbackify(getLatestHistory), + getChanges: callbackify(getChanges), promises: { loadGlobalBlobs, initializeProject, @@ -417,5 +444,7 @@ module.exports = { uploadBlobFromDisk, copyBlob, requestBlobWithFallback, + getLatestHistory, + getChanges, }, } diff --git a/services/web/app/src/Features/History/HistoryRouter.mjs b/services/web/app/src/Features/History/HistoryRouter.mjs index 5b37236166..d5c7b46804 100644 --- a/services/web/app/src/Features/History/HistoryRouter.mjs +++ b/services/web/app/src/Features/History/HistoryRouter.mjs @@ -151,15 +151,28 @@ function apply(webRouter, privateApiRouter) { webRouter.get( '/project/:project_id/latest/history', + validate({ + params: Joi.object({ + project_id: Joi.objectId().required(), + }), + }), AuthorizationMiddleware.blockRestrictedUserFromProject, AuthorizationMiddleware.ensureUserCanReadProject, - HistoryController.proxyToHistoryApi + HistoryController.getLatestHistory ) webRouter.get( '/project/:project_id/changes', + validate({ + params: Joi.object({ + project_id: Joi.objectId().required(), + }), + query: Joi.object({ + since: Joi.number().integer().min(0).optional(), + }), + }), AuthorizationMiddleware.blockRestrictedUserFromProject, AuthorizationMiddleware.ensureUserCanReadProject, - HistoryController.proxyToHistoryApi + HistoryController.getChanges ) }