hypercall_api/
username_service.rs1use anyhow::{Context, Result};
7use redis::aio::ConnectionManager;
8use redis::AsyncCommands;
9use std::sync::Arc;
10use tracing::{debug, info, warn};
11
12use hypercall_db::{UsernameRecord, UsernameWriteError, UsernameWriter};
13
14pub use hypercall_db::UsernameWriteError as UsernameServiceError;
16
17const MAX_LEN: usize = 20;
23const MIN_LEN: usize = 3;
25const RESERVED_USERNAMES: &[&str] = &[
26 "admin",
27 "administrator",
28 "api",
29 "help",
30 "hypercall",
31 "moderator",
32 "root",
33 "staff",
34 "support",
35 "system",
36];
37
38pub fn validate_username(name: &str) -> std::result::Result<(), String> {
43 if name.trim() != name {
44 return Err("Username cannot start or end with whitespace".to_string());
45 }
46 if name.len() < MIN_LEN || name.len() > MAX_LEN {
47 return Err(format!(
48 "Username must be between {} and {} characters",
49 MIN_LEN, MAX_LEN
50 ));
51 }
52
53 let normalized = name.to_ascii_lowercase();
54 if RESERVED_USERNAMES.contains(&normalized.as_str()) {
55 return Err("Username is reserved".to_string());
56 }
57 if normalized.starts_with("0x") {
58 return Err("Username cannot look like a wallet address".to_string());
59 }
60
61 if normalized.contains(".hl") {
63 return Err("Username cannot contain \".hl\"".to_string());
64 }
65
66 for ch in name.chars() {
70 if !ch.is_ascii_alphanumeric() && ch != '_' {
71 return Err(format!(
72 "Username can only contain alphanumeric characters and underscores (found '{}')",
73 ch
74 ));
75 }
76 }
77
78 if let Some(first) = name.chars().next() {
80 if !first.is_ascii_alphanumeric() {
81 return Err("Username must start with an alphanumeric character".to_string());
82 }
83 }
84
85 Ok(())
86}
87
88fn redis_wallet_key(wallet: &str) -> String {
93 format!("username:wallet:{}", wallet.to_lowercase())
94}
95
96fn redis_reverse_key(username: &str) -> String {
97 format!("username:reverse:{}", username.to_lowercase())
98}
99
100pub struct UsernameService {
105 db: Arc<dyn UsernameWriter>,
106 redis: Option<tokio::sync::Mutex<ConnectionManager>>,
107}
108
109impl UsernameService {
110 pub async fn new(db: Arc<dyn UsernameWriter>, redis_client: Option<redis::Client>) -> Self {
116 let redis = match redis_client {
117 Some(client) => match ConnectionManager::new(client).await {
118 Ok(conn) => {
119 info!("UsernameService: Redis cache enabled");
120 Some(tokio::sync::Mutex::new(conn))
121 }
122 Err(e) => {
123 warn!("UsernameService: failed to connect to Redis, cache disabled: {e}");
124 None
125 }
126 },
127 None => {
128 info!("UsernameService: no Redis client provided, cache disabled");
129 None
130 }
131 };
132
133 Self { db, redis }
134 }
135
136 pub async fn get_by_wallet(&self, wallet: &str) -> Result<Option<UsernameRecord>> {
141 self.db
142 .get_username_by_wallet(wallet)
143 .await
144 .with_context(|| "Failed to query username by wallet")
145 }
146
147 pub async fn get_by_username(&self, username: &str) -> Result<Option<UsernameRecord>> {
150 self.db
151 .get_username_by_name(username)
152 .await
153 .with_context(|| "Failed to query username by name")
154 }
155
156 pub async fn set_username(
162 &self,
163 wallet: &str,
164 username: &str,
165 ) -> std::result::Result<UsernameRecord, UsernameServiceError> {
166 validate_username(username)
167 .map_err(|msg| UsernameWriteError::Internal(anyhow::anyhow!(msg)))?;
168
169 let now = chrono::Utc::now();
170
171 let (row, old_username) = self.db.set_username(wallet, username, now).await?;
172
173 if let Some(ref old_name) = old_username {
176 if old_name.to_lowercase() != row.username.to_lowercase() {
177 self.delete_reverse_key(old_name).await;
178 }
179 }
180
181 self.publish_set(&row.wallet_address, &row.username).await;
183
184 Ok(row)
185 }
186
187 pub async fn delete_username(&self, wallet: &str) -> Result<bool> {
189 let deleted_row = self
190 .db
191 .delete_username(wallet)
192 .await
193 .with_context(|| "Failed to delete username")?;
194
195 if let Some(row) = deleted_row {
196 self.publish_delete(&row.wallet_address, &row.username)
197 .await;
198 Ok(true)
199 } else {
200 Ok(false)
201 }
202 }
203
204 async fn publish_set(&self, wallet: &str, username: &str) {
207 let Some(ref redis_mutex) = self.redis else {
208 return;
209 };
210 let mut redis = redis_mutex.lock().await;
211
212 let wallet_key = redis_wallet_key(wallet);
213 let reverse_key = redis_reverse_key(username);
214
215 if let Err(e) = redis.set::<_, _, ()>(&wallet_key, username).await {
217 warn!("UsernameService: failed to SET {wallet_key}: {e}");
218 }
219 if let Err(e) = redis.set::<_, _, ()>(&reverse_key, wallet).await {
220 warn!("UsernameService: failed to SET {reverse_key}: {e}");
221 }
222
223 debug!("UsernameService: published SET {wallet_key}={username}, {reverse_key}={wallet}");
224 }
225
226 async fn delete_reverse_key(&self, username: &str) {
229 let Some(ref redis_mutex) = self.redis else {
230 return;
231 };
232 let mut redis = redis_mutex.lock().await;
233 let reverse_key = redis_reverse_key(username);
234
235 if let Err(e) = redis.del::<_, ()>(&reverse_key).await {
236 warn!("UsernameService: failed to DEL stale reverse key {reverse_key}: {e}");
237 }
238 debug!("UsernameService: deleted stale reverse key {reverse_key}");
239 }
240
241 async fn publish_delete(&self, wallet: &str, username: &str) {
242 let Some(ref redis_mutex) = self.redis else {
243 return;
244 };
245 let mut redis = redis_mutex.lock().await;
246
247 let wallet_key = redis_wallet_key(wallet);
248 let reverse_key = redis_reverse_key(username);
249
250 if let Err(e) = redis.del::<_, ()>(&wallet_key).await {
251 warn!("UsernameService: failed to DEL {wallet_key}: {e}");
252 }
253 if let Err(e) = redis.del::<_, ()>(&reverse_key).await {
254 warn!("UsernameService: failed to DEL {reverse_key}: {e}");
255 }
256
257 debug!("UsernameService: published DEL {wallet_key}, {reverse_key}");
258 }
259}
260
261#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn valid_usernames() {
271 let good = [
272 "alice",
273 "Bob",
274 "a12",
275 "user_name",
276 "abc_123",
277 "a2345678901234567890", ];
279 for name in &good {
280 assert!(validate_username(name).is_ok(), "expected OK for '{name}'");
281 }
282 }
283
284 #[test]
285 fn rejects_too_short() {
286 assert!(validate_username("a").is_err());
287 }
288
289 #[test]
290 fn rejects_too_long() {
291 let long = "a".repeat(21);
292 assert!(validate_username(&long).is_err());
293 }
294
295 #[test]
296 fn rejects_dots_and_dot_hl() {
297 assert!(validate_username("user.name").is_err());
298 assert!(validate_username("alice.hl").is_err());
299 assert!(validate_username("alice.HL").is_err());
300 assert!(validate_username("a.hlx").is_err());
301 }
302
303 #[test]
304 fn rejects_invalid_chars() {
305 assert!(validate_username("ali ce").is_err()); assert!(validate_username(" alice").is_err());
307 assert!(validate_username("alice ").is_err());
308 assert!(validate_username("ali@ce").is_err()); assert!(validate_username("ali!ce").is_err()); assert!(validate_username("user-name").is_err()); }
312
313 #[test]
314 fn rejects_reserved_and_wallet_like_names() {
315 assert!(validate_username("support").is_err());
316 assert!(validate_username("Hypercall").is_err());
317 assert!(validate_username("0xalice").is_err());
318 }
319
320 #[test]
321 fn rejects_non_alnum_start_or_end() {
322 assert!(validate_username("_alice").is_err());
323 assert!(validate_username("-alice").is_err());
324 assert!(validate_username(".alice").is_err());
325 }
326
327 #[test]
328 fn redis_key_format() {
329 assert_eq!(redis_wallet_key("0xAbC123"), "username:wallet:0xabc123");
330 assert_eq!(redis_reverse_key("Alice"), "username:reverse:alice");
331 }
332}