ProjectDeleter.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. const Features = require('../../infrastructure/Features')
  2. const _ = require('lodash')
  3. const { db, ObjectId } = require('../../infrastructure/mongodb')
  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 TpdsUpdateSender = require('../ThirdPartyDataStore/TpdsUpdateSender')
  21. const ChatApiHandler = require('../Chat/ChatApiHandler')
  22. const moment = require('moment')
  23. const { promiseMapWithLimit } = require('../../util/promises')
  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. { project_id: 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: { $ne: null },
  88. },
  89. { 'deleterData.deletedProjectId': 1 }
  90. )
  91. .limit(PROJECT_EXPIRATION_BATCH_SIZE)
  92. .read('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: 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: 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: 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. deletedProjectReadWriteTokenAccessIds:
  225. project.tokenAccessReadAndWrite_refs,
  226. deletedProjectOverleafId: project.overleaf
  227. ? project.overleaf.id
  228. : undefined,
  229. deletedProjectOverleafHistoryId:
  230. project.overleaf && project.overleaf.history
  231. ? project.overleaf.history.id
  232. : undefined,
  233. deletedProjectReadOnlyTokenAccessIds: project.tokenAccessReadOnly_refs,
  234. deletedProjectReadWriteToken: project.tokens.readAndWrite,
  235. deletedProjectReadOnlyToken: project.tokens.readOnly,
  236. deletedProjectLastUpdatedAt: project.lastUpdated,
  237. }
  238. Object.keys(deleterData).forEach(key =>
  239. deleterData[key] === undefined ? delete deleterData[key] : ''
  240. )
  241. await DeletedProject.updateOne(
  242. { 'deleterData.deletedProjectId': projectId },
  243. { project, deleterData },
  244. { upsert: true }
  245. )
  246. await Project.deleteOne({ _id: projectId }).exec()
  247. } catch (err) {
  248. logger.warn({ err }, 'problem deleting project')
  249. throw err
  250. }
  251. logger.debug({ project_id: projectId }, 'successfully deleted project')
  252. }
  253. async function undeleteProject(projectId, options = {}) {
  254. projectId = ObjectId(projectId)
  255. const deletedProject = await DeletedProject.findOne({
  256. 'deleterData.deletedProjectId': projectId,
  257. }).exec()
  258. if (!deletedProject) {
  259. throw new Errors.NotFoundError('project_not_found')
  260. }
  261. if (!deletedProject.project) {
  262. throw new Errors.NotFoundError('project_too_old_to_restore')
  263. }
  264. const restored = new Project(deletedProject.project)
  265. if (options.userId) {
  266. restored.owner_ref = options.userId
  267. }
  268. // if we're undeleting, we want the document to show up
  269. restored.name = await ProjectDetailsHandler.promises.generateUniqueName(
  270. deletedProject.deleterData.deletedProjectOwnerId,
  271. restored.name + ' (Restored)'
  272. )
  273. restored.archived = undefined
  274. if (restored.deletedDocs && restored.deletedDocs.length > 0) {
  275. await promiseMapWithLimit(10, restored.deletedDocs, async deletedDoc => {
  276. // back fill context of deleted docs
  277. const { _id: docId, name, deletedAt } = deletedDoc
  278. await DocstoreManager.promises.deleteDoc(
  279. projectId,
  280. docId,
  281. name,
  282. deletedAt
  283. )
  284. })
  285. restored.deletedDocs = []
  286. }
  287. if (restored.deletedFiles && restored.deletedFiles.length > 0) {
  288. filterDuplicateDeletedFilesInPlace(restored)
  289. const deletedFiles = restored.deletedFiles.map(file => {
  290. // break free from the model
  291. file = file.toObject()
  292. // add projectId
  293. file.projectId = projectId
  294. return file
  295. })
  296. await db.deletedFiles.insertMany(deletedFiles)
  297. restored.deletedFiles = []
  298. }
  299. // we can't use Mongoose to re-insert the project, as it won't
  300. // create a new document with an _id already specified. We need to
  301. // insert it directly into the collection
  302. await db.projects.insertOne(restored)
  303. await DeletedProject.deleteOne({ _id: deletedProject._id }).exec()
  304. }
  305. async function expireDeletedProject(projectId) {
  306. try {
  307. const activeProject = await Project.findById(projectId).exec()
  308. if (activeProject) {
  309. // That project is active. The deleted project record might be there
  310. // because of an incomplete delete or undelete operation. Clean it up and
  311. // return.
  312. await DeletedProject.deleteOne({
  313. 'deleterData.deletedProjectId': projectId,
  314. })
  315. await ProjectAuditLogEntry.deleteMany({ projectId })
  316. return
  317. }
  318. const deletedProject = await DeletedProject.findOne({
  319. 'deleterData.deletedProjectId': projectId,
  320. }).exec()
  321. if (!deletedProject) {
  322. throw new Errors.NotFoundError(
  323. `No deleted project found for project id ${projectId}`
  324. )
  325. }
  326. if (!deletedProject.project) {
  327. logger.warn(
  328. { projectId },
  329. `Attempted to expire already-expired deletedProject`
  330. )
  331. return
  332. }
  333. const historyId =
  334. deletedProject.project.overleaf &&
  335. deletedProject.project.overleaf.history &&
  336. deletedProject.project.overleaf.history.id
  337. await Promise.all([
  338. DocstoreManager.promises.destroyProject(deletedProject.project._id),
  339. Features.hasFeature('history-v1')
  340. ? HistoryManager.promises.deleteProject(
  341. deletedProject.project._id,
  342. historyId
  343. )
  344. : Promise.resolve(),
  345. FilestoreHandler.promises.deleteProject(deletedProject.project._id),
  346. TpdsUpdateSender.promises.deleteProject({
  347. projectId: deletedProject.project._id,
  348. }),
  349. ChatApiHandler.promises.destroyProject(deletedProject.project._id),
  350. hardDeleteDeletedFiles(deletedProject.project._id),
  351. ProjectAuditLogEntry.deleteMany({ projectId }),
  352. ])
  353. await DeletedProject.updateOne(
  354. {
  355. _id: deletedProject._id,
  356. },
  357. {
  358. $set: {
  359. 'deleterData.deleterIpAddress': null,
  360. project: null,
  361. },
  362. }
  363. ).exec()
  364. } catch (error) {
  365. logger.warn({ projectId, error }, 'error expiring deleted project')
  366. throw error
  367. }
  368. }
  369. function filterDuplicateDeletedFilesInPlace(project) {
  370. const fileIds = new Set()
  371. project.deletedFiles = project.deletedFiles.filter(file => {
  372. const id = file._id.toString()
  373. if (fileIds.has(id)) return false
  374. fileIds.add(id)
  375. return true
  376. })
  377. }
  378. let deletedFilesProjectIdIndexExist
  379. async function doesDeletedFilesProjectIdIndexExist() {
  380. if (typeof deletedFilesProjectIdIndexExist !== 'boolean') {
  381. // Resolve this about once. No need for locking or retry handling.
  382. deletedFilesProjectIdIndexExist = await db.deletedFiles.indexExists(
  383. 'projectId_1'
  384. )
  385. }
  386. return deletedFilesProjectIdIndexExist
  387. }
  388. async function hardDeleteDeletedFiles(projectId) {
  389. if (!(await doesDeletedFilesProjectIdIndexExist())) {
  390. // Running the deletion command w/o index would kill mongo performance
  391. return
  392. }
  393. return db.deletedFiles.deleteMany({ projectId })
  394. }