1use m4a_product_kit::model::{StoreError, StoreResult, User, UserStore};
9use tesserax_store::rusqlite::{self, params, Connection, OptionalExtension};
10use tesserax_store::{Db, DbConfig};
11
12pub struct SqliteStore {
13 db: Db,
14}
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
18pub enum Role {
19 User,
20 Moderator,
21 Admin,
22}
23
24impl Role {
25 pub fn parse(s: &str) -> Option<Self> {
26 match s {
27 "user" => Some(Self::User),
28 "moderator" => Some(Self::Moderator),
29 "admin" => Some(Self::Admin),
30 _ => None,
31 }
32 }
33 pub fn as_str(self) -> &'static str {
34 match self {
35 Self::User => "user",
36 Self::Moderator => "moderator",
37 Self::Admin => "admin",
38 }
39 }
40}
41
42impl SqliteStore {
43 pub fn roles(&self, user_id: i64) -> StoreResult<(String, Role)> {
45 self.with(|c| {
46 let row: Option<(String, String)> = c
47 .query_row("SELECT kind, role FROM user_roles WHERE user_id = ?1", params![user_id], |r| Ok((r.get(0)?, r.get(1)?)))
48 .map(Some)
49 .or_else(|e| if matches!(e, rusqlite::Error::QueryReturnedNoRows) { Ok(None) } else { Err(e) })
50 .map_err(be)?;
51 Ok(row.map(|(k, r)| (k, Role::parse(&r).unwrap_or(Role::User))).unwrap_or_else(|| ("agent".into(), Role::User)))
52 })
53 }
54 pub fn set_roles(&self, user_id: i64, kind: Option<&str>, role: Option<Role>) -> StoreResult<(String, Role)> {
56 let (k0, r0) = self.roles(user_id)?;
57 let (k, r) = (kind.map(str::to_owned).unwrap_or(k0), role.unwrap_or(r0));
58 self.with(|c| {
59 c.execute("INSERT INTO user_roles (user_id, kind, role) VALUES (?1, ?2, ?3) ON CONFLICT(user_id) DO UPDATE SET kind = ?2, role = ?3", params![user_id, k, r.as_str()]).map_err(be)?;
60 Ok(())
61 })?;
62 Ok((k, r))
63 }
64}
65
66fn be(e: rusqlite::Error) -> StoreError {
67 match &e {
68 rusqlite::Error::SqliteFailure(f, _) if f.code == rusqlite::ErrorCode::ConstraintViolation => StoreError::NickTaken,
69 _ => StoreError::Backend(e.to_string()),
70 }
71}
72
73const SCHEMA: &str = r#"
74-- Account kind (human|agent) and server role (admin|moderator|user); independent axes. A missing
75-- row means kind=agent, role=user. Granted only with the admin token.
76CREATE TABLE IF NOT EXISTS user_roles (
77 user_id INTEGER PRIMARY KEY,
78 kind TEXT NOT NULL DEFAULT 'agent' CHECK (kind IN ('human','agent')),
79 role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin','moderator','user'))
80);
81CREATE TABLE IF NOT EXISTS users (
82 id INTEGER PRIMARY KEY AUTOINCREMENT,
83 nick TEXT NOT NULL,
84 nick_ci TEXT NOT NULL UNIQUE,
85 tier TEXT NOT NULL,
86 secret_hash TEXT,
87 nick_changes INTEGER NOT NULL DEFAULT 0,
88 nick_changed_ms INTEGER NOT NULL DEFAULT 0,
89 created_ms INTEGER NOT NULL
90);
91CREATE TABLE IF NOT EXISTS credentials (
92 cred_ref TEXT PRIMARY KEY,
93 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
94 token_hash TEXT NOT NULL UNIQUE,
95 created_ms INTEGER NOT NULL,
96 access_expires_ms INTEGER,
97 refresh_hash TEXT,
98 refresh_expires_ms INTEGER
99);
100CREATE TABLE IF NOT EXISTS user_keys (
101 key_id TEXT PRIMARY KEY,
102 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
103 public_key TEXT NOT NULL,
104 label TEXT NOT NULL,
105 created_ms INTEGER NOT NULL
106);
107CREATE TABLE IF NOT EXISTS invites (
108 code_hash TEXT PRIMARY KEY,
109 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
110 expires_ms INTEGER NOT NULL
111);
112CREATE TABLE IF NOT EXISTS door_links (
113 source TEXT NOT NULL,
114 subject TEXT NOT NULL,
115 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
116 PRIMARY KEY (source, subject)
117);
118"#;
119
120const UCOLS: &str = "id, nick, tier, nick_changes, nick_changed_ms";
121
122fn user(r: &rusqlite::Row<'_>) -> rusqlite::Result<User> {
123 Ok(User { id: r.get(0)?, nick: r.get(1)?, tier: r.get(2)?, nick_changes: r.get::<_, i64>(3)? as u32, nick_changed_ms: r.get(4)? })
124}
125
126impl SqliteStore {
127 pub fn new(db: Db) -> Result<Self, String> {
129 db.blocking(|c| {
130 c.execute_batch(SCHEMA)?;
131 for (col, ty) in [("access_expires_ms", "INTEGER"), ("refresh_hash", "TEXT"), ("refresh_expires_ms", "INTEGER")] {
133 let have: bool = c.query_row("SELECT COUNT(*) FROM pragma_table_info('credentials') WHERE name = ?1", params![col], |r| r.get::<_, i64>(0)).map(|n| n > 0)?;
134 if !have {
135 c.execute_batch(&format!("ALTER TABLE credentials ADD COLUMN {col} {ty}"))?;
136 }
137 }
138 let have: bool = c.query_row("SELECT COUNT(*) FROM pragma_table_info('invites') WHERE name = 'reserved'", [], |r| r.get::<_, i64>(0)).map(|n| n > 0)?;
140 if !have {
141 c.execute_batch("ALTER TABLE invites ADD COLUMN reserved INTEGER NOT NULL DEFAULT 1")?;
142 }
143 Ok(())
144 })
145 .map_err(|e| e.to_string())?;
146 Ok(Self { db })
147 }
148 pub fn open(cfg: &DbConfig) -> Result<Self, String> {
150 Self::new(Db::open(cfg).map_err(|e| e.to_string())?)
151 }
152 pub fn memory() -> Result<Self, String> {
154 Self::open(&DbConfig::in_memory())
155 }
156 pub fn db(&self) -> Db {
158 self.db.clone()
159 }
160 fn with<T>(&self, f: impl FnOnce(&mut Connection) -> StoreResult<T>) -> StoreResult<T> {
161 match self.db.write_blocking(|c| Ok(f(c))) {
162 Ok(r) => r,
163 Err(e) => Err(be(e)),
164 }
165 }
166}
167
168impl UserStore for SqliteStore {
169 fn user_by_nick(&self, nick: &str) -> StoreResult<Option<User>> {
170 self.with(|c| {
171 c.query_row(&format!("SELECT {UCOLS} FROM users WHERE nick_ci = ?1"), params![nick.to_ascii_lowercase()], user).optional().map_err(be)
172 })
173 }
174 fn insert_user(&self, nick: &str, tier: &str, secret_hash: Option<&str>, now_ms: i64) -> StoreResult<User> {
175 self.with(|c| {
176 c.execute("INSERT INTO users (nick, nick_ci, tier, secret_hash, created_ms) VALUES (?1, ?2, ?3, ?4, ?5)", params![nick, nick.to_ascii_lowercase(), tier, secret_hash, now_ms]).map_err(be)?;
177 let id = c.last_insert_rowid();
178 c.query_row(&format!("SELECT {UCOLS} FROM users WHERE id = ?1"), params![id], user).map_err(be)
179 })
180 }
181 fn secret_hash(&self, user_id: i64) -> StoreResult<Option<String>> {
182 self.with(|c| {
183 Ok(c.query_row("SELECT secret_hash FROM users WHERE id = ?1", params![user_id], |r| r.get::<_, Option<String>>(0)).optional().map_err(be)?.flatten())
184 })
185 }
186 fn update_nick(&self, user_id: i64, new_nick: &str, now_ms: i64) -> StoreResult<User> {
187 self.with(|c| {
188 let n = c.execute("UPDATE users SET nick = ?2, nick_ci = ?3, nick_changes = nick_changes + 1, nick_changed_ms = ?4 WHERE id = ?1", params![user_id, new_nick, new_nick.to_ascii_lowercase(), now_ms]).map_err(be)?;
189 if n == 0 {
190 return Err(StoreError::NotFound);
191 }
192 c.query_row(&format!("SELECT {UCOLS} FROM users WHERE id = ?1"), params![user_id], user).map_err(be)
193 })
194 }
195 fn set_tier(&self, user_id: i64, tier: &str) -> StoreResult<()> {
196 self.with(|c| {
197 match c.execute("UPDATE users SET tier = ?2 WHERE id = ?1", params![user_id, tier]).map_err(be)? {
198 0 => Err(StoreError::NotFound),
199 _ => Ok(()),
200 }
201 })
202 }
203 fn delete_user(&self, user_id: i64) -> StoreResult<()> {
204 self.with(|c| {
205 c.execute("DELETE FROM user_roles WHERE user_id = ?1", params![user_id]).map_err(be)?;
206 c.execute("DELETE FROM users WHERE id = ?1", params![user_id]).map_err(be).map(|_| ())
207 })
208 }
209 fn add_credential(&self, user_id: i64, cred_ref: &str, token_hash: &str, now_ms: i64) -> StoreResult<()> {
210 self.with(|c| {
211 c.execute("INSERT INTO credentials (cred_ref, user_id, token_hash, created_ms) VALUES (?1, ?2, ?3, ?4)", params![cred_ref, user_id, token_hash, now_ms]).map_err(be).map(|_| ())
212 })
213 }
214 fn user_by_token_hash(&self, token_hash: &str) -> StoreResult<Option<(User, String)>> {
215 self.with(|c| {
216 c
217 .query_row(
218 "SELECT u.id, u.nick, u.tier, u.nick_changes, u.nick_changed_ms, c.cred_ref FROM credentials c JOIN users u ON u.id = c.user_id WHERE c.token_hash = ?1 AND (c.access_expires_ms IS NULL OR c.access_expires_ms > CAST(strftime('%s','now') AS INTEGER) * 1000)",
219 params![token_hash],
220 |r| Ok((user(r)?, r.get::<_, String>(5)?)),
221 )
222 .optional()
223 .map_err(be)
224 })
225 }
226 fn delete_credential(&self, cred_ref: &str) -> StoreResult<Option<i64>> {
227 self.with(|c| {
228 let owner: Option<i64> = c.query_row("SELECT user_id FROM credentials WHERE cred_ref = ?1", params![cred_ref], |r| r.get(0)).optional().map_err(be)?;
229 if owner.is_some() {
230 c.execute("DELETE FROM credentials WHERE cred_ref = ?1", params![cred_ref]).map_err(be)?;
231 }
232 Ok(owner)
233 })
234 }
235 fn credentials_of(&self, user_id: i64) -> StoreResult<Vec<String>> {
236 self.with(|c| {
237 let mut st = c.prepare("SELECT cred_ref FROM credentials WHERE user_id = ?1").map_err(be)?;
238 let rows = st.query_map(params![user_id], |r| r.get(0)).map_err(be)?;
239 rows.collect::<Result<_, _>>().map_err(be)
240 })
241 }
242 fn live_credentials(&self) -> StoreResult<Vec<(String, Vec<String>)>> {
243 self.with(|c| {
244 let mut out: Vec<(String, Vec<String>)> = Vec::new();
245 let mut users = c.prepare("SELECT id, nick FROM users ORDER BY id").map_err(be)?;
246 let rows: Vec<(i64, String)> = users.query_map([], |r| Ok((r.get(0)?, r.get(1)?))).map_err(be)?.collect::<Result<_, _>>().map_err(be)?;
247 let mut creds = c.prepare("SELECT cred_ref FROM credentials WHERE user_id = ?1").map_err(be)?;
248 for (id, nick) in rows {
249 let list: Vec<String> = creds.query_map(params![id], |r| r.get(0)).map_err(be)?.collect::<Result<_, _>>().map_err(be)?;
250 out.push((nick, list));
251 }
252 Ok(out)
253 })
254 }
255 fn user_by_door(&self, source: &str, subject: &str) -> StoreResult<Option<User>> {
256 self.with(|c| {
257 c
258 .query_row("SELECT u.id, u.nick, u.tier, u.nick_changes, u.nick_changed_ms FROM door_links d JOIN users u ON u.id = d.user_id WHERE d.source = ?1 AND d.subject = ?2", params![source, subject], user)
259 .optional()
260 .map_err(be)
261 })
262 }
263 fn set_secret_hash(&self, user_id: i64, hash: &str) -> StoreResult<()> {
264 self.with(|c| match c.execute("UPDATE users SET secret_hash = ?2 WHERE id = ?1", params![user_id, hash]).map_err(be)? {
265 0 => Err(StoreError::NotFound),
266 _ => Ok(()),
267 })
268 }
269 fn supports_refresh(&self) -> bool {
270 true
271 }
272 fn set_refresh(&self, cred_ref: &str, refresh_hash: &str, access_expires_ms: i64, refresh_expires_ms: i64) -> StoreResult<()> {
273 self.with(|c| {
274 c.execute("UPDATE credentials SET refresh_hash = ?2, access_expires_ms = ?3, refresh_expires_ms = ?4 WHERE cred_ref = ?1", params![cred_ref, refresh_hash, access_expires_ms, refresh_expires_ms]).map_err(be).map(|_| ())
275 })
276 }
277 fn take_refresh(&self, refresh_hash: &str, now_ms: i64) -> StoreResult<Option<String>> {
278 self.with(|c| {
279 let found: Option<String> = c.query_row("SELECT cred_ref FROM credentials WHERE refresh_hash = ?1 AND refresh_expires_ms > ?2", params![refresh_hash, now_ms], |r| r.get(0)).optional().map_err(be)?;
280 if let Some(cr) = &found {
281 c.execute("UPDATE credentials SET refresh_hash = NULL WHERE cred_ref = ?1", params![cr]).map_err(be)?;
282 }
283 Ok(found)
284 })
285 }
286 fn replace_token(&self, cred_ref: &str, token_hash: &str, access_expires_ms: i64) -> StoreResult<()> {
287 self.with(|c| {
288 c.execute("UPDATE credentials SET token_hash = ?2, access_expires_ms = ?3 WHERE cred_ref = ?1", params![cred_ref, token_hash, access_expires_ms]).map_err(be).map(|_| ())
289 })
290 }
291 fn supports_keys(&self) -> bool {
292 true
293 }
294 fn create_invite(&self, code_hash: &str, user_id: i64, expires_ms: i64) -> StoreResult<()> {
295 self.with(|c| c.execute("INSERT INTO invites (code_hash, user_id, expires_ms) VALUES (?1, ?2, ?3)", params![code_hash, user_id, expires_ms]).map_err(be).map(|_| ()))
296 }
297 fn create_invite_with(&self, code_hash: &str, user_id: i64, expires_ms: i64, reserved: bool) -> StoreResult<()> {
298 self.with(|c| {
299 c.execute("INSERT OR REPLACE INTO invites (code_hash, user_id, expires_ms, reserved) VALUES (?1, ?2, ?3, ?4)", params![code_hash, user_id, expires_ms, reserved as i64]).map_err(be).map(|_| ())
300 })
301 }
302 fn peek_invite(&self, code_hash: &str, now_ms: i64) -> StoreResult<Option<(User, bool)>> {
303 self.with(|c| {
304 c.query_row(
305 "SELECT u.id, u.nick, u.tier, u.nick_changes, u.nick_changed_ms, i.reserved FROM invites i JOIN users u ON u.id = i.user_id WHERE i.code_hash = ?1 AND i.expires_ms > ?2",
306 params![code_hash, now_ms],
307 |r| Ok((user(r)?, r.get::<_, i64>(5)? != 0)),
308 )
309 .optional()
310 .map_err(be)
311 })
312 }
313 fn take_invite(&self, code_hash: &str, now_ms: i64) -> StoreResult<Option<User>> {
314 Ok(self.take_invite_with(code_hash, now_ms)?.map(|(u, _)| u))
315 }
316 fn take_invite_with(&self, code_hash: &str, now_ms: i64) -> StoreResult<Option<(User, bool)>> {
317 self.with(|c| {
318 let found = c
319 .query_row(
320 "SELECT u.id, u.nick, u.tier, u.nick_changes, u.nick_changed_ms, i.reserved FROM invites i JOIN users u ON u.id = i.user_id WHERE i.code_hash = ?1 AND i.expires_ms > ?2",
321 params![code_hash, now_ms],
322 |r| Ok((user(r)?, r.get::<_, i64>(5)? != 0)),
323 )
324 .optional()
325 .map_err(be)?;
326 c.execute("DELETE FROM invites WHERE code_hash = ?1", params![code_hash]).map_err(be)?;
328 Ok(found)
329 })
330 }
331 fn add_key(&self, user_id: i64, key_id: &str, public_key: &str, label: &str, now_ms: i64) -> StoreResult<()> {
332 self.with(|c| c.execute("INSERT INTO user_keys (key_id, user_id, public_key, label, created_ms) VALUES (?1, ?2, ?3, ?4, ?5)", params![key_id, user_id, public_key, label, now_ms]).map_err(be).map(|_| ()))
333 }
334 fn user_by_key(&self, key_id: &str) -> StoreResult<Option<(User, String)>> {
335 self.with(|c| {
336 c.query_row(
337 "SELECT u.id, u.nick, u.tier, u.nick_changes, u.nick_changed_ms, k.public_key FROM user_keys k JOIN users u ON u.id = k.user_id WHERE k.key_id = ?1",
338 params![key_id],
339 |r| Ok((user(r)?, r.get::<_, String>(5)?)),
340 )
341 .optional()
342 .map_err(be)
343 })
344 }
345 fn delete_key(&self, key_id: &str) -> StoreResult<bool> {
346 self.with(|c| c.execute("DELETE FROM user_keys WHERE key_id = ?1", params![key_id]).map_err(be).map(|n| n > 0))
347 }
348 fn link_door(&self, user_id: i64, source: &str, subject: &str) -> StoreResult<()> {
349 self.with(|c| {
350 c.execute("INSERT INTO door_links (source, subject, user_id) VALUES (?1, ?2, ?3)", params![source, subject, user_id]).map_err(be).map(|_| ())
351 })
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 const KEY: &str = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
360 const OTHER: &str = "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100";
361
362 #[test]
363 fn the_product_database_is_encrypted_and_keeps_its_users() {
364 let path = format!("/tmp/m4a-example-store-{}.db", std::process::id());
365 let open = |k: &str| SqliteStore::open(&m4a_product_kit::dbkey::config(&path, k).unwrap());
366 {
367 let s = open(KEY).unwrap();
368 s.insert_user("zoe_nick", "free", Some("hash"), 1).unwrap();
369 }
370 assert_eq!(open(KEY).unwrap().user_by_nick("zoe_nick").unwrap().unwrap().nick, "zoe_nick");
371 assert!(open(OTHER).is_err());
372 let raw = std::fs::read(&path).unwrap();
373 assert!(!raw.starts_with(b"SQLite format 3") && !raw.windows(8).any(|w| w == b"zoe_nick"));
374 for ext in ["", "-wal", "-shm"] {
375 let _ = std::fs::remove_file(format!("{path}{ext}"));
376 }
377 }
378}