| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380 |
- import _ from 'lodash'
- import {
- db,
- ObjectId,
- READ_PREFERENCE_SECONDARY,
- } from '../../infrastructure/mongodb.mjs'
- import Modules from '../../infrastructure/Modules.mjs'
- import { callbackify } from 'node:util'
- import { Project } from '../../models/Project.mjs'
- import { DeletedProject } from '../../models/DeletedProject.mjs'
- import { ProjectAuditLogEntry } from '../../models/ProjectAuditLogEntry.mjs'
- import Errors from '../Errors/Errors.js'
- import logger from '@overleaf/logger'
- import Settings from '@overleaf/settings'
- import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.mjs'
- import TagsHandler from '../Tags/TagsHandler.mjs'
- import ProjectDetailsHandler from './ProjectDetailsHandler.mjs'
- import CollaboratorsHandler from '../Collaborators/CollaboratorsHandler.mjs'
- import CollaboratorsGetter from '../Collaborators/CollaboratorsGetter.mjs'
- import DocstoreManager from '../Docstore/DocstoreManager.mjs'
- import EditorRealTimeController from '../Editor/EditorRealTimeController.mjs'
- import HistoryManager from '../History/HistoryManager.mjs'
- import ChatApiHandler from '../Chat/ChatApiHandler.mjs'
- import { promiseMapWithLimit } from '@overleaf/promise-utils'
- const PROJECT_EXPIRATION_BATCH_SIZE = 10000
- export default {
- markAsDeletedByExternalSource: callbackify(markAsDeletedByExternalSource),
- unmarkAsDeletedByExternalSource: callbackify(unmarkAsDeletedByExternalSource),
- deleteUsersProjects: callbackify(deleteUsersProjects),
- expireDeletedProjectsAfterDuration: callbackify(
- expireDeletedProjectsAfterDuration
- ),
- restoreProject: callbackify(restoreProject),
- archiveProject: callbackify(archiveProject),
- unarchiveProject: callbackify(unarchiveProject),
- trashProject: callbackify(trashProject),
- untrashProject: callbackify(untrashProject),
- deleteProject: callbackify(deleteProject),
- undeleteProject: callbackify(undeleteProject),
- expireDeletedProject: callbackify(expireDeletedProject),
- promises: {
- archiveProject,
- unarchiveProject,
- trashProject,
- untrashProject,
- deleteProject,
- undeleteProject,
- expireDeletedProject,
- markAsDeletedByExternalSource,
- unmarkAsDeletedByExternalSource,
- deleteUsersProjects,
- expireDeletedProjectsAfterDuration,
- restoreProject,
- },
- }
- async function markAsDeletedByExternalSource(projectId) {
- logger.debug(
- { projectId },
- 'marking project as deleted by external data source'
- )
- await Project.updateOne(
- { _id: projectId },
- { deletedByExternalDataSource: true }
- ).exec()
- EditorRealTimeController.emitToRoom(
- projectId,
- 'projectRenamedOrDeletedByExternalSource'
- )
- }
- async function unmarkAsDeletedByExternalSource(projectId) {
- await Project.updateOne(
- { _id: projectId },
- { deletedByExternalDataSource: false }
- ).exec()
- }
- async function deleteUsersProjects(userId) {
- const projects = await Project.find({ owner_ref: userId }).exec()
- logger.info(
- { userId, projectCount: projects.length },
- 'found user projects to delete'
- )
- await promiseMapWithLimit(5, projects, project => deleteProject(project._id))
- logger.info({ userId }, 'deleted all user projects')
- await CollaboratorsHandler.promises.removeUserFromAllProjects(userId)
- }
- async function expireDeletedProjectsAfterDuration() {
- const deletedProjects = await DeletedProject.find(
- {
- 'deleterData.deletedAt': {
- $lt: new Date(Date.now() - Settings.projectHardDeletionDelay),
- },
- project: { $type: 'object' },
- },
- { 'deleterData.deletedProjectId': 1 }
- )
- .limit(PROJECT_EXPIRATION_BATCH_SIZE)
- .read(READ_PREFERENCE_SECONDARY)
- const projectIds = _.shuffle(
- deletedProjects.map(
- deletedProject => deletedProject.deleterData.deletedProjectId
- )
- )
- logger.info(
- {
- projectCount: projectIds.length,
- retentionPeriodInDays:
- Settings.projectHardDeletionDelay / (1000 * 60 * 60 * 24),
- },
- 'expiring batch of deleted projects'
- )
- try {
- for (const projectId of projectIds) {
- await expireDeletedProject(projectId)
- }
- logger.info(
- { projectCount: projectIds.length },
- 'batch of deleted projects expired successfully'
- )
- } catch (error) {
- logger.warn(
- { error },
- 'something went wrong expiring batch of deleted projects'
- )
- throw error
- }
- }
- async function restoreProject(projectId) {
- await Project.updateOne(
- { _id: projectId },
- { $unset: { archived: true } }
- ).exec()
- }
- async function archiveProject(projectId, userId) {
- await Project.updateOne(
- { _id: projectId },
- {
- $addToSet: { archived: new ObjectId(userId) },
- $pull: { trashed: new ObjectId(userId) },
- }
- )
- }
- async function unarchiveProject(projectId, userId) {
- await Project.updateOne(
- { _id: projectId },
- { $pull: { archived: new ObjectId(userId) } }
- )
- }
- async function trashProject(projectId, userId) {
- await Project.updateOne(
- { _id: projectId },
- {
- $addToSet: { trashed: new ObjectId(userId) },
- $pull: { archived: new ObjectId(userId) },
- }
- )
- }
- async function untrashProject(projectId, userId) {
- await Project.updateOne(
- { _id: projectId },
- { $pull: { trashed: new ObjectId(userId) } }
- )
- }
- async function deleteProject(projectId, options = {}) {
- try {
- const project = await Project.findOne({ _id: projectId }).exec()
- if (!project) {
- throw new Errors.NotFoundError('project not found')
- }
- await DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete(
- projectId
- )
- try {
- // OPTIMIZATION: flush docs out of mongo
- await DocstoreManager.promises.archiveProject(projectId)
- } catch (err) {
- // It is OK to fail here, the docs will get hard-deleted eventually after
- // the grace-period for soft-deleted projects has passed.
- logger.warn(
- { projectId, err },
- 'failed archiving doc via docstore as part of project soft-deletion'
- )
- }
- const memberIds = await CollaboratorsGetter.promises.getMemberIds(projectId)
- // fire these jobs in the background
- for (const memberId of memberIds) {
- TagsHandler.promises
- .removeProjectFromAllTags(memberId, projectId)
- .catch(err => {
- logger.err(
- { err, memberId, projectId },
- 'failed to remove project from tags'
- )
- })
- }
- const deleterData = {
- deletedAt: new Date(),
- deleterId:
- options.deleterUser != null ? options.deleterUser._id : undefined,
- deleterIpAddress: options.ipAddress,
- deletedProjectId: project._id,
- deletedProjectOwnerId: project.owner_ref,
- deletedProjectCollaboratorIds: project.collaberator_refs,
- deletedProjectReadOnlyIds: project.readOnly_refs,
- deletedProjectReviewerIds: project.reviewer_refs,
- deletedProjectReadWriteTokenAccessIds:
- project.tokenAccessReadAndWrite_refs,
- deletedProjectOverleafId: project.overleaf
- ? project.overleaf.id
- : undefined,
- deletedProjectOverleafHistoryId:
- project.overleaf && project.overleaf.history
- ? project.overleaf.history.id
- : undefined,
- deletedProjectReadOnlyTokenAccessIds: project.tokenAccessReadOnly_refs,
- deletedProjectReadWriteToken: project.tokens.readAndWrite,
- deletedProjectReadOnlyToken: project.tokens.readOnly,
- deletedProjectLastUpdatedAt: project.lastUpdated,
- }
- Object.keys(deleterData).forEach(key =>
- deleterData[key] === undefined ? delete deleterData[key] : ''
- )
- await DeletedProject.updateOne(
- { 'deleterData.deletedProjectId': projectId },
- { project, deleterData },
- { upsert: true }
- )
- await Project.deleteOne({ _id: projectId }).exec()
- logger.info(
- { projectId, userId: project.owner_ref },
- 'successfully deleted project'
- )
- } catch (err) {
- logger.warn({ err }, 'problem deleting project')
- throw err
- }
- }
- async function undeleteProject(projectId, options = {}) {
- projectId = new ObjectId(projectId)
- const deletedProject = await DeletedProject.findOne({
- 'deleterData.deletedProjectId': projectId,
- }).exec()
- if (!deletedProject) {
- throw new Errors.NotFoundError('project_not_found')
- }
- if (!deletedProject.project) {
- throw new Errors.NotFoundError('project_too_old_to_restore')
- }
- const restored = new Project(deletedProject.project)
- if (options.userId) {
- restored.owner_ref = options.userId
- }
- // if we're undeleting, we want the document to show up
- restored.name = await ProjectDetailsHandler.promises.generateUniqueName(
- deletedProject.deleterData.deletedProjectOwnerId,
- restored.name + ' (Restored)'
- )
- restored.archived = undefined
- if (restored.deletedDocs && restored.deletedDocs.length > 0) {
- await promiseMapWithLimit(10, restored.deletedDocs, async deletedDoc => {
- // back fill context of deleted docs
- const { _id: docId, name, deletedAt } = deletedDoc
- await DocstoreManager.promises.deleteDoc(
- projectId,
- docId,
- name,
- deletedAt
- )
- })
- restored.deletedDocs = []
- }
- // we can't use Mongoose to re-insert the project, as it won't
- // create a new document with an _id already specified. We need to
- // insert it directly into the collection
- await db.projects.insertOne(restored)
- await DeletedProject.deleteOne({ _id: deletedProject._id }).exec()
- }
- async function expireDeletedProject(projectId) {
- try {
- logger.info({ projectId }, 'expiring deleted project')
- const activeProject = await Project.findById(projectId).exec()
- if (activeProject) {
- // That project is active. The deleted project record might be there
- // because of an incomplete delete or undelete operation. Clean it up and
- // return.
- logger.info(
- { projectId },
- 'deleted project record found but project is active'
- )
- await DeletedProject.deleteOne({
- 'deleterData.deletedProjectId': projectId,
- })
- return
- }
- const deletedProject = await DeletedProject.findOne({
- 'deleterData.deletedProjectId': projectId,
- }).exec()
- if (!deletedProject) {
- throw new Errors.NotFoundError(
- `No deleted project found for project id ${projectId}`
- )
- }
- if (!deletedProject.project) {
- logger.warn(
- { projectId },
- `Attempted to expire already-expired deletedProject`
- )
- return
- }
- const userId = deletedProject.deleterData?.deletedProjectOwnerId?.toString()
- const historyId =
- deletedProject.project.overleaf &&
- deletedProject.project.overleaf.history &&
- deletedProject.project.overleaf.history.id
- logger.info({ projectId, userId }, 'destroying expired project data')
- await Promise.all([
- DocstoreManager.promises.destroyProject(deletedProject.project._id),
- HistoryManager.promises.deleteProject(
- deletedProject.project._id,
- historyId
- ),
- ChatApiHandler.promises.destroyProject(deletedProject.project._id),
- ProjectAuditLogEntry.deleteMany({ projectId }),
- Modules.promises.hooks.fire('projectExpired', deletedProject.project._id),
- ])
- logger.info(
- { projectId, userId },
- 'redacting PII from the deleted project record'
- )
- await DeletedProject.updateOne(
- {
- _id: deletedProject._id,
- },
- {
- $set: {
- 'deleterData.deleterIpAddress': null,
- project: null,
- },
- }
- ).exec()
- logger.info({ projectId, userId }, 'expired deleted project successfully')
- } catch (error) {
- logger.warn({ projectId, error }, 'error expiring deleted project')
- throw error
- }
- }
|