ProjectDeleter.js 12 KB

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