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