ProjectDeleter.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. const _ = require('lodash')
  2. const { db, ObjectId } = require('../../infrastructure/mongodb')
  3. const { callbackify } = require('util')
  4. const { Project } = require('../../models/Project')
  5. const { DeletedProject } = require('../../models/DeletedProject')
  6. const Errors = require('../Errors/Errors')
  7. const logger = require('logger-sharelatex')
  8. const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
  9. const TagsHandler = require('../Tags/TagsHandler')
  10. const ProjectHelper = require('./ProjectHelper')
  11. const ProjectDetailsHandler = require('./ProjectDetailsHandler')
  12. const CollaboratorsHandler = require('../Collaborators/CollaboratorsHandler')
  13. const CollaboratorsGetter = require('../Collaborators/CollaboratorsGetter')
  14. const DocstoreManager = require('../Docstore/DocstoreManager')
  15. const EditorRealTimeController = require('../Editor/EditorRealTimeController')
  16. const HistoryManager = require('../History/HistoryManager')
  17. const FilestoreHandler = require('../FileStore/FileStoreHandler')
  18. const TpdsUpdateSender = require('../ThirdPartyDataStore/TpdsUpdateSender')
  19. const moment = require('moment')
  20. const { promiseMapWithLimit } = require('../../util/promises')
  21. const EXPIRE_PROJECTS_AFTER_DAYS = 90
  22. const PROJECT_EXPIRATION_BATCH_SIZE = 10000
  23. module.exports = {
  24. markAsDeletedByExternalSource: callbackify(markAsDeletedByExternalSource),
  25. unmarkAsDeletedByExternalSource: callbackify(unmarkAsDeletedByExternalSource),
  26. deleteUsersProjects: callbackify(deleteUsersProjects),
  27. expireDeletedProjectsAfterDuration: callbackify(
  28. expireDeletedProjectsAfterDuration
  29. ),
  30. restoreProject: callbackify(restoreProject),
  31. archiveProject: callbackify(archiveProject),
  32. unarchiveProject: callbackify(unarchiveProject),
  33. trashProject: callbackify(trashProject),
  34. untrashProject: callbackify(untrashProject),
  35. deleteProject: callbackify(deleteProject),
  36. undeleteProject: callbackify(undeleteProject),
  37. expireDeletedProject: callbackify(expireDeletedProject),
  38. promises: {
  39. archiveProject,
  40. unarchiveProject,
  41. trashProject,
  42. untrashProject,
  43. deleteProject,
  44. undeleteProject,
  45. expireDeletedProject,
  46. markAsDeletedByExternalSource,
  47. unmarkAsDeletedByExternalSource,
  48. deleteUsersProjects,
  49. expireDeletedProjectsAfterDuration,
  50. restoreProject,
  51. },
  52. }
  53. async function markAsDeletedByExternalSource(projectId) {
  54. logger.log(
  55. { project_id: projectId },
  56. 'marking project as deleted by external data source'
  57. )
  58. await Project.updateOne(
  59. { _id: projectId },
  60. { deletedByExternalDataSource: true }
  61. ).exec()
  62. EditorRealTimeController.emitToRoom(
  63. projectId,
  64. 'projectRenamedOrDeletedByExternalSource'
  65. )
  66. }
  67. async function unmarkAsDeletedByExternalSource(projectId) {
  68. await Project.updateOne(
  69. { _id: projectId },
  70. { deletedByExternalDataSource: false }
  71. ).exec()
  72. }
  73. async function deleteUsersProjects(userId) {
  74. const projects = await Project.find({ owner_ref: userId }).exec()
  75. await promiseMapWithLimit(5, projects, project => deleteProject(project._id))
  76. await CollaboratorsHandler.promises.removeUserFromAllProjects(userId)
  77. }
  78. async function expireDeletedProjectsAfterDuration() {
  79. const deletedProjects = await DeletedProject.find(
  80. {
  81. 'deleterData.deletedAt': {
  82. $lt: new Date(moment().subtract(EXPIRE_PROJECTS_AFTER_DAYS, 'days')),
  83. },
  84. project: { $ne: null },
  85. },
  86. { 'deleterData.deletedProjectId': 1 }
  87. )
  88. .limit(PROJECT_EXPIRATION_BATCH_SIZE)
  89. .read('secondary')
  90. const projectIds = _.shuffle(
  91. deletedProjects.map(
  92. deletedProject => deletedProject.deleterData.deletedProjectId
  93. )
  94. )
  95. for (const projectId of projectIds) {
  96. await expireDeletedProject(projectId)
  97. }
  98. }
  99. async function restoreProject(projectId) {
  100. await Project.updateOne(
  101. { _id: projectId },
  102. { $unset: { archived: true } }
  103. ).exec()
  104. }
  105. async function archiveProject(projectId, userId) {
  106. try {
  107. const project = await Project.findOne({ _id: projectId }).exec()
  108. if (!project) {
  109. throw new Errors.NotFoundError('project not found')
  110. }
  111. const archived = ProjectHelper.calculateArchivedArray(
  112. project,
  113. userId,
  114. 'ARCHIVE'
  115. )
  116. await Project.updateOne(
  117. { _id: projectId },
  118. { $set: { archived: archived }, $pull: { trashed: ObjectId(userId) } }
  119. )
  120. } catch (err) {
  121. logger.warn({ err }, 'problem archiving project')
  122. throw err
  123. }
  124. }
  125. async function unarchiveProject(projectId, userId) {
  126. try {
  127. const project = await Project.findOne({ _id: projectId }).exec()
  128. if (!project) {
  129. throw new Errors.NotFoundError('project not found')
  130. }
  131. const archived = ProjectHelper.calculateArchivedArray(
  132. project,
  133. userId,
  134. 'UNARCHIVE'
  135. )
  136. await Project.updateOne(
  137. { _id: projectId },
  138. { $set: { archived: archived } }
  139. )
  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: 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.log({ 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. return
  316. }
  317. const deletedProject = await DeletedProject.findOne({
  318. 'deleterData.deletedProjectId': projectId,
  319. }).exec()
  320. if (!deletedProject) {
  321. throw new Errors.NotFoundError(
  322. `No deleted project found for project id ${projectId}`
  323. )
  324. }
  325. if (!deletedProject.project) {
  326. logger.warn(
  327. { projectId },
  328. `Attempted to expire already-expired deletedProject`
  329. )
  330. return
  331. }
  332. const historyId =
  333. deletedProject.project.overleaf &&
  334. deletedProject.project.overleaf.history &&
  335. deletedProject.project.overleaf.history.id
  336. await Promise.all([
  337. DocstoreManager.promises.destroyProject(deletedProject.project._id),
  338. HistoryManager.promises.deleteProject(
  339. deletedProject.project._id,
  340. historyId
  341. ),
  342. FilestoreHandler.promises.deleteProject(deletedProject.project._id),
  343. TpdsUpdateSender.promises.deleteProject({
  344. project_id: deletedProject.project._id,
  345. }),
  346. hardDeleteDeletedFiles(deletedProject.project._id),
  347. ])
  348. await DeletedProject.updateOne(
  349. {
  350. _id: deletedProject._id,
  351. },
  352. {
  353. $set: {
  354. 'deleterData.deleterIpAddress': null,
  355. project: null,
  356. },
  357. }
  358. ).exec()
  359. } catch (error) {
  360. logger.warn({ projectId, error }, 'error expiring deleted project')
  361. throw error
  362. }
  363. }
  364. function filterDuplicateDeletedFilesInPlace(project) {
  365. const fileIds = new Set()
  366. project.deletedFiles = project.deletedFiles.filter(file => {
  367. const id = file._id.toString()
  368. if (fileIds.has(id)) return false
  369. fileIds.add(id)
  370. return true
  371. })
  372. }
  373. let deletedFilesProjectIdIndexExist
  374. async function doesDeletedFilesProjectIdIndexExist() {
  375. if (typeof deletedFilesProjectIdIndexExist !== 'boolean') {
  376. // Resolve this about once. No need for locking or retry handling.
  377. deletedFilesProjectIdIndexExist = await db.deletedFiles.indexExists(
  378. 'projectId_1'
  379. )
  380. }
  381. return deletedFilesProjectIdIndexExist
  382. }
  383. async function hardDeleteDeletedFiles(projectId) {
  384. if (!(await doesDeletedFilesProjectIdIndexExist())) {
  385. // Running the deletion command w/o index would kill mongo performance
  386. return
  387. }
  388. return db.deletedFiles.deleteMany({ projectId })
  389. }