HistoryStoreManager.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660
  1. import { promisify } from 'node:util'
  2. import fs from 'node:fs'
  3. import stream from 'node:stream'
  4. import logger from '@overleaf/logger'
  5. import _ from 'lodash'
  6. import { URL } from 'node:url'
  7. import OError from '@overleaf/o-error'
  8. import Settings from '@overleaf/settings'
  9. import {
  10. fetchStream,
  11. fetchString,
  12. fetchNothing,
  13. RequestFailedError,
  14. } from '@overleaf/fetch-utils'
  15. import * as Versions from './Versions.js'
  16. import * as Errors from './Errors.js'
  17. import * as LocalFileWriter from './LocalFileWriter.js'
  18. import * as HashManager from './HashManager.js'
  19. import * as HistoryBlobTranslator from './HistoryBlobTranslator.js'
  20. import { callbackify, promisifyMultiResult } from '@overleaf/promise-utils'
  21. const HTTP_REQUEST_TIMEOUT = Settings.overleaf.history.requestTimeout
  22. /**
  23. * Container for functions that need to be mocked in tests
  24. *
  25. * TODO: Rewrite tests in terms of exported functions only
  26. */
  27. export const _mocks = {}
  28. class StringStream extends stream.Readable {
  29. _read() {}
  30. }
  31. _mocks.getMostRecentChunk = (projectId, historyId, callback) => {
  32. const path = `projects/${historyId}/latest/history`
  33. logger.debug({ projectId, historyId }, 'getting chunk from history service')
  34. _requestChunk({ path, json: true }, (err, chunk) => {
  35. if (err) return callback(OError.tag(err))
  36. callback(null, chunk)
  37. })
  38. }
  39. /**
  40. * @param {Callback} callback
  41. */
  42. export function getMostRecentChunk(projectId, historyId, callback) {
  43. _mocks.getMostRecentChunk(projectId, historyId, callback)
  44. }
  45. /**
  46. * @param {Callback} callback
  47. */
  48. export function getChunkAtVersion(projectId, historyId, version, callback) {
  49. const path = `projects/${historyId}/versions/${version}/history`
  50. logger.debug(
  51. { projectId, historyId, version },
  52. 'getting chunk from history service for version'
  53. )
  54. _requestChunk({ path, json: true }, (err, chunk) => {
  55. if (err) return callback(OError.tag(err))
  56. callback(null, chunk)
  57. })
  58. }
  59. export function getMostRecentVersion(projectId, historyId, callback) {
  60. getMostRecentChunk(projectId, historyId, (error, chunk) => {
  61. if (error) {
  62. return callback(OError.tag(error))
  63. }
  64. const mostRecentVersion =
  65. chunk.chunk.startVersion + (chunk.chunk.history.changes || []).length
  66. const lastChange = _.last(
  67. _.sortBy(chunk.chunk.history.changes || [], x => x.timestamp)
  68. )
  69. // find the latest project and doc versions in the chunk
  70. _getLatestProjectVersion(projectId, chunk, (err1, projectVersion) => {
  71. if (err1) err1 = OError.tag(err1)
  72. _getLatestV2DocVersions(projectId, chunk, (err2, v2DocVersions) => {
  73. if (err2) err2 = OError.tag(err2)
  74. // return the project and doc versions
  75. const projectStructureAndDocVersions = {
  76. project: projectVersion,
  77. docs: v2DocVersions,
  78. }
  79. callback(
  80. err1 || err2,
  81. mostRecentVersion,
  82. projectStructureAndDocVersions,
  83. lastChange,
  84. chunk
  85. )
  86. })
  87. })
  88. })
  89. }
  90. /**
  91. * @param {string} projectId
  92. * @param {string} historyId
  93. * @param {Object} opts
  94. * @param {boolean} [opts.readOnly]
  95. * @param {(error: Error, rawChunk?: { startVersion: number, endVersion: number, endTimestamp: Date}) => void} callback
  96. */
  97. export function getMostRecentVersionRaw(projectId, historyId, opts, callback) {
  98. const path = `projects/${historyId}/latest/history/raw`
  99. logger.debug(
  100. { projectId, historyId },
  101. 'getting raw chunk from history service'
  102. )
  103. const qs = opts.readOnly ? { readOnly: true } : {}
  104. _requestHistoryService({ path, json: true, qs }, (err, body) => {
  105. if (err) return callback(OError.tag(err))
  106. const { startVersion, endVersion, endTimestamp } = body
  107. callback(null, {
  108. startVersion,
  109. endVersion,
  110. endTimestamp: new Date(endTimestamp),
  111. })
  112. })
  113. }
  114. function _requestChunk(options, callback) {
  115. _requestHistoryService(options, (err, chunk) => {
  116. if (err) {
  117. return callback(OError.tag(err))
  118. }
  119. if (
  120. chunk == null ||
  121. chunk.chunk == null ||
  122. chunk.chunk.startVersion == null
  123. ) {
  124. const { path } = options
  125. return callback(new OError('unexpected response', { path }))
  126. }
  127. callback(null, chunk)
  128. })
  129. }
  130. function _getLatestProjectVersion(projectId, chunk, callback) {
  131. // find the initial project version
  132. const projectVersionInSnapshot = chunk.chunk.history.snapshot?.projectVersion
  133. let projectVersion = projectVersionInSnapshot
  134. const chunkStartVersion = chunk.chunk.startVersion
  135. // keep track of any first error
  136. let error = null
  137. // iterate over the changes in chunk to find the most recent project version
  138. for (const [changeIdx, change] of (
  139. chunk.chunk.history.changes || []
  140. ).entries()) {
  141. const projectVersionInChange = change.projectVersion
  142. if (projectVersionInChange != null) {
  143. if (
  144. projectVersion != null &&
  145. Versions.lt(projectVersionInChange, projectVersion)
  146. ) {
  147. if (!error) {
  148. error = new Errors.OpsOutOfOrderError(
  149. 'project structure version out of order',
  150. {
  151. projectId,
  152. chunkStartVersion,
  153. projectVersionInSnapshot,
  154. changeIdx,
  155. projectVersion,
  156. projectVersionInChange,
  157. }
  158. )
  159. }
  160. } else {
  161. projectVersion = projectVersionInChange
  162. }
  163. }
  164. }
  165. callback(error, projectVersion)
  166. }
  167. function _getLatestV2DocVersions(projectId, chunk, callback) {
  168. // find the initial doc versions (indexed by docId as this is immutable)
  169. const v2DocVersions =
  170. (chunk.chunk.history.snapshot &&
  171. chunk.chunk.history.snapshot.v2DocVersions) ||
  172. {}
  173. // keep track of any errors
  174. let error = null
  175. // iterate over the changes in the chunk to find the most recent doc versions
  176. for (const change of chunk.chunk.history.changes || []) {
  177. if (change.v2DocVersions != null) {
  178. for (const docId in change.v2DocVersions) {
  179. const docInfo = change.v2DocVersions[docId]
  180. const { v } = docInfo
  181. if (
  182. v2DocVersions[docId] &&
  183. v2DocVersions[docId].v != null &&
  184. Versions.lt(v, v2DocVersions[docId].v)
  185. ) {
  186. if (!error) {
  187. logger.warn(
  188. {
  189. projectId,
  190. docId,
  191. changeVersion: docInfo,
  192. previousVersion: v2DocVersions[docId],
  193. },
  194. 'doc version out of order in chunk'
  195. )
  196. error = new Errors.OpsOutOfOrderError('doc version out of order')
  197. }
  198. } else {
  199. v2DocVersions[docId] = docInfo
  200. }
  201. }
  202. }
  203. }
  204. callback(error, v2DocVersions)
  205. }
  206. export function getProjectBlob(historyId, blobHash, callback) {
  207. logger.debug({ historyId, blobHash }, 'getting blob from history service')
  208. _requestHistoryService(
  209. { path: `projects/${historyId}/blobs/${blobHash}` },
  210. (err, blob) => {
  211. if (err) return callback(OError.tag(err))
  212. callback(null, blob)
  213. }
  214. )
  215. }
  216. /**
  217. * @param {Callback} callback
  218. */
  219. export function getProjectBlobStream(historyId, blobHash, callback) {
  220. const url = `${Settings.overleaf.history.host}/projects/${historyId}/blobs/${blobHash}`
  221. logger.debug(
  222. { historyId, blobHash },
  223. 'getting blob stream from history service'
  224. )
  225. fetchStream(url, getHistoryFetchOptions())
  226. .then(stream => {
  227. callback(null, stream)
  228. })
  229. .catch(err => callback(OError.tag(err)))
  230. }
  231. export function sendChanges(
  232. projectId,
  233. historyId,
  234. changes,
  235. endVersion,
  236. callback
  237. ) {
  238. logger.debug(
  239. { projectId, historyId, endVersion },
  240. 'sending changes to history service'
  241. )
  242. _requestHistoryService(
  243. {
  244. path: `projects/${historyId}/legacy_changes`,
  245. qs: { end_version: endVersion },
  246. method: 'POST',
  247. json: changes,
  248. },
  249. (error, response) => {
  250. if (error) {
  251. OError.tag(error, 'failed to send changes to v1', {
  252. projectId,
  253. historyId,
  254. endVersion,
  255. errorCode: error.code,
  256. statusCode: error.statusCode,
  257. body: error.body,
  258. })
  259. return callback(error)
  260. }
  261. callback(null, { resyncNeeded: response?.resyncNeeded ?? false })
  262. }
  263. )
  264. }
  265. function createBlobFromString(historyId, data, fileId, callback) {
  266. const stringStream = new StringStream()
  267. stringStream.push(data)
  268. stringStream.push(null)
  269. LocalFileWriter.bufferOnDisk(
  270. stringStream,
  271. '',
  272. fileId,
  273. (fsPath, cb) => {
  274. _createBlob(historyId, fsPath, cb)
  275. },
  276. (err, hash) => {
  277. if (err) return callback(OError.tag(err))
  278. callback(null, hash)
  279. }
  280. )
  281. }
  282. function _checkBlobExists(historyId, hash, callback) {
  283. if (!hash) return callback(null, false)
  284. const url = `${Settings.overleaf.history.host}/projects/${historyId}/blobs/${hash}`
  285. fetchNothing(url, {
  286. method: 'HEAD',
  287. ...getHistoryFetchOptions(),
  288. })
  289. .then(res => {
  290. callback(null, true)
  291. })
  292. .catch(err => {
  293. if (err instanceof RequestFailedError && err.response.status === 404) {
  294. return callback(null, false)
  295. }
  296. callback(OError.tag(err), false)
  297. })
  298. }
  299. function _rewriteFilestoreUrl(url, projectId, callback) {
  300. if (!url) {
  301. return { fileId: null, filestoreURL: null }
  302. }
  303. // Rewrite the filestore url to point to the location in the local
  304. // settings for this service (this avoids problems with cross-
  305. // datacentre requests when running filestore in multiple locations).
  306. const { pathname: fileStorePath } = new URL(url)
  307. const urlMatch = /^\/project\/([0-9a-f]{24})\/file\/([0-9a-f]{24})$/.exec(
  308. fileStorePath
  309. )
  310. if (urlMatch == null) {
  311. return callback(new OError('invalid file for blob creation'))
  312. }
  313. if (urlMatch[1] !== projectId) {
  314. return callback(new OError('invalid project for blob creation'))
  315. }
  316. const fileId = urlMatch[2]
  317. const filestoreURL = `${Settings.apis.filestore.url}/project/${projectId}/file/${fileId}`
  318. return { filestoreURL, fileId }
  319. }
  320. export function createBlobForUpdate(projectId, historyId, update, callback) {
  321. callback = _.once(callback)
  322. if (update.doc != null && update.docLines != null) {
  323. let ranges
  324. try {
  325. ranges = HistoryBlobTranslator.createRangeBlobDataFromUpdate(update)
  326. } catch (error) {
  327. return callback(OError.tag(error))
  328. }
  329. createBlobFromString(
  330. historyId,
  331. update.docLines,
  332. `project-${projectId}-doc-${update.doc}`,
  333. (err, fileHash) => {
  334. if (err) {
  335. return callback(OError.tag(err))
  336. }
  337. if (ranges) {
  338. createBlobFromString(
  339. historyId,
  340. JSON.stringify(ranges),
  341. `project-${projectId}-doc-${update.doc}-ranges`,
  342. (err, rangesHash) => {
  343. if (err) {
  344. return callback(OError.tag(err))
  345. }
  346. logger.debug(
  347. { fileHash, rangesHash },
  348. 'created blobs for both ranges and content'
  349. )
  350. return callback(null, { file: fileHash, ranges: rangesHash })
  351. }
  352. )
  353. } else {
  354. logger.debug({ fileHash }, 'created blob for content')
  355. return callback(null, { file: fileHash })
  356. }
  357. }
  358. )
  359. } else if (
  360. update.file != null &&
  361. (update.url != null || update.createdBlob)
  362. ) {
  363. const { fileId, filestoreURL } = _rewriteFilestoreUrl(
  364. update.url,
  365. projectId,
  366. callback
  367. )
  368. _checkBlobExists(historyId, update.hash, (err, blobExists) => {
  369. if (err) {
  370. return callback(
  371. new OError(
  372. 'error checking whether blob exists',
  373. { projectId, historyId, update },
  374. err
  375. )
  376. )
  377. } else if (blobExists) {
  378. logger.debug(
  379. { projectId, fileId, update },
  380. 'Skipping blob creation as it has already been created'
  381. )
  382. return callback(null, { file: update.hash })
  383. } else if (update.createdBlob) {
  384. logger.warn(
  385. { projectId, fileId, update },
  386. 'created blob does not exist, reading from filestore'
  387. )
  388. }
  389. if (!filestoreURL) {
  390. return callback(
  391. new OError('no filestore URL provided and blob was not created')
  392. )
  393. }
  394. if (!Settings.apis.filestore.enabled) {
  395. return callback(new OError('blocking filestore read', { update }))
  396. }
  397. fetchStream(filestoreURL, {
  398. signal: AbortSignal.timeout(HTTP_REQUEST_TIMEOUT),
  399. })
  400. .then(stream => {
  401. LocalFileWriter.bufferOnDisk(
  402. stream,
  403. filestoreURL,
  404. `project-${projectId}-file-${fileId}`,
  405. (fsPath, cb) => {
  406. _createBlob(historyId, fsPath, cb)
  407. },
  408. (err, fileHash) => {
  409. if (err) {
  410. return callback(OError.tag(err))
  411. }
  412. if (update.hash && update.hash !== fileHash) {
  413. logger.warn(
  414. { projectId, fileId, webHash: update.hash, fileHash },
  415. 'hash mismatch between web and project-history'
  416. )
  417. }
  418. logger.debug({ fileHash }, 'created blob for file')
  419. callback(null, { file: fileHash })
  420. }
  421. )
  422. })
  423. .catch(err => {
  424. if (
  425. err instanceof RequestFailedError &&
  426. err.response.status === 404
  427. ) {
  428. logger.warn(
  429. { projectId, historyId, filestoreURL },
  430. 'File contents not found in filestore. Storing in history as an empty file'
  431. )
  432. const emptyStream = new StringStream()
  433. LocalFileWriter.bufferOnDisk(
  434. emptyStream,
  435. filestoreURL,
  436. `project-${projectId}-file-${fileId}`,
  437. (fsPath, cb) => {
  438. _createBlob(historyId, fsPath, cb)
  439. },
  440. (err, fileHash) => {
  441. if (err) {
  442. return callback(OError.tag(err))
  443. }
  444. logger.debug({ fileHash }, 'created empty blob for file')
  445. callback(null, { file: fileHash })
  446. }
  447. )
  448. emptyStream.push(null) // send an EOF signal
  449. } else {
  450. callback(OError.tag(err, 'error from filestore', { filestoreURL }))
  451. }
  452. })
  453. })
  454. } else {
  455. const error = new OError('invalid update for blob creation')
  456. callback(error)
  457. }
  458. }
  459. function _createBlob(historyId, fsPath, _callback) {
  460. const callback = _.once(_callback)
  461. HashManager._getBlobHash(fsPath, (error, hash, byteLength) => {
  462. if (error) {
  463. return callback(OError.tag(error))
  464. }
  465. const outStream = fs.createReadStream(fsPath)
  466. logger.debug(
  467. { fsPath, historyId, hash, byteLength },
  468. 'sending blob to history service'
  469. )
  470. const url = `${Settings.overleaf.history.host}/projects/${historyId}/blobs/${hash}`
  471. fetchNothing(url, {
  472. method: 'PUT',
  473. body: outStream,
  474. headers: { 'Content-Length': byteLength }, // add the content length to work around problems with chunked encoding in node 18
  475. ...getHistoryFetchOptions(),
  476. })
  477. .then(res => {
  478. callback(null, hash)
  479. })
  480. .catch(err => {
  481. callback(OError.tag(err))
  482. })
  483. })
  484. }
  485. export function initializeProject(historyId, callback) {
  486. _requestHistoryService(
  487. {
  488. method: 'POST',
  489. path: 'projects',
  490. json: historyId == null ? true : { projectId: historyId },
  491. },
  492. (error, project) => {
  493. if (error) {
  494. return callback(OError.tag(error))
  495. }
  496. const id = project.projectId
  497. if (id == null) {
  498. error = new OError('history store did not return a project id', id)
  499. return callback(error)
  500. }
  501. callback(null, id)
  502. }
  503. )
  504. }
  505. async function _cloneProject(sourceProjectId, targetProjectId, signal) {
  506. return await fetchStream(
  507. `${Settings.overleaf.history.host}/projects/${sourceProjectId}/clone`,
  508. {
  509. method: 'POST',
  510. json: { targetProjectId },
  511. ...getHistoryFetchOptions(),
  512. signal,
  513. }
  514. )
  515. }
  516. export function deleteProject(projectId, callback) {
  517. _requestHistoryService(
  518. { method: 'DELETE', path: `projects/${projectId}` },
  519. err => {
  520. if (err) return callback(OError.tag(err))
  521. callback(null)
  522. }
  523. )
  524. }
  525. const getProjectBlobAsync = promisify(getProjectBlob)
  526. class BlobStore {
  527. constructor(projectId) {
  528. this.projectId = projectId
  529. }
  530. async getString(hash) {
  531. return await getProjectBlobAsync(this.projectId, hash)
  532. }
  533. async getObject(hash) {
  534. const string = await this.getString(hash)
  535. return JSON.parse(string)
  536. }
  537. }
  538. export function getBlobStore(projectId) {
  539. return new BlobStore(projectId)
  540. }
  541. /**
  542. * @return {RequestInit}
  543. */
  544. function getHistoryFetchOptions() {
  545. return {
  546. signal: AbortSignal.timeout(HTTP_REQUEST_TIMEOUT),
  547. basicAuth: {
  548. user: Settings.overleaf.history.user,
  549. password: Settings.overleaf.history.pass,
  550. },
  551. }
  552. }
  553. function _buildUrl(options) {
  554. const url = new URL(`${Settings.overleaf.history.host}/${options.path}`)
  555. if (options.qs) {
  556. for (const [key, value] of Object.entries(options.qs)) {
  557. url.searchParams.set(key, String(value))
  558. }
  559. }
  560. return url.href
  561. }
  562. function _requestHistoryService(options, callback) {
  563. const url = _buildUrl(options)
  564. const method = options.method || 'GET'
  565. const fetchOptions = {
  566. method,
  567. ...getHistoryFetchOptions(),
  568. }
  569. if (options.json != null && options.json !== true) {
  570. fetchOptions.json = options.json
  571. }
  572. if (options.body != null) {
  573. fetchOptions.body = options.body
  574. }
  575. const useJson = options.json != null
  576. fetchString(url, fetchOptions).then(
  577. body => {
  578. if (useJson && body) {
  579. callback(null, JSON.parse(body))
  580. } else {
  581. callback(null, body)
  582. }
  583. },
  584. err => {
  585. if (err instanceof RequestFailedError) {
  586. const error = new OError(
  587. // Keep the status code in the message. It is used by the ErrorRecorder.
  588. `history store a non-success status code: ${err.response.status}`,
  589. { method, url, qs: options.qs, statusCode: err.response.status }
  590. )
  591. return callback(error)
  592. }
  593. callback(OError.tag(err))
  594. }
  595. )
  596. }
  597. export const cloneProject = callbackify(_cloneProject)
  598. export const promises = {
  599. /** @type {(projectId: string, historyId: string) => Promise<{chunk: import('overleaf-editor-core/lib/types.js').RawChunk}>} */
  600. getMostRecentChunk: promisify(getMostRecentChunk),
  601. getChunkAtVersion: promisify(getChunkAtVersion),
  602. getMostRecentVersion: promisifyMultiResult(getMostRecentVersion, [
  603. 'version',
  604. 'projectStructureAndDocVersions',
  605. 'lastChange',
  606. 'mostRecentChunk',
  607. ]),
  608. getMostRecentVersionRaw: promisify(getMostRecentVersionRaw),
  609. getProjectBlob: promisify(getProjectBlob),
  610. getProjectBlobStream: promisify(getProjectBlobStream),
  611. sendChanges: promisify(sendChanges),
  612. createBlobForUpdate: promisify(createBlobForUpdate),
  613. initializeProject: promisify(initializeProject),
  614. deleteProject: promisify(deleteProject),
  615. cloneProject: _cloneProject,
  616. }