Skip to main content

mail4agent_server/
nick.rs

1//! Nick stored on `messenger_sessions`. The HTTP layer stamps member display
2//! names from [`effective_label`] and refuses create/invite when
3//! [`require_nick`] fails. [`set_nick`] writes that session row. There is no
4//! reserved-nick list, no cooldown, and no billing. `matrix_users.nick` is
5//! not the source of truth.
6
7use rusqlite::{params, Connection, OptionalExtension};
8
9use crate::error::MatrixError;
10
11/// A client session. The nick is this row. `device_id` is only a mark.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct MessengerSession {
14    pub session_id: String,
15    pub user_id: i64,
16    pub device_id: String,
17    pub nick: String,
18}
19
20pub fn normalize_nick(nick: &str) -> Result<String, MatrixError> {
21    let nick = nick.trim();
22    if nick.is_empty()
23        || nick.len() > 32
24        || !nick
25            .chars()
26            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
27    {
28        return Err(MatrixError::invalid_param(
29            "nick must be 1..=32 of [A-Za-z0-9_-]",
30        ));
31    }
32    Ok(nick.to_string())
33}
34
35/// Placeholder row [`set_nick`] uses when a client has not registered yet.
36/// Register adopts it. Clients cannot choose this id.
37pub(crate) fn legacy_session_id(user_id: i64) -> String {
38    format!("legacy-user-{user_id}")
39}
40
41/// True when some other session already has this nick, case-insensitively.
42pub(crate) fn nick_taken(
43    conn: &Connection,
44    nick: &str,
45    except_session_id: Option<&str>,
46) -> Result<bool, MatrixError> {
47    let except = except_session_id.unwrap_or("");
48    let taken = conn
49        .query_row(
50            "SELECT 1 FROM messenger_sessions WHERE LOWER(nick) = LOWER(?1) AND session_id != ?2",
51            params![nick, except],
52            |_| Ok(()),
53        )
54        .optional()?
55        .is_some();
56    Ok(taken)
57}
58
59pub fn set_nick(conn: &Connection, user_id: i64, nick: &str) -> Result<(), MatrixError> {
60    let nick = normalize_nick(nick)?;
61    let user_exists: bool = conn
62        .query_row(
63            "SELECT 1 FROM matrix_users WHERE user_id = ?1",
64            params![user_id],
65            |_| Ok(()),
66        )
67        .optional()?
68        .is_some();
69    if !user_exists {
70        return Err(MatrixError::not_found("unknown user"));
71    }
72    let current: Option<String> = conn
73        .query_row(
74            "SELECT session_id FROM messenger_sessions WHERE user_id = ?1 ORDER BY rowid LIMIT 1",
75            params![user_id],
76            |row| row.get(0),
77        )
78        .optional()?;
79    if nick_taken(conn, &nick, current.as_deref())? {
80        return Err(MatrixError::invalid_param("nick is taken"));
81    }
82    if let Some(session_id) = current {
83        conn.execute(
84            "UPDATE messenger_sessions SET nick = ?1 WHERE session_id = ?2",
85            params![nick, session_id],
86        )?;
87    } else {
88        let session_id = legacy_session_id(user_id);
89        conn.execute(
90            "INSERT INTO messenger_sessions (session_id, user_id, device_id, nick) VALUES (?1, ?2, '', ?3)",
91            params![session_id, user_id, nick],
92        )?;
93    }
94    Ok(())
95}
96
97pub fn user_nick(conn: &Connection, user_id: i64) -> rusqlite::Result<Option<String>> {
98    let nick: Option<String> = conn
99        .query_row(
100            "SELECT nick FROM messenger_sessions
101             WHERE user_id = ?1 AND nick != ''
102             ORDER BY rowid LIMIT 1",
103            params![user_id],
104            |row| row.get(0),
105        )
106        .optional()?;
107    if let Some(n) = nick.filter(|n| !n.is_empty()) {
108        return Ok(Some(n));
109    }
110    crate::identities::nick_of_user(conn, user_id)
111}
112
113/// Earliest session nick when one is set, otherwise the mxid localpart.
114pub fn effective_label(conn: &Connection, user_id: i64) -> rusqlite::Result<String> {
115    if let Some(nick) = user_nick(conn, user_id)? {
116        return Ok(nick);
117    }
118    let mxid: String = conn.query_row(
119        "SELECT mxid FROM matrix_users WHERE user_id = ?1",
120        params![user_id],
121        |row| row.get(0),
122    )?;
123    Ok(crate::store::public_id_from_mxid(&mxid)
124        .unwrap_or("")
125        .to_string())
126}
127
128pub fn require_nick(
129    conn: &Connection,
130    user_id: i64,
131    missing: &'static str,
132) -> Result<String, MatrixError> {
133    match user_nick(conn, user_id)? {
134        Some(nick) => Ok(nick),
135        None => Err(MatrixError::forbidden(missing)),
136    }
137}
138
139pub struct NickHit {
140    pub mxid: String,
141    pub nick: String,
142}
143
144/// Case-insensitive substring search over session nicks. `limited` is true
145/// when more than `limit` rows matched.
146pub fn search_nicks(
147    conn: &Connection,
148    term: &str,
149    limit: usize,
150) -> rusqlite::Result<(Vec<NickHit>, bool)> {
151    let escaped = term
152        .replace('\\', "\\\\")
153        .replace('%', "\\%")
154        .replace('_', "\\_");
155    let pattern = format!("%{escaped}%");
156    let mut stmt = conn.prepare(
157        "SELECT mxid, nick FROM (
158             SELECT u.mxid AS mxid, s.nick AS nick
159             FROM messenger_sessions s JOIN matrix_users u ON u.user_id = s.user_id
160             WHERE s.nick != ''
161             UNION
162             SELECT u.mxid, i.nick
163             FROM identities i JOIN matrix_users u ON u.user_id = i.id
164         )
165         WHERE LOWER(nick) LIKE LOWER(?1) ESCAPE '\\'
166         ORDER BY LOWER(nick)
167         LIMIT ?2",
168    )?;
169    let rows = stmt.query_map(params![pattern, (limit as i64) + 1], |row| {
170        Ok(NickHit {
171            mxid: row.get(0)?,
172            nick: row.get(1)?,
173        })
174    })?;
175    let mut hits = Vec::new();
176    for row in rows {
177        hits.push(row?);
178    }
179    let limited = hits.len() > limit;
180    hits.truncate(limit);
181    Ok((hits, limited))
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::store::{create_matrix_schema, ensure_matrix_user};
188
189    const T0: &str = "2026-10-05T00:00:00+00:00";
190
191    fn conn() -> Connection {
192        let conn = Connection::open_in_memory().expect("memory");
193        create_matrix_schema(&conn).expect("schema");
194        conn
195    }
196
197    #[test]
198    fn set_nick_is_unique_and_gates_an_empty_user() {
199        let conn = conn();
200        ensure_matrix_user(&conn, 1, "alice000000000000000000000000a1", T0).expect("alice");
201        ensure_matrix_user(&conn, 2, "bob0000000000000000000000000b02", T0).expect("bob");
202        assert!(require_nick(&conn, 1, "choose a nick before creating a room").is_err());
203        set_nick(&conn, 1, "alice_nick").expect("set");
204        assert_eq!(effective_label(&conn, 1).expect("label"), "alice_nick");
205        let on_session: String = conn
206            .query_row(
207                "SELECT nick FROM messenger_sessions WHERE user_id = 1",
208                [],
209                |row| row.get(0),
210            )
211            .expect("session nick");
212        assert_eq!(on_session, "alice_nick");
213        let column: Option<String> = conn
214            .query_row(
215                "SELECT nick FROM matrix_users WHERE user_id = 1",
216                [],
217                |row| row.get(0),
218            )
219            .expect("column");
220        assert!(column.is_none());
221        assert!(set_nick(&conn, 2, "Alice_Nick").is_err());
222        assert!(set_nick(&conn, 1, "bad nick").is_err());
223    }
224}