HistoryStoreManager.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. import fs from 'fs'
  2. import request from 'request'
  3. import stream from 'stream'
  4. import logger from '@overleaf/logger'
  5. import _ from 'lodash'
  6. import BPromise from 'bluebird'
  7. import { URL } from 'url'
  8. import OError from '@overleaf/o-error'
  9. import Settings from '@overleaf/settings'
  10. import * as Versions from './Versions.js'
  11. import * as Errors from './Errors.js'
  12. import * as LocalFileWriter from './LocalFileWriter.js'
  13. import * as HashManager from './HashManager.js'
  14. import fetch from 'node-fetch'
  15. const HTTP_REQUEST_TIMEOUT = 300 * 1000 // 5 minutes
  16. /**
  17. * Container for functions that need to be mocked in tests
  18. *
  19. * TODO: Rewrite tests in terms of exported functions only
  20. */
  21. export const _mocks = {}
  22. class StringStream extends stream.Readable {
  23. _read() {}
  24. }
  25. _mocks.getMostRecentChunk = (projectId, historyId, callback) => {
  26. const path = `projects/${historyId}/latest/history`
  27. logger.debug({ projectId, historyId }, 'getting chunk from history service')
  28. _requestChunk({ path, json: true }, callback)
  29. }
  30. export function getMostRecentChunk(...args) {
  31. _mocks.getMostRecentChunk(...args)
  32. }
  33. export function getChunkAtVersion(projectId, historyId, version, callback) {
  34. const path = `projects/${historyId}/versions/${version}/history`
  35. logger.debug(
  36. { projectId, historyId, version },
  37. 'getting chunk from history service for version'
  38. )
  39. _requestChunk({ path, json: true }, callback)
  40. }
  41. export function getMostRecentVersion(projectId, historyId, callback) {
  42. getMostRecentChunk(projectId, historyId, (error, chunk) => {
  43. if (error) {
  44. return callback(OError.tag(error))
  45. }
  46. const mostRecentVersion =
  47. chunk.chunk.startVersion + (chunk.chunk.history.changes || []).length
  48. const lastChange = _.last(
  49. _.sortBy(chunk.chunk.history.changes || [], x => x.timestamp)
  50. )
  51. // find the latest project and doc versions in the chunk
  52. _getLatestProjectVersion(projectId, chunk, (err1, projectVersion) =>
  53. _getLatestV2DocVersions(projectId, chunk, (err2, v2DocVersions) => {
  54. // return the project and doc versions
  55. const projectStructureAndDocVersions = {
  56. project: projectVersion,
  57. docs: v2DocVersions,
  58. }
  59. callback(
  60. err1 || err2,
  61. mostRecentVersion,
  62. projectStructureAndDocVersions,
  63. lastChange
  64. )
  65. })
  66. )
  67. })
  68. }
  69. function _requestChunk(options, callback) {
  70. _requestHistoryService(options, (err, chunk) => {
  71. if (err) {
  72. return callback(OError.tag(err))
  73. }
  74. if (
  75. chunk == null ||
  76. chunk.chunk == null ||
  77. chunk.chunk.startVersion == null
  78. ) {
  79. return callback(new OError('unexpected response'))
  80. }
  81. callback(null, chunk)
  82. })
  83. }
  84. function _getLatestProjectVersion(projectId, chunk, callback) {
  85. // find the initial project version
  86. let projectVersion =
  87. chunk.chunk.history.snapshot && chunk.chunk.history.snapshot.projectVersion
  88. // keep track of any errors
  89. let error = null
  90. // iterate over the changes in chunk to find the most recent project version
  91. for (const change of chunk.chunk.history.changes || []) {
  92. if (change.projectVersion != null) {
  93. if (
  94. projectVersion != null &&
  95. Versions.lt(change.projectVersion, projectVersion)
  96. ) {
  97. logger.warn(
  98. { projectId, chunk, projectVersion, change },
  99. 'project structure version out of order in chunk'
  100. )
  101. if (!error) {
  102. error = new Errors.OpsOutOfOrderError(
  103. 'project structure version out of order'
  104. )
  105. }
  106. } else {
  107. projectVersion = change.projectVersion
  108. }
  109. }
  110. }
  111. callback(error, projectVersion)
  112. }
  113. function _getLatestV2DocVersions(projectId, chunk, callback) {
  114. // find the initial doc versions (indexed by docId as this is immutable)
  115. const v2DocVersions =
  116. (chunk.chunk.history.snapshot &&
  117. chunk.chunk.history.snapshot.v2DocVersions) ||
  118. {}
  119. // keep track of any errors
  120. let error = null
  121. // iterate over the changes in the chunk to find the most recent doc versions
  122. for (const change of chunk.chunk.history.changes || []) {
  123. if (change.v2DocVersions != null) {
  124. for (const docId in change.v2DocVersions) {
  125. const docInfo = change.v2DocVersions[docId]
  126. const { v } = docInfo
  127. if (
  128. v2DocVersions[docId] &&
  129. v2DocVersions[docId].v != null &&
  130. Versions.lt(v, v2DocVersions[docId].v)
  131. ) {
  132. logger.warn(
  133. {
  134. projectId,
  135. docId,
  136. changeVersion: docInfo,
  137. previousVersion: v2DocVersions[docId],
  138. },
  139. 'doc version out of order in chunk'
  140. )
  141. if (!error) {
  142. error = new Errors.OpsOutOfOrderError('doc version out of order')
  143. }
  144. } else {
  145. v2DocVersions[docId] = docInfo
  146. }
  147. }
  148. }
  149. }
  150. callback(error, v2DocVersions)
  151. }
  152. export function getProjectBlob(historyId, blobHash, callback) {
  153. logger.debug({ historyId, blobHash }, 'getting blob from history service')
  154. _requestHistoryService(
  155. { path: `projects/${historyId}/blobs/${blobHash}` },
  156. callback
  157. )
  158. }
  159. export function getProjectBlobStream(historyId, blobHash, callback) {
  160. const url = `${Settings.overleaf.history.host}/projects/${historyId}/blobs/${blobHash}`
  161. logger.debug(
  162. { historyId, blobHash },
  163. 'getting blob stream from history service'
  164. )
  165. fetch(url, getHistoryFetchOptions())
  166. .then(res => {
  167. if (!res.ok) {
  168. const err = new OError(
  169. `history store a non-success status code: ${res.status}`
  170. )
  171. err.statusCode = res.status
  172. logger.warn({ err, url }, 'cannot get project blob')
  173. return callback(err)
  174. }
  175. callback(null, res.body)
  176. })
  177. .catch(err => callback(OError.tag(err)))
  178. }
  179. export function sendChanges(
  180. projectId,
  181. historyId,
  182. changes,
  183. endVersion,
  184. callback
  185. ) {
  186. logger.debug(
  187. { projectId, historyId, endVersion },
  188. 'sending changes to history service'
  189. )
  190. _requestHistoryService(
  191. {
  192. path: `projects/${historyId}/legacy_changes`,
  193. qs: { end_version: endVersion },
  194. method: 'POST',
  195. json: changes,
  196. },
  197. error => {
  198. if (error) {
  199. OError.tag(error, 'failed to send changes to v1', {
  200. projectId,
  201. historyId,
  202. endVersion,
  203. errorCode: error.code,
  204. statusCode: error.statusCode,
  205. body: error.body,
  206. })
  207. logger.warn(error)
  208. return callback(error)
  209. }
  210. callback()
  211. }
  212. )
  213. }
  214. export function createBlobForUpdate(projectId, historyId, update, callback) {
  215. callback = _.once(callback)
  216. if (update.doc != null && update.docLines != null) {
  217. const stringStream = new StringStream()
  218. stringStream.push(update.docLines)
  219. stringStream.push(null)
  220. LocalFileWriter.bufferOnDisk(
  221. stringStream,
  222. '',
  223. `project-${projectId}-doc-${update.doc}`,
  224. (fsPath, cb) => {
  225. _createBlob(historyId, fsPath, cb)
  226. },
  227. callback
  228. )
  229. } else if (update.file != null && update.url != null) {
  230. // Rewrite the filestore url to point to the location in the local
  231. // settings for this service (this avoids problems with cross-
  232. // datacentre requests when running filestore in multiple locations).
  233. const { pathname: fileStorePath } = new URL(update.url)
  234. const urlMatch = /^\/project\/([0-9a-f]{24})\/file\/([0-9a-f]{24})$/.exec(
  235. fileStorePath
  236. )
  237. if (urlMatch == null) {
  238. return callback(new OError('invalid file for blob creation'))
  239. }
  240. if (urlMatch[1] !== projectId) {
  241. return callback(new OError('invalid project for blob creation'))
  242. }
  243. const fileId = urlMatch[2]
  244. const filestoreURL = `${Settings.apis.filestore.url}/project/${projectId}/file/${fileId}`
  245. fetch(filestoreURL, { signal: AbortSignal.timeout(HTTP_REQUEST_TIMEOUT) })
  246. .then(response => {
  247. const statusCode = response.status
  248. if (statusCode >= 200 && statusCode < 300) {
  249. LocalFileWriter.bufferOnDisk(
  250. response.body,
  251. filestoreURL,
  252. `project-${projectId}-file-${fileId}`,
  253. (fsPath, cb) => {
  254. _createBlob(historyId, fsPath, cb)
  255. },
  256. callback
  257. )
  258. } else if (statusCode === 404) {
  259. logger.warn(
  260. { projectId, historyId, filestoreURL },
  261. 'File contents not found in filestore. Storing in history as an empty file'
  262. )
  263. const emptyStream = new StringStream()
  264. LocalFileWriter.bufferOnDisk(
  265. emptyStream,
  266. filestoreURL,
  267. `project-${projectId}-file-${fileId}`,
  268. (fsPath, cb) => {
  269. _createBlob(historyId, fsPath, cb)
  270. },
  271. callback
  272. )
  273. emptyStream.push(null) // send an EOF signal
  274. } else {
  275. const error = new OError(
  276. `bad response from filestore: ${statusCode}`,
  277. { filestoreURL, statusCode }
  278. )
  279. callback(error)
  280. }
  281. })
  282. .catch(err =>
  283. callback(OError.tag(err, 'error from filestore', { filestoreURL }))
  284. )
  285. } else {
  286. const error = new OError('invalid update for blob creation')
  287. callback(error)
  288. }
  289. }
  290. function _createBlob(historyId, fsPath, _callback) {
  291. const callback = _.once(_callback)
  292. HashManager._getBlobHash(fsPath, (error, hash, byteLength) => {
  293. if (error) {
  294. return callback(OError.tag(error))
  295. }
  296. const outStream = fs.createReadStream(fsPath)
  297. logger.debug(
  298. { fsPath, historyId, hash, byteLength },
  299. 'sending blob to history service'
  300. )
  301. const url = `${Settings.overleaf.history.host}/projects/${historyId}/blobs/${hash}`
  302. fetch(url, { method: 'PUT', body: outStream, ...getHistoryFetchOptions() })
  303. .then(res => {
  304. if (!res.ok) {
  305. const err = new OError(
  306. `history store a non-success status code: ${res.status}`
  307. )
  308. err.statusCode = res.status
  309. logger.warn({ err, url }, 'cannot create project blob')
  310. return callback(err)
  311. }
  312. callback(null, hash)
  313. })
  314. .catch(err => callback(OError.tag(err)))
  315. })
  316. }
  317. export function initializeProject(historyId, callback) {
  318. _requestHistoryService(
  319. {
  320. method: 'POST',
  321. path: 'projects',
  322. json: historyId == null ? true : { projectId: historyId },
  323. },
  324. (error, project) => {
  325. if (error) {
  326. return callback(OError.tag(error))
  327. }
  328. const id = project.projectId
  329. if (id == null) {
  330. error = new OError('history store did not return a project id', id)
  331. return callback(error)
  332. }
  333. callback(null, id)
  334. }
  335. )
  336. }
  337. export function deleteProject(projectId, callback) {
  338. _requestHistoryService(
  339. { method: 'DELETE', path: `projects/${projectId}` },
  340. callback
  341. )
  342. }
  343. const getProjectBlobAsync = BPromise.promisify(getProjectBlob)
  344. class BlobStore {
  345. constructor(projectId) {
  346. this.projectId = projectId
  347. }
  348. getString(hash) {
  349. return getProjectBlobAsync(this.projectId, hash)
  350. }
  351. }
  352. export function getBlobStore(projectId) {
  353. return new BlobStore(projectId)
  354. }
  355. function _requestOptions(options) {
  356. const requestOptions = {
  357. method: options.method || 'GET',
  358. url: `${Settings.overleaf.history.host}/${options.path}`,
  359. timeout: HTTP_REQUEST_TIMEOUT,
  360. auth: {
  361. user: Settings.overleaf.history.user,
  362. pass: Settings.overleaf.history.pass,
  363. sendImmediately: true,
  364. },
  365. }
  366. if (options.json != null) {
  367. requestOptions.json = options.json
  368. }
  369. if (options.body != null) {
  370. requestOptions.body = options.body
  371. }
  372. if (options.qs != null) {
  373. requestOptions.qs = options.qs
  374. }
  375. return requestOptions
  376. }
  377. /**
  378. * @return {RequestInit}
  379. */
  380. function getHistoryFetchOptions() {
  381. return {
  382. signal: AbortSignal.timeout(HTTP_REQUEST_TIMEOUT),
  383. headers: {
  384. Authorization:
  385. 'Basic ' +
  386. Buffer.from(
  387. `${Settings.overleaf.history.user}:${Settings.overleaf.history.pass}`
  388. ).toString('base64'),
  389. },
  390. }
  391. }
  392. function _requestHistoryService(options, callback) {
  393. const requestOptions = _requestOptions(options)
  394. request(requestOptions, (error, res, body) => {
  395. if (error) {
  396. return callback(OError.tag(error))
  397. }
  398. if (res.statusCode >= 200 && res.statusCode < 300) {
  399. callback(null, body)
  400. } else {
  401. error = new OError(
  402. `history store a non-success status code: ${res.statusCode}`
  403. )
  404. error.statusCode = res.statusCode
  405. error.body = body
  406. logger.warn({ err: error }, error.message)
  407. callback(error)
  408. }
  409. })
  410. }