1use redis::aio::ConnectionManager;
8use serde::{Deserialize, Serialize};
9
10use crate::config::BotConfig;
11use crate::error::Result;
12
13pub const TOKEN_TTL_SECS: u64 = 60 * 60 * 24 * 30;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct StoredToken {
19 pub token: String,
21 pub fichub_user_id: i64,
23 pub username: String,
25 pub linked_at: String,
27}
28
29#[derive(Clone)]
31pub struct TokenStore {
32 conn: ConnectionManager,
33}
34
35impl TokenStore {
36 pub async fn connect(config: &BotConfig) -> Result<Self> {
38 let client = redis::Client::open(config.redis_url.as_str())?;
39 let conn = ConnectionManager::new(client).await?;
40 Ok(Self { conn })
41 }
42
43 fn key(discord_id: &str) -> String {
46 format!("archivist:token:{discord_id}")
47 }
48
49 fn key_for(platform: &str, ext_id: &str) -> String {
51 format!("archivist:token:{platform}:{ext_id}")
52 }
53
54 fn user_index_key(fichub_user_id: i64) -> String {
57 format!("archivist:user_tokens:{fichub_user_id}")
58 }
59
60 pub async fn set(&self, discord_id: u64, stored: &StoredToken) -> Result<()> {
62 let key = Self::key(&discord_id.to_string());
63 let data = serde_json::to_vec(stored)?;
64 redis::cmd("SETEX")
65 .arg(&key)
66 .arg(TOKEN_TTL_SECS)
67 .arg(data)
68 .exec_async(&mut self.conn.clone())
69 .await?;
70 Ok(())
71 }
72
73 pub async fn get(&self, discord_id: &str) -> Result<Option<StoredToken>> {
75 let key = Self::key(discord_id);
76 let raw: Option<Vec<u8>> = redis::cmd("GET")
77 .arg(&key)
78 .query_async(&mut self.conn.clone())
79 .await?;
80 match raw {
81 Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
82 None => Ok(None),
83 }
84 }
85
86 pub async fn delete(&self, discord_id: u64) -> Result<()> {
88 let key = Self::key(&discord_id.to_string());
89 redis::cmd("DEL")
90 .arg(&key)
91 .exec_async(&mut self.conn.clone())
92 .await?;
93 Ok(())
94 }
95
96 pub async fn touch(&self, discord_id: &str) -> Result<()> {
98 let key = Self::key(discord_id);
99 redis::cmd("EXPIRE")
100 .arg(&key)
101 .arg(TOKEN_TTL_SECS)
102 .exec_async(&mut self.conn.clone())
103 .await?;
104 Ok(())
105 }
106
107 pub async fn set_for(
111 &self,
112 platform: &str,
113 ext_id: &str,
114 stored: &StoredToken,
115 ) -> Result<()> {
116 let key = Self::key_for(platform, ext_id);
117 let data = serde_json::to_vec(stored)?;
118 redis::cmd("SETEX")
119 .arg(&key)
120 .arg(TOKEN_TTL_SECS)
121 .arg(data)
122 .exec_async(&mut self.conn.clone())
123 .await?;
124 redis::cmd("SADD")
125 .arg(Self::user_index_key(stored.fichub_user_id))
126 .arg(format!("{platform}:{ext_id}"))
127 .exec_async(&mut self.conn.clone())
128 .await?;
129 Ok(())
130 }
131
132 pub async fn get_for(&self, platform: &str, ext_id: &str) -> Result<Option<StoredToken>> {
134 let key = Self::key_for(platform, ext_id);
135 let raw: Option<Vec<u8>> = redis::cmd("GET")
136 .arg(&key)
137 .query_async(&mut self.conn.clone())
138 .await?;
139 match raw {
140 Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
141 None => Ok(None),
142 }
143 }
144
145 pub async fn delete_for(&self, platform: &str, ext_id: &str) -> Result<()> {
147 if let Some(stored) = self.get_for(platform, ext_id).await? {
148 redis::cmd("SREM")
149 .arg(Self::user_index_key(stored.fichub_user_id))
150 .arg(format!("{platform}:{ext_id}"))
151 .exec_async(&mut self.conn.clone())
152 .await?;
153 }
154 redis::cmd("DEL")
155 .arg(Self::key_for(platform, ext_id))
156 .exec_async(&mut self.conn.clone())
157 .await?;
158 Ok(())
159 }
160
161 pub async fn touch_for(&self, platform: &str, ext_id: &str) -> Result<()> {
163 let key = Self::key_for(platform, ext_id);
164 redis::cmd("EXPIRE")
165 .arg(&key)
166 .arg(TOKEN_TTL_SECS)
167 .exec_async(&mut self.conn.clone())
168 .await?;
169 Ok(())
170 }
171
172 pub async fn list_for_user(&self, fichub_user_id: i64) -> Result<Vec<String>> {
175 let members: Vec<String> = redis::cmd("SMEMBERS")
176 .arg(Self::user_index_key(fichub_user_id))
177 .query_async(&mut self.conn.clone())
178 .await?;
179 Ok(members)
180 }
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct PendingLink {
186 pub discord_id: u64,
188 pub created_at: String,
190}
191
192#[derive(Clone)]
194pub struct PendingLinkStore {
195 conn: ConnectionManager,
196 ttl_secs: u64,
197}
198
199impl PendingLinkStore {
200 pub async fn connect(config: &BotConfig) -> Result<Self> {
202 let client = redis::Client::open(config.redis_url.as_str())?;
203 let conn = ConnectionManager::new(client).await?;
204 Ok(Self {
205 conn,
206 ttl_secs: config.link_code_ttl_secs,
207 })
208 }
209
210 fn key(code: &str) -> String {
211 format!("archivist:link:{code}")
212 }
213
214 pub async fn create(&self, discord_id: u64) -> Result<String> {
216 let code = Self::gen_code();
217 let pending = PendingLink {
218 discord_id,
219 created_at: chrono::Utc::now().to_rfc3339(),
220 };
221 let data = serde_json::to_vec(&pending)?;
222 redis::cmd("SETEX")
223 .arg(Self::key(&code))
224 .arg(self.ttl_secs)
225 .arg(data)
226 .exec_async(&mut self.conn.clone())
227 .await?;
228 Ok(code)
229 }
230
231 pub async fn consume(&self, code: &str) -> Result<Option<PendingLink>> {
233 let key = Self::key(code);
234 let raw: Option<Vec<u8>> = redis::cmd("GET")
235 .arg(&key)
236 .query_async(&mut self.conn.clone())
237 .await?;
238 let pending: Option<PendingLink> = match raw {
239 Some(bytes) => Some(serde_json::from_slice(&bytes)?),
240 None => None,
241 };
242 redis::cmd("DEL")
244 .arg(&key)
245 .exec_async(&mut self.conn.clone())
246 .await?;
247 Ok(pending)
248 }
249
250 fn gen_code() -> String {
252 const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; let mut out = String::with_capacity(8);
254 for _ in 0..8 {
255 let idx = (uuid::Uuid::new_v4().as_u128() % ALPHABET.len() as u128) as usize;
256 out.push(ALPHABET[idx] as char);
257 }
258 out
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn link_code_format() {
268 let code = PendingLinkStore::gen_code();
269 assert_eq!(code.len(), 8);
270 assert!(code.chars().all(|c| c.is_ascii_alphanumeric()));
271 assert!(!code.contains('I'));
273 assert!(!code.contains('O'));
274 assert!(!code.contains('0'));
275 assert!(!code.contains('1'));
276 }
277
278 #[test]
279 fn stored_token_roundtrip() {
280 let st = StoredToken {
281 token: "abc".into(),
282 fichub_user_id: 42,
283 username: "tester".into(),
284 linked_at: "now".into(),
285 };
286 let bytes = serde_json::to_vec(&st).unwrap();
287 let back: StoredToken = serde_json::from_slice(&bytes).unwrap();
288 assert_eq!(back.token, "abc");
289 assert_eq!(back.fichub_user_id, 42);
290 assert_eq!(back.username, "tester");
291 }
292
293 #[test]
294 fn platform_keys_are_namespaced() {
295 assert_eq!(
296 TokenStore::key_for("matrix", "@u:server"),
297 "archivist:token:matrix:@u:server"
298 );
299 assert_eq!(
300 TokenStore::key_for("telegram", "123"),
301 "archivist:token:telegram:123"
302 );
303 assert_eq!(TokenStore::key("123"), "archivist:token:123");
305 assert_ne!(TokenStore::key_for("discord", "123"), TokenStore::key("123"));
306 }
307}