RestoreManager.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. import Settings from '@overleaf/settings'
  2. import Path from 'node:path'
  3. import FileWriter from '../../infrastructure/FileWriter.js'
  4. import Metrics from '../../infrastructure/Metrics.js'
  5. import FileSystemImportManager from '../Uploads/FileSystemImportManager.js'
  6. import EditorController from '../Editor/EditorController.js'
  7. import Errors from '../Errors/Errors.js'
  8. import moment from 'moment'
  9. import { callbackifyAll } from '@overleaf/promise-utils'
  10. import { fetchJson } from '@overleaf/fetch-utils'
  11. import ProjectLocator from '../Project/ProjectLocator.js'
  12. import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.js'
  13. import ChatApiHandler from '../Chat/ChatApiHandler.js'
  14. import DocstoreManager from '../Docstore/DocstoreManager.js'
  15. import logger from '@overleaf/logger'
  16. import EditorRealTimeController from '../Editor/EditorRealTimeController.js'
  17. import ChatManager from '../Chat/ChatManager.js'
  18. import OError from '@overleaf/o-error'
  19. import ProjectGetter from '../Project/ProjectGetter.js'
  20. import ProjectEntityHandler from '../Project/ProjectEntityHandler.js'
  21. async function getCommentThreadIds(projectId) {
  22. await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
  23. const raw = await DocstoreManager.promises.getCommentThreadIds(projectId)
  24. return new Map(Object.entries(raw).map(([doc, ids]) => [doc, new Set(ids)]))
  25. }
  26. const RestoreManager = {
  27. async restoreFileFromV2(userId, projectId, version, pathname) {
  28. const fsPath = await RestoreManager._writeFileVersionToDisk(
  29. projectId,
  30. version,
  31. pathname
  32. )
  33. const basename = Path.basename(pathname)
  34. let dirname = Path.dirname(pathname)
  35. if (dirname === '.') {
  36. // no directory
  37. dirname = ''
  38. }
  39. const parentFolderId = await RestoreManager._findOrCreateFolder(
  40. projectId,
  41. dirname,
  42. userId
  43. )
  44. const addEntityWithName = async name =>
  45. await FileSystemImportManager.promises.addEntity(
  46. userId,
  47. projectId,
  48. parentFolderId,
  49. name,
  50. fsPath,
  51. false
  52. )
  53. return await RestoreManager._addEntityWithUniqueName(
  54. addEntityWithName,
  55. basename
  56. )
  57. },
  58. async revertFile(userId, projectId, version, pathname, options = {}) {
  59. const threadIds = await getCommentThreadIds(projectId)
  60. return await RestoreManager._revertSingleFile(
  61. userId,
  62. projectId,
  63. version,
  64. pathname,
  65. threadIds,
  66. options
  67. )
  68. },
  69. async _revertSingleFile(
  70. userId,
  71. projectId,
  72. version,
  73. pathname,
  74. threadIds,
  75. options = {}
  76. ) {
  77. const endTimer = Metrics.revertFileDurationSeconds.startTimer()
  78. const project = await ProjectGetter.promises.getProject(projectId, {
  79. overleaf: true,
  80. rootDoc_id: true,
  81. })
  82. if (!project?.overleaf?.history?.rangesSupportEnabled) {
  83. throw new OError('project does not have ranges support', { projectId })
  84. }
  85. const fsPath = await RestoreManager._writeFileVersionToDisk(
  86. projectId,
  87. version,
  88. pathname
  89. )
  90. const basename = Path.basename(pathname)
  91. let dirname = Path.dirname(pathname)
  92. if (dirname === '.') {
  93. // root directory
  94. dirname = '/'
  95. }
  96. const parentFolderId = await RestoreManager._findOrCreateFolder(
  97. projectId,
  98. dirname,
  99. userId
  100. )
  101. const file = await ProjectLocator.promises
  102. .findElementByPath({
  103. project_id: projectId,
  104. path: pathname,
  105. })
  106. .catch(() => null)
  107. const updates = await RestoreManager._getUpdatesFromHistory(
  108. projectId,
  109. version
  110. )
  111. const updateAtVersion = updates.find(update => update.toV === version)
  112. const origin = options.origin || {
  113. kind: 'file-restore',
  114. path: pathname,
  115. version,
  116. timestamp: new Date(updateAtVersion.meta.end_ts).toISOString(),
  117. }
  118. const importInfo = await FileSystemImportManager.promises.importFile(
  119. fsPath,
  120. pathname
  121. )
  122. let hadDeletedRootFile = false
  123. if (file) {
  124. if (file.type !== 'doc' && file.type !== 'file') {
  125. throw new OError('unexpected file type', { type: file.type })
  126. }
  127. logger.debug(
  128. { projectId, fileId: file.element._id, type: importInfo.type },
  129. 'deleting entity before reverting it'
  130. )
  131. await EditorController.promises.deleteEntity(
  132. projectId,
  133. file.element._id,
  134. file.type,
  135. origin,
  136. userId
  137. )
  138. if (
  139. file.element._id &&
  140. project.rootDoc_id &&
  141. file.element._id.toString() === project.rootDoc_id.toString()
  142. ) {
  143. hadDeletedRootFile = true
  144. }
  145. threadIds.delete(file.element._id.toString())
  146. }
  147. const { metadata } = await RestoreManager._getMetadataFromHistory(
  148. projectId,
  149. version,
  150. pathname
  151. )
  152. // Look for metadata indicating a linked file.
  153. const isFileMetadata = metadata && 'provider' in metadata
  154. logger.debug({ metadata }, 'metadata from history')
  155. if (importInfo.type === 'file' || isFileMetadata) {
  156. const newFile = await EditorController.promises.upsertFile(
  157. projectId,
  158. parentFolderId,
  159. basename,
  160. fsPath,
  161. metadata,
  162. origin,
  163. userId
  164. )
  165. endTimer({ type: 'file' })
  166. return {
  167. _id: newFile._id,
  168. type: 'file',
  169. }
  170. }
  171. const ranges = await RestoreManager._getRangesFromHistory(
  172. projectId,
  173. version,
  174. pathname
  175. )
  176. const documentCommentIds = new Set(
  177. ranges.comments?.map(({ op: { t } }) => t)
  178. )
  179. const commentIdsToDuplicate = Array.from(documentCommentIds).filter(id => {
  180. for (const ids of threadIds.values()) {
  181. if (ids.has(id)) return true
  182. }
  183. return false
  184. })
  185. const newRanges = { changes: ranges.changes, comments: [] }
  186. if (commentIdsToDuplicate.length > 0) {
  187. const { newThreads: newCommentIds } =
  188. await ChatApiHandler.promises.duplicateCommentThreads(
  189. projectId,
  190. commentIdsToDuplicate
  191. )
  192. logger.debug({ mapping: newCommentIds }, 'replacing comment threads')
  193. for (const comment of ranges.comments ?? []) {
  194. if (Object.prototype.hasOwnProperty.call(newCommentIds, comment.op.t)) {
  195. const result = newCommentIds[comment.op.t]
  196. if (result.error) {
  197. // We couldn't duplicate the thread, so we need to delete it from
  198. // the resulting ranges.
  199. continue
  200. }
  201. // We have a new id for this comment thread
  202. comment.id = result.duplicateId
  203. comment.op.t = result.duplicateId
  204. }
  205. newRanges.comments.push(comment)
  206. }
  207. } else {
  208. newRanges.comments = ranges.comments
  209. }
  210. const newCommentThreadData =
  211. await ChatApiHandler.promises.generateThreadData(
  212. projectId,
  213. newRanges.comments.map(({ op: { t } }) => t)
  214. )
  215. // Resolve/reopen threads in chat service to match what is in history
  216. for (const commentRange of newRanges.comments) {
  217. const threadData = newCommentThreadData[commentRange.op.t]
  218. if (!threadData) {
  219. // comment thread was deleted
  220. continue
  221. }
  222. if (commentRange.op.resolved && threadData.resolved == null) {
  223. // The history snapshot stores the comment's resolved property as a boolean,
  224. // but it does not include information about who resolved the comment or the timestamp.
  225. // Until this is fixed, we will resolve the thread with the current user and the current timestamp.
  226. await ChatApiHandler.promises.resolveThread(
  227. projectId,
  228. commentRange.op.t,
  229. userId
  230. )
  231. threadData.resolved = true
  232. threadData.resolved_by_user_id = userId
  233. threadData.resolved_at = new Date().toISOString()
  234. } else if (!commentRange.op.resolved && threadData.resolved != null) {
  235. await ChatApiHandler.promises.reopenThread(projectId, commentRange.op.t)
  236. delete threadData.resolved
  237. delete threadData.resolved_by_user_id
  238. delete threadData.resolved_at
  239. }
  240. }
  241. await ChatManager.promises.injectUserInfoIntoThreads(newCommentThreadData)
  242. // Only keep restored comment ranges that point to a valid thread.
  243. // The chat service won't have generated thread data for deleted threads.
  244. newRanges.comments = newRanges.comments.filter(
  245. comment => newCommentThreadData[comment.op.t] != null
  246. )
  247. logger.debug({ newCommentThreadData }, 'emitting new comment threads')
  248. EditorRealTimeController.emitToRoom(
  249. projectId,
  250. 'new-comment-threads',
  251. newCommentThreadData
  252. )
  253. const { _id } = await EditorController.promises.addDocWithRanges(
  254. projectId,
  255. parentFolderId,
  256. basename,
  257. importInfo.lines,
  258. newRanges,
  259. origin,
  260. userId
  261. )
  262. if (hadDeletedRootFile) {
  263. await EditorController.promises.setRootDoc(projectId, _id)
  264. }
  265. // For revertProject: The next doc that gets reverted will need to duplicate all the threads seen here.
  266. threadIds.set(
  267. _id.toString(),
  268. new Set(newRanges.comments.map(({ op: { t } }) => t))
  269. )
  270. endTimer({ type: 'doc' })
  271. return {
  272. _id,
  273. type: importInfo.type,
  274. }
  275. },
  276. async _findOrCreateFolder(projectId, dirname, userId) {
  277. const { lastFolder } = await EditorController.promises.mkdirp(
  278. projectId,
  279. dirname,
  280. userId
  281. )
  282. return lastFolder?._id
  283. },
  284. async _addEntityWithUniqueName(addEntityWithName, basename) {
  285. try {
  286. return await addEntityWithName(basename)
  287. } catch (error) {
  288. if (error instanceof Errors.DuplicateNameError) {
  289. // Duplicate name, so try with a prefix
  290. const date = moment(new Date()).format('Do MMM YY H:mm:ss')
  291. // Move extension to the end so the file type is preserved
  292. const extension = Path.extname(basename)
  293. basename = Path.basename(basename, extension)
  294. basename = `${basename} (Restored on ${date})`
  295. if (extension !== '') {
  296. basename = `${basename}${extension}`
  297. }
  298. return await addEntityWithName(basename)
  299. } else {
  300. throw error
  301. }
  302. }
  303. },
  304. async revertProject(userId, projectId, version) {
  305. const endTimer = Metrics.revertProjectDurationSeconds.startTimer()
  306. const project = await ProjectGetter.promises.getProject(projectId, {
  307. overleaf: true,
  308. })
  309. if (!project?.overleaf?.history?.rangesSupportEnabled) {
  310. throw new OError('project does not have ranges support', { projectId })
  311. }
  312. // Get project paths at version
  313. const pathsAtPastVersion = await RestoreManager._getProjectPathsAtVersion(
  314. projectId,
  315. version
  316. )
  317. const updates = await RestoreManager._getUpdatesFromHistory(
  318. projectId,
  319. version
  320. )
  321. const updateAtVersion = updates.find(update => update.toV === version)
  322. const origin = {
  323. kind: 'project-restore',
  324. version,
  325. timestamp: new Date(updateAtVersion.meta.end_ts).toISOString(),
  326. }
  327. const threadIds = await getCommentThreadIds(projectId)
  328. const reverted = []
  329. for (const pathname of pathsAtPastVersion) {
  330. const res = await RestoreManager._revertSingleFile(
  331. userId,
  332. projectId,
  333. version,
  334. pathname,
  335. threadIds,
  336. { origin }
  337. )
  338. reverted.push({
  339. id: res._id,
  340. type: res.type,
  341. path: pathname,
  342. })
  343. }
  344. const entitiesAtLiveVersion =
  345. await ProjectEntityHandler.promises.getAllEntities(projectId)
  346. const trimLeadingSlash = path => path.replace(/^\//, '')
  347. const pathsAtLiveVersion = entitiesAtLiveVersion.docs
  348. .map(doc => doc.path)
  349. .concat(entitiesAtLiveVersion.files.map(file => file.path))
  350. .map(trimLeadingSlash)
  351. // Delete files that were not present at the reverted version
  352. for (const path of pathsAtLiveVersion) {
  353. if (!pathsAtPastVersion.includes(path)) {
  354. await EditorController.promises.deleteEntityWithPath(
  355. projectId,
  356. path,
  357. origin,
  358. userId
  359. )
  360. }
  361. }
  362. endTimer()
  363. return reverted
  364. },
  365. async _writeFileVersionToDisk(projectId, version, pathname) {
  366. const url = `${
  367. Settings.apis.project_history.url
  368. }/project/${projectId}/version/${version}/${encodeURIComponent(pathname)}`
  369. return await FileWriter.promises.writeUrlToDisk(projectId, url)
  370. },
  371. async _getRangesFromHistory(projectId, version, pathname) {
  372. const url = `${
  373. Settings.apis.project_history.url
  374. }/project/${projectId}/ranges/version/${version}/${encodeURIComponent(pathname)}`
  375. return await fetchJson(url)
  376. },
  377. async _getMetadataFromHistory(projectId, version, pathname) {
  378. const url = `${
  379. Settings.apis.project_history.url
  380. }/project/${projectId}/metadata/version/${version}/${encodeURIComponent(pathname)}`
  381. return await fetchJson(url)
  382. },
  383. async _getUpdatesFromHistory(projectId, version) {
  384. const url = `${Settings.apis.project_history.url}/project/${projectId}/updates?before=${version}&min_count=1`
  385. const res = await fetchJson(url)
  386. return res.updates
  387. },
  388. async _getProjectPathsAtVersion(projectId, version) {
  389. const url = `${Settings.apis.project_history.url}/project/${projectId}/paths/version/${version}`
  390. const res = await fetchJson(url)
  391. return res.paths
  392. },
  393. }
  394. export default { ...callbackifyAll(RestoreManager), promises: RestoreManager }