db.rs
⎇
Raw
1use std::path::Path;
2use std::sync::Arc;
3
4pub use api_types::Mode;
5use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
6use rusqlite::{Connection, OptionalExtension, params};
7
8const SCHEMA_VERSION: i64 = 3;
9
10/// SQL adapter for [`Mode`]. A newtype is needed because both the rusqlite
11/// traits and `Mode` are foreign to this crate.
12///
13/// The stored strings are unchanged ("rw"/"ro"), so old databases still read.
14struct SqlMode(Mode);
15
16impl FromSql for SqlMode {
17 fn column_result(v: ValueRef<'_>) -> FromSqlResult<Self> {
18 let s = v.as_str()?;
19 Mode::from_wire(s)
20 .map(SqlMode)
21 .ok_or_else(|| FromSqlError::Other(format!("unknown mode {s:?}").into()))
22 }
23}
24
25impl ToSql for SqlMode {
26 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
27 Ok(ToSqlOutput::from(self.0.as_str()))
28 }
29}
30
31#[derive(Debug, Clone)]
32pub struct User {
33 pub id: i64,
34 pub name: String,
35 pub is_admin: bool,
36 /// Disabled users cannot sign in and their sessions are rejected.
37 pub active: bool,
38 /// Profile setting: single click opens entries (off = click selects).
39 pub single_click: bool,
40}
41
42#[derive(Debug, Clone)]
43pub struct RootRow {
44 pub id: i64,
45 /// Path relative to the server root; "." means the whole root.
46 pub path: String,
47 pub mode: Mode,
48}
49
50#[derive(Debug, Clone)]
51pub struct ShareRow {
52 pub id: i64,
53 pub token: String,
54 pub creator_id: i64,
55 /// Path of the shared item relative to the server root.
56 pub target: String,
57 pub is_file: bool,
58 pub mode: Mode,
59 pub created_at: String,
60 pub expires_at: Option<String>,
61}
62
63impl ShareRow {
64 pub fn is_expired(&self) -> bool {
65 match &self.expires_at {
66 Some(e) => chrono::DateTime::parse_from_rfc3339(e)
67 .map(|t| chrono::Utc::now() >= t.with_timezone(&chrono::Utc))
68 .unwrap_or(false),
69 None => false,
70 }
71 }
72}
73
74/// Every query can fail, and every caller decides what to do about it.
75///
76/// Earlier versions swallowed read errors and returned a default (an empty
77/// root list, a count of 0). That turned a broken database into a plausible
78/// answer: "you have no folders" instead of an error. One contract now.
79pub type DbResult<T> = Result<T, rusqlite::Error>;
80
81#[derive(Clone)]
82pub struct Db(Arc<tokio::sync::Mutex<Connection>>);
83
84impl std::fmt::Debug for Db {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 f.debug_struct("Db").finish()
87 }
88}
89
90impl Db {
91 pub async fn open(path: &Path) -> anyhow::Result<Self> {
92 if let Some(parent) = path.parent()
93 && !parent.as_os_str().is_empty()
94 {
95 std::fs::create_dir_all(parent)?;
96 }
97 let conn = Connection::open(path)?;
98 conn.pragma_update(None, "journal_mode", "WAL")?;
99 conn.pragma_update(None, "foreign_keys", "ON")?;
100 conn.pragma_update(None, "busy_timeout", "5000")?;
101 Self::migrate(&conn)?;
102 Ok(Self(Arc::new(tokio::sync::Mutex::new(conn))))
103 }
104
105 /// Open a fresh in-memory database (used by tests — no temp file needed).
106 pub async fn open_in_memory() -> anyhow::Result<Self> {
107 let conn = Connection::open_in_memory()?;
108 conn.pragma_update(None, "foreign_keys", "ON")?;
109 conn.pragma_update(None, "busy_timeout", "5000")?;
110 Self::migrate(&conn)?;
111 Ok(Self(Arc::new(tokio::sync::Mutex::new(conn))))
112 }
113
114 fn migrate(conn: &Connection) -> rusqlite::Result<()> {
115 conn.execute(
116 "CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
117 [],
118 )?;
119 let version: i64 = conn
120 .query_row(
121 "SELECT value FROM meta WHERE key = 'schema_version'",
122 [],
123 |r| r.get::<_, String>(0),
124 )
125 .optional()?
126 .and_then(|v| v.parse().ok())
127 .unwrap_or(0);
128
129 if version < 1 {
130 conn.execute_batch(SCHEMA_V1)?;
131 }
132 if version < 2 {
133 // User management (M7): a disabled flag so admins can suspend
134 // accounts without deleting them.
135 conn.execute_batch("ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1")?;
136 }
137 if version < 3 {
138 // Per-user profile settings: click-to-open mode. No migration
139 // from the old behaviour — everyone starts on the new default
140 // (off: single click selects, double click opens).
141 conn.execute_batch(
142 "ALTER TABLE users ADD COLUMN single_click INTEGER NOT NULL DEFAULT 0",
143 )?;
144 }
145 conn.execute(
146 "INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
147 [SCHEMA_VERSION.to_string()],
148 )?;
149 Ok(())
150 }
151
152 // ---------- users ----------
153
154 pub async fn user_count(&self) -> DbResult<i64> {
155 let c = self.0.lock().await;
156 c.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0))
157 }
158
159 /// Create the first admin account with the whole root visible (read-write).
160 /// Only valid while no users exist (enforced by the caller).
161 pub async fn create_admin(&self, name: &str, pass_hash: &str) -> DbResult<User> {
162 let mut c = self.0.lock().await;
163 let tx = c.transaction()?;
164 tx.execute(
165 "INSERT INTO users (name, pass_hash, is_admin, created_at)
166 VALUES (?1, ?2, 1, ?3)",
167 params![name, pass_hash, now()],
168 )?;
169 let user_id = tx.last_insert_rowid();
170 tx.execute(
171 "INSERT INTO user_roots (user_id, path, mode) VALUES (?1, '.', 'rw')",
172 params![user_id],
173 )?;
174 tx.commit()?;
175 Ok(User {
176 id: user_id,
177 name: name.to_string(),
178 is_admin: true,
179 active: true,
180 single_click: false,
181 })
182 }
183
184 pub async fn verify_password(&self, name: &str, password: &str) -> DbResult<Option<User>> {
185 // The guard is scoped to the query alone. Argon2 below is slow by
186 // design; holding the single connection lock across it would make one
187 // login serialize every other database access.
188 let row: Option<(i64, String, bool, String, bool, bool)> = {
189 let c = self.0.lock().await;
190 c.query_row(
191 "SELECT id, name, is_admin != 0, pass_hash, active != 0, single_click != 0
192 FROM users WHERE name = ?1",
193 [name],
194 |r| {
195 Ok((
196 r.get(0)?,
197 r.get(1)?,
198 r.get(2)?,
199 r.get(3)?,
200 r.get(4)?,
201 r.get(5)?,
202 ))
203 },
204 )
205 .optional()?
206 };
207 let Some((id, name, is_admin, hash, active, single_click)) = row else {
208 return Ok(None);
209 };
210 if !active {
211 return Ok(None);
212 }
213 // Argon2 is CPU-bound, so it must not run on an async worker thread.
214 let password = password.to_string();
215 let ok =
216 tokio::task::spawn_blocking(move || crate::auth::verify_password(&password, &hash))
217 .await
218 .unwrap_or(false);
219 Ok(ok.then_some(User {
220 id,
221 name,
222 is_admin,
223 active,
224 single_click,
225 }))
226 }
227
228 // ---------- sessions ----------
229
230 pub async fn create_session(&self, user_id: i64, token: &str) -> DbResult<()> {
231 let c = self.0.lock().await;
232 c.execute(
233 "INSERT INTO sessions (token, user_id, created_at, last_seen_at)
234 VALUES (?1, ?2, ?3, ?4)",
235 params![token, user_id, now(), now()],
236 )?;
237 Ok(())
238 }
239
240 pub async fn delete_session(&self, token: &str) -> DbResult<()> {
241 let c = self.0.lock().await;
242 c.execute("DELETE FROM sessions WHERE token = ?1", [token])?;
243 Ok(())
244 }
245
246 pub async fn session_user(&self, token: &str) -> DbResult<Option<User>> {
247 let c = self.0.lock().await;
248 c.query_row(
249 "SELECT u.id, u.name, u.is_admin != 0, u.active != 0, u.single_click != 0
250 FROM sessions s JOIN users u ON u.id = s.user_id
251 WHERE s.token = ?1 AND u.active = 1",
252 [token],
253 map_user,
254 )
255 .optional()
256 }
257
258 // ---------- roots ----------
259
260 pub async fn user_roots(&self, user_id: i64) -> DbResult<Vec<RootRow>> {
261 let c = self.0.lock().await;
262 let mut stmt =
263 c.prepare("SELECT id, path, mode FROM user_roots WHERE user_id = ?1 ORDER BY id")?;
264 let rows = stmt.query_map([user_id], |r| {
265 Ok(RootRow {
266 id: r.get(0)?,
267 path: r.get(1)?,
268 mode: r.get::<_, SqlMode>(2)?.0,
269 })
270 })?;
271 rows.collect()
272 }
273
274 // ---------- admin: user management (M7) ----------
275
276 pub async fn all_users(&self) -> DbResult<Vec<User>> {
277 let c = self.0.lock().await;
278 let mut stmt = c.prepare(
279 "SELECT id, name, is_admin != 0, active != 0, single_click != 0 FROM users ORDER BY id",
280 )?;
281 let rows = stmt.query_map([], map_user)?;
282 rows.collect()
283 }
284
285 pub async fn find_user_by_id(&self, id: i64) -> DbResult<Option<User>> {
286 let c = self.0.lock().await;
287 c.query_row(
288 "SELECT id, name, is_admin != 0, active != 0, single_click != 0 FROM users WHERE id = ?1",
289 [id],
290 map_user,
291 )
292 .optional()
293 }
294
295 pub async fn find_user_by_name(&self, name: &str) -> DbResult<Option<User>> {
296 let c = self.0.lock().await;
297 c.query_row(
298 "SELECT id, name, is_admin != 0, active != 0, single_click != 0 FROM users WHERE name = ?1",
299 [name],
300 map_user,
301 )
302 .optional()
303 }
304
305 pub async fn count_admins(&self) -> DbResult<i64> {
306 let c = self.0.lock().await;
307 c.query_row(
308 "SELECT COUNT(*) FROM users WHERE is_admin = 1 AND active = 1",
309 [],
310 |r| r.get(0),
311 )
312 }
313
314 /// Create a user with the given roots (path, mode) pairs.
315 pub async fn create_user(
316 &self,
317 name: &str,
318 pass_hash: &str,
319 is_admin: bool,
320 roots: &[(String, Mode)],
321 ) -> DbResult<User> {
322 let mut c = self.0.lock().await;
323 let tx = c.transaction()?;
324 tx.execute(
325 "INSERT INTO users (name, pass_hash, is_admin, active, created_at)
326 VALUES (?1, ?2, ?3, 1, ?4)",
327 params![name, pass_hash, is_admin as i64, now()],
328 )?;
329 let user_id = tx.last_insert_rowid();
330 for (path, mode) in roots {
331 tx.execute(
332 "INSERT INTO user_roots (user_id, path, mode) VALUES (?1, ?2, ?3)",
333 params![user_id, path, SqlMode(*mode)],
334 )?;
335 }
336 tx.commit()?;
337 Ok(User {
338 id: user_id,
339 name: name.to_string(),
340 is_admin,
341 active: true,
342 single_click: false,
343 })
344 }
345
346 pub async fn update_user_password(&self, id: i64, pass_hash: &str) -> DbResult<()> {
347 let c = self.0.lock().await;
348 c.execute(
349 "UPDATE users SET pass_hash = ?1 WHERE id = ?2",
350 params![pass_hash, id],
351 )?;
352 Ok(())
353 }
354
355 pub async fn set_user_admin(&self, id: i64, is_admin: bool) -> DbResult<()> {
356 let c = self.0.lock().await;
357 c.execute(
358 "UPDATE users SET is_admin = ?1 WHERE id = ?2",
359 params![is_admin as i64, id],
360 )?;
361 Ok(())
362 }
363
364 pub async fn set_user_active(&self, id: i64, active: bool) -> DbResult<()> {
365 let c = self.0.lock().await;
366 c.execute(
367 "UPDATE users SET active = ?1 WHERE id = ?2",
368 params![active as i64, id],
369 )?;
370 Ok(())
371 }
372
373 pub async fn set_user_single_click(&self, id: i64, single_click: bool) -> DbResult<()> {
374 let c = self.0.lock().await;
375 c.execute(
376 "UPDATE users SET single_click = ?1 WHERE id = ?2",
377 params![single_click as i64, id],
378 )?;
379 Ok(())
380 }
381
382 /// Delete a user. `false` means no row matched.
383 pub async fn delete_user(&self, id: i64) -> DbResult<bool> {
384 let c = self.0.lock().await;
385 Ok(c.execute("DELETE FROM users WHERE id = ?1", [id])? > 0)
386 }
387
388 /// Replace a user's roots with the given (path, mode) pairs.
389 pub async fn set_user_roots(&self, user_id: i64, roots: &[(String, Mode)]) -> DbResult<()> {
390 let mut c = self.0.lock().await;
391 let tx = c.transaction()?;
392 tx.execute("DELETE FROM user_roots WHERE user_id = ?1", [user_id])?;
393 for (path, mode) in roots {
394 tx.execute(
395 "INSERT INTO user_roots (user_id, path, mode) VALUES (?1, ?2, ?3)",
396 params![user_id, path, SqlMode(*mode)],
397 )?;
398 }
399 tx.commit()?;
400 Ok(())
401 }
402
403 // ---------- shares ----------
404
405 pub async fn create_share(
406 &self,
407 creator_id: i64,
408 token: &str,
409 target: &str,
410 is_file: bool,
411 mode: Mode,
412 expires_at: Option<&str>,
413 ) -> DbResult<ShareRow> {
414 let c = self.0.lock().await;
415 c.execute(
416 "INSERT INTO shares (token, creator_id, target, is_file, mode, created_at, expires_at)
417 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
418 params![
419 token,
420 creator_id,
421 target,
422 is_file as i64,
423 SqlMode(mode),
424 now(),
425 expires_at
426 ],
427 )?;
428 let id = c.last_insert_rowid();
429 Ok(ShareRow {
430 id,
431 token: token.to_string(),
432 creator_id,
433 target: target.to_string(),
434 is_file,
435 mode,
436 created_at: now(),
437 expires_at: expires_at.map(|s| s.to_string()),
438 })
439 }
440
441 pub async fn share_by_token(&self, token: &str) -> DbResult<Option<ShareRow>> {
442 let c = self.0.lock().await;
443 let sql = "SELECT id, token, creator_id, target, is_file, mode, created_at, expires_at
444 FROM shares WHERE token = ?1";
445 c.query_row(sql, [token], map_share).optional()
446 }
447
448 pub async fn user_shares(&self, creator_id: i64) -> DbResult<Vec<ShareRow>> {
449 let c = self.0.lock().await;
450 let sql = "SELECT id, token, creator_id, target, is_file, mode, created_at, expires_at
451 FROM shares WHERE creator_id = ?1 ORDER BY id DESC";
452 let mut stmt = c.prepare(sql)?;
453 let rows = stmt.query_map([creator_id], map_share)?;
454 rows.collect()
455 }
456
457 /// Delete one of `creator_id`'s shares. `false` means no row matched.
458 pub async fn delete_share(&self, id: i64, creator_id: i64) -> DbResult<bool> {
459 let c = self.0.lock().await;
460 let n = c.execute(
461 "DELETE FROM shares WHERE id = ?1 AND creator_id = ?2",
462 params![id, creator_id],
463 )?;
464 Ok(n > 0)
465 }
466
467 // ---------- settings ----------
468
469 pub async fn get_setting(&self, key: &str) -> DbResult<Option<String>> {
470 let c = self.0.lock().await;
471 c.query_row("SELECT value FROM settings WHERE key = ?1", [key], |r| {
472 r.get(0)
473 })
474 .optional()
475 }
476
477 pub async fn set_setting(&self, key: &str, value: &str) -> DbResult<()> {
478 let c = self.0.lock().await;
479 c.execute(
480 "INSERT INTO settings (key, value) VALUES (?1, ?2)
481 ON CONFLICT(key) DO UPDATE SET value = ?2",
482 params![key, value],
483 )?;
484 Ok(())
485 }
486
487 /// Whether users may create writable (read-write) shares. Off by default;
488 /// the admin setting gates it.
489 pub async fn allow_writable_shares(&self) -> DbResult<bool> {
490 Ok(self.get_setting("allow_writable_shares").await?.as_deref() == Some("1"))
491 }
492
493 pub async fn set_allow_writable_shares(&self, v: bool) -> DbResult<()> {
494 self.set_setting("allow_writable_shares", if v { "1" } else { "0" })
495 .await
496 }
497}
498
499/// Column order matched by the four `users` SELECTs above.
500fn map_user(r: &rusqlite::Row) -> DbResult<User> {
501 Ok(User {
502 id: r.get(0)?,
503 name: r.get(1)?,
504 is_admin: r.get(2)?,
505 active: r.get(3)?,
506 single_click: r.get(4)?,
507 })
508}
509
510/// Column order matched by the two `shares` SELECTs above.
511fn map_share(r: &rusqlite::Row) -> DbResult<ShareRow> {
512 Ok(ShareRow {
513 id: r.get(0)?,
514 token: r.get(1)?,
515 creator_id: r.get(2)?,
516 target: r.get(3)?,
517 is_file: r.get::<_, i64>(4)? != 0,
518 mode: r.get::<_, SqlMode>(5)?.0,
519 created_at: r.get(6)?,
520 expires_at: r.get(7)?,
521 })
522}
523
524fn now() -> String {
525 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
526}
527
528const SCHEMA_V1: &str = r#"
529CREATE TABLE IF NOT EXISTS users (
530 id INTEGER PRIMARY KEY AUTOINCREMENT,
531 name TEXT NOT NULL UNIQUE COLLATE NOCASE,
532 pass_hash TEXT NOT NULL,
533 is_admin INTEGER NOT NULL DEFAULT 0,
534 created_at TEXT NOT NULL
535);
536
537CREATE TABLE IF NOT EXISTS user_roots (
538 id INTEGER PRIMARY KEY AUTOINCREMENT,
539 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
540 path TEXT NOT NULL,
541 mode TEXT NOT NULL CHECK (mode IN ('rw','ro')),
542 UNIQUE (user_id, path)
543);
544
545CREATE TABLE IF NOT EXISTS shares (
546 id INTEGER PRIMARY KEY AUTOINCREMENT,
547 token TEXT NOT NULL UNIQUE,
548 creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
549 target TEXT NOT NULL,
550 is_file INTEGER NOT NULL,
551 mode TEXT NOT NULL CHECK (mode IN ('rw','ro')),
552 created_at TEXT NOT NULL,
553 expires_at TEXT
554);
555
556CREATE TABLE IF NOT EXISTS sessions (
557 token TEXT PRIMARY KEY,
558 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
559 created_at TEXT NOT NULL,
560 last_seen_at TEXT NOT NULL
561);
562
563CREATE TABLE IF NOT EXISTS settings (
564 key TEXT PRIMARY KEY,
565 value TEXT NOT NULL
566);
567INSERT OR IGNORE INTO settings (key, value) VALUES ('allow_writable_shares', '0');
568"#;
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573
574 // Most tests use an in-memory DB (the file-based path is still covered
575 // by `v1_db_migrates_to_v2` and the integration harness' `Db::open`).
576 async fn mem() -> Db {
577 Db::open_in_memory().await.unwrap()
578 }
579
580 async fn db_with_admin() -> (Db, User) {
581 let db = mem().await;
582 let hash = crate::auth::hash_password("admin1234").unwrap();
583 let admin = db.create_admin("admin", &hash).await.unwrap();
584 (db, admin)
585 }
586
587 #[tokio::test]
588 async fn fresh_db_state() {
589 let db = mem().await;
590 assert_eq!(db.user_count().await.unwrap(), 0);
591 assert_eq!(db.count_admins().await.unwrap(), 0);
592 assert!(!db.allow_writable_shares().await.unwrap());
593 assert!(db.find_user_by_name("nobody").await.unwrap().is_none());
594 assert!(db.find_user_by_id(1).await.unwrap().is_none());
595 assert!(db.all_users().await.unwrap().is_empty());
596 }
597
598 #[tokio::test]
599 async fn v1_db_migrates_to_v2() {
600 let dir = tempfile::tempdir().unwrap();
601 let path = dir.path().join("legacy.sqlite");
602 {
603 let conn = rusqlite::Connection::open(&path).unwrap();
604 conn.execute_batch(SCHEMA_V1).unwrap();
605 conn.execute(
606 "INSERT INTO users (name, pass_hash, is_admin, created_at)
607 VALUES ('legacy', 'hash', 1, '2024-01-01T00:00:00Z')",
608 [],
609 )
610 .unwrap();
611 conn.execute(
612 "INSERT INTO user_roots (user_id, path, mode) VALUES (1, 'docs', 'rw')",
613 [],
614 )
615 .unwrap();
616 }
617 let db = Db::open(&path).await.unwrap();
618 assert_eq!(db.user_count().await.unwrap(), 1);
619 let u = db.find_user_by_name("legacy").await.unwrap().unwrap();
620 assert!(u.active, "v2 migration must default active to true");
621 assert!(u.is_admin);
622 assert_eq!(db.user_roots(u.id).await.unwrap().len(), 1);
623 // Migrations are idempotent.
624 let db2 = Db::open(&path).await.unwrap();
625 assert_eq!(db2.user_count().await.unwrap(), 1);
626 assert!(
627 db2.find_user_by_name("legacy")
628 .await
629 .unwrap()
630 .unwrap()
631 .active
632 );
633 }
634
635 #[tokio::test]
636 async fn admin_user_and_passwords() {
637 let (db, admin) = db_with_admin().await;
638 assert!(admin.is_admin);
639 assert!(admin.active);
640 // Root "." rw is assigned by create_admin.
641 let roots = db.user_roots(admin.id).await.unwrap();
642 assert_eq!(roots.len(), 1);
643 assert_eq!(roots[0].path, ".");
644 assert_eq!(roots[0].mode, Mode::Rw);
645
646 assert!(
647 db.verify_password("admin", "admin1234")
648 .await
649 .unwrap()
650 .is_some()
651 );
652 assert!(db.verify_password("admin", "nope").await.unwrap().is_none());
653 // Name lookup is case-insensitive (COLLATE NOCASE).
654 assert!(
655 db.verify_password("ADMIN", "admin1234")
656 .await
657 .unwrap()
658 .is_some()
659 );
660 // Disabled users cannot verify.
661 db.set_user_active(admin.id, false).await.unwrap();
662 assert!(
663 db.verify_password("admin", "admin1234")
664 .await
665 .unwrap()
666 .is_none()
667 );
668 db.set_user_active(admin.id, true).await.unwrap();
669 assert!(
670 db.verify_password("admin", "admin1234")
671 .await
672 .unwrap()
673 .is_some()
674 );
675 }
676
677 #[tokio::test]
678 async fn sessions_lifecycle() {
679 let (db, admin) = db_with_admin().await;
680 assert!(db.session_user("ghost-token").await.unwrap().is_none());
681 db.create_session(admin.id, "tok1").await.unwrap();
682 let u = db.session_user("tok1").await.unwrap().unwrap();
683 assert_eq!(u.id, admin.id);
684 // Disabling the user invalidates existing sessions.
685 db.set_user_active(admin.id, false).await.unwrap();
686 assert!(db.session_user("tok1").await.unwrap().is_none());
687 db.set_user_active(admin.id, true).await.unwrap();
688 assert!(db.session_user("tok1").await.unwrap().is_some());
689 db.delete_session("tok1").await.unwrap();
690 assert!(db.session_user("tok1").await.unwrap().is_none());
691 }
692
693 #[tokio::test]
694 async fn user_crud_and_roots() {
695 let (db, _admin) = db_with_admin().await;
696 let h = crate::auth::hash_password("bobpass1").unwrap();
697 let bob = db
698 .create_user("bob", &h, false, &[("docs".into(), Mode::Rw)])
699 .await
700 .unwrap();
701 assert!(!bob.is_admin);
702 assert!(bob.active);
703
704 // Duplicate name (case-insensitive) is rejected.
705 let h2 = crate::auth::hash_password("carolpass1").unwrap();
706 assert!(db.create_user("BOB", &h2, false, &[]).await.is_err());
707 assert!(db.create_user("carol", &h2, false, &[]).await.is_ok());
708
709 // Lookup helpers.
710 assert_eq!(
711 db.find_user_by_name("Bob").await.unwrap().unwrap().id,
712 bob.id
713 );
714 assert_eq!(
715 db.find_user_by_id(bob.id).await.unwrap().unwrap().name,
716 "bob"
717 );
718 assert!(db.find_user_by_name("dave").await.unwrap().is_none());
719 assert_eq!(db.all_users().await.unwrap().len(), 3);
720
721 // Root replacement semantics.
722 let roots = db.user_roots(bob.id).await.unwrap();
723 assert_eq!(roots.len(), 1);
724 db.set_user_roots(bob.id, &[(".".into(), Mode::Ro), ("docs".into(), Mode::Rw)])
725 .await
726 .unwrap();
727 let roots = db.user_roots(bob.id).await.unwrap();
728 assert_eq!(roots.len(), 2);
729 assert!(roots.iter().any(|r| r.path == "." && r.mode == Mode::Ro));
730 db.set_user_roots(bob.id, &[]).await.unwrap();
731 assert!(db.user_roots(bob.id).await.unwrap().is_empty());
732
733 // Password update.
734 let new_h = crate::auth::hash_password("bobpass2").unwrap();
735 db.update_user_password(bob.id, &new_h).await.unwrap();
736 assert!(
737 db.verify_password("bob", "bobpass1")
738 .await
739 .unwrap()
740 .is_none()
741 );
742 assert!(
743 db.verify_password("bob", "bobpass2")
744 .await
745 .unwrap()
746 .is_some()
747 );
748
749 // Admin flag + count (only active admins count).
750 db.set_user_admin(bob.id, true).await.unwrap();
751 assert_eq!(db.count_admins().await.unwrap(), 2);
752 db.set_user_active(bob.id, false).await.unwrap();
753 assert_eq!(db.count_admins().await.unwrap(), 1);
754 db.set_user_admin(bob.id, false).await.unwrap();
755
756 // Deletion.
757 assert!(db.delete_user(bob.id).await.unwrap());
758 assert!(db.find_user_by_id(bob.id).await.unwrap().is_none());
759 assert!(!db.delete_user(bob.id).await.unwrap());
760 assert_eq!(db.user_count().await.unwrap(), 2);
761 }
762
763 fn share_row(expires_at: Option<&str>) -> ShareRow {
764 ShareRow {
765 id: 1,
766 token: "t".into(),
767 creator_id: 1,
768 target: "docs".into(),
769 is_file: false,
770 mode: Mode::Ro,
771 created_at: "2024-01-01T00:00:00Z".into(),
772 expires_at: expires_at.map(str::to_string),
773 }
774 }
775
776 #[test]
777 fn share_expiry_logic() {
778 assert!(!share_row(None).is_expired());
779 assert!(!share_row(Some("2999-01-01T00:00:00Z")).is_expired());
780 assert!(share_row(Some("2000-01-01T00:00:00Z")).is_expired());
781 // Unparseable expiry → treated as not expired (fail open for reads).
782 assert!(!share_row(Some("not-a-date")).is_expired());
783 }
784
785 #[tokio::test]
786 async fn shares_crud() {
787 let (db, admin) = db_with_admin().await;
788 let s1 = db
789 .create_share(admin.id, "tok-a", "docs", false, Mode::Ro, None)
790 .await
791 .unwrap();
792 let s2 = db
793 .create_share(
794 admin.id,
795 "tok-b",
796 "file.txt",
797 true,
798 Mode::Rw,
799 Some("2999-01-01T00:00:00Z"),
800 )
801 .await
802 .unwrap();
803 assert!(s2.id > s1.id);
804
805 let found = db.share_by_token("tok-b").await.unwrap().unwrap();
806 assert!(found.is_file);
807 assert_eq!(found.mode, Mode::Rw);
808 assert!(db.share_by_token("nope").await.unwrap().is_none());
809
810 // Listed newest-first.
811 let list = db.user_shares(admin.id).await.unwrap();
812 assert_eq!(list.len(), 2);
813 assert_eq!(list[0].id, s2.id);
814 // Other users see nothing.
815 let h = crate::auth::hash_password("bobpass1").unwrap();
816 let bob = db.create_user("bob", &h, false, &[]).await.unwrap();
817 assert!(db.user_shares(bob.id).await.unwrap().is_empty());
818
819 // Only the creator can delete.
820 assert!(!db.delete_share(s1.id, bob.id).await.unwrap());
821 assert!(db.delete_share(s1.id, admin.id).await.unwrap());
822 assert!(db.share_by_token("tok-a").await.unwrap().is_none());
823 assert!(!db.delete_share(s1.id, admin.id).await.unwrap());
824 }
825
826 #[tokio::test]
827 async fn settings_round_trip() {
828 let (db, _admin) = db_with_admin().await;
829 assert!(!db.allow_writable_shares().await.unwrap());
830 db.set_allow_writable_shares(true).await.unwrap();
831 assert!(db.allow_writable_shares().await.unwrap());
832 // Upsert semantics.
833 db.set_allow_writable_shares(false).await.unwrap();
834 assert!(!db.allow_writable_shares().await.unwrap());
835 // Generic get/set.
836 db.set_setting("custom", "v").await.unwrap();
837 assert_eq!(
838 db.get_setting("custom").await.unwrap().as_deref(),
839 Some("v")
840 );
841 assert_eq!(db.get_setting("missing").await.unwrap(), None);
842 }
843}
844