Skip to content

Commit 37a63de

Browse files
authored
Merge pull request #8290 from hi-rustin/rustin-patch-expiry_notification_job
Send API token expiry notification emails
2 parents 153a4b5 + cb19848 commit 37a63de

File tree

8 files changed

+240
-0
lines changed

8 files changed

+240
-0
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- Remove the `expiry_notification_at` column from the `api_tokens` table.
2+
ALTER TABLE api_tokens DROP expiry_notification_at;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
ALTER TABLE api_tokens ADD expiry_notification_at TIMESTAMP;
2+
3+
COMMENT ON COLUMN api_tokens.expiry_notification_at IS 'timestamp of when the user was informed about their token''s impending expiration';

src/admin/enqueue_job.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ pub enum Command {
4444
#[arg(long)]
4545
force: bool,
4646
},
47+
SendTokenExpiryNotifications,
4748
}
4849

4950
pub fn run(command: Command) -> Result<()> {
@@ -130,6 +131,9 @@ pub fn run(command: Command) -> Result<()> {
130131

131132
jobs::CheckTyposquat::new(&name).enqueue(conn)?;
132133
}
134+
Command::SendTokenExpiryNotifications => {
135+
jobs::SendTokenExpiryNotifications.enqueue(conn)?;
136+
}
133137
};
134138

135139
Ok(())

src/schema.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ diesel::table! {
8181
///
8282
/// (Automatically generated by Diesel.)
8383
expired_at -> Nullable<Timestamp>,
84+
/// timestamp of when the user was informed about their token's impending expiration
85+
expiry_notification_at -> Nullable<Timestamp>,
8486
}
8587
}
8688

src/worker/jobs/dump_db/dump-db.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ revoked = "private"
2929
crate_scopes = "private"
3030
endpoint_scopes = "private"
3131
expired_at = "private"
32+
expiry_notification_at = "private"
3233

3334
[background_jobs.columns]
3435
id = "private"
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
use crate::models::ApiToken;
2+
use crate::schema::api_tokens;
3+
use crate::{email::Email, models::User, worker::Environment, Emails};
4+
use anyhow::anyhow;
5+
use chrono::SecondsFormat;
6+
use crates_io_worker::BackgroundJob;
7+
use diesel::dsl::now;
8+
use diesel::prelude::*;
9+
use std::sync::Arc;
10+
11+
/// The threshold for the expiry notification.
12+
const EXPIRY_THRESHOLD: chrono::TimeDelta = chrono::TimeDelta::days(3);
13+
14+
/// The maximum number of tokens to check per run.
15+
const MAX_ROWS: i64 = 10000;
16+
17+
#[derive(Default, Serialize, Deserialize, Debug)]
18+
pub struct SendTokenExpiryNotifications;
19+
20+
impl BackgroundJob for SendTokenExpiryNotifications {
21+
const JOB_NAME: &'static str = "expiry_notification";
22+
23+
type Context = Arc<Environment>;
24+
25+
#[instrument(skip(env), err)]
26+
async fn run(&self, env: Self::Context) -> anyhow::Result<()> {
27+
let conn = env.deadpool.get().await?;
28+
conn.interact(move |conn| {
29+
// Check if the token is about to expire
30+
// If the token is about to expire, trigger a notification.
31+
check(&env.emails, conn)
32+
})
33+
.await
34+
.map_err(|err| anyhow!(err.to_string()))?
35+
}
36+
}
37+
38+
/// Find tokens that are about to expire and send notifications to their owners.
39+
fn check(emails: &Emails, conn: &mut PgConnection) -> anyhow::Result<()> {
40+
info!("Checking if tokens are about to expire");
41+
let before = chrono::Utc::now() + EXPIRY_THRESHOLD;
42+
let expired_tokens = find_expiring_tokens(conn, before)?;
43+
if expired_tokens.len() == MAX_ROWS as usize {
44+
warn!("The maximum number of API tokens per query has been reached. More API tokens might be processed on the next run.");
45+
}
46+
for token in &expired_tokens {
47+
if let Err(e) = handle_expiring_token(conn, token, emails) {
48+
error!(?e, "Failed to handle expiring token");
49+
}
50+
}
51+
52+
Ok(())
53+
}
54+
55+
/// Send an email to the user associated with the token.
56+
fn handle_expiring_token(
57+
conn: &mut PgConnection,
58+
token: &ApiToken,
59+
emails: &Emails,
60+
) -> Result<(), anyhow::Error> {
61+
let user = User::find(conn, token.user_id)?;
62+
let recipient = user
63+
.email(conn)?
64+
.ok_or_else(|| anyhow!("No address found"))?;
65+
let email = ExpiryNotificationEmail {
66+
name: &user.gh_login,
67+
token_name: &token.name,
68+
expiry_date: token.expired_at.unwrap().and_utc(),
69+
};
70+
emails.send(&recipient, email)?;
71+
// Update the token to prevent duplicate notifications.
72+
diesel::update(token)
73+
.set(api_tokens::expiry_notification_at.eq(now.nullable()))
74+
.execute(conn)?;
75+
Ok(())
76+
}
77+
78+
/// Find tokens that will expire before the given date, but haven't expired yet
79+
/// and haven't been notified about their impending expiry. Revoked tokens are
80+
/// also ignored.
81+
///
82+
/// This function returns at most `MAX_ROWS` tokens.
83+
pub fn find_expiring_tokens(
84+
conn: &mut PgConnection,
85+
before: chrono::DateTime<chrono::Utc>,
86+
) -> QueryResult<Vec<ApiToken>> {
87+
api_tokens::table
88+
.filter(api_tokens::revoked.eq(false))
89+
.filter(api_tokens::expired_at.is_not_null())
90+
// Ignore already expired tokens
91+
.filter(api_tokens::expired_at.assume_not_null().gt(now))
92+
.filter(
93+
api_tokens::expired_at
94+
.assume_not_null()
95+
.lt(before.naive_utc()),
96+
)
97+
.filter(api_tokens::expiry_notification_at.is_null())
98+
.select(ApiToken::as_select())
99+
.order_by(api_tokens::expired_at.asc()) // The most urgent tokens first
100+
.limit(MAX_ROWS)
101+
.get_results(conn)
102+
}
103+
104+
#[derive(Debug, Clone)]
105+
struct ExpiryNotificationEmail<'a> {
106+
name: &'a str,
107+
token_name: &'a str,
108+
expiry_date: chrono::DateTime<chrono::Utc>,
109+
}
110+
111+
impl<'a> Email for ExpiryNotificationEmail<'a> {
112+
const SUBJECT: &'static str = "Your token is about to expire";
113+
114+
fn body(&self) -> String {
115+
format!(
116+
r#"Hi {},
117+
118+
We noticed your token "{}" will expire on {}.
119+
120+
If this token is still needed, visit https://crates.io/settings/tokens/new to generate a new one.
121+
122+
Thanks,
123+
The crates.io team"#,
124+
self.name,
125+
self.token_name,
126+
self.expiry_date.to_rfc3339_opts(SecondsFormat::Secs, true)
127+
)
128+
}
129+
}
130+
131+
#[cfg(test)]
132+
mod tests {
133+
use super::*;
134+
use crate::models::NewUser;
135+
use crate::{
136+
models::token::ApiToken, schema::api_tokens, test_util::test_db_connection,
137+
util::token::PlainToken,
138+
};
139+
use diesel::dsl::IntervalDsl;
140+
use lettre::Address;
141+
142+
#[tokio::test]
143+
async fn test_expiry_notification() -> anyhow::Result<()> {
144+
let emails = Emails::new_in_memory();
145+
let (_test_db, mut conn) = test_db_connection();
146+
147+
// Set up a user and a token that is about to expire.
148+
let user = NewUser::new(0, "a", None, None, "token").create_or_update(
149+
150+
&Emails::new_in_memory(),
151+
&mut conn,
152+
)?;
153+
let token = PlainToken::generate();
154+
155+
let token: ApiToken = diesel::insert_into(api_tokens::table)
156+
.values((
157+
api_tokens::user_id.eq(user.id),
158+
api_tokens::name.eq("test_token"),
159+
api_tokens::token.eq(token.hashed()),
160+
api_tokens::expired_at.eq(now.nullable() + (EXPIRY_THRESHOLD.num_days() - 1).day()),
161+
))
162+
.returning(ApiToken::as_returning())
163+
.get_result(&mut conn)?;
164+
165+
// Insert a few tokens that are not set to expire.
166+
let not_expired_offset = EXPIRY_THRESHOLD.num_days() + 1;
167+
for i in 0..3 {
168+
let token = PlainToken::generate();
169+
diesel::insert_into(api_tokens::table)
170+
.values((
171+
api_tokens::user_id.eq(user.id),
172+
api_tokens::name.eq(format!("test_token{i}")),
173+
api_tokens::token.eq(token.hashed()),
174+
api_tokens::expired_at.eq(now.nullable() + not_expired_offset.day()),
175+
))
176+
.returning(ApiToken::as_returning())
177+
.get_result(&mut conn)?;
178+
}
179+
180+
// Check that the token is about to expire.
181+
check(&emails, &mut conn)?;
182+
183+
// Check that an email was sent.
184+
let sent_mail = emails.mails_in_memory().unwrap();
185+
assert_eq!(sent_mail.len(), 1);
186+
let sent = &sent_mail[0];
187+
assert_eq!(&sent.0.to(), &["[email protected]".parse::<Address>()?]);
188+
assert!(sent.1.contains("Your token is about to expire"));
189+
let updated_token = api_tokens::table
190+
.filter(api_tokens::id.eq(token.id))
191+
.filter(api_tokens::expiry_notification_at.is_not_null())
192+
.select(ApiToken::as_select())
193+
.first::<ApiToken>(&mut conn)?;
194+
assert_eq!(updated_token.name, "test_token".to_owned());
195+
196+
// Check that the token is not about to expire.
197+
let tokens = api_tokens::table
198+
.filter(api_tokens::revoked.eq(false))
199+
.filter(api_tokens::expiry_notification_at.is_null())
200+
.select(ApiToken::as_select())
201+
.load::<ApiToken>(&mut conn)?;
202+
assert_eq!(tokens.len(), 3);
203+
204+
// Insert a already expired token.
205+
let token = PlainToken::generate();
206+
diesel::insert_into(api_tokens::table)
207+
.values((
208+
api_tokens::user_id.eq(user.id),
209+
api_tokens::name.eq("expired_token"),
210+
api_tokens::token.eq(token.hashed()),
211+
api_tokens::expired_at.eq(now.nullable() - 1.day()),
212+
))
213+
.returning(ApiToken::as_returning())
214+
.get_result(&mut conn)?;
215+
216+
// Check that the token is not about to expire.
217+
check(&emails, &mut conn)?;
218+
219+
// Check that no email was sent.
220+
let sent_mail = emails.mails_in_memory().unwrap();
221+
assert_eq!(sent_mail.len(), 1);
222+
223+
Ok(())
224+
}
225+
}

src/worker/jobs/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ mod archive_version_downloads;
99
mod daily_db_maintenance;
1010
mod downloads;
1111
pub mod dump_db;
12+
mod expiry_notification;
1213
mod git;
1314
mod readmes;
1415
mod sync_admins;
@@ -21,6 +22,7 @@ pub use self::downloads::{
2122
CleanProcessedLogFiles, ProcessCdnLog, ProcessCdnLogQueue, UpdateDownloads,
2223
};
2324
pub use self::dump_db::DumpDb;
25+
pub use self::expiry_notification::SendTokenExpiryNotifications;
2426
pub use self::git::{NormalizeIndex, SquashIndex, SyncToGitIndex, SyncToSparseIndex};
2527
pub use self::readmes::RenderAndUploadReadme;
2628
pub use self::sync_admins::SyncAdmins;

src/worker/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,6 @@ impl RunnerExt for Runner<Arc<Environment>> {
3434
.register_job_type::<jobs::SyncToSparseIndex>()
3535
.register_job_type::<jobs::UpdateDownloads>()
3636
.register_job_type::<jobs::UpdateDefaultVersion>()
37+
.register_job_type::<jobs::SendTokenExpiryNotifications>()
3738
}
3839
}

0 commit comments

Comments
 (0)