backupDeletion.mjs 2.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
  1. // @ts-check
  2. import { callbackify } from 'node:util'
  3. import { ObjectId } from 'mongodb'
  4. import config from 'config'
  5. import OError from '@overleaf/o-error'
  6. import { db } from './mongodb.js'
  7. import projectKey from '@overleaf/object-persistor/src/ProjectKey.js'
  8. import chunkStore from '../lib/chunk_store/index.js'
  9. import {
  10. backupPersistor,
  11. chunksBucket,
  12. projectBlobsBucket,
  13. } from './backupPersistor.mjs'
  14. const MS_PER_DAY = 24 * 60 * 60 * 1000
  15. const EXPIRE_PROJECTS_AFTER_MS =
  16. parseInt(config.get('minSoftDeletionPeriodDays'), 10) * MS_PER_DAY
  17. const deletedProjectsCollection = db.collection('deletedProjects')
  18. /**
  19. * @param {string} historyId
  20. * @return {Promise<boolean>}
  21. */
  22. async function projectHasLatestChunk(historyId) {
  23. const chunk = await chunkStore.getBackend(historyId).getLatestChunk(historyId)
  24. return chunk != null
  25. }
  26. export class NotReadyToDelete extends OError {}
  27. /**
  28. * @param {string} projectId
  29. * @return {Promise<void>}
  30. */
  31. async function deleteProjectBackup(projectId) {
  32. const deletedProject = await deletedProjectsCollection.findOne(
  33. { 'deleterData.deletedProjectId': new ObjectId(projectId) },
  34. {
  35. projection: {
  36. 'deleterData.deletedProjectOverleafHistoryId': 1,
  37. 'deleterData.deletedAt': 1,
  38. },
  39. }
  40. )
  41. if (!deletedProject) {
  42. throw new NotReadyToDelete('refusing to delete non-deleted project')
  43. }
  44. const expiresAt =
  45. deletedProject.deleterData.deletedAt.getTime() + EXPIRE_PROJECTS_AFTER_MS
  46. if (expiresAt > Date.now()) {
  47. throw new NotReadyToDelete('refusing to delete non-expired project')
  48. }
  49. const historyId =
  50. deletedProject.deleterData.deletedProjectOverleafHistoryId?.toString()
  51. if (!historyId) {
  52. throw new NotReadyToDelete(
  53. 'refusing to delete project with unknown historyId'
  54. )
  55. }
  56. if (await projectHasLatestChunk(historyId)) {
  57. throw new NotReadyToDelete(
  58. 'refusing to delete project with remaining chunks'
  59. )
  60. }
  61. const prefix = projectKey.format(historyId) + '/'
  62. await backupPersistor.deleteDirectory(chunksBucket, prefix)
  63. await backupPersistor.deleteDirectory(projectBlobsBucket, prefix)
  64. }
  65. export async function healthCheck() {
  66. const HEALTH_CHECK_PROJECTS = JSON.parse(config.get('healthCheckProjects'))
  67. if (HEALTH_CHECK_PROJECTS.length !== 2) {
  68. throw new Error('expected 2 healthCheckProjects')
  69. }
  70. if (!HEALTH_CHECK_PROJECTS.some(id => id.length === 24)) {
  71. throw new Error('expected mongo id in healthCheckProjects')
  72. }
  73. if (!HEALTH_CHECK_PROJECTS.some(id => id.length < 24)) {
  74. throw new Error('expected postgres id in healthCheckProjects')
  75. }
  76. for (const historyId of HEALTH_CHECK_PROJECTS) {
  77. if (!(await projectHasLatestChunk(historyId))) {
  78. throw new OError('project has no history', { historyId })
  79. }
  80. }
  81. }
  82. export const healthCheckCb = callbackify(healthCheck)
  83. export const deleteProjectBackupCb = callbackify(deleteProjectBackup)