ProjectDeleter.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. import _ from 'lodash'
  2. import {
  3. db,
  4. ObjectId,
  5. READ_PREFERENCE_SECONDARY,
  6. } from '../../infrastructure/mongodb.mjs'
  7. import Modules from '../../infrastructure/Modules.mjs'
  8. import { callbackify } from 'node:util'
  9. import { Project } from '../../models/Project.mjs'
  10. import { DeletedProject } from '../../models/DeletedProject.mjs'
  11. import { ProjectAuditLogEntry } from '../../models/ProjectAuditLogEntry.mjs'
  12. import Errors from '../Errors/Errors.js'
  13. import logger from '@overleaf/logger'
  14. import Settings from '@overleaf/settings'
  15. import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.mjs'
  16. import TagsHandler from '../Tags/TagsHandler.mjs'
  17. import ProjectDetailsHandler from './ProjectDetailsHandler.mjs'
  18. import CollaboratorsHandler from '../Collaborators/CollaboratorsHandler.mjs'
  19. import CollaboratorsGetter from '../Collaborators/CollaboratorsGetter.mjs'
  20. import DocstoreManager from '../Docstore/DocstoreManager.mjs'
  21. import EditorRealTimeController from '../Editor/EditorRealTimeController.mjs'
  22. import HistoryManager from '../History/HistoryManager.mjs'
  23. import ChatApiHandler from '../Chat/ChatApiHandler.mjs'
  24. import { promiseMapWithLimit } from '@overleaf/promise-utils'
  25. import { DeletedProjectReasons } from './DeletedProjectReasons.mjs'
  26. const PROJECT_EXPIRATION_BATCH_SIZE = 10000
  27. export default {
  28. markAsDeletedByExternalSource: callbackify(markAsDeletedByExternalSource),
  29. unmarkAsDeletedByExternalSource: callbackify(unmarkAsDeletedByExternalSource),
  30. deleteUsersProjects: callbackify(deleteUsersProjects),
  31. expireDeletedProjectsAfterDuration: callbackify(
  32. expireDeletedProjectsAfterDuration
  33. ),
  34. restoreProject: callbackify(restoreProject),
  35. archiveProject: callbackify(archiveProject),
  36. unarchiveProject: callbackify(unarchiveProject),
  37. trashProject: callbackify(trashProject),
  38. untrashProject: callbackify(untrashProject),
  39. deleteProject: callbackify(deleteProject),
  40. undeleteProject: callbackify(undeleteProject),
  41. expireDeletedProject: callbackify(expireDeletedProject),
  42. promises: {
  43. archiveProject,
  44. unarchiveProject,
  45. trashProject,
  46. untrashProject,
  47. deleteProject,
  48. undeleteProject,
  49. expireDeletedProject,
  50. markAsDeletedByExternalSource,
  51. unmarkAsDeletedByExternalSource,
  52. deleteUsersProjects,
  53. expireDeletedProjectsAfterDuration,
  54. restoreProject,
  55. },
  56. }
  57. async function markAsDeletedByExternalSource(projectId) {
  58. logger.debug(
  59. { projectId },
  60. 'marking project as deleted by external data source'
  61. )
  62. await Project.updateOne(
  63. { _id: projectId },
  64. { deletedByExternalDataSource: true }
  65. ).exec()
  66. EditorRealTimeController.emitToRoom(
  67. projectId,
  68. 'projectRenamedOrDeletedByExternalSource'
  69. )
  70. }
  71. async function unmarkAsDeletedByExternalSource(projectId) {
  72. await Project.updateOne(
  73. { _id: projectId },
  74. { deletedByExternalDataSource: false }
  75. ).exec()
  76. }
  77. async function deleteUsersProjects(userId) {
  78. const projects = await Project.find({ owner_ref: userId }).exec()
  79. logger.info(
  80. { userId, projectCount: projects.length },
  81. 'found user projects to delete'
  82. )
  83. await promiseMapWithLimit(5, projects, project =>
  84. deleteProject(project._id, {
  85. deletedReason: DeletedProjectReasons.ACCOUNT_DELETION,
  86. })
  87. )
  88. logger.info({ userId }, 'deleted all user projects')
  89. await CollaboratorsHandler.promises.removeUserFromAllProjects(userId)
  90. }
  91. async function expireDeletedProjectsAfterDuration() {
  92. const deletedProjects = await DeletedProject.find(
  93. {
  94. 'deleterData.deletedAt': {
  95. $lt: new Date(Date.now() - Settings.projectHardDeletionDelay),
  96. },
  97. project: { $type: 'object' },
  98. },
  99. { 'deleterData.deletedProjectId': 1 }
  100. )
  101. .limit(PROJECT_EXPIRATION_BATCH_SIZE)
  102. .read(READ_PREFERENCE_SECONDARY)
  103. const projectIds = _.shuffle(
  104. deletedProjects.map(
  105. deletedProject => deletedProject.deleterData.deletedProjectId
  106. )
  107. )
  108. logger.info(
  109. {
  110. projectCount: projectIds.length,
  111. retentionPeriodInDays:
  112. Settings.projectHardDeletionDelay / (1000 * 60 * 60 * 24),
  113. },
  114. 'expiring batch of deleted projects'
  115. )
  116. try {
  117. for (const projectId of projectIds) {
  118. await expireDeletedProject(projectId)
  119. }
  120. logger.info(
  121. { projectCount: projectIds.length },
  122. 'batch of deleted projects expired successfully'
  123. )
  124. } catch (error) {
  125. logger.warn(
  126. { error },
  127. 'something went wrong expiring batch of deleted projects'
  128. )
  129. throw error
  130. }
  131. }
  132. async function restoreProject(projectId) {
  133. await Project.updateOne(
  134. { _id: projectId },
  135. { $unset: { archived: true } }
  136. ).exec()
  137. }
  138. async function archiveProject(projectId, userId) {
  139. await Project.updateOne(
  140. { _id: projectId },
  141. {
  142. $addToSet: { archived: new ObjectId(userId) },
  143. $pull: { trashed: new ObjectId(userId) },
  144. }
  145. )
  146. }
  147. async function unarchiveProject(projectId, userId) {
  148. await Project.updateOne(
  149. { _id: projectId },
  150. { $pull: { archived: new ObjectId(userId) } }
  151. )
  152. }
  153. async function trashProject(projectId, userId) {
  154. await Project.updateOne(
  155. { _id: projectId },
  156. {
  157. $addToSet: { trashed: new ObjectId(userId) },
  158. $pull: { archived: new ObjectId(userId) },
  159. }
  160. )
  161. }
  162. async function untrashProject(projectId, userId) {
  163. await Project.updateOne(
  164. { _id: projectId },
  165. { $pull: { trashed: new ObjectId(userId) } }
  166. )
  167. }
  168. async function deleteProject(projectId, options = {}) {
  169. try {
  170. const project = await Project.findOne({ _id: projectId }).exec()
  171. if (!project) {
  172. throw new Errors.NotFoundError('project not found')
  173. }
  174. await DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete(
  175. projectId
  176. )
  177. try {
  178. // OPTIMIZATION: flush docs out of mongo
  179. await DocstoreManager.promises.archiveProject(projectId)
  180. } catch (err) {
  181. // It is OK to fail here, the docs will get hard-deleted eventually after
  182. // the grace-period for soft-deleted projects has passed.
  183. logger.warn(
  184. { projectId, err },
  185. 'failed archiving doc via docstore as part of project soft-deletion'
  186. )
  187. }
  188. const memberIds = await CollaboratorsGetter.promises.getMemberIds(projectId)
  189. // fire these jobs in the background
  190. for (const memberId of memberIds) {
  191. TagsHandler.promises
  192. .removeProjectFromAllTags(memberId, projectId)
  193. .catch(err => {
  194. logger.err(
  195. { err, memberId, projectId },
  196. 'failed to remove project from tags'
  197. )
  198. })
  199. }
  200. const deleterData = {
  201. deletedAt: new Date(),
  202. deleterId:
  203. options.deleterUser != null ? options.deleterUser._id : undefined,
  204. deleterIpAddress: options.ipAddress,
  205. deletedReason: options.deletedReason,
  206. deletedProjectId: project._id,
  207. deletedProjectOwnerId: project.owner_ref,
  208. deletedProjectCollaboratorIds: project.collaberator_refs,
  209. deletedProjectReadOnlyIds: project.readOnly_refs,
  210. deletedProjectReviewerIds: project.reviewer_refs,
  211. deletedProjectReadWriteTokenAccessIds:
  212. project.tokenAccessReadAndWrite_refs,
  213. deletedProjectOverleafId: project.overleaf
  214. ? project.overleaf.id
  215. : undefined,
  216. deletedProjectOverleafHistoryId:
  217. project.overleaf && project.overleaf.history
  218. ? project.overleaf.history.id
  219. : undefined,
  220. deletedProjectReadOnlyTokenAccessIds: project.tokenAccessReadOnly_refs,
  221. deletedProjectReadWriteToken: project.tokens.readAndWrite,
  222. deletedProjectReadOnlyToken: project.tokens.readOnly,
  223. deletedProjectLastUpdatedAt: project.lastUpdated,
  224. }
  225. Object.keys(deleterData).forEach(key =>
  226. deleterData[key] === undefined ? delete deleterData[key] : ''
  227. )
  228. await DeletedProject.updateOne(
  229. { 'deleterData.deletedProjectId': projectId },
  230. { project, deleterData },
  231. { upsert: true }
  232. )
  233. await Project.deleteOne({ _id: projectId }).exec()
  234. logger.info(
  235. { projectId, userId: project.owner_ref },
  236. 'successfully deleted project'
  237. )
  238. } catch (err) {
  239. logger.warn({ err }, 'problem deleting project')
  240. throw err
  241. }
  242. }
  243. async function undeleteProject(projectId, options = {}) {
  244. projectId = new ObjectId(projectId)
  245. const deletedProject = await DeletedProject.findOne({
  246. 'deleterData.deletedProjectId': projectId,
  247. }).exec()
  248. if (!deletedProject) {
  249. throw new Errors.NotFoundError('project_not_found')
  250. }
  251. if (!deletedProject.project) {
  252. throw new Errors.NotFoundError('project_too_old_to_restore')
  253. }
  254. const restored = new Project(deletedProject.project)
  255. if (options.userId) {
  256. restored.owner_ref = options.userId
  257. }
  258. // if we're undeleting, we want the document to show up
  259. restored.name = await ProjectDetailsHandler.promises.generateUniqueName(
  260. deletedProject.deleterData.deletedProjectOwnerId,
  261. restored.name + ' (Restored)'
  262. )
  263. restored.archived = undefined
  264. if (restored.deletedDocs && restored.deletedDocs.length > 0) {
  265. await promiseMapWithLimit(10, restored.deletedDocs, async deletedDoc => {
  266. // back fill context of deleted docs
  267. const { _id: docId, name, deletedAt } = deletedDoc
  268. await DocstoreManager.promises.deleteDoc(
  269. projectId,
  270. docId,
  271. name,
  272. deletedAt
  273. )
  274. })
  275. restored.deletedDocs = []
  276. }
  277. // we can't use Mongoose to re-insert the project, as it won't
  278. // create a new document with an _id already specified. We need to
  279. // insert it directly into the collection
  280. await db.projects.insertOne(restored)
  281. await DeletedProject.deleteOne({ _id: deletedProject._id }).exec()
  282. }
  283. async function expireDeletedProject(projectId) {
  284. try {
  285. logger.info({ projectId }, 'expiring deleted project')
  286. const activeProject = await Project.findById(projectId).exec()
  287. if (activeProject) {
  288. // That project is active. The deleted project record might be there
  289. // because of an incomplete delete or undelete operation. Clean it up and
  290. // return.
  291. logger.info(
  292. { projectId },
  293. 'deleted project record found but project is active'
  294. )
  295. await DeletedProject.deleteOne({
  296. 'deleterData.deletedProjectId': projectId,
  297. })
  298. return
  299. }
  300. const deletedProject = await DeletedProject.findOne({
  301. 'deleterData.deletedProjectId': projectId,
  302. }).exec()
  303. if (!deletedProject) {
  304. throw new Errors.NotFoundError(
  305. `No deleted project found for project id ${projectId}`
  306. )
  307. }
  308. if (!deletedProject.project) {
  309. logger.warn(
  310. { projectId },
  311. `Attempted to expire already-expired deletedProject`
  312. )
  313. return
  314. }
  315. const userId = deletedProject.deleterData?.deletedProjectOwnerId?.toString()
  316. const historyId =
  317. deletedProject.project.overleaf &&
  318. deletedProject.project.overleaf.history &&
  319. deletedProject.project.overleaf.history.id
  320. logger.info({ projectId, userId }, 'destroying expired project data')
  321. await Promise.all([
  322. DocstoreManager.promises.destroyProject(deletedProject.project._id),
  323. HistoryManager.promises.deleteProject(
  324. deletedProject.project._id,
  325. historyId
  326. ),
  327. ChatApiHandler.promises.destroyProject(deletedProject.project._id),
  328. ProjectAuditLogEntry.deleteMany({ projectId }),
  329. Modules.promises.hooks.fire('projectExpired', deletedProject.project._id),
  330. ])
  331. logger.info(
  332. { projectId, userId },
  333. 'redacting PII from the deleted project record'
  334. )
  335. await DeletedProject.updateOne(
  336. {
  337. _id: deletedProject._id,
  338. },
  339. {
  340. $set: {
  341. 'deleterData.deleterIpAddress': null,
  342. project: null,
  343. },
  344. }
  345. ).exec()
  346. logger.info({ projectId, userId }, 'expired deleted project successfully')
  347. } catch (error) {
  348. logger.warn({ projectId, error }, 'error expiring deleted project')
  349. throw error
  350. }
  351. }