HistoryManager.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. import { callbackify } from 'node:util'
  2. import {
  3. fetchJson,
  4. fetchNothing,
  5. fetchStream,
  6. fetchStreamWithResponse,
  7. RequestFailedError,
  8. } from '@overleaf/fetch-utils'
  9. import fs from 'node:fs'
  10. import settings from '@overleaf/settings'
  11. import OError from '@overleaf/o-error'
  12. import UserGetter from '../User/UserGetter.mjs'
  13. import ProjectGetter from '../Project/ProjectGetter.mjs'
  14. import HistoryBackupDeletionHandler from './HistoryBackupDeletionHandler.mjs'
  15. import { db, waitForDb } from '../../infrastructure/mongodb.mjs'
  16. import Metrics from '@overleaf/metrics'
  17. import { NotFoundError } from '../Errors/Errors.js'
  18. const HISTORY_V1_URL = settings.apis.v1_history.url
  19. const HISTORY_V1_BASIC_AUTH = {
  20. user: settings.apis.v1_history.user,
  21. password: settings.apis.v1_history.pass,
  22. }
  23. // BEGIN copy from services/history-v1/storage/lib/blob_store/index.js
  24. const GLOBAL_BLOBS = new Set() // CHANGE FROM SOURCE: only store hashes.
  25. async function loadGlobalBlobs() {
  26. await waitForDb() // CHANGE FROM SOURCE: wait for db before running query.
  27. const blobs = db.projectHistoryGlobalBlobs.find()
  28. for await (const blob of blobs) {
  29. GLOBAL_BLOBS.add(blob._id) // CHANGE FROM SOURCE: only store hashes.
  30. }
  31. }
  32. // END copy from services/history-v1/storage/lib/blob_store/index.js
  33. function isGlobalBlob(hash) {
  34. return GLOBAL_BLOBS.has(hash)
  35. }
  36. function getFilestoreBlobURL(historyId, hash) {
  37. if (GLOBAL_BLOBS.has(hash)) {
  38. return `${settings.apis.filestore.url}/history/global/hash/${hash}`
  39. } else {
  40. return `${settings.apis.filestore.url}/history/project/${historyId}/hash/${hash}`
  41. }
  42. }
  43. async function initializeProject(projectId) {
  44. const body = await fetchJson(`${settings.apis.project_history.url}/project`, {
  45. method: 'POST',
  46. json: { historyId: projectId },
  47. })
  48. const historyId = body && body.project && body.project.id
  49. if (!historyId) {
  50. throw new OError('project-history did not provide an id', { body })
  51. }
  52. return historyId
  53. }
  54. async function cloneProject(sourceProjectId, targetProjectId) {
  55. return await fetchStream(
  56. `${settings.apis.project_history.url}/project/${sourceProjectId}/clone`,
  57. {
  58. method: 'POST',
  59. json: { targetProjectId },
  60. signal: AbortSignal.timeout(10 * 60_000),
  61. }
  62. )
  63. }
  64. async function flushProject(projectId) {
  65. try {
  66. await fetchNothing(
  67. `${settings.apis.project_history.url}/project/${projectId}/flush`,
  68. { method: 'POST' }
  69. )
  70. } catch (err) {
  71. throw OError.tag(err, 'failed to flush project to project history', {
  72. projectId,
  73. })
  74. }
  75. }
  76. async function deleteProjectHistory(projectId) {
  77. try {
  78. await fetchNothing(
  79. `${settings.apis.project_history.url}/project/${projectId}`,
  80. { method: 'DELETE' }
  81. )
  82. } catch (err) {
  83. throw OError.tag(err, 'failed to delete project history', {
  84. projectId,
  85. })
  86. }
  87. }
  88. async function resyncProject(projectId, options = {}) {
  89. const body = {}
  90. if (options.force) {
  91. body.force = options.force
  92. }
  93. if (options.origin) {
  94. body.origin = options.origin
  95. }
  96. if (options.historyRangesMigration) {
  97. body.historyRangesMigration = options.historyRangesMigration
  98. }
  99. try {
  100. await fetchNothing(
  101. `${settings.apis.project_history.url}/project/${projectId}/resync`,
  102. {
  103. method: 'POST',
  104. json: body,
  105. signal: AbortSignal.timeout(6 * 60 * 1000),
  106. }
  107. )
  108. } catch (err) {
  109. throw OError.tag(err, 'failed to resync project history', {
  110. projectId,
  111. })
  112. }
  113. }
  114. async function deleteProject(projectId, historyId) {
  115. const tasks = []
  116. tasks.push(_deleteProjectInProjectHistory(projectId))
  117. if (historyId != null) {
  118. tasks.push(_deleteProjectInFullProjectHistory(historyId))
  119. }
  120. await Promise.all(tasks)
  121. await HistoryBackupDeletionHandler.deleteProject(projectId)
  122. }
  123. async function _deleteProjectInProjectHistory(projectId) {
  124. try {
  125. await fetchNothing(
  126. `${settings.apis.project_history.url}/project/${projectId}`,
  127. { method: 'DELETE' }
  128. )
  129. } catch (err) {
  130. throw OError.tag(
  131. err,
  132. 'failed to clear project history in project-history',
  133. { projectId }
  134. )
  135. }
  136. }
  137. async function _deleteProjectInFullProjectHistory(historyId) {
  138. try {
  139. await fetchNothing(`${HISTORY_V1_URL}/projects/${historyId}`, {
  140. method: 'DELETE',
  141. basicAuth: HISTORY_V1_BASIC_AUTH,
  142. })
  143. } catch (err) {
  144. throw OError.tag(err, 'failed to clear project history', { historyId })
  145. }
  146. }
  147. async function uploadBlobFromDisk(historyId, hash, byteLength, fsPath) {
  148. const outStream = fs.createReadStream(fsPath)
  149. const url = `${HISTORY_V1_URL}/projects/${historyId}/blobs/${hash}`
  150. await fetchNothing(url, {
  151. method: 'PUT',
  152. body: outStream,
  153. headers: { 'Content-Length': byteLength }, // add the content length to work around problems with chunked encoding in node 18
  154. signal: AbortSignal.timeout(60 * 1000),
  155. basicAuth: HISTORY_V1_BASIC_AUTH,
  156. })
  157. }
  158. async function copyBlob(sourceHistoryId, targetHistoryId, hash) {
  159. const url = `${HISTORY_V1_URL}/projects/${targetHistoryId}/blobs/${hash}`
  160. await fetchNothing(
  161. `${url}?${new URLSearchParams({ copyFrom: sourceHistoryId })}`,
  162. {
  163. method: 'POST',
  164. basicAuth: HISTORY_V1_BASIC_AUTH,
  165. }
  166. )
  167. }
  168. async function requestBlobWithProjectId(
  169. projectId,
  170. hash,
  171. method = 'GET',
  172. range = ''
  173. ) {
  174. const project = await ProjectGetter.promises.getProject(projectId, {
  175. 'overleaf.history.id': true,
  176. })
  177. return requestBlob(project.overleaf.history.id, hash, method, range)
  178. }
  179. async function requestBlob(historyId, hash, method = 'GET', range = '') {
  180. // Talk to history-v1 directly to avoid streaming via project-history.
  181. const url = new URL(HISTORY_V1_URL)
  182. url.pathname += `/projects/${historyId}/blobs/${hash}`
  183. const opts = { method, headers: { Range: range } }
  184. let stream, response
  185. try {
  186. ;({ stream, response } = await fetchStreamWithResponse(url, {
  187. ...opts,
  188. signal: AbortSignal.timeout(10 * 60 * 1000),
  189. basicAuth: {
  190. user: settings.apis.v1_history.user,
  191. password: settings.apis.v1_history.pass,
  192. },
  193. }))
  194. } catch (err) {
  195. if (err instanceof RequestFailedError && err.response.status === 404) {
  196. throw new NotFoundError()
  197. } else {
  198. throw err
  199. }
  200. }
  201. Metrics.inc('request_blob', 1, { path: 'history-v1' })
  202. return {
  203. url,
  204. stream,
  205. contentLength: parseInt(response.headers.get('Content-Length'), 10),
  206. contentRange: response.headers.get('Content-Range'),
  207. }
  208. }
  209. /**
  210. * Warning: Don't use this method for large projects. It will eagerly load all
  211. * the history data and apply all operations.
  212. * @param {string} projectId
  213. * @returns Promise<object>
  214. */
  215. async function getCurrentContent(projectId) {
  216. const historyId = await getHistoryId(projectId)
  217. try {
  218. return await fetchJson(
  219. `${HISTORY_V1_URL}/projects/${historyId}/latest/content`,
  220. {
  221. method: 'GET',
  222. basicAuth: HISTORY_V1_BASIC_AUTH,
  223. }
  224. )
  225. } catch (err) {
  226. throw OError.tag(err, 'failed to load project history', { historyId })
  227. }
  228. }
  229. /**
  230. * Warning: Don't use this method for large projects. It will eagerly load all
  231. * the history data and apply all operations.
  232. * @param {string} projectId
  233. * @param {number} version
  234. *
  235. * @returns Promise<object>
  236. */
  237. async function getContentAtVersion(projectId, version) {
  238. const historyId = await getHistoryId(projectId)
  239. try {
  240. return await fetchJson(
  241. `${HISTORY_V1_URL}/projects/${historyId}/versions/${version}/content`,
  242. {
  243. method: 'GET',
  244. basicAuth: HISTORY_V1_BASIC_AUTH,
  245. }
  246. )
  247. } catch (err) {
  248. throw OError.tag(
  249. err,
  250. 'failed to load project history snapshot at version',
  251. { historyId, version }
  252. )
  253. }
  254. }
  255. /**
  256. * Get the latest chunk from history
  257. *
  258. * @param {string} projectId
  259. */
  260. async function getLatestHistory(projectId) {
  261. const historyId = await getHistoryId(projectId)
  262. return await getLatestHistoryWithHistoryId(historyId)
  263. }
  264. /**
  265. * Get the latest chunk from history using already resolved historyId
  266. *
  267. * @param {string} historyId
  268. */
  269. async function getLatestHistoryWithHistoryId(historyId) {
  270. return await fetchJson(
  271. `${HISTORY_V1_URL}/projects/${historyId}/latest/history`,
  272. {
  273. basicAuth: HISTORY_V1_BASIC_AUTH,
  274. }
  275. )
  276. }
  277. async function ensureNoResyncPending(projectId) {
  278. const { resyncPending } = await fetchJson(
  279. `${settings.apis.project_history.url}/project/${projectId}/resync-pending`
  280. )
  281. if (resyncPending) throw new OError('broken history with pending resync')
  282. }
  283. async function getDebugInfo(projectId) {
  284. return await fetchJson(
  285. `${settings.apis.project_history.url}/project/${projectId}/debug-info`
  286. )
  287. }
  288. async function getHistoryFailures() {
  289. return await fetchJson(
  290. `${settings.apis.project_history.url}/status/failures-full`
  291. )
  292. }
  293. /**
  294. * Get history changes since a given version
  295. *
  296. * @param {string} projectId
  297. * @param {object} [opts]
  298. * @param {number} [opts.since] - The start version of changes to get
  299. */
  300. async function getChanges(projectId, opts = {}) {
  301. const historyId = await getHistoryId(projectId)
  302. return await getChangesWithHistoryId(historyId, opts)
  303. }
  304. /**
  305. * Get history changes since a given version and historyId
  306. *
  307. * @param {string} historyId
  308. * @param {object} [opts]
  309. * @param {number} [opts.since] - The start version of changes to get
  310. */
  311. async function getChangesWithHistoryId(historyId, opts = {}) {
  312. const url = new URL(`${HISTORY_V1_URL}/projects/${historyId}/changes`)
  313. if (opts.since) {
  314. url.searchParams.set('since', opts.since)
  315. }
  316. return await fetchJson(url, {
  317. basicAuth: HISTORY_V1_BASIC_AUTH,
  318. })
  319. }
  320. async function getHistoryId(projectId) {
  321. const project = await ProjectGetter.promises.getProject(projectId, {
  322. overleaf: true,
  323. })
  324. const historyId = project?.overleaf?.history?.id
  325. if (!historyId) {
  326. throw new OError('project does not have a history id', { projectId })
  327. }
  328. return historyId
  329. }
  330. async function getBlobStats(historyId, blobHashes) {
  331. return await fetchJson(`${HISTORY_V1_URL}/projects/${historyId}/blob-stats`, {
  332. method: 'POST',
  333. basicAuth: HISTORY_V1_BASIC_AUTH,
  334. json: { blobHashes: blobHashes.map(id => id.toString()) },
  335. })
  336. }
  337. async function getProjectBlobStats(historyIds) {
  338. return await fetchJson(`${HISTORY_V1_URL}/projects/blob-stats`, {
  339. method: 'POST',
  340. basicAuth: HISTORY_V1_BASIC_AUTH,
  341. json: { projectIds: historyIds.map(id => id.toString()) },
  342. })
  343. }
  344. async function injectUserDetails(data) {
  345. // data can be either:
  346. // {
  347. // diff: [{
  348. // i: "foo",
  349. // meta: {
  350. // users: ["user_id", v1_user_id, ...]
  351. // ...
  352. // }
  353. // }, ...]
  354. // }
  355. // or
  356. // {
  357. // updates: [{
  358. // pathnames: ["main.tex"]
  359. // meta: {
  360. // users: ["user_id", v1_user_id, ...]
  361. // ...
  362. // },
  363. // ...
  364. // }, ...]
  365. // }
  366. // Either way, the top level key points to an array of objects with a meta.users property
  367. // that we need to replace user_ids with populated user objects.
  368. // Note that some entries in the users arrays may be v1 ids returned by the v1 history
  369. // service. v1 ids will be `numbers`
  370. let userIds = new Set()
  371. let v1UserIds = new Set()
  372. const entries = Array.isArray(data.diff)
  373. ? data.diff
  374. : Array.isArray(data.updates)
  375. ? data.updates
  376. : []
  377. for (const entry of entries) {
  378. for (const user of (entry.meta && entry.meta.users) || []) {
  379. if (typeof user === 'string') {
  380. userIds.add(user)
  381. } else if (typeof user === 'number') {
  382. v1UserIds.add(user)
  383. }
  384. }
  385. }
  386. userIds = Array.from(userIds)
  387. v1UserIds = Array.from(v1UserIds)
  388. const projection = { first_name: 1, last_name: 1, email: 1 }
  389. const usersArray = await UserGetter.promises.getUsers(userIds, projection)
  390. const users = {}
  391. for (const user of usersArray) {
  392. users[user._id.toString()] = _userView(user)
  393. }
  394. projection.overleaf = 1
  395. const v1IdentifiedUsersArray = await UserGetter.promises.getUsersByV1Ids(
  396. v1UserIds,
  397. projection
  398. )
  399. for (const user of v1IdentifiedUsersArray) {
  400. users[user.overleaf.id] = _userView(user)
  401. }
  402. for (const entry of entries) {
  403. if (entry.meta != null) {
  404. entry.meta.users = ((entry.meta && entry.meta.users) || []).map(user => {
  405. if (typeof user === 'string' || typeof user === 'number') {
  406. return users[user]
  407. } else {
  408. return user
  409. }
  410. })
  411. }
  412. }
  413. return data
  414. }
  415. function _userView(user) {
  416. const { _id, first_name: firstName, last_name: lastName, email } = user
  417. return { first_name: firstName, last_name: lastName, email, id: _id }
  418. }
  419. const loadGlobalBlobsPromise = loadGlobalBlobs()
  420. export default {
  421. isGlobalBlob,
  422. getFilestoreBlobURL,
  423. loadGlobalBlobsPromise,
  424. initializeProject: callbackify(initializeProject),
  425. flushProject: callbackify(flushProject),
  426. resyncProject: callbackify(resyncProject),
  427. deleteProject: callbackify(deleteProject),
  428. deleteProjectHistory: callbackify(deleteProjectHistory),
  429. injectUserDetails: callbackify(injectUserDetails),
  430. getCurrentContent: callbackify(getCurrentContent),
  431. uploadBlobFromDisk: callbackify(uploadBlobFromDisk),
  432. copyBlob: callbackify(copyBlob),
  433. requestBlob: callbackify(requestBlob),
  434. requestBlobWithProjectId: callbackify(requestBlobWithProjectId),
  435. getLatestHistory: callbackify(getLatestHistory),
  436. getChanges: callbackify(getChanges),
  437. promises: {
  438. initializeProject,
  439. cloneProject,
  440. flushProject,
  441. resyncProject,
  442. deleteProject,
  443. injectUserDetails,
  444. deleteProjectHistory,
  445. getCurrentContent,
  446. getContentAtVersion,
  447. uploadBlobFromDisk,
  448. copyBlob,
  449. requestBlob,
  450. requestBlobWithProjectId,
  451. getLatestHistory,
  452. getChanges,
  453. getChangesWithHistoryId,
  454. getProjectBlobStats,
  455. getBlobStats,
  456. getLatestHistoryWithHistoryId,
  457. ensureNoResyncPending,
  458. getDebugInfo,
  459. getHistoryFailures,
  460. },
  461. }