| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618 |
- import { promisify } from 'node:util'
- import fs from 'node:fs'
- import request from 'request'
- import stream from 'node:stream'
- import logger from '@overleaf/logger'
- import _ from 'lodash'
- import { URL } from 'node:url'
- import OError from '@overleaf/o-error'
- import Settings from '@overleaf/settings'
- import {
- fetchStream,
- fetchNothing,
- RequestFailedError,
- } from '@overleaf/fetch-utils'
- import * as Versions from './Versions.js'
- import * as Errors from './Errors.js'
- import * as LocalFileWriter from './LocalFileWriter.js'
- import * as HashManager from './HashManager.js'
- import * as HistoryBlobTranslator from './HistoryBlobTranslator.js'
- import { promisifyMultiResult } from '@overleaf/promise-utils'
- const HTTP_REQUEST_TIMEOUT = Settings.overleaf.history.requestTimeout
- /**
- * Container for functions that need to be mocked in tests
- *
- * TODO: Rewrite tests in terms of exported functions only
- */
- export const _mocks = {}
- class StringStream extends stream.Readable {
- _read() {}
- }
- _mocks.getMostRecentChunk = (projectId, historyId, callback) => {
- const path = `projects/${historyId}/latest/history`
- logger.debug({ projectId, historyId }, 'getting chunk from history service')
- _requestChunk({ path, json: true }, callback)
- }
- /**
- * @param {Callback} callback
- */
- export function getMostRecentChunk(projectId, historyId, callback) {
- _mocks.getMostRecentChunk(projectId, historyId, callback)
- }
- /**
- * @param {Callback} callback
- */
- export function getChunkAtVersion(projectId, historyId, version, callback) {
- const path = `projects/${historyId}/versions/${version}/history`
- logger.debug(
- { projectId, historyId, version },
- 'getting chunk from history service for version'
- )
- _requestChunk({ path, json: true }, callback)
- }
- export function getMostRecentVersion(projectId, historyId, callback) {
- getMostRecentChunk(projectId, historyId, (error, chunk) => {
- if (error) {
- return callback(OError.tag(error))
- }
- const mostRecentVersion =
- chunk.chunk.startVersion + (chunk.chunk.history.changes || []).length
- const lastChange = _.last(
- _.sortBy(chunk.chunk.history.changes || [], x => x.timestamp)
- )
- // find the latest project and doc versions in the chunk
- _getLatestProjectVersion(projectId, chunk, (err1, projectVersion) =>
- _getLatestV2DocVersions(projectId, chunk, (err2, v2DocVersions) => {
- // return the project and doc versions
- const projectStructureAndDocVersions = {
- project: projectVersion,
- docs: v2DocVersions,
- }
- callback(
- err1 || err2,
- mostRecentVersion,
- projectStructureAndDocVersions,
- lastChange,
- chunk
- )
- })
- )
- })
- }
- /**
- * @param {string} projectId
- * @param {string} historyId
- * @param {Object} opts
- * @param {boolean} [opts.readOnly]
- * @param {(error: Error, rawChunk?: { startVersion: number, endVersion: number, endTimestamp: Date}) => void} callback
- */
- export function getMostRecentVersionRaw(projectId, historyId, opts, callback) {
- const path = `projects/${historyId}/latest/history/raw`
- logger.debug(
- { projectId, historyId },
- 'getting raw chunk from history service'
- )
- const qs = opts.readOnly ? { readOnly: true } : {}
- _requestHistoryService({ path, json: true, qs }, (err, body) => {
- if (err) return callback(OError.tag(err))
- const { startVersion, endVersion, endTimestamp } = body
- callback(null, {
- startVersion,
- endVersion,
- endTimestamp: new Date(endTimestamp),
- })
- })
- }
- function _requestChunk(options, callback) {
- _requestHistoryService(options, (err, chunk) => {
- if (err) {
- return callback(OError.tag(err))
- }
- if (
- chunk == null ||
- chunk.chunk == null ||
- chunk.chunk.startVersion == null
- ) {
- return callback(new OError('unexpected response'))
- }
- callback(null, chunk)
- })
- }
- function _getLatestProjectVersion(projectId, chunk, callback) {
- // find the initial project version
- let projectVersion =
- chunk.chunk.history.snapshot && chunk.chunk.history.snapshot.projectVersion
- // keep track of any errors
- let error = null
- // iterate over the changes in chunk to find the most recent project version
- for (const change of chunk.chunk.history.changes || []) {
- if (change.projectVersion != null) {
- if (
- projectVersion != null &&
- Versions.lt(change.projectVersion, projectVersion)
- ) {
- logger.warn(
- { projectId, chunk, projectVersion, change },
- 'project structure version out of order in chunk'
- )
- if (!error) {
- error = new Errors.OpsOutOfOrderError(
- 'project structure version out of order'
- )
- }
- } else {
- projectVersion = change.projectVersion
- }
- }
- }
- callback(error, projectVersion)
- }
- function _getLatestV2DocVersions(projectId, chunk, callback) {
- // find the initial doc versions (indexed by docId as this is immutable)
- const v2DocVersions =
- (chunk.chunk.history.snapshot &&
- chunk.chunk.history.snapshot.v2DocVersions) ||
- {}
- // keep track of any errors
- let error = null
- // iterate over the changes in the chunk to find the most recent doc versions
- for (const change of chunk.chunk.history.changes || []) {
- if (change.v2DocVersions != null) {
- for (const docId in change.v2DocVersions) {
- const docInfo = change.v2DocVersions[docId]
- const { v } = docInfo
- if (
- v2DocVersions[docId] &&
- v2DocVersions[docId].v != null &&
- Versions.lt(v, v2DocVersions[docId].v)
- ) {
- logger.warn(
- {
- projectId,
- docId,
- changeVersion: docInfo,
- previousVersion: v2DocVersions[docId],
- },
- 'doc version out of order in chunk'
- )
- if (!error) {
- error = new Errors.OpsOutOfOrderError('doc version out of order')
- }
- } else {
- v2DocVersions[docId] = docInfo
- }
- }
- }
- }
- callback(error, v2DocVersions)
- }
- export function getProjectBlob(historyId, blobHash, callback) {
- logger.debug({ historyId, blobHash }, 'getting blob from history service')
- _requestHistoryService(
- { path: `projects/${historyId}/blobs/${blobHash}` },
- callback
- )
- }
- /**
- * @param {Callback} callback
- */
- export function getProjectBlobStream(historyId, blobHash, callback) {
- const url = `${Settings.overleaf.history.host}/projects/${historyId}/blobs/${blobHash}`
- logger.debug(
- { historyId, blobHash },
- 'getting blob stream from history service'
- )
- fetchStream(url, getHistoryFetchOptions())
- .then(stream => {
- callback(null, stream)
- })
- .catch(err => callback(OError.tag(err)))
- }
- export function sendChanges(
- projectId,
- historyId,
- changes,
- endVersion,
- callback
- ) {
- logger.debug(
- { projectId, historyId, endVersion },
- 'sending changes to history service'
- )
- _requestHistoryService(
- {
- path: `projects/${historyId}/legacy_changes`,
- qs: { end_version: endVersion },
- method: 'POST',
- json: changes,
- },
- error => {
- if (error) {
- OError.tag(error, 'failed to send changes to v1', {
- projectId,
- historyId,
- endVersion,
- errorCode: error.code,
- statusCode: error.statusCode,
- body: error.body,
- })
- logger.warn({ error, projectId, historyId, endVersion }, error.message)
- return callback(error)
- }
- callback()
- }
- )
- }
- function createBlobFromString(historyId, data, fileId, callback) {
- const stringStream = new StringStream()
- stringStream.push(data)
- stringStream.push(null)
- LocalFileWriter.bufferOnDisk(
- stringStream,
- '',
- fileId,
- (fsPath, cb) => {
- _createBlob(historyId, fsPath, cb)
- },
- callback
- )
- }
- function _checkBlobExists(historyId, hash, callback) {
- if (!hash) return callback(null, false)
- const url = `${Settings.overleaf.history.host}/projects/${historyId}/blobs/${hash}`
- fetchNothing(url, {
- method: 'HEAD',
- ...getHistoryFetchOptions(),
- })
- .then(res => {
- callback(null, true)
- })
- .catch(err => {
- if (err instanceof RequestFailedError && err.response.status === 404) {
- return callback(null, false)
- }
- callback(OError.tag(err), false)
- })
- }
- function _rewriteFilestoreUrl(url, projectId, callback) {
- if (!url) {
- return { fileId: null, filestoreURL: null }
- }
- // Rewrite the filestore url to point to the location in the local
- // settings for this service (this avoids problems with cross-
- // datacentre requests when running filestore in multiple locations).
- const { pathname: fileStorePath } = new URL(url)
- const urlMatch = /^\/project\/([0-9a-f]{24})\/file\/([0-9a-f]{24})$/.exec(
- fileStorePath
- )
- if (urlMatch == null) {
- return callback(new OError('invalid file for blob creation'))
- }
- if (urlMatch[1] !== projectId) {
- return callback(new OError('invalid project for blob creation'))
- }
- const fileId = urlMatch[2]
- const filestoreURL = `${Settings.apis.filestore.url}/project/${projectId}/file/${fileId}`
- return { filestoreURL, fileId }
- }
- export function createBlobForUpdate(projectId, historyId, update, callback) {
- callback = _.once(callback)
- if (update.doc != null && update.docLines != null) {
- let ranges
- try {
- ranges = HistoryBlobTranslator.createRangeBlobDataFromUpdate(update)
- } catch (error) {
- return callback(error)
- }
- createBlobFromString(
- historyId,
- update.docLines,
- `project-${projectId}-doc-${update.doc}`,
- (err, fileHash) => {
- if (err) {
- return callback(err)
- }
- if (ranges) {
- createBlobFromString(
- historyId,
- JSON.stringify(ranges),
- `project-${projectId}-doc-${update.doc}-ranges`,
- (err, rangesHash) => {
- if (err) {
- return callback(err)
- }
- logger.debug(
- { fileHash, rangesHash },
- 'created blobs for both ranges and content'
- )
- return callback(null, { file: fileHash, ranges: rangesHash })
- }
- )
- } else {
- logger.debug({ fileHash }, 'created blob for content')
- return callback(null, { file: fileHash })
- }
- }
- )
- } else if (
- update.file != null &&
- (update.url != null || update.createdBlob)
- ) {
- const { fileId, filestoreURL } = _rewriteFilestoreUrl(
- update.url,
- projectId,
- callback
- )
- _checkBlobExists(historyId, update.hash, (err, blobExists) => {
- if (err) {
- return callback(
- new OError(
- 'error checking whether blob exists',
- { projectId, historyId, update },
- err
- )
- )
- } else if (blobExists) {
- logger.debug(
- { projectId, fileId, update },
- 'Skipping blob creation as it has already been created'
- )
- return callback(null, { file: update.hash })
- } else if (update.createdBlob) {
- logger.warn(
- { projectId, fileId, update },
- 'created blob does not exist, reading from filestore'
- )
- }
- if (!filestoreURL) {
- return callback(
- new OError('no filestore URL provided and blob was not created')
- )
- }
- if (!Settings.apis.filestore.enabled) {
- return callback(new OError('blocking filestore read', { update }))
- }
- fetchStream(filestoreURL, {
- signal: AbortSignal.timeout(HTTP_REQUEST_TIMEOUT),
- })
- .then(stream => {
- LocalFileWriter.bufferOnDisk(
- stream,
- filestoreURL,
- `project-${projectId}-file-${fileId}`,
- (fsPath, cb) => {
- _createBlob(historyId, fsPath, cb)
- },
- (err, fileHash) => {
- if (err) {
- return callback(err)
- }
- if (update.hash && update.hash !== fileHash) {
- logger.warn(
- { projectId, fileId, webHash: update.hash, fileHash },
- 'hash mismatch between web and project-history'
- )
- }
- logger.debug({ fileHash }, 'created blob for file')
- callback(null, { file: fileHash })
- }
- )
- })
- .catch(err => {
- if (
- err instanceof RequestFailedError &&
- err.response.status === 404
- ) {
- logger.warn(
- { projectId, historyId, filestoreURL },
- 'File contents not found in filestore. Storing in history as an empty file'
- )
- const emptyStream = new StringStream()
- LocalFileWriter.bufferOnDisk(
- emptyStream,
- filestoreURL,
- `project-${projectId}-file-${fileId}`,
- (fsPath, cb) => {
- _createBlob(historyId, fsPath, cb)
- },
- (err, fileHash) => {
- if (err) {
- return callback(err)
- }
- logger.debug({ fileHash }, 'created empty blob for file')
- callback(null, { file: fileHash })
- }
- )
- emptyStream.push(null) // send an EOF signal
- } else {
- callback(OError.tag(err, 'error from filestore', { filestoreURL }))
- }
- })
- })
- } else {
- const error = new OError('invalid update for blob creation')
- callback(error)
- }
- }
- function _createBlob(historyId, fsPath, _callback) {
- const callback = _.once(_callback)
- HashManager._getBlobHash(fsPath, (error, hash, byteLength) => {
- if (error) {
- return callback(OError.tag(error))
- }
- const outStream = fs.createReadStream(fsPath)
- logger.debug(
- { fsPath, historyId, hash, byteLength },
- 'sending blob to history service'
- )
- const url = `${Settings.overleaf.history.host}/projects/${historyId}/blobs/${hash}`
- fetchNothing(url, {
- method: 'PUT',
- body: outStream,
- headers: { 'Content-Length': byteLength }, // add the content length to work around problems with chunked encoding in node 18
- ...getHistoryFetchOptions(),
- })
- .then(res => {
- callback(null, hash)
- })
- .catch(err => {
- callback(OError.tag(err))
- })
- })
- }
- export function initializeProject(historyId, callback) {
- _requestHistoryService(
- {
- method: 'POST',
- path: 'projects',
- json: historyId == null ? true : { projectId: historyId },
- },
- (error, project) => {
- if (error) {
- return callback(OError.tag(error))
- }
- const id = project.projectId
- if (id == null) {
- error = new OError('history store did not return a project id', id)
- return callback(error)
- }
- callback(null, id)
- }
- )
- }
- export function deleteProject(projectId, callback) {
- _requestHistoryService(
- { method: 'DELETE', path: `projects/${projectId}` },
- callback
- )
- }
- const getProjectBlobAsync = promisify(getProjectBlob)
- class BlobStore {
- constructor(projectId) {
- this.projectId = projectId
- }
- async getString(hash) {
- return await getProjectBlobAsync(this.projectId, hash)
- }
- async getObject(hash) {
- const string = await this.getString(hash)
- return JSON.parse(string)
- }
- }
- export function getBlobStore(projectId) {
- return new BlobStore(projectId)
- }
- function _requestOptions(options) {
- const requestOptions = {
- method: options.method || 'GET',
- url: `${Settings.overleaf.history.host}/${options.path}`,
- timeout: HTTP_REQUEST_TIMEOUT,
- auth: {
- user: Settings.overleaf.history.user,
- pass: Settings.overleaf.history.pass,
- sendImmediately: true,
- },
- }
- if (options.json != null) {
- requestOptions.json = options.json
- }
- if (options.body != null) {
- requestOptions.body = options.body
- }
- if (options.qs != null) {
- requestOptions.qs = options.qs
- }
- return requestOptions
- }
- /**
- * @return {RequestInit}
- */
- function getHistoryFetchOptions() {
- return {
- signal: AbortSignal.timeout(HTTP_REQUEST_TIMEOUT),
- basicAuth: {
- user: Settings.overleaf.history.user,
- password: Settings.overleaf.history.pass,
- },
- }
- }
- function _requestHistoryService(options, callback) {
- const requestOptions = _requestOptions(options)
- request(requestOptions, (error, res, body) => {
- if (error) {
- return callback(OError.tag(error))
- }
- if (res.statusCode >= 200 && res.statusCode < 300) {
- callback(null, body)
- } else {
- const { method, url, qs } = requestOptions
- error = new OError(
- `history store a non-success status code: ${res.statusCode}`,
- { method, url, qs, statusCode: res.statusCode }
- )
- logger.warn({ err: error }, error.message)
- callback(error)
- }
- })
- }
- export const promises = {
- /** @type {(projectId: string, historyId: string) => Promise<{chunk: import('overleaf-editor-core/lib/types.js').RawChunk}>} */
- getMostRecentChunk: promisify(getMostRecentChunk),
- getChunkAtVersion: promisify(getChunkAtVersion),
- getMostRecentVersion: promisifyMultiResult(getMostRecentVersion, [
- 'version',
- 'projectStructureAndDocVersions',
- 'lastChange',
- 'mostRecentChunk',
- ]),
- getMostRecentVersionRaw: promisify(getMostRecentVersionRaw),
- getProjectBlob: promisify(getProjectBlob),
- getProjectBlobStream: promisify(getProjectBlobStream),
- sendChanges: promisify(sendChanges),
- createBlobForUpdate: promisify(createBlobForUpdate),
- initializeProject: promisify(initializeProject),
- deleteProject: promisify(deleteProject),
- }
|