Skip to main content

archivist_core/
store.rs

1//! Redis-backed auth token store.
2//!
3//! Maps Discord user ids → FicHub JWT tokens. Tokens live in the same Redis
4//! instance FicHub uses (`archivist:token:<discord_id>`), so the bot can be
5//! restarted without losing linked accounts. TTL is 30 days by default.
6
7use redis::aio::ConnectionManager;
8use serde::{Deserialize, Serialize};
9
10use crate::config::BotConfig;
11use crate::error::Result;
12
13/// Default TTL for stored tokens (30 days).
14pub const TOKEN_TTL_SECS: u64 = 60 * 60 * 24 * 30;
15
16/// A stored token + the user info it maps to.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct StoredToken {
19    /// The FicHub JWT.
20    pub token: String,
21    /// FicHub user id this token belongs to.
22    pub fichub_user_id: i64,
23    /// Username on FicHub.
24    pub username: String,
25    /// ISO timestamp when the link was created.
26    pub linked_at: String,
27}
28
29/// Redis-backed token store.
30#[derive(Clone)]
31pub struct TokenStore {
32    conn: ConnectionManager,
33}
34
35impl TokenStore {
36    /// Connect to Redis.
37    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    /// Legacy Discord-only key (backward compatible with existing linked
44    /// Discord users; new code should use `key_for`).
45    fn key(discord_id: &str) -> String {
46        format!("archivist:token:{discord_id}")
47    }
48
49    /// Platform-scoped key: `archivist:token:<platform>:<ext_id>`.
50    fn key_for(platform: &str, ext_id: &str) -> String {
51        format!("archivist:token:{platform}:{ext_id}")
52    }
53
54    /// Index key mapping a FicHub user id → set of `platform:ext_id` strings
55    /// (for the linked-accounts page).
56    fn user_index_key(fichub_user_id: i64) -> String {
57        format!("archivist:user_tokens:{fichub_user_id}")
58    }
59
60    /// Store a token for a Discord user.
61    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    /// Fetch the stored token for a Discord user.
74    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    /// Delete a stored token (unlink).
87    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    /// Refresh a token's TTL (called on each successful authed call).
97    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    /// Store a token for a platform-scoped user id (`matrix:@user:server`,
108    /// `telegram:123456`, `slack:U123`, `irc:nick`, `mastodon:user@inst`).
109    /// Also maintains the per-FicHub-user index for the linked-accounts page.
110    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    /// Fetch a platform-scoped token.
133    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    /// Delete a platform-scoped token (unlink). Removes the index entry too.
146    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    /// Refresh a platform-scoped token's TTL.
162    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    /// List all linked platform identities for a FicHub user
173    /// (`platform:ext_id` strings, for the web linked-accounts page).
174    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/// Pending `/link` code: Discord id + creation time.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct PendingLink {
186    /// Platform user id that initiated the link.
187    pub discord_id: u64,
188    /// ISO timestamp when the code was created.
189    pub created_at: String,
190}
191
192/// Store for pending one-time link codes (web-side confirmation flow).
193#[derive(Clone)]
194pub struct PendingLinkStore {
195    conn: ConnectionManager,
196    ttl_secs: u64,
197}
198
199impl PendingLinkStore {
200    /// Connect to Redis (uses `config.redis_url` and `config.link_code_ttl_secs`).
201    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    /// Generate + store a pending link code.
215    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    /// Consume a pending link code (returns the Discord id it belongs to).
232    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        // One-time use: delete regardless of whether it existed.
243        redis::cmd("DEL")
244            .arg(&key)
245            .exec_async(&mut self.conn.clone())
246            .await?;
247        Ok(pending)
248    }
249
250    /// Generate a short alphanumeric code (8 chars, unambiguous alphabet).
251    fn gen_code() -> String {
252        const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I/O/0/1
253        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        // Alphabet excludes ambiguous chars.
272        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        // Legacy Discord key keeps old shape (no platform segment).
304        assert_eq!(TokenStore::key("123"), "archivist:token:123");
305        assert_ne!(TokenStore::key_for("discord", "123"), TokenStore::key("123"));
306    }
307}