notify_expiring_tokens.mjs 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. // Recurring job. Sends two kinds of email about personal access token expiry:
  2. //
  3. // - "expiring soon": token has not expired yet and falls within the
  4. // configured warning window
  5. // (Settings.personalAccessTokens.expiry.warningWindowDays, default 2 days).
  6. // Sent at most once per token.
  7. //
  8. // - "expired": token has expired and we have not yet emailed the owner.
  9. // Sent at most once per token. Tokens that were already expired before
  10. // the feature shipped have `notificationsSuppressedAt` set by the
  11. // backfill_suppress_expired_token_notifications.mjs script and are
  12. // deliberately excluded.
  13. //
  14. // Pass --dry-run to log who would be emailed without sending or marking
  15. // `lastNotifiedAt`.
  16. import settings from '@overleaf/settings'
  17. import logger from '@overleaf/logger'
  18. import {
  19. db,
  20. READ_PREFERENCE_SECONDARY,
  21. } from '../../app/src/infrastructure/mongodb.mjs'
  22. import { User } from '../../app/src/models/User.mjs'
  23. import EmailHandler from '../../app/src/Features/Email/EmailHandler.mjs'
  24. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  25. const MS_PER_DAY = 24 * 60 * 60 * 1000
  26. export async function main({ dryRun = false } = {}) {
  27. const now = new Date()
  28. const warningWindowDays =
  29. settings.personalAccessTokens.expiry.warningWindowDays
  30. const warningHorizon = new Date(
  31. now.getTime() + warningWindowDays * MS_PER_DAY
  32. )
  33. logger.info(
  34. { warningWindowDays, warningHorizon, dryRun },
  35. 'starting notify_expiring_tokens'
  36. )
  37. const warningCount = await processBucket({
  38. kind: 'warning',
  39. template: 'gitTokenExpiringSoon',
  40. dryRun,
  41. query: {
  42. type: 'pat',
  43. accessTokenExpiresAt: { $gt: now, $lte: warningHorizon },
  44. 'lastNotifiedAt.warning': { $exists: false },
  45. },
  46. })
  47. const expiredCount = await processBucket({
  48. kind: 'expired',
  49. template: 'gitTokenExpired',
  50. dryRun,
  51. query: {
  52. type: 'pat',
  53. accessTokenExpiresAt: { $lt: now },
  54. 'lastNotifiedAt.expired': { $exists: false },
  55. notificationsSuppressedAt: { $exists: false },
  56. },
  57. })
  58. logger.info(
  59. { warningCount, expiredCount, dryRun },
  60. 'finished notify_expiring_tokens'
  61. )
  62. }
  63. export async function processBucket({ kind, template, query, dryRun = false }) {
  64. const cursor = db.oauthAccessTokens.find(query, {
  65. projection: {
  66. _id: 1,
  67. user_id: 1,
  68. },
  69. readPreference: READ_PREFERENCE_SECONDARY,
  70. })
  71. let sent = 0
  72. for await (const token of cursor) {
  73. const ok = await notifyOwner({ token, kind, template, dryRun })
  74. if (ok) sent++
  75. }
  76. return sent
  77. }
  78. export async function notifyOwner({ token, kind, template, dryRun = false }) {
  79. const user = await User.findOne(
  80. { _id: token.user_id },
  81. { email: 1, first_name: 1 }
  82. ).exec()
  83. if (!user?.email) {
  84. logger.warn(
  85. { tokenId: token._id, userId: token.user_id },
  86. 'skipping token notification: user not found or has no email'
  87. )
  88. return false
  89. }
  90. if (dryRun) {
  91. logger.info(
  92. { tokenId: token._id, userId: token.user_id, kind, template },
  93. 'dry run: would send git token expiry notification'
  94. )
  95. return true
  96. }
  97. try {
  98. await EmailHandler.promises.sendEmail(template, {
  99. to: user.email,
  100. firstName: user.first_name,
  101. })
  102. } catch (err) {
  103. logger.error(
  104. { err, tokenId: token._id, userId: token.user_id, kind },
  105. 'failed to send git token expiry notification; will retry next run'
  106. )
  107. return false
  108. }
  109. await db.oauthAccessTokens.updateOne(
  110. { _id: token._id },
  111. { $set: { [`lastNotifiedAt.${kind}`]: new Date() } }
  112. )
  113. return true
  114. }
  115. if (import.meta.url === `file://${process.argv[1]}`) {
  116. const dryRun = process.argv.includes('--dry-run')
  117. try {
  118. await scriptRunner(() => main({ dryRun }))
  119. process.exit(0)
  120. } catch (error) {
  121. logger.error({ err: error }, 'notify_expiring_tokens failed')
  122. process.exit(1)
  123. }
  124. }