| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553 |
- import OError from '@overleaf/o-error'
- import settings from '@overleaf/settings'
- import {
- fetchJson,
- fetchNothing,
- fetchString,
- RequestFailedError,
- } from '@overleaf/fetch-utils'
- import _ from 'lodash'
- import logger from '@overleaf/logger'
- import { callbackifyAll } from '@overleaf/promise-utils'
- import ProjectGetter from '../Project/ProjectGetter.mjs'
- import Modules from '../../infrastructure/Modules.mjs'
- const REQUEST_TIMEOUT_MS = 30 * 1000
- const RESYNC_TIMEOUT_MS = 6 * 60 * 1000
- const BASE_URL = settings.apis.documentupdater.url
- async function getProjectLastUpdatedAt(projectId) {
- const body = await fetchJson(
- `${BASE_URL}/project/${projectId}/last_updated_at`,
- { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }
- )
- return body.lastUpdatedAt != null ? new Date(body.lastUpdatedAt) : null
- }
- async function getProjectRanges(projectId) {
- const { docs } = await fetchJson(`${BASE_URL}/project/${projectId}/ranges`, {
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- })
- return docs
- }
- /**
- * @param {string} projectId
- */
- async function flushProjectToMongo(projectId) {
- await fetchNothing(`${BASE_URL}/project/${projectId}/flush`, {
- method: 'POST',
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- })
- }
- async function flushMultipleProjectsToMongo(projectIds) {
- for (const projectId of projectIds) {
- await flushProjectToMongo(projectId)
- }
- }
- /**
- * @param {string} projectId
- */
- async function flushProjectToMongoAndDelete(projectId) {
- await fetchNothing(`${BASE_URL}/project/${projectId}`, {
- method: 'DELETE',
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- })
- }
- /**
- * @param {string} projectId
- * @param {string} docId
- */
- async function flushDocToMongo(projectId, docId) {
- await fetchNothing(`${BASE_URL}/project/${projectId}/doc/${docId}/flush`, {
- method: 'POST',
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- })
- }
- async function deleteDoc(projectId, docId, ignoreFlushErrors = false) {
- const url = new URL(`${BASE_URL}/project/${projectId}/doc/${docId}`)
- if (ignoreFlushErrors) {
- url.searchParams.set('ignore_flush_errors', 'true')
- }
- await fetchNothing(url, {
- method: 'DELETE',
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- })
- }
- async function getComment(projectId, docId, commentId) {
- const comment = await fetchJson(
- `${BASE_URL}/project/${projectId}/doc/${docId}/comment/${commentId}`,
- { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }
- )
- return comment
- }
- async function getDocument(projectId, docId, fromVersion) {
- const url = new URL(`${BASE_URL}/project/${projectId}/doc/${docId}`)
- url.searchParams.set('fromVersion', fromVersion)
- const doc = await fetchJson(url, {
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- })
- return {
- lines: doc.lines,
- version: doc.version,
- ranges: doc.ranges,
- ops: doc.ops,
- }
- }
- /**
- * Get a document with its history ranges
- * @param {string} projectId
- * @param {string} docId
- */
- async function getDocumentWithHistoryRanges(projectId, docId) {
- const doc = await fetchJson(
- `${BASE_URL}/project/${projectId}/doc/${docId}?historyRanges=true`,
- { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }
- )
- return doc
- }
- async function setDocument(projectId, docId, userId, docLines, source) {
- const maybeJson = await fetchString(
- `${BASE_URL}/project/${projectId}/doc/${docId}`,
- {
- method: 'POST',
- json: {
- lines: docLines,
- source,
- user_id: userId,
- },
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- }
- )
- // The set document endpoint sometimes returns json and sometimes just an
- // empty response
- try {
- const { rev, modified } = JSON.parse(maybeJson)
- return { rev, modified }
- } catch (err) {
- return undefined
- }
- }
- async function appendToDocument(projectId, docId, userId, lines, source) {
- const maybeJson = await fetchString(
- `${BASE_URL}/project/${projectId}/doc/${docId}/append`,
- {
- method: 'POST',
- json: {
- lines,
- source,
- user_id: userId,
- },
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- }
- )
- // The append to document endpoint sometimes returns json and sometimes just an
- // empty response
- try {
- const { rev, modified } = JSON.parse(maybeJson)
- return { rev, modified }
- } catch (err) {
- return undefined
- }
- }
- async function getProjectDocsIfMatch(projectId, projectStateHash) {
- // If the project state hasn't changed, we can get all the latest
- // docs from redis via the docupdater. Otherwise we will need to
- // fall back to getting them from mongo.
- const url = new URL(`${BASE_URL}/project/${projectId}/get_and_flush_if_old`)
- url.searchParams.set('state', projectStateHash)
- let docs
- try {
- docs = await fetchJson(url, {
- method: 'POST',
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- })
- } catch (err) {
- if (err instanceof RequestFailedError && err.response.status === 409) {
- // HTTP response code "409 Conflict"
- // Docupdater has checked the projectStateHash and found that
- // it has changed. This means that the docs currently in redis
- // aren't the only change to the project and the full set of
- // docs/files should be retreived from docstore/filestore
- // instead.
- return undefined
- } else {
- throw err
- }
- }
- return docs
- }
- async function clearProjectState(projectId) {
- await fetchNothing(`${BASE_URL}/project/${projectId}/clearState`, {
- method: 'POST',
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- })
- }
- /**
- * @param {string} projectId
- * @param {string} docId
- * @param {string[]} changeIds
- */
- async function acceptChanges(projectId, docId, changeIds, userId) {
- await fetchNothing(
- `${BASE_URL}/project/${projectId}/doc/${docId}/change/accept`,
- {
- method: 'POST',
- json: { change_ids: changeIds },
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- }
- )
- await Modules.promises.hooks.fire('changesAccepted', projectId, docId, userId)
- }
- /**
- * @param {string} projectId
- * @param {string} docId
- * @param {string[]} changeIds
- */
- async function rejectChanges(projectId, docId, changeIds, userId) {
- const { rejectedChangeIds } = await fetchJson(
- `${BASE_URL}/project/${projectId}/doc/${docId}/change/reject`,
- {
- method: 'POST',
- json: { change_ids: changeIds, user_id: userId },
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- }
- )
- return { rejectedChangeIds }
- }
- /**
- * @param {string} projectId
- * @param {string} docId
- * @param {string} threadId
- * @param {string} userId
- */
- async function resolveThread(projectId, docId, threadId, userId) {
- await fetchNothing(
- `${BASE_URL}/project/${projectId}/doc/${docId}/comment/${threadId}/resolve`,
- {
- method: 'POST',
- json: { user_id: userId },
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- }
- )
- }
- /**
- * @param {string} projectId
- * @param {string} docId
- * @param {string} threadId
- * @param {string} userId
- */
- async function reopenThread(projectId, docId, threadId, userId) {
- await fetchNothing(
- `${BASE_URL}/project/${projectId}/doc/${docId}/comment/${threadId}/reopen`,
- {
- method: 'POST',
- json: { user_id: userId },
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- }
- )
- }
- async function deleteThread(projectId, docId, threadId, userId) {
- await fetchNothing(
- `${BASE_URL}/project/${projectId}/doc/${docId}/comment/${threadId}`,
- {
- method: 'DELETE',
- json: { user_id: userId },
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- }
- )
- }
- async function resyncProjectHistory(
- projectId,
- projectHistoryId,
- docs,
- files,
- opts
- ) {
- docs = docs.map(doc => ({
- doc: doc.doc._id,
- path: doc.path,
- }))
- // Files without a hash likely do not have a blob. Abort.
- for (const { file } of files) {
- if (!file.hash) {
- throw new OError('found file with missing hash', { projectId, file })
- }
- }
- files = files.map(file => ({
- file: file.file._id,
- path: file.path,
- _hash: file.file.hash,
- createdBlob: true,
- metadata: buildFileMetadataForHistory(file.file),
- }))
- const body = { docs, files, projectHistoryId }
- if (opts.historyRangesMigration) {
- body.historyRangesMigration = opts.historyRangesMigration
- }
- if (opts.resyncProjectStructureOnly) {
- body.resyncProjectStructureOnly = opts.resyncProjectStructureOnly
- }
- await fetchNothing(`${BASE_URL}/project/${projectId}/history/resync`, {
- json: body,
- method: 'POST',
- signal: AbortSignal.timeout(RESYNC_TIMEOUT_MS), // allow 6 minutes for resync
- })
- }
- /**
- * Block a project from being loaded in docupdater
- *
- * @param {string} projectId
- */
- async function blockProject(projectId) {
- const body = await fetchJson(`${BASE_URL}/project/${projectId}/block`, {
- method: 'POST',
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- })
- return body.blocked
- }
- /**
- * Unblock a previously blocked project
- *
- * @param {string} projectId
- */
- async function unblockProject(projectId) {
- const body = await fetchJson(`${BASE_URL}/project/${projectId}/unblock`, {
- method: 'POST',
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- })
- return body.wasBlocked
- }
- async function updateProjectStructure(
- projectId,
- projectHistoryId,
- userId,
- changes,
- source
- ) {
- if (
- settings.apis.project_history == null ||
- !settings.apis.project_history.sendProjectStructureOps
- ) {
- return
- }
- const project = await ProjectGetter.promises.getProjectWithoutLock(
- projectId,
- { overleaf: true }
- )
- const historyRangesSupport = _.get(
- project,
- 'overleaf.history.rangesSupportEnabled',
- false
- )
- const {
- deletes: docDeletes,
- adds: docAdds,
- renames: docRenames,
- } = _getUpdates('doc', changes.oldDocs, changes.newDocs, historyRangesSupport)
- for (const newEntity of changes.newFiles || []) {
- if (!newEntity.file.hash) {
- // Files without a hash likely do not have a blob. Abort.
- throw new OError('found file with missing hash', { newEntity })
- }
- }
- const {
- deletes: fileDeletes,
- adds: fileAdds,
- renames: fileRenames,
- } = _getUpdates(
- 'file',
- changes.oldFiles,
- changes.newFiles,
- historyRangesSupport
- )
- const updates = [].concat(
- docDeletes,
- fileDeletes,
- docAdds,
- fileAdds,
- docRenames,
- fileRenames
- )
- const projectVersion =
- changes && changes.newProject && changes.newProject.version
- if (updates.length < 1) {
- return
- }
- if (projectVersion == null) {
- logger.warn(
- { projectId, changes, projectVersion },
- 'did not receive project version in changes'
- )
- throw new Error('did not receive project version in changes')
- }
- await fetchNothing(`${BASE_URL}/project/${projectId}`, {
- method: 'POST',
- json: {
- updates,
- userId,
- version: projectVersion,
- projectHistoryId,
- source,
- },
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- })
- }
- function _getUpdates(
- entityType,
- oldEntities,
- newEntities,
- historyRangesSupport
- ) {
- if (!oldEntities) {
- oldEntities = []
- }
- if (!newEntities) {
- newEntities = []
- }
- const deletes = []
- const adds = []
- const renames = []
- const oldEntitiesHash = _.keyBy(oldEntities, entity =>
- entity[entityType]._id.toString()
- )
- const newEntitiesHash = _.keyBy(newEntities, entity =>
- entity[entityType]._id.toString()
- )
- // Send deletes before adds (and renames) to keep a 1:1 mapping between
- // paths and ids
- //
- // When a file is replaced, we first delete the old file and then add the
- // new file. If the 'add' operation is sent to project history before the
- // 'delete' then we would have two files with the same path at that point
- // in time.
- for (const id in oldEntitiesHash) {
- const oldEntity = oldEntitiesHash[id]
- const newEntity = newEntitiesHash[id]
- if (newEntity == null) {
- // entity deleted
- deletes.push({
- type: `rename-${entityType}`,
- id,
- pathname: oldEntity.path,
- newPathname: '',
- })
- }
- }
- for (const id in newEntitiesHash) {
- const newEntity = newEntitiesHash[id]
- const oldEntity = oldEntitiesHash[id]
- if (oldEntity == null) {
- // entity added
- adds.push({
- type: `add-${entityType}`,
- id,
- pathname: newEntity.path,
- docLines: newEntity.docLines,
- ranges: newEntity.ranges,
- historyRangesSupport,
- hash: newEntity.file?.hash,
- metadata: buildFileMetadataForHistory(newEntity.file),
- createdBlob: true,
- })
- } else if (newEntity.path !== oldEntity.path) {
- // entity renamed
- renames.push({
- type: `rename-${entityType}`,
- id,
- pathname: oldEntity.path,
- newPathname: newEntity.path,
- })
- }
- }
- return { deletes, adds, renames }
- }
- function buildFileMetadataForHistory(file) {
- if (!file?.linkedFileData) return undefined
- const metadata = {
- // Files do not have a created at timestamp in the history.
- // For cloned projects, the importedAt timestamp needs to remain untouched.
- // Record the timestamp in the metadata blob to keep everything self-contained.
- importedAt: file.created,
- ...file.linkedFileData,
- }
- if (metadata.provider === 'project_output_file') {
- // The build-id and clsi-server-id are only used for downloading file.
- // Omit them from history as they are not useful in the future.
- delete metadata.build_id
- delete metadata.clsiServerId
- }
- return metadata
- }
- const DocumentUpdaterHandler = {
- flushProjectToMongo,
- flushMultipleProjectsToMongo,
- flushProjectToMongoAndDelete,
- flushDocToMongo,
- deleteDoc,
- getComment,
- getDocument,
- getProjectLastUpdatedAt,
- getProjectRanges,
- setDocument,
- appendToDocument,
- getProjectDocsIfMatch,
- clearProjectState,
- acceptChanges,
- rejectChanges,
- resolveThread,
- reopenThread,
- deleteThread,
- resyncProjectHistory,
- blockProject,
- unblockProject,
- updateProjectStructure,
- getDocumentWithHistoryRanges,
- }
- export default {
- ...callbackifyAll(DocumentUpdaterHandler, {
- multiResult: {
- getDocument: ['lines', 'version', 'ranges', 'ops'],
- },
- }),
- promises: DocumentUpdaterHandler,
- }
|