ProjectDeleter.mjs 12 KB

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