backend/
user.rs

1use regex::Regex;
2use serde::{Deserialize, Serialize};
3use ts_rs::TS;
4
5use super::app::{AppCtx, AppError, AppState};
6
7/// Notify the backend that a user has signed up or signed in.
8pub async fn sign_up_or_sign_in(ctx: AppCtx) -> Result<(), AppError> {
9    let Some(user) = ctx.user else {
10        return Err(AppError::Unauthorized);
11    };
12    let query = sqlx::query!(
13        "
14        INSERT INTO users(id, created, signed_in)
15        VALUES ($1, NOW(), NOW())
16        ON CONFLICT (id) DO UPDATE
17        SET signed_in = EXCLUDED.signed_in
18        ",
19        user.user_id,
20    );
21    query.execute(&ctx.state.db).await?;
22    Ok(())
23}
24
25/// Look up a user by username.
26pub async fn user_by_username(
27    state: AppState,
28    username: &str,
29) -> Result<Option<UserSummary>, AppError> {
30    let query = sqlx::query_as!(
31        UserSummary,
32        "
33        SELECT id, username, display_name FROM users WHERE username = $1
34        ",
35        username
36    );
37    Ok(query.fetch_optional(&state.db).await?)
38}
39
40/// Summary of a user.
41///
42/// The minimal information needed to uniquely identify a user and display the user
43/// in human-readable form.
44#[derive(Clone, Debug, Serialize, Deserialize, TS)]
45pub struct UserSummary {
46    pub id: String,
47    pub username: Option<String>,
48    #[serde(rename = "displayName")]
49    pub display_name: Option<String>,
50}
51
52/// Get the status of a username.
53pub async fn username_status(state: AppState, username: &str) -> Result<UsernameStatus, AppError> {
54    if is_username_valid(username) {
55        let query = sqlx::query_scalar!("SELECT 1 FROM users WHERE username = $1", username);
56        if query.fetch_optional(&state.db).await?.is_none() {
57            Ok(UsernameStatus::Available)
58        } else {
59            Ok(UsernameStatus::Unavailable)
60        }
61    } else {
62        Ok(UsernameStatus::Invalid)
63    }
64}
65
66/// Status of a username.
67#[derive(Clone, Debug, Serialize, TS)]
68pub enum UsernameStatus {
69    /// The username is valid and available.
70    Available,
71
72    /// The username is already in use by another user.
73    Unavailable,
74
75    /// The username is invalid.
76    Invalid,
77}
78
79/// Get profile data for the active user.
80pub async fn get_active_user_profile(ctx: AppCtx) -> Result<UserProfile, AppError> {
81    let Some(user) = ctx.user else {
82        return Err(AppError::Unauthorized);
83    };
84    let query = sqlx::query_as!(
85        UserProfile,
86        "
87        SELECT username, display_name FROM users
88        WHERE id = $1
89        ",
90        user.user_id,
91    );
92    Ok(query.fetch_one(&ctx.state.db).await?)
93}
94
95/// Set profile data for the active user.
96pub async fn set_active_user_profile(ctx: AppCtx, profile: UserProfile) -> Result<(), AppError> {
97    let Some(user) = ctx.user else {
98        return Err(AppError::Unauthorized);
99    };
100    profile.validate().map_err(AppError::Invalid)?;
101
102    // Once set, a username cannot be unset, only changed to a different name.
103    // This should be validated in the frontend, and it is enforced below by
104    // using `COALESCE`.
105    let query = sqlx::query!(
106        "
107        UPDATE users SET username = COALESCE($2, username), display_name = $3
108        WHERE id = $1
109        ",
110        user.user_id,
111        profile.username,
112        profile.display_name,
113    );
114    query.execute(&ctx.state.db).await?;
115    Ok(())
116}
117
118/// Data of a user profile.
119#[derive(Clone, Debug, Serialize, Deserialize, TS)]
120pub struct UserProfile {
121    pub username: Option<String>,
122    #[serde(rename = "displayName")]
123    pub display_name: Option<String>,
124    // TODO: More fields, such as:
125    // pub bio: Option<String>,
126    // pub url: Option<String>,
127}
128
129impl UserProfile {
130    fn validate(&self) -> Result<(), String> {
131        if let Some(username) = self.username.as_ref() {
132            is_username_valid(username)
133                .then_some(())
134                .ok_or_else(|| "Username does not follow the rules".into())
135        } else {
136            Ok(())
137        }
138    }
139}
140
141/// Is the proposed user name valid?
142///
143/// A username is **valid** when it
144///
145/// - is nonempty
146/// - comprises ASCII alphanumeric characters, dashes, dots, and underscores
147/// - has alphanumeric first and last characters
148///
149/// In particular, this ensures that a valid username is also a valid URL. Similar
150/// rules for usernames are enforced by sites such as GitHub.
151pub fn is_username_valid(username: &str) -> bool {
152    let valid_chars = Regex::new(r"^[0-9A-Za-z_\-\.]*$").unwrap();
153    let starts_alpha = Regex::new(r"^[0-9A-Za-z]").unwrap();
154    let ends_alpha = Regex::new(r"[0-9A-Za-z]$").unwrap();
155
156    valid_chars.is_match(username)
157        && starts_alpha.is_match(username)
158        && ends_alpha.is_match(username)
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn validate_user_profile() {
167        assert!(
168            UserProfile {
169                username: None,
170                display_name: None
171            }
172            .validate()
173            .is_ok()
174        );
175
176        assert!(
177            UserProfile {
178                username: Some("evan!".into()),
179                display_name: Some("Evan".into()),
180            }
181            .validate()
182            .is_err()
183        );
184    }
185
186    #[test]
187    fn validate_username() {
188        assert!(!is_username_valid(""));
189        assert!(is_username_valid("foo"));
190        assert!(!is_username_valid("_foo"));
191        assert!(!is_username_valid("foo_"));
192        assert!(is_username_valid("foo_bar"));
193        assert!(is_username_valid("foo-bar"));
194        assert!(is_username_valid("foo.bar"));
195        assert!(!is_username_valid("foo!bar"));
196    }
197}