ProjectDeleter.js 13 KB

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