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