| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451 |
- import Settings from '@overleaf/settings'
- import Path from 'node:path'
- import FileWriter from '../../infrastructure/FileWriter.mjs'
- import Metrics from '../../infrastructure/Metrics.mjs'
- import FileSystemImportManager from '../Uploads/FileSystemImportManager.mjs'
- import FileTypeManager from '../Uploads/FileTypeManager.mjs'
- import EditorController from '../Editor/EditorController.mjs'
- import Errors from '../Errors/Errors.js'
- import moment from 'moment'
- import { callbackifyAll } from '@overleaf/promise-utils'
- import ProjectLocator from '../Project/ProjectLocator.mjs'
- import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.mjs'
- import ChatApiHandler from '../Chat/ChatApiHandler.mjs'
- import DocstoreManager from '../Docstore/DocstoreManager.mjs'
- import logger from '@overleaf/logger'
- import EditorRealTimeController from '../Editor/EditorRealTimeController.mjs'
- import ChatManager from '../Chat/ChatManager.mjs'
- import OError from '@overleaf/o-error'
- import ProjectGetter from '../Project/ProjectGetter.mjs'
- import ProjectEntityHandler from '../Project/ProjectEntityHandler.mjs'
- import HistoryManager from './HistoryManager.mjs'
- import { Snapshot, getDocUpdaterCompatibleRanges } from 'overleaf-editor-core'
- async function getCommentThreadIds(projectId) {
- await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
- const raw = await DocstoreManager.promises.getCommentThreadIds(projectId)
- return new Map(Object.entries(raw).map(([doc, ids]) => [doc, new Set(ids)]))
- }
- const RestoreManager = {
- async restoreFileFromV2(userId, projectId, version, pathname) {
- const fsPath = await RestoreManager._writeFileVersionToDisk(
- projectId,
- version,
- pathname
- )
- const basename = Path.basename(pathname)
- let dirname = Path.dirname(pathname)
- if (dirname === '.') {
- // no directory
- dirname = ''
- }
- const parentFolderId = await RestoreManager._findOrCreateFolder(
- projectId,
- dirname,
- userId
- )
- const addEntityWithName = async name =>
- await FileSystemImportManager.promises.addEntity(
- userId,
- projectId,
- parentFolderId,
- name,
- fsPath,
- false
- )
- return await RestoreManager._addEntityWithUniqueName(
- addEntityWithName,
- basename
- )
- },
- async revertFile(userId, projectId, version, pathname, options = {}) {
- const threadIds = await getCommentThreadIds(projectId)
- const snapshotRaw = await HistoryManager.promises.getContentAtVersion(
- projectId,
- version
- )
- const snapshot = Snapshot.fromRaw(snapshotRaw)
- const origin = options.origin ?? {
- kind: 'file-restore',
- path: pathname,
- version,
- timestamp: snapshot.getTimestamp()?.toISOString(),
- }
- return await RestoreManager._revertSingleFile(
- userId,
- projectId,
- version,
- pathname,
- threadIds,
- snapshot,
- { origin }
- )
- },
- /**
- *
- * @param {string} userId
- * @param {string} projectId
- * @param {string} version
- * @param {string} pathname
- * @param {Set<string>} threadIds
- * @param {Snapshot} projectSnapshotAtVersion
- * @param {object} options
- */
- async _revertSingleFile(
- userId,
- projectId,
- version,
- pathname,
- threadIds,
- projectSnapshotAtVersion,
- options = {}
- ) {
- const endTimer = Metrics.revertFileDurationSeconds.startTimer()
- const project = await ProjectGetter.promises.getProject(projectId, {
- overleaf: true,
- rootDoc_id: true,
- })
- if (!project?.overleaf?.history?.rangesSupportEnabled) {
- throw new OError('project does not have ranges support', { projectId })
- }
- const historyId = project.overleaf.history.id
- const basename = Path.basename(pathname)
- let dirname = Path.dirname(pathname)
- if (dirname === '.') {
- // root directory
- dirname = '/'
- }
- const parentFolderId = await RestoreManager._findOrCreateFolder(
- projectId,
- dirname,
- userId
- )
- const file = await ProjectLocator.promises
- .findElementByPath({
- project_id: projectId,
- path: pathname,
- })
- .catch(() => null)
- const snapshotFile = projectSnapshotAtVersion.getFile(pathname)
- if (!snapshotFile) {
- throw new OError('file not found in snapshot', {
- projectId,
- version,
- pathname,
- })
- }
- let hadDeletedRootFile = false
- if (file) {
- if (file.type !== 'doc' && file.type !== 'file') {
- throw new OError('unexpected file type', { type: file.type })
- }
- logger.debug(
- { projectId, fileId: file.element._id },
- 'deleting entity before reverting it'
- )
- await EditorController.promises.deleteEntity(
- projectId,
- file.element._id,
- file.type,
- options.origin,
- userId
- )
- if (
- file.element._id &&
- project.rootDoc_id &&
- file.element._id.toString() === project.rootDoc_id.toString()
- ) {
- hadDeletedRootFile = true
- }
- threadIds.delete(file.element._id.toString())
- }
- // Look for metadata indicating a linked file.
- const fileMetadata = snapshotFile.getMetadata()
- const isLinkedFile = fileMetadata && 'provider' in fileMetadata
- logger.debug({ fileMetadata }, 'metadata from history')
- if (
- isLinkedFile ||
- !snapshotFile.isEditable() ||
- !FileTypeManager.isEditable(snapshotFile.getContent(), {
- filename: pathname,
- })
- ) {
- const fsPath = await RestoreManager._writeSnapshotFileToDisk(
- historyId,
- snapshotFile
- )
- const newFile = await EditorController.promises.upsertFile(
- projectId,
- parentFolderId,
- basename,
- fsPath,
- fileMetadata,
- options.origin,
- userId
- )
- endTimer({ type: 'file' })
- return {
- _id: newFile._id,
- type: 'file',
- }
- }
- const ranges = getDocUpdaterCompatibleRanges(snapshotFile)
- const documentCommentIds = new Set(
- ranges.comments?.map(({ op: { t } }) => t)
- )
- const commentIdsToDuplicate = Array.from(documentCommentIds).filter(id => {
- for (const ids of threadIds.values()) {
- if (ids.has(id)) return true
- }
- return false
- })
- const newRanges = { changes: ranges.changes, comments: [] }
- if (commentIdsToDuplicate.length > 0) {
- const { newThreads: newCommentIds } =
- await ChatApiHandler.promises.duplicateCommentThreads(
- projectId,
- commentIdsToDuplicate
- )
- logger.debug({ mapping: newCommentIds }, 'replacing comment threads')
- for (const comment of ranges.comments ?? []) {
- if (Object.prototype.hasOwnProperty.call(newCommentIds, comment.op.t)) {
- const result = newCommentIds[comment.op.t]
- if (result.error) {
- // We couldn't duplicate the thread, so we need to delete it from
- // the resulting ranges.
- continue
- }
- // We have a new id for this comment thread
- comment.id = result.duplicateId
- comment.op.t = result.duplicateId
- }
- newRanges.comments.push(comment)
- }
- } else {
- newRanges.comments = ranges.comments
- }
- const newCommentThreadData =
- await ChatApiHandler.promises.generateThreadData(
- projectId,
- newRanges.comments.map(({ op: { t } }) => t)
- )
- // Resolve/reopen threads in chat service to match what is in history
- for (const commentRange of newRanges.comments) {
- const threadData = newCommentThreadData[commentRange.op.t]
- if (!threadData) {
- // comment thread was deleted
- continue
- }
- if (commentRange.op.resolved && threadData.resolved == null) {
- // The history snapshot stores the comment's resolved property as a boolean,
- // but it does not include information about who resolved the comment or the timestamp.
- // Until this is fixed, we will resolve the thread with the current user and the current timestamp.
- await ChatApiHandler.promises.resolveThread(
- projectId,
- commentRange.op.t,
- userId
- )
- threadData.resolved = true
- threadData.resolved_by_user_id = userId
- threadData.resolved_at = new Date().toISOString()
- } else if (!commentRange.op.resolved && threadData.resolved != null) {
- await ChatApiHandler.promises.reopenThread(projectId, commentRange.op.t)
- delete threadData.resolved
- delete threadData.resolved_by_user_id
- delete threadData.resolved_at
- }
- }
- await ChatManager.promises.injectUserInfoIntoThreads(newCommentThreadData)
- // Only keep restored comment ranges that point to a valid thread.
- // The chat service won't have generated thread data for deleted threads.
- newRanges.comments = newRanges.comments.filter(
- comment => newCommentThreadData[comment.op.t] != null
- )
- logger.debug({ newCommentThreadData }, 'emitting new comment threads')
- EditorRealTimeController.emitToRoom(
- projectId,
- 'new-comment-threads',
- newCommentThreadData
- )
- const lines = snapshotFile
- .getContent({ filterTrackedDeletes: true })
- .split('\n')
- const { _id } = await EditorController.promises.addDocWithRanges(
- projectId,
- parentFolderId,
- basename,
- lines,
- newRanges,
- options.origin,
- userId
- )
- if (hadDeletedRootFile) {
- await EditorController.promises.setRootDoc(projectId, _id)
- }
- // For revertProject: The next doc that gets reverted will need to duplicate all the threads seen here.
- threadIds.set(
- _id.toString(),
- new Set(newRanges.comments.map(({ op: { t } }) => t))
- )
- endTimer({ type: 'doc' })
- return {
- _id,
- type: 'doc',
- }
- },
- async _findOrCreateFolder(projectId, dirname, userId) {
- const { lastFolder } = await EditorController.promises.mkdirp(
- projectId,
- dirname,
- userId
- )
- return lastFolder?._id
- },
- async _addEntityWithUniqueName(addEntityWithName, basename) {
- try {
- return await addEntityWithName(basename)
- } catch (error) {
- if (error instanceof Errors.DuplicateNameError) {
- // Duplicate name, so try with a prefix
- const date = moment(new Date()).format('Do MMM YY H:mm:ss')
- // Move extension to the end so the file type is preserved
- const extension = Path.extname(basename)
- basename = Path.basename(basename, extension)
- basename = `${basename} (Restored on ${date})`
- if (extension !== '') {
- basename = `${basename}${extension}`
- }
- return await addEntityWithName(basename)
- } else {
- throw error
- }
- }
- },
- async revertProject(userId, projectId, version) {
- const endTimer = Metrics.revertProjectDurationSeconds.startTimer()
- const project = await ProjectGetter.promises.getProject(projectId, {
- overleaf: true,
- })
- if (!project?.overleaf?.history?.rangesSupportEnabled) {
- throw new OError('project does not have ranges support', { projectId })
- }
- const snapshotRaw = await HistoryManager.promises.getContentAtVersion(
- projectId,
- version
- )
- const snapshot = Snapshot.fromRaw(snapshotRaw)
- const pathsAtPastVersion = snapshot.getFilePathnames()
- const origin = {
- kind: 'project-restore',
- version,
- timestamp: snapshot.getTimestamp()?.toISOString(),
- }
- const threadIds = await getCommentThreadIds(projectId)
- const reverted = []
- for (const pathname of pathsAtPastVersion) {
- const res = await RestoreManager._revertSingleFile(
- userId,
- projectId,
- version,
- pathname,
- threadIds,
- snapshot,
- { origin }
- )
- reverted.push({
- id: res._id,
- type: res.type,
- path: pathname,
- })
- }
- const entitiesAtLiveVersion =
- await ProjectEntityHandler.promises.getAllEntities(projectId)
- const trimLeadingSlash = path => path.replace(/^\//, '')
- const pathsAtLiveVersion = entitiesAtLiveVersion.docs
- .map(doc => doc.path)
- .concat(entitiesAtLiveVersion.files.map(file => file.path))
- .map(trimLeadingSlash)
- // Delete files that were not present at the reverted version
- for (const path of pathsAtLiveVersion) {
- if (!pathsAtPastVersion.includes(path)) {
- await EditorController.promises.deleteEntityWithPath(
- projectId,
- path,
- origin,
- userId
- )
- }
- }
- endTimer()
- return reverted
- },
- async _writeFileVersionToDisk(projectId, version, pathname) {
- const url = `${
- Settings.apis.project_history.url
- }/project/${projectId}/version/${version}/${encodeURIComponent(pathname)}`
- return await FileWriter.promises.writeUrlToDisk(projectId, url)
- },
- async _writeSnapshotFileToDisk(historyId, file) {
- if (file.isEditable()) {
- return await FileWriter.promises.writeContentToDisk(
- historyId,
- file.getContent()
- )
- } else {
- const hash = file.getHash()
- const { stream } = await HistoryManager.promises.requestBlob(
- historyId,
- hash
- )
- return await FileWriter.promises.writeStreamToDisk(historyId, stream)
- }
- },
- }
- export default { ...callbackifyAll(RestoreManager), promises: RestoreManager }
|