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