RestoreManager.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  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 FileTypeManager from '../Uploads/FileTypeManager.js'
  7. import EditorController from '../Editor/EditorController.js'
  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.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.mjs'
  18. import OError from '@overleaf/o-error'
  19. import ProjectGetter from '../Project/ProjectGetter.js'
  20. import ProjectEntityHandler from '../Project/ProjectEntityHandler.js'
  21. import HistoryManager from './HistoryManager.js'
  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 basename = Path.basename(pathname)
  111. let dirname = Path.dirname(pathname)
  112. if (dirname === '.') {
  113. // root directory
  114. dirname = '/'
  115. }
  116. const parentFolderId = await RestoreManager._findOrCreateFolder(
  117. projectId,
  118. dirname,
  119. userId
  120. )
  121. const file = await ProjectLocator.promises
  122. .findElementByPath({
  123. project_id: projectId,
  124. path: pathname,
  125. })
  126. .catch(() => null)
  127. const snapshotFile = projectSnapshotAtVersion.getFile(pathname)
  128. if (!snapshotFile) {
  129. throw new OError('file not found in snapshot', { pathname })
  130. }
  131. let hadDeletedRootFile = false
  132. if (file) {
  133. if (file.type !== 'doc' && file.type !== 'file') {
  134. throw new OError('unexpected file type', { type: file.type })
  135. }
  136. logger.debug(
  137. { projectId, fileId: file.element._id },
  138. 'deleting entity before reverting it'
  139. )
  140. await EditorController.promises.deleteEntity(
  141. projectId,
  142. file.element._id,
  143. file.type,
  144. options.origin,
  145. userId
  146. )
  147. if (
  148. file.element._id &&
  149. project.rootDoc_id &&
  150. file.element._id.toString() === project.rootDoc_id.toString()
  151. ) {
  152. hadDeletedRootFile = true
  153. }
  154. threadIds.delete(file.element._id.toString())
  155. }
  156. // Look for metadata indicating a linked file.
  157. const fileMetadata = snapshotFile.getMetadata()
  158. const isLinkedFile = fileMetadata && 'provider' in fileMetadata
  159. logger.debug({ fileMetadata }, 'metadata from history')
  160. if (
  161. isLinkedFile ||
  162. !snapshotFile.isEditable() ||
  163. !FileTypeManager.isEditable(snapshotFile.getContent(), {
  164. filename: pathname,
  165. })
  166. ) {
  167. const fsPath = await RestoreManager._writeFileVersionToDisk(
  168. projectId,
  169. version,
  170. pathname
  171. )
  172. const newFile = await EditorController.promises.upsertFile(
  173. projectId,
  174. parentFolderId,
  175. basename,
  176. fsPath,
  177. fileMetadata,
  178. options.origin,
  179. userId
  180. )
  181. endTimer({ type: 'file' })
  182. return {
  183. _id: newFile._id,
  184. type: 'file',
  185. }
  186. }
  187. const ranges = getDocUpdaterCompatibleRanges(snapshotFile)
  188. const documentCommentIds = new Set(
  189. ranges.comments?.map(({ op: { t } }) => t)
  190. )
  191. const commentIdsToDuplicate = Array.from(documentCommentIds).filter(id => {
  192. for (const ids of threadIds.values()) {
  193. if (ids.has(id)) return true
  194. }
  195. return false
  196. })
  197. const newRanges = { changes: ranges.changes, comments: [] }
  198. if (commentIdsToDuplicate.length > 0) {
  199. const { newThreads: newCommentIds } =
  200. await ChatApiHandler.promises.duplicateCommentThreads(
  201. projectId,
  202. commentIdsToDuplicate
  203. )
  204. logger.debug({ mapping: newCommentIds }, 'replacing comment threads')
  205. for (const comment of ranges.comments ?? []) {
  206. if (Object.prototype.hasOwnProperty.call(newCommentIds, comment.op.t)) {
  207. const result = newCommentIds[comment.op.t]
  208. if (result.error) {
  209. // We couldn't duplicate the thread, so we need to delete it from
  210. // the resulting ranges.
  211. continue
  212. }
  213. // We have a new id for this comment thread
  214. comment.id = result.duplicateId
  215. comment.op.t = result.duplicateId
  216. }
  217. newRanges.comments.push(comment)
  218. }
  219. } else {
  220. newRanges.comments = ranges.comments
  221. }
  222. const newCommentThreadData =
  223. await ChatApiHandler.promises.generateThreadData(
  224. projectId,
  225. newRanges.comments.map(({ op: { t } }) => t)
  226. )
  227. // Resolve/reopen threads in chat service to match what is in history
  228. for (const commentRange of newRanges.comments) {
  229. const threadData = newCommentThreadData[commentRange.op.t]
  230. if (!threadData) {
  231. // comment thread was deleted
  232. continue
  233. }
  234. if (commentRange.op.resolved && threadData.resolved == null) {
  235. // The history snapshot stores the comment's resolved property as a boolean,
  236. // but it does not include information about who resolved the comment or the timestamp.
  237. // Until this is fixed, we will resolve the thread with the current user and the current timestamp.
  238. await ChatApiHandler.promises.resolveThread(
  239. projectId,
  240. commentRange.op.t,
  241. userId
  242. )
  243. threadData.resolved = true
  244. threadData.resolved_by_user_id = userId
  245. threadData.resolved_at = new Date().toISOString()
  246. } else if (!commentRange.op.resolved && threadData.resolved != null) {
  247. await ChatApiHandler.promises.reopenThread(projectId, commentRange.op.t)
  248. delete threadData.resolved
  249. delete threadData.resolved_by_user_id
  250. delete threadData.resolved_at
  251. }
  252. }
  253. await ChatManager.promises.injectUserInfoIntoThreads(newCommentThreadData)
  254. // Only keep restored comment ranges that point to a valid thread.
  255. // The chat service won't have generated thread data for deleted threads.
  256. newRanges.comments = newRanges.comments.filter(
  257. comment => newCommentThreadData[comment.op.t] != null
  258. )
  259. logger.debug({ newCommentThreadData }, 'emitting new comment threads')
  260. EditorRealTimeController.emitToRoom(
  261. projectId,
  262. 'new-comment-threads',
  263. newCommentThreadData
  264. )
  265. const lines = snapshotFile
  266. .getContent({ filterTrackedDeletes: true })
  267. .split('\n')
  268. const { _id } = await EditorController.promises.addDocWithRanges(
  269. projectId,
  270. parentFolderId,
  271. basename,
  272. lines,
  273. newRanges,
  274. options.origin,
  275. userId
  276. )
  277. if (hadDeletedRootFile) {
  278. await EditorController.promises.setRootDoc(projectId, _id)
  279. }
  280. // For revertProject: The next doc that gets reverted will need to duplicate all the threads seen here.
  281. threadIds.set(
  282. _id.toString(),
  283. new Set(newRanges.comments.map(({ op: { t } }) => t))
  284. )
  285. endTimer({ type: 'doc' })
  286. return {
  287. _id,
  288. type: 'doc',
  289. }
  290. },
  291. async _findOrCreateFolder(projectId, dirname, userId) {
  292. const { lastFolder } = await EditorController.promises.mkdirp(
  293. projectId,
  294. dirname,
  295. userId
  296. )
  297. return lastFolder?._id
  298. },
  299. async _addEntityWithUniqueName(addEntityWithName, basename) {
  300. try {
  301. return await addEntityWithName(basename)
  302. } catch (error) {
  303. if (error instanceof Errors.DuplicateNameError) {
  304. // Duplicate name, so try with a prefix
  305. const date = moment(new Date()).format('Do MMM YY H:mm:ss')
  306. // Move extension to the end so the file type is preserved
  307. const extension = Path.extname(basename)
  308. basename = Path.basename(basename, extension)
  309. basename = `${basename} (Restored on ${date})`
  310. if (extension !== '') {
  311. basename = `${basename}${extension}`
  312. }
  313. return await addEntityWithName(basename)
  314. } else {
  315. throw error
  316. }
  317. }
  318. },
  319. async revertProject(userId, projectId, version) {
  320. const endTimer = Metrics.revertProjectDurationSeconds.startTimer()
  321. const project = await ProjectGetter.promises.getProject(projectId, {
  322. overleaf: true,
  323. })
  324. if (!project?.overleaf?.history?.rangesSupportEnabled) {
  325. throw new OError('project does not have ranges support', { projectId })
  326. }
  327. const snapshotRaw = await HistoryManager.promises.getContentAtVersion(
  328. projectId,
  329. version
  330. )
  331. const snapshot = Snapshot.fromRaw(snapshotRaw)
  332. const pathsAtPastVersion = snapshot.getFilePathnames()
  333. const origin = {
  334. kind: 'project-restore',
  335. version,
  336. timestamp: snapshot.getTimestamp()?.toISOString(),
  337. }
  338. const threadIds = await getCommentThreadIds(projectId)
  339. const reverted = []
  340. for (const pathname of pathsAtPastVersion) {
  341. const res = await RestoreManager._revertSingleFile(
  342. userId,
  343. projectId,
  344. version,
  345. pathname,
  346. threadIds,
  347. snapshot,
  348. { origin }
  349. )
  350. reverted.push({
  351. id: res._id,
  352. type: res.type,
  353. path: pathname,
  354. })
  355. }
  356. const entitiesAtLiveVersion =
  357. await ProjectEntityHandler.promises.getAllEntities(projectId)
  358. const trimLeadingSlash = path => path.replace(/^\//, '')
  359. const pathsAtLiveVersion = entitiesAtLiveVersion.docs
  360. .map(doc => doc.path)
  361. .concat(entitiesAtLiveVersion.files.map(file => file.path))
  362. .map(trimLeadingSlash)
  363. // Delete files that were not present at the reverted version
  364. for (const path of pathsAtLiveVersion) {
  365. if (!pathsAtPastVersion.includes(path)) {
  366. await EditorController.promises.deleteEntityWithPath(
  367. projectId,
  368. path,
  369. origin,
  370. userId
  371. )
  372. }
  373. }
  374. endTimer()
  375. return reverted
  376. },
  377. async _writeFileVersionToDisk(projectId, version, pathname) {
  378. const url = `${
  379. Settings.apis.project_history.url
  380. }/project/${projectId}/version/${version}/${encodeURIComponent(pathname)}`
  381. return await FileWriter.promises.writeUrlToDisk(projectId, url)
  382. },
  383. }
  384. export default { ...callbackifyAll(RestoreManager), promises: RestoreManager }