Skip to content

Commit ec5d41f

Browse files
committed
implement API for username changes
1 parent 895e1a1 commit ec5d41f

8 files changed

Lines changed: 381 additions & 2 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
use crate::schema::abandoned_usernames;
2+
use chrono::{DateTime, Utc};
3+
use diesel::prelude::*;
4+
5+
#[derive(Debug, Clone, Identifiable, HasQuery)]
6+
#[diesel(
7+
table_name = abandoned_usernames,
8+
check_for_backend(diesel::pg::Pg),
9+
)]
10+
pub struct AbandonedUsername {
11+
pub id: i64,
12+
pub username: String,
13+
pub previous_user_id: Option<i32>,
14+
pub adopted_at: Option<DateTime<Utc>>,
15+
pub abandoned_at: DateTime<Utc>,
16+
pub available_at: DateTime<Utc>,
17+
}
18+
19+
#[derive(Insertable, Debug)]
20+
#[diesel(table_name = abandoned_usernames, check_for_backend(diesel::pg::Pg))]
21+
pub struct NewAbandonedUsername<'a> {
22+
pub username: &'a str,
23+
pub previous_user_id: Option<i32>,
24+
pub adopted_at: Option<&'a DateTime<Utc>>,
25+
pub abandoned_at: &'a DateTime<Utc>,
26+
pub available_at: &'a DateTime<Utc>,
27+
}

crates/crates_io_database/src/models/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub use self::abandoned_username::{AbandonedUsername, NewAbandonedUsername};
12
pub use self::action::{NewVersionOwnerAction, VersionAction, VersionOwnerAction};
23
pub use self::cache_tags_backfill::NewCacheTagsBackfillRow;
34
pub use self::category::{Category, CrateCategory, NewCategory};
@@ -24,6 +25,7 @@ pub use self::version::{NewVersion, TopVersions, Version};
2425

2526
pub mod helpers;
2627

28+
mod abandoned_username;
2729
mod action;
2830
mod cache_tags_backfill;
2931
pub mod category;

crates/crates_io_validation/src/lib.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,30 @@ pub enum InvalidDependencyName {
8686
Char(char, String),
8787
}
8888

89+
pub const MAX_USERNAME_LENGTH: usize = 39;
90+
91+
// Only allows usernames to contain alphanumeric characters ([a-zA-Z0-9]), hyphens (-), and underscores (_).
92+
// Doesn't allow usernames to start with a hyphen or underscore.
93+
// Doesn't allow usernames to exceed 39 characters.
94+
95+
#[derive(Debug, Eq, PartialEq, thiserror::Error)]
96+
pub enum InvalidUsername {
97+
#[error("the username `{0}` is too long (max {MAX_USERNAME_LENGTH} characters)")]
98+
TooLong(String),
99+
#[error("username cannot be empty")]
100+
Empty,
101+
#[error(
102+
"invalid character `{0}` in username: `{1}`, \
103+
the first character must be an ASCII alphanumeric character"
104+
)]
105+
Start(char, String),
106+
#[error(
107+
"invalid character `{0}` in username: `{1}`, \
108+
characters must be ASCII alphanumeric characters, `-`, or `_`"
109+
)]
110+
Char(char, String),
111+
}
112+
89113
// Validates the name is a valid crate name.
90114
// This is also used for validating the name of dependencies.
91115
// So the `for_what` parameter is used to indicate what the name is used for.
@@ -212,6 +236,28 @@ pub fn validate_feature(name: &str) -> Result<(), InvalidFeature> {
212236
}
213237
}
214238

239+
pub fn validate_username(username: &str) -> Result<(), InvalidUsername> {
240+
if username.chars().count() > MAX_USERNAME_LENGTH {
241+
return Err(InvalidUsername::TooLong(username.into()));
242+
}
243+
244+
let mut chars = username.chars();
245+
let Some(first_char) = chars.next() else {
246+
return Err(InvalidUsername::Empty);
247+
};
248+
if !first_char.is_ascii_alphanumeric() {
249+
return Err(InvalidUsername::Start(first_char, username.into()));
250+
}
251+
252+
for ch in chars {
253+
if !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') {
254+
return Err(InvalidUsername::Char(ch, username.into()));
255+
}
256+
}
257+
258+
Ok(())
259+
}
260+
215261
#[cfg(test)]
216262
mod tests {
217263
use super::*;
@@ -367,4 +413,41 @@ mod tests {
367413
InvalidDependencyName::StartWithDigit("0foo".into()).into()
368414
);
369415
}
416+
417+
#[test]
418+
fn test_validate_username() {
419+
use super::{InvalidUsername, MAX_USERNAME_LENGTH};
420+
421+
assert_ok!(validate_username("foo"));
422+
assert_err_eq!(
423+
validate_username("京"),
424+
InvalidUsername::Start('京', "京".into())
425+
);
426+
assert_err_eq!(validate_username(""), InvalidUsername::Empty);
427+
assert_err_eq!(
428+
validate_username("💝"),
429+
InvalidUsername::Start('💝', "💝".into())
430+
);
431+
432+
assert_ok!(validate_username("foo_underscore"));
433+
assert_ok!(validate_username("foo-dash"));
434+
assert_err_eq!(
435+
validate_username("foo+plus"),
436+
InvalidUsername::Char('+', "foo+plus".into())
437+
);
438+
assert_err_eq!(
439+
validate_username("_foo"),
440+
InvalidUsername::Start('_', "_foo".into())
441+
);
442+
assert_err_eq!(
443+
validate_username("-foo"),
444+
InvalidUsername::Start('-', "-foo".into())
445+
);
446+
assert_ok!(validate_username("123"));
447+
448+
assert_err_eq!(
449+
validate_username("o".repeat(MAX_USERNAME_LENGTH + 1).as_str()),
450+
InvalidUsername::TooLong("o".repeat(MAX_USERNAME_LENGTH + 1).as_str().into())
451+
);
452+
}
370453
}

packages/crates-io-api-client/schema.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4106,6 +4106,7 @@ export interface operations {
41064106
user: {
41074107
email?: string | null;
41084108
publish_notifications?: boolean | null;
4109+
username?: string | null;
41094110
};
41104111
};
41114112
};

src/controllers/user/update.rs

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@ use crate::auth::AuthCheck;
33
use crate::controllers::helpers::OkResponse;
44
use crate::email::EmailMessage;
55
use crate::models::NewEmail;
6-
use crate::schema::users;
6+
use crate::schema::{abandoned_usernames, reserved_usernames, users};
77
use crate::util::errors::{AppResult, bad_request, server_error};
8+
89
use axum::Json;
910
use axum::extract::Path;
11+
use chrono::{DateTime, SecondsFormat, TimeDelta, Utc};
12+
use crates_io_database::fns::canon_username;
13+
use crates_io_database::models::NewAbandonedUsername;
14+
use crates_io_validation::validate_username;
1015
use diesel::prelude::*;
11-
use diesel_async::RunQueryDsl;
16+
use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl};
1217
use http::request::Parts;
1318
use lettre::Address;
1419
use minijinja::context;
@@ -24,10 +29,14 @@ pub struct UserUpdate {
2429

2530
#[derive(Deserialize, utoipa::ToSchema)]
2631
pub struct User {
32+
username: Option<String>,
2733
email: Option<String>,
2834
publish_notifications: Option<bool>,
2935
}
3036

37+
/// Amount of time after a username is given up before it can be re-used
38+
const USERNAME_COOLDOWN: TimeDelta = TimeDelta::days(30);
39+
3140
/// Update user settings.
3241
///
3342
/// This endpoint allows users to update their email address and publish notifications settings.
@@ -97,6 +106,55 @@ pub async fn update_user(
97106
}
98107
}
99108

109+
if let Some(newname) = &user_update.user.username
110+
&& newname != &user.username
111+
{
112+
// stop immediately if username is invalid
113+
validate_username(newname).map_err(bad_request)?;
114+
115+
conn.transaction(async |conn|
116+
{
117+
// checks to ensure the new username is available
118+
if is_reserved_username(newname, conn).await? {
119+
return Err(bad_request(format!("the username `{newname}` is reserved")));
120+
}
121+
if username_conflict(newname, user.id, conn).await?
122+
{
123+
return Err(bad_request(format!(
124+
"the username `{newname}` is not available"
125+
)));
126+
}
127+
if let Some(available_at) = username_has_cooldown(newname, conn).await? {
128+
return Err(bad_request(format!(
129+
"The username `{}` was recently in use. This username will be available after {}.",
130+
newname,
131+
available_at.to_rfc3339_opts(SecondsFormat::Secs, true)
132+
)));
133+
}
134+
135+
// build and apply updates to both `users` and `abandoned_usernames` tables
136+
let now = Utc::now();
137+
let available_at = now + USERNAME_COOLDOWN;
138+
let abandonment_record = NewAbandonedUsername { username: &user.username,
139+
previous_user_id: Some(user.id),
140+
adopted_at: user.current_username_adopted_at.as_ref(),
141+
abandoned_at: &now,
142+
available_at: &available_at
143+
};
144+
145+
diesel::update(user)
146+
.set((users::username.eq(newname), users::current_username_adopted_at.eq(now)))
147+
.execute(conn)
148+
.await?;
149+
diesel::insert_into(abandoned_usernames::table)
150+
.values(abandonment_record)
151+
.execute(conn)
152+
.await?;
153+
154+
Ok(())
155+
}).await?;
156+
}
157+
100158
if let Some(user_email) = &user_update.user.email {
101159
let user_email = user_email.trim();
102160

@@ -141,3 +199,64 @@ pub async fn update_user(
141199

142200
Ok(OkResponse::new())
143201
}
202+
203+
/// Returns true if any users *besides the current one*
204+
/// have a username that conflicts with this one
205+
async fn username_conflict(
206+
username: &str,
207+
user_id: i32,
208+
conn: &mut AsyncPgConnection,
209+
) -> Result<bool, diesel::result::Error> {
210+
let in_use_name_query: Option<String> = users::table
211+
.filter(users::id.ne(user_id))
212+
.filter(canon_username(users::username).eq(canon_username(username)))
213+
.select(users::username)
214+
.first(conn)
215+
.await
216+
.optional()?;
217+
if let Some(_in_use_name) = in_use_name_query {
218+
Ok(true)
219+
} else {
220+
Ok(false)
221+
}
222+
}
223+
224+
async fn is_reserved_username(
225+
username: &str,
226+
mut conn: &mut AsyncPgConnection,
227+
) -> Result<bool, diesel::result::Error> {
228+
let reserved_name_query: Option<String> = reserved_usernames::table
229+
.filter(canon_username(reserved_usernames::username).eq(canon_username(username)))
230+
.select(reserved_usernames::username)
231+
.first(&mut conn)
232+
.await
233+
.optional()?;
234+
235+
if let Some(_reserved_name) = reserved_name_query {
236+
Ok(true)
237+
} else {
238+
Ok(false)
239+
}
240+
}
241+
242+
async fn username_has_cooldown(
243+
username: &str,
244+
conn: &mut AsyncPgConnection,
245+
) -> Result<Option<DateTime<Utc>>, diesel::result::Error> {
246+
let abandoned_name_query: Option<(String, DateTime<Utc>)> = abandoned_usernames::table
247+
.filter(canon_username(abandoned_usernames::username).eq(canon_username(username)))
248+
.filter(abandoned_usernames::available_at.gt(Utc::now()))
249+
.select((
250+
abandoned_usernames::username,
251+
abandoned_usernames::available_at,
252+
))
253+
.first(conn)
254+
.await
255+
.optional()?;
256+
257+
if let Some((_abandoned_name, available_at)) = abandoned_name_query {
258+
Ok(Some(available_at))
259+
} else {
260+
Ok(None)
261+
}
262+
}

0 commit comments

Comments
 (0)