RestoreManager.mjs 13 KB

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