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 = 9;
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 /// Profile setting: show thumbnails in the grid.
41 pub thumbnails: bool,
42 /// Preferred UI language tag ("en", "de", "fr"); None = follow the
43 /// browser.
44 pub language: Option<String>,
45 /// Profile setting: the root the UI opens by default. May point at a
46 /// root the user no longer has; the API filters that out.
47 pub default_root_id: Option<i64>,
48}
49
50#[derive(Debug, Clone)]
51pub struct RootRow {
52 pub id: i64,
53 /// Path relative to the server root; "." means the whole root.
54 pub path: String,
55 pub mode: Mode,
56}
57
58#[derive(Debug, Clone)]
59pub struct ShareRow {
60 pub id: i64,
61 pub token: String,
62 pub creator_id: i64,
63 /// Path of the shared item relative to the server root.
64 pub target: String,
65 pub is_file: bool,
66 pub mode: Mode,
67 pub created_at: String,
68 pub expires_at: Option<String>,
69 /// Argon2 hash of the share's password, when it has one. Resolve,
70 /// listing and download all stay locked until the visitor enters it and
71 /// gets an unlock cookie.
72 pub password_hash: Option<String>,
73}
74
75impl ShareRow {
76 pub fn is_expired(&self) -> bool {
77 match &self.expires_at {
78 Some(e) => chrono::DateTime::parse_from_rfc3339(e)
79 .map(|t| chrono::Utc::now() >= t.with_timezone(&chrono::Utc))
80 .unwrap_or(false),
81 None => false,
82 }
83 }
84}
85
86/// A [`ShareRow`] together with the account that created it.
87#[derive(Debug, Clone)]
88pub struct ShareWithCreator {
89 pub share: ShareRow,
90 pub creator_name: String,
91 /// Whether that account can still sign in. Deactivating an account leaves
92 /// its shares live.
93 pub creator_active: bool,
94}
95
96/// Every query can fail, and every caller decides what to do about it.
97///
98/// Earlier versions swallowed read errors and returned a default (an empty
99/// root list, a count of 0). That turned a broken database into a plausible
100/// answer: "you have no folders" instead of an error. One contract now.
101pub type DbResult<T> = Result<T, rusqlite::Error>;
102
103#[derive(Clone)]
104pub struct Db(Arc<tokio::sync::Mutex<Connection>>);
105
106impl std::fmt::Debug for Db {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 f.debug_struct("Db").finish()
109 }
110}
111
112impl Db {
113 pub async fn open(path: &Path) -> anyhow::Result<Self> {
114 if let Some(parent) = path.parent()
115 && !parent.as_os_str().is_empty()
116 {
117 std::fs::create_dir_all(parent)?;
118 }
119 let conn = Connection::open(path)?;
120 conn.pragma_update(None, "journal_mode", "WAL")?;
121 // WAL plus NORMAL: fsync only at checkpoints. A crash can lose the
122 // last commits, never the database file.
123 conn.pragma_update(None, "synchronous", "NORMAL")?;
124 conn.pragma_update(None, "foreign_keys", "ON")?;
125 conn.pragma_update(None, "busy_timeout", "5000")?;
126 Self::migrate(&conn)?;
127 Ok(Self(Arc::new(tokio::sync::Mutex::new(conn))))
128 }
129
130 /// Open a fresh in-memory database (used by tests — no temp file needed).
131 pub async fn open_in_memory() -> anyhow::Result<Self> {
132 let conn = Connection::open_in_memory()?;
133 conn.pragma_update(None, "foreign_keys", "ON")?;
134 conn.pragma_update(None, "busy_timeout", "5000")?;
135 Self::migrate(&conn)?;
136 Ok(Self(Arc::new(tokio::sync::Mutex::new(conn))))
137 }
138
139 fn migrate(conn: &Connection) -> rusqlite::Result<()> {
140 conn.execute(
141 "CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
142 [],
143 )?;
144 let version: i64 = conn
145 .query_row(
146 "SELECT value FROM meta WHERE key = 'schema_version'",
147 [],
148 |r| r.get::<_, String>(0),
149 )
150 .optional()?
151 .and_then(|v| v.parse().ok())
152 .unwrap_or(0);
153
154 if version < 1 {
155 conn.execute_batch(SCHEMA_V1)?;
156 }
157 if version < 2 {
158 // User management (M7): a disabled flag so admins can suspend
159 // accounts without deleting them.
160 conn.execute_batch("ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1")?;
161 }
162 if version < 3 {
163 // Per-user profile settings: click-to-open mode. No migration
164 // from the old behaviour — everyone starts on the new default
165 // (off: single click selects, double click opens).
166 conn.execute_batch(
167 "ALTER TABLE users ADD COLUMN single_click INTEGER NOT NULL DEFAULT 0",
168 )?;
169 }
170 if version < 4 {
171 // Per-user UI language preference; NULL means "follow the
172 // browser".
173 conn.execute_batch("ALTER TABLE users ADD COLUMN language TEXT")?;
174 }
175 if version < 5 {
176 // The share list is queried by creator on every shares page.
177 conn.execute_batch(
178 "CREATE INDEX IF NOT EXISTS idx_shares_creator ON shares(creator_id)",
179 )?;
180 }
181 if version < 6 {
182 // Unlocks cascade with their share, which cascades with its
183 // creator's account.
184 conn.execute_batch(
185 "ALTER TABLE shares ADD COLUMN password_hash TEXT;
186 CREATE TABLE IF NOT EXISTS share_unlocks (
187 token TEXT PRIMARY KEY,
188 share_id INTEGER NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
189 created_at TEXT NOT NULL
190 );",
191 )?;
192 }
193 if version < 7 {
194 // `delete_share` cascades into share_unlocks, which is a full
195 // scan of that table without this.
196 conn.execute_batch(
197 "CREATE INDEX IF NOT EXISTS idx_share_unlocks_share
198 ON share_unlocks(share_id)",
199 )?;
200 }
201 if version < 8 {
202 // On by default, so `--cache` is the only step needed to get
203 // thumbnails.
204 conn.execute_batch(
205 "ALTER TABLE users ADD COLUMN thumbnails INTEGER NOT NULL DEFAULT 1",
206 )?;
207 }
208 if version < 9 {
209 // Per-user default root. No foreign key on purpose: removing a
210 // root must not fail because of this column.
211 conn.execute_batch("ALTER TABLE users ADD COLUMN default_root_id INTEGER")?;
212 }
213 conn.execute(
214 "INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
215 [SCHEMA_VERSION.to_string()],
216 )?;
217 Ok(())
218 }
219
220 // ---------- users ----------
221
222 pub async fn user_count(&self) -> DbResult<i64> {
223 let c = self.0.lock().await;
224 let mut stmt = c.prepare_cached("SELECT COUNT(*) FROM users")?;
225 stmt.query_row([], |r| r.get(0))
226 }
227
228 /// Create the first admin account with the whole root visible (read-write).
229 ///
230 /// `None` means a user already existed. The `WHERE NOT EXISTS` guard runs
231 /// inside the same transaction as the insert, so two concurrent first-boot
232 /// setups cannot both win; a caller's earlier `user_count` check is only
233 /// an optimization, not the guarantee.
234 pub async fn create_admin(&self, name: &str, pass_hash: &str) -> DbResult<Option<User>> {
235 let mut c = self.0.lock().await;
236 let tx = c.transaction()?;
237 let inserted = tx.execute(
238 "INSERT INTO users (name, pass_hash, is_admin, created_at)
239 SELECT ?1, ?2, 1, ?3 WHERE NOT EXISTS (SELECT 1 FROM users)",
240 params![name, pass_hash, now()],
241 )?;
242 if inserted == 0 {
243 return Ok(None); // dropping `tx` rolls back
244 }
245 let user_id = tx.last_insert_rowid();
246 tx.execute(
247 "INSERT INTO user_roots (user_id, path, mode) VALUES (?1, '.', 'rw')",
248 params![user_id],
249 )?;
250 tx.commit()?;
251 Ok(Some(User {
252 id: user_id,
253 name: name.to_string(),
254 is_admin: true,
255 active: true,
256 single_click: false,
257 thumbnails: true,
258 language: None,
259 default_root_id: None,
260 }))
261 }
262
263 pub async fn verify_password(&self, name: &str, password: &str) -> DbResult<Option<User>> {
264 // The guard is scoped to the query alone. Argon2 below is slow by
265 // design; holding the single connection lock across it would make one
266 // login serialize every other database access.
267 type UserRow = (
268 i64,
269 String,
270 bool,
271 String,
272 bool,
273 bool,
274 bool,
275 Option<String>,
276 Option<i64>,
277 );
278 let row: Option<UserRow> = {
279 let c = self.0.lock().await;
280 c.query_row(
281 "SELECT id, name, is_admin != 0, pass_hash, active != 0, single_click != 0,
282 thumbnails != 0, language, default_root_id
283 FROM users WHERE name = ?1",
284 [name],
285 |r| {
286 Ok((
287 r.get(0)?,
288 r.get(1)?,
289 r.get(2)?,
290 r.get(3)?,
291 r.get(4)?,
292 r.get(5)?,
293 r.get(6)?,
294 r.get(7)?,
295 r.get(8)?,
296 ))
297 },
298 )
299 .optional()?
300 };
301 // An unknown or disabled name still pays for one Argon2 verify, so the
302 // response time does not reveal which names exist.
303 let (row, hash) = match row {
304 Some((
305 id,
306 name,
307 is_admin,
308 hash,
309 active,
310 single_click,
311 thumbnails,
312 language,
313 default_root_id,
314 )) if active => (
315 Some((
316 id,
317 name,
318 is_admin,
319 single_click,
320 thumbnails,
321 language,
322 default_root_id,
323 )),
324 hash,
325 ),
326 _ => (None, DUMMY_HASH.clone()),
327 };
328 let ok = crate::auth::verify_password_async(password, &hash).await;
329 let Some((id, name, is_admin, single_click, thumbnails, language, default_root_id)) = row
330 else {
331 return Ok(None);
332 };
333 Ok(ok.then_some(User {
334 id,
335 name,
336 is_admin,
337 active: true,
338 single_click,
339 thumbnails,
340 language,
341 default_root_id,
342 }))
343 }
344
345 // ---------- sessions ----------
346
347 pub async fn create_session(&self, user_id: i64, token: &str) -> DbResult<()> {
348 let c = self.0.lock().await;
349 c.execute(
350 "INSERT INTO sessions (token, user_id, created_at, last_seen_at)
351 VALUES (?1, ?2, ?3, ?4)",
352 params![token, user_id, now(), now()],
353 )?;
354 Ok(())
355 }
356
357 pub async fn delete_session(&self, token: &str) -> DbResult<()> {
358 let c = self.0.lock().await;
359 c.execute("DELETE FROM sessions WHERE token = ?1", [token])?;
360 Ok(())
361 }
362
363 /// The session's user plus that user's roots, in one round trip. Every
364 /// authenticated request needs both, so they are not two queries.
365 pub async fn session_user_with_roots(
366 &self,
367 token: &str,
368 ) -> DbResult<Option<(User, Vec<RootRow>)>> {
369 let c = self.0.lock().await;
370 let mut stmt = c.prepare_cached(
371 "SELECT u.id, u.name, u.is_admin != 0, u.active != 0, u.single_click != 0,
372 u.thumbnails != 0, u.language, u.default_root_id,
373 r.id, r.path, r.mode
374 FROM sessions s
375 JOIN users u ON u.id = s.user_id
376 LEFT JOIN user_roots r ON r.user_id = u.id
377 WHERE s.token = ?1 AND u.active = 1
378 ORDER BY r.id",
379 )?;
380 // One row per root; a user without roots still returns one row, with
381 // the root columns NULL.
382 let mut user: Option<User> = None;
383 let mut roots: Vec<RootRow> = Vec::new();
384 let mut rows = stmt.query([token])?;
385 while let Some(r) = rows.next()? {
386 if user.is_none() {
387 user = Some(map_user(r)?);
388 }
389 if let Some(id) = r.get::<_, Option<i64>>(8)? {
390 roots.push(RootRow {
391 id,
392 path: r.get(9)?,
393 mode: r.get::<_, SqlMode>(10)?.0,
394 });
395 }
396 }
397 Ok(user.map(|u| (u, roots)))
398 }
399
400 // ---------- roots ----------
401
402 pub async fn user_roots(&self, user_id: i64) -> DbResult<Vec<RootRow>> {
403 let c = self.0.lock().await;
404 let mut stmt = c.prepare_cached(
405 "SELECT id, path, mode FROM user_roots WHERE user_id = ?1 ORDER BY id",
406 )?;
407 let rows = stmt.query_map([user_id], |r| {
408 Ok(RootRow {
409 id: r.get(0)?,
410 path: r.get(1)?,
411 mode: r.get::<_, SqlMode>(2)?.0,
412 })
413 })?;
414 rows.collect()
415 }
416
417 // ---------- admin: user management (M7) ----------
418
419 /// Every user with their roots, in one query. The admin user list needs
420 /// both, and a per-user roots query would be one round trip per user.
421 pub async fn all_users_with_roots(&self) -> DbResult<Vec<(User, Vec<RootRow>)>> {
422 let c = self.0.lock().await;
423 let mut stmt = c.prepare_cached(
424 "SELECT u.id, u.name, u.is_admin != 0, u.active != 0, u.single_click != 0,
425 u.thumbnails != 0, u.language, u.default_root_id,
426 r.id, r.path, r.mode
427 FROM users u
428 LEFT JOIN user_roots r ON r.user_id = u.id
429 ORDER BY u.id, r.id",
430 )?;
431 // Rows arrive grouped by user, so a new user id starts a new group.
432 let mut out: Vec<(User, Vec<RootRow>)> = Vec::new();
433 let mut rows = stmt.query([])?;
434 while let Some(r) = rows.next()? {
435 let uid: i64 = r.get(0)?;
436 if out.last().is_none_or(|(u, _)| u.id != uid) {
437 out.push((map_user(r)?, Vec::new()));
438 }
439 if let Some(id) = r.get::<_, Option<i64>>(8)? {
440 out.last_mut().expect("pushed above").1.push(RootRow {
441 id,
442 path: r.get(9)?,
443 mode: r.get::<_, SqlMode>(10)?.0,
444 });
445 }
446 }
447 Ok(out)
448 }
449
450 pub async fn find_user_by_id(&self, id: i64) -> DbResult<Option<User>> {
451 let c = self.0.lock().await;
452 c.query_row(
453 "SELECT id, name, is_admin != 0, active != 0, single_click != 0, thumbnails != 0,
454 language, default_root_id
455 FROM users WHERE id = ?1",
456 [id],
457 map_user,
458 )
459 .optional()
460 }
461
462 pub async fn find_user_by_name(&self, name: &str) -> DbResult<Option<User>> {
463 let c = self.0.lock().await;
464 c.query_row(
465 "SELECT id, name, is_admin != 0, active != 0, single_click != 0, thumbnails != 0,
466 language, default_root_id
467 FROM users WHERE name = ?1",
468 [name],
469 map_user,
470 )
471 .optional()
472 }
473
474 pub async fn count_admins(&self) -> DbResult<i64> {
475 let c = self.0.lock().await;
476 c.query_row(
477 "SELECT COUNT(*) FROM users WHERE is_admin = 1 AND active = 1",
478 [],
479 |r| r.get(0),
480 )
481 }
482
483 /// Create a user with the given roots (path, mode) pairs.
484 pub async fn create_user(
485 &self,
486 name: &str,
487 pass_hash: &str,
488 is_admin: bool,
489 roots: &[(String, Mode)],
490 ) -> DbResult<User> {
491 let mut c = self.0.lock().await;
492 let tx = c.transaction()?;
493 tx.execute(
494 "INSERT INTO users (name, pass_hash, is_admin, active, created_at)
495 VALUES (?1, ?2, ?3, 1, ?4)",
496 params![name, pass_hash, is_admin as i64, now()],
497 )?;
498 let user_id = tx.last_insert_rowid();
499 for (path, mode) in roots {
500 tx.execute(
501 "INSERT INTO user_roots (user_id, path, mode) VALUES (?1, ?2, ?3)",
502 params![user_id, path, SqlMode(*mode)],
503 )?;
504 }
505 tx.commit()?;
506 Ok(User {
507 id: user_id,
508 name: name.to_string(),
509 is_admin,
510 active: true,
511 single_click: false,
512 thumbnails: true,
513 language: None,
514 default_root_id: None,
515 })
516 }
517
518 pub async fn set_user_single_click(&self, id: i64, single_click: bool) -> DbResult<()> {
519 let c = self.0.lock().await;
520 c.execute(
521 "UPDATE users SET single_click = ?1 WHERE id = ?2",
522 params![single_click as i64, id],
523 )?;
524 Ok(())
525 }
526
527 pub async fn set_user_thumbnails(&self, id: i64, thumbnails: bool) -> DbResult<()> {
528 let c = self.0.lock().await;
529 c.execute(
530 "UPDATE users SET thumbnails = ?1 WHERE id = ?2",
531 params![thumbnails as i64, id],
532 )?;
533 Ok(())
534 }
535
536 pub async fn set_user_default_root(&self, id: i64, root_id: Option<i64>) -> DbResult<()> {
537 let c = self.0.lock().await;
538 c.execute(
539 "UPDATE users SET default_root_id = ?1 WHERE id = ?2",
540 params![root_id, id],
541 )?;
542 Ok(())
543 }
544
545 pub async fn set_user_language(&self, id: i64, language: Option<&str>) -> DbResult<()> {
546 let c = self.0.lock().await;
547 c.execute(
548 "UPDATE users SET language = ?1 WHERE id = ?2",
549 params![language, id],
550 )?;
551 Ok(())
552 }
553
554 /// Apply an admin edit atomically: every `Some` field is written in one
555 /// transaction, so a failure midway leaves the user unchanged.
556 pub async fn update_user(
557 &self,
558 id: i64,
559 pass_hash: Option<&str>,
560 is_admin: Option<bool>,
561 active: Option<bool>,
562 roots: Option<&[(String, Mode)]>,
563 ) -> DbResult<()> {
564 let mut c = self.0.lock().await;
565 let tx = c.transaction()?;
566 if let Some(h) = pass_hash {
567 set_password(&tx, id, h)?;
568 }
569 if let Some(a) = is_admin {
570 tx.execute(
571 "UPDATE users SET is_admin = ?1 WHERE id = ?2",
572 params![a as i64, id],
573 )?;
574 }
575 if let Some(a) = active {
576 tx.execute(
577 "UPDATE users SET active = ?1 WHERE id = ?2",
578 params![a as i64, id],
579 )?;
580 }
581 if let Some(roots) = roots {
582 tx.execute("DELETE FROM user_roots WHERE user_id = ?1", [id])?;
583 for (path, mode) in roots {
584 tx.execute(
585 "INSERT INTO user_roots (user_id, path, mode) VALUES (?1, ?2, ?3)",
586 params![id, path, SqlMode(*mode)],
587 )?;
588 }
589 }
590 tx.commit()
591 }
592
593 /// Delete a user. `false` means no row matched.
594 pub async fn delete_user(&self, id: i64) -> DbResult<bool> {
595 let c = self.0.lock().await;
596 Ok(c.execute("DELETE FROM users WHERE id = ?1", [id])? > 0)
597 }
598
599 // ---------- shares ----------
600
601 #[allow(clippy::too_many_arguments)] // one row's columns, all required
602 pub async fn create_share(
603 &self,
604 creator_id: i64,
605 token: &str,
606 target: &str,
607 is_file: bool,
608 mode: Mode,
609 expires_at: Option<&str>,
610 password_hash: Option<&str>,
611 ) -> DbResult<ShareRow> {
612 let c = self.0.lock().await;
613 c.execute(
614 "INSERT INTO shares
615 (token, creator_id, target, is_file, mode, created_at, expires_at, password_hash)
616 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
617 params![
618 token,
619 creator_id,
620 target,
621 is_file as i64,
622 SqlMode(mode),
623 now(),
624 expires_at,
625 password_hash
626 ],
627 )?;
628 let id = c.last_insert_rowid();
629 Ok(ShareRow {
630 id,
631 token: token.to_string(),
632 creator_id,
633 target: target.to_string(),
634 is_file,
635 mode,
636 created_at: now(),
637 expires_at: expires_at.map(|s| s.to_string()),
638 password_hash: password_hash.map(|s| s.to_string()),
639 })
640 }
641
642 /// Record that a visitor entered `share_id`'s password, and return the
643 /// token that proves it (the value of their unlock cookie).
644 pub async fn create_share_unlock(&self, share_id: i64) -> DbResult<String> {
645 let token = crate::auth::random_token();
646 let c = self.0.lock().await;
647 // Old unlocks go first. The cookie carrying them is a session
648 // cookie, so it is already gone from every browser; without this the
649 // rows would accumulate forever, one per unlock.
650 c.execute(
651 "DELETE FROM share_unlocks WHERE created_at < ?1",
652 [expiry_cutoff()],
653 )?;
654 c.execute(
655 "INSERT INTO share_unlocks (token, share_id, created_at) VALUES (?1, ?2, ?3)",
656 params![token, share_id, now()],
657 )?;
658 Ok(token)
659 }
660
661 /// Whether `token` is a live unlock for `share_id`.
662 ///
663 /// The share id is part of the lookup, so an unlock for one share cannot
664 /// open another.
665 pub async fn share_unlock_valid(&self, token: &str, share_id: i64) -> DbResult<bool> {
666 let c = self.0.lock().await;
667 let mut stmt =
668 c.prepare_cached("SELECT 1 FROM share_unlocks WHERE token = ?1 AND share_id = ?2")?;
669 Ok(stmt
670 .query_row(params![token, share_id], |_| Ok(()))
671 .optional()?
672 .is_some())
673 }
674
675 pub async fn share_by_token(&self, token: &str) -> DbResult<Option<ShareRow>> {
676 let c = self.0.lock().await;
677 let sql = "SELECT id, token, creator_id, target, is_file, mode, created_at, expires_at,
678 password_hash
679 FROM shares WHERE token = ?1";
680 let mut stmt = c.prepare_cached(sql)?;
681 stmt.query_row([token], map_share).optional()
682 }
683
684 pub async fn user_shares(&self, creator_id: i64) -> DbResult<Vec<ShareRow>> {
685 let c = self.0.lock().await;
686 let sql = "SELECT id, token, creator_id, target, is_file, mode, created_at, expires_at,
687 password_hash
688 FROM shares WHERE creator_id = ?1 ORDER BY id DESC";
689 let mut stmt = c.prepare_cached(sql)?;
690 let rows = stmt.query_map([creator_id], map_share)?;
691 rows.collect()
692 }
693
694 /// Revoke every share on `target` or on anything beneath it. Returns how
695 /// many were dropped.
696 ///
697 /// Called when a path stops meaning what it meant: the item was deleted,
698 /// renamed, or moved away. A share names a path, and a path is not a
699 /// stable identity, so leaving the row behind would let a *new* item that
700 /// later takes the freed path inherit the old link's audience.
701 ///
702 /// `substr` rather than `LIKE`: a target containing `%` or `_` would make
703 /// a `LIKE` pattern over-match and revoke unrelated shares.
704 pub async fn revoke_shares_at(&self, target: &str) -> DbResult<usize> {
705 let c = self.0.lock().await;
706 c.execute(
707 "DELETE FROM shares
708 WHERE target = ?1 OR substr(target, 1, length(?1) + 1) = ?1 || '/'",
709 [target],
710 )
711 }
712
713 /// Delete one of `creator_id`'s shares. `false` means no row matched.
714 pub async fn delete_share(&self, id: i64, creator_id: i64) -> DbResult<bool> {
715 let c = self.0.lock().await;
716 let n = c.execute(
717 "DELETE FROM shares WHERE id = ?1 AND creator_id = ?2",
718 params![id, creator_id],
719 )?;
720 Ok(n > 0)
721 }
722
723 /// Every share on the server with its creator. Grouped by account name,
724 /// newest link within an account first.
725 ///
726 /// The join cannot miss: `shares.creator_id` cascades on delete, so a share
727 /// never outlives the account that made it.
728 pub async fn all_shares_with_creators(&self) -> DbResult<Vec<ShareWithCreator>> {
729 let c = self.0.lock().await;
730 // Columns 0..8 are `map_share`'s order, unchanged from `user_shares`.
731 let sql = "SELECT s.id, s.token, s.creator_id, s.target, s.is_file, s.mode,
732 s.created_at, s.expires_at, s.password_hash,
733 u.name, u.active != 0
734 FROM shares s
735 JOIN users u ON u.id = s.creator_id
736 ORDER BY u.name COLLATE NOCASE, s.id DESC";
737 let mut stmt = c.prepare_cached(sql)?;
738 let rows = stmt.query_map([], |r| {
739 Ok(ShareWithCreator {
740 share: map_share(r)?,
741 creator_name: r.get(9)?,
742 creator_active: r.get(10)?,
743 })
744 })?;
745 rows.collect()
746 }
747
748 /// Revoke a share whoever created it. The owner-scoped
749 /// [`Self::delete_share`] is what the user-facing API uses.
750 pub async fn admin_delete_share(&self, id: i64) -> DbResult<bool> {
751 let c = self.0.lock().await;
752 Ok(c.execute("DELETE FROM shares WHERE id = ?1", [id])? > 0)
753 }
754
755 // ---------- settings ----------
756
757 /// Folders excluded from search, as paths relative to the server root.
758 ///
759 /// Stored as one JSON array in a settings row. A table of its own would
760 /// be overkill for a hand-edited list read once per search.
761 pub async fn search_excludes(&self) -> DbResult<Vec<String>> {
762 let raw = self.get_setting("search_excludes").await?;
763 // Normalised on read as well as on write. A value edited straight
764 // into the database would otherwise never match: `is_excluded`
765 // compares against paths with no slash at either end.
766 let clean = |v: Vec<String>| -> Vec<String> {
767 v.into_iter()
768 .map(|p| p.trim().replace('\\', "/").trim_matches('/').to_string())
769 .filter(|p| !p.is_empty() && p != ".")
770 .collect()
771 };
772 // A hand-edited, unparseable value falls back to no exclusions,
773 // the same as an absent row.
774 Ok(raw
775 .as_deref()
776 .and_then(|v| serde_json::from_str::<Vec<String>>(v).ok())
777 .map(clean)
778 .unwrap_or_default())
779 }
780
781 pub async fn set_search_excludes(&self, paths: &[String]) -> DbResult<()> {
782 let json = serde_json::to_string(paths).unwrap_or_else(|_| "[]".to_string());
783 self.set_setting("search_excludes", &json).await
784 }
785
786 pub async fn get_setting(&self, key: &str) -> DbResult<Option<String>> {
787 let c = self.0.lock().await;
788 let mut stmt = c.prepare_cached("SELECT value FROM settings WHERE key = ?1")?;
789 stmt.query_row([key], |r| r.get(0)).optional()
790 }
791
792 pub async fn set_setting(&self, key: &str, value: &str) -> DbResult<()> {
793 let c = self.0.lock().await;
794 c.execute(
795 "INSERT INTO settings (key, value) VALUES (?1, ?2)
796 ON CONFLICT(key) DO UPDATE SET value = ?2",
797 params![key, value],
798 )?;
799 Ok(())
800 }
801
802 /// Whether users may create writable (read-write) shares. Off by default;
803 /// the admin setting gates it.
804 pub async fn allow_writable_shares(&self) -> DbResult<bool> {
805 Ok(self.get_setting("allow_writable_shares").await?.as_deref() == Some("1"))
806 }
807
808 pub async fn set_allow_writable_shares(&self, v: bool) -> DbResult<()> {
809 self.set_setting("allow_writable_shares", if v { "1" } else { "0" })
810 .await
811 }
812}
813
814/// Write a new password hash and drop every session that was opened with the
815/// old one.
816///
817/// The two belong together: a password is changed because the old one is
818/// suspect (an admin resetting a compromised account), and a session that
819/// survives the reset leaves whoever holds it signed in. Takes the
820/// transaction so the caller can bundle it with its other edits.
821fn set_password(tx: &rusqlite::Transaction<'_>, id: i64, pass_hash: &str) -> DbResult<()> {
822 tx.execute(
823 "UPDATE users SET pass_hash = ?1 WHERE id = ?2",
824 params![pass_hash, id],
825 )?;
826 tx.execute("DELETE FROM sessions WHERE user_id = ?1", [id])?;
827 Ok(())
828}
829
830/// Column order matched by the four `users` SELECTs above.
831fn map_user(r: &rusqlite::Row) -> DbResult<User> {
832 Ok(User {
833 id: r.get(0)?,
834 name: r.get(1)?,
835 is_admin: r.get(2)?,
836 active: r.get(3)?,
837 single_click: r.get(4)?,
838 thumbnails: r.get(5)?,
839 language: r.get(6)?,
840 default_root_id: r.get(7)?,
841 })
842}
843
844/// Column order matched by the two `shares` SELECTs above.
845fn map_share(r: &rusqlite::Row) -> DbResult<ShareRow> {
846 Ok(ShareRow {
847 id: r.get(0)?,
848 token: r.get(1)?,
849 creator_id: r.get(2)?,
850 target: r.get(3)?,
851 is_file: r.get::<_, i64>(4)? != 0,
852 mode: r.get::<_, SqlMode>(5)?.0,
853 created_at: r.get(6)?,
854 expires_at: r.get(7)?,
855 password_hash: r.get(8)?,
856 })
857}
858
859/// A hash of a random string nobody knows. Verified against when the login
860/// name does not exist, so both paths cost one Argon2 run.
861static DUMMY_HASH: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
862 crate::auth::hash_password(&crate::auth::random_token()).expect("argon2 hash")
863});
864
865/// How long an unlock row outlives its cookie. The cookie dies with the
866/// browser, so this only bounds the rows left behind by closed sessions.
867const UNLOCK_MAX_AGE_DAYS: i64 = 7;
868
869/// The timestamp an unlock row must be newer than to survive a cleanup.
870fn expiry_cutoff() -> String {
871 (chrono::Utc::now() - chrono::Duration::days(UNLOCK_MAX_AGE_DAYS))
872 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
873}
874
875fn now() -> String {
876 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
877}
878
879const SCHEMA_V1: &str = r#"
880CREATE TABLE IF NOT EXISTS users (
881 id INTEGER PRIMARY KEY AUTOINCREMENT,
882 name TEXT NOT NULL UNIQUE COLLATE NOCASE,
883 pass_hash TEXT NOT NULL,
884 is_admin INTEGER NOT NULL DEFAULT 0,
885 created_at TEXT NOT NULL
886);
887
888CREATE TABLE IF NOT EXISTS user_roots (
889 id INTEGER PRIMARY KEY AUTOINCREMENT,
890 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
891 path TEXT NOT NULL,
892 mode TEXT NOT NULL CHECK (mode IN ('rw','ro')),
893 UNIQUE (user_id, path)
894);
895
896CREATE TABLE IF NOT EXISTS shares (
897 id INTEGER PRIMARY KEY AUTOINCREMENT,
898 token TEXT NOT NULL UNIQUE,
899 creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
900 target TEXT NOT NULL,
901 is_file INTEGER NOT NULL,
902 mode TEXT NOT NULL CHECK (mode IN ('rw','ro')),
903 created_at TEXT NOT NULL,
904 expires_at TEXT
905);
906
907CREATE TABLE IF NOT EXISTS sessions (
908 token TEXT PRIMARY KEY,
909 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
910 created_at TEXT NOT NULL,
911 last_seen_at TEXT NOT NULL
912);
913
914CREATE TABLE IF NOT EXISTS settings (
915 key TEXT PRIMARY KEY,
916 value TEXT NOT NULL
917);
918INSERT OR IGNORE INTO settings (key, value) VALUES ('allow_writable_shares', '0');
919"#;
920
921#[cfg(test)]
922mod tests {
923 use super::*;
924
925 // Most tests use an in-memory DB (the file-based path is still covered
926 // by `v1_db_migrates_to_v2` and the integration harness' `Db::open`).
927 async fn mem() -> Db {
928 Db::open_in_memory().await.unwrap()
929 }
930
931 /// `update_user` is the only way production edits these fields, so the
932 /// tests exercise that path rather than per-field helpers.
933 async fn edit(db: &Db, id: i64, pass: Option<&str>, admin: Option<bool>, active: Option<bool>) {
934 db.update_user(id, pass, admin, active, None).await.unwrap();
935 }
936
937 async fn db_with_admin() -> (Db, User) {
938 let db = mem().await;
939 let hash = crate::auth::hash_password("admin1234").unwrap();
940 let admin = db.create_admin("admin", &hash).await.unwrap().unwrap();
941 (db, admin)
942 }
943
944 #[tokio::test]
945 async fn fresh_db_state() {
946 let db = mem().await;
947 assert_eq!(db.user_count().await.unwrap(), 0);
948 assert_eq!(db.count_admins().await.unwrap(), 0);
949 assert!(!db.allow_writable_shares().await.unwrap());
950 assert!(db.find_user_by_name("nobody").await.unwrap().is_none());
951 assert!(db.find_user_by_id(1).await.unwrap().is_none());
952 assert!(db.all_users_with_roots().await.unwrap().is_empty());
953 }
954
955 #[tokio::test]
956 async fn v1_db_migrates_to_v2() {
957 let dir = tempfile::tempdir().unwrap();
958 let path = dir.path().join("legacy.sqlite");
959 {
960 let conn = rusqlite::Connection::open(&path).unwrap();
961 conn.execute_batch(SCHEMA_V1).unwrap();
962 conn.execute(
963 "INSERT INTO users (name, pass_hash, is_admin, created_at)
964 VALUES ('legacy', 'hash', 1, '2024-01-01T00:00:00Z')",
965 [],
966 )
967 .unwrap();
968 conn.execute(
969 "INSERT INTO user_roots (user_id, path, mode) VALUES (1, 'docs', 'rw')",
970 [],
971 )
972 .unwrap();
973 }
974 let db = Db::open(&path).await.unwrap();
975 assert_eq!(db.user_count().await.unwrap(), 1);
976 let u = db.find_user_by_name("legacy").await.unwrap().unwrap();
977 assert!(u.active, "v2 migration must default active to true");
978 assert!(u.is_admin);
979 assert_eq!(db.user_roots(u.id).await.unwrap().len(), 1);
980 // Migrations are idempotent.
981 let db2 = Db::open(&path).await.unwrap();
982 assert_eq!(db2.user_count().await.unwrap(), 1);
983 assert!(
984 db2.find_user_by_name("legacy")
985 .await
986 .unwrap()
987 .unwrap()
988 .active
989 );
990 }
991
992 #[tokio::test]
993 async fn admin_user_and_passwords() {
994 let (db, admin) = db_with_admin().await;
995 assert!(admin.is_admin);
996 assert!(admin.active);
997 // Root "." rw is assigned by create_admin.
998 let roots = db.user_roots(admin.id).await.unwrap();
999 assert_eq!(roots.len(), 1);
1000 assert_eq!(roots[0].path, ".");
1001 assert_eq!(roots[0].mode, Mode::Rw);
1002
1003 assert!(
1004 db.verify_password("admin", "admin1234")
1005 .await
1006 .unwrap()
1007 .is_some()
1008 );
1009 assert!(db.verify_password("admin", "nope").await.unwrap().is_none());
1010 // Name lookup is case-insensitive (COLLATE NOCASE).
1011 assert!(
1012 db.verify_password("ADMIN", "admin1234")
1013 .await
1014 .unwrap()
1015 .is_some()
1016 );
1017 // Disabled users cannot verify.
1018 edit(&db, admin.id, None, None, Some(false)).await;
1019 assert!(
1020 db.verify_password("admin", "admin1234")
1021 .await
1022 .unwrap()
1023 .is_none()
1024 );
1025 edit(&db, admin.id, None, None, Some(true)).await;
1026 assert!(
1027 db.verify_password("admin", "admin1234")
1028 .await
1029 .unwrap()
1030 .is_some()
1031 );
1032 }
1033
1034 #[tokio::test]
1035 async fn setup_is_won_by_exactly_one_caller() {
1036 let db = mem().await;
1037 let hash = crate::auth::hash_password("admin1234").unwrap();
1038 assert!(db.create_admin("first", &hash).await.unwrap().is_some());
1039 // The guard lives in the insert, so a different name loses too.
1040 assert!(db.create_admin("second", &hash).await.unwrap().is_none());
1041 assert_eq!(db.user_count().await.unwrap(), 1);
1042 // The loser rolled back cleanly: no orphaned root row.
1043 let first = db.find_user_by_name("first").await.unwrap().unwrap();
1044 assert_eq!(db.user_roots(first.id).await.unwrap().len(), 1);
1045 }
1046
1047 #[tokio::test]
1048 async fn changing_a_password_drops_that_users_sessions() {
1049 let (db, admin) = db_with_admin().await;
1050 let h = crate::auth::hash_password("bobpass1").unwrap();
1051 let bob = db.create_user("bob", &h, false, &[]).await.unwrap();
1052 db.create_session(admin.id, "admin-tok").await.unwrap();
1053 db.create_session(bob.id, "bob-tok-1").await.unwrap();
1054 db.create_session(bob.id, "bob-tok-2").await.unwrap();
1055
1056 let new_h = crate::auth::hash_password("bobpass2").unwrap();
1057 edit(&db, bob.id, Some(&new_h), None, None).await;
1058 assert!(
1059 db.session_user_with_roots("bob-tok-1")
1060 .await
1061 .unwrap()
1062 .is_none()
1063 );
1064 assert!(
1065 db.session_user_with_roots("bob-tok-2")
1066 .await
1067 .unwrap()
1068 .is_none()
1069 );
1070 // Only the reset user is signed out.
1071 assert!(
1072 db.session_user_with_roots("admin-tok")
1073 .await
1074 .unwrap()
1075 .is_some()
1076 );
1077
1078 // The admin-edit path bundles the same rule into its transaction.
1079 db.create_session(bob.id, "bob-tok-3").await.unwrap();
1080 let h3 = crate::auth::hash_password("bobpass3").unwrap();
1081 db.update_user(bob.id, Some(&h3), None, None, None)
1082 .await
1083 .unwrap();
1084 assert!(
1085 db.session_user_with_roots("bob-tok-3")
1086 .await
1087 .unwrap()
1088 .is_none()
1089 );
1090 // An edit that leaves the password alone keeps the session.
1091 db.create_session(bob.id, "bob-tok-4").await.unwrap();
1092 db.update_user(bob.id, None, Some(true), None, None)
1093 .await
1094 .unwrap();
1095 assert!(
1096 db.session_user_with_roots("bob-tok-4")
1097 .await
1098 .unwrap()
1099 .is_some()
1100 );
1101 }
1102
1103 #[tokio::test]
1104 async fn sessions_lifecycle() {
1105 let (db, admin) = db_with_admin().await;
1106 assert!(
1107 db.session_user_with_roots("ghost-token")
1108 .await
1109 .unwrap()
1110 .is_none()
1111 );
1112 db.create_session(admin.id, "tok1").await.unwrap();
1113 let (u, _) = db.session_user_with_roots("tok1").await.unwrap().unwrap();
1114 assert_eq!(u.id, admin.id);
1115 // Disabling the user invalidates existing sessions.
1116 edit(&db, admin.id, None, None, Some(false)).await;
1117 assert!(db.session_user_with_roots("tok1").await.unwrap().is_none());
1118 edit(&db, admin.id, None, None, Some(true)).await;
1119 assert!(db.session_user_with_roots("tok1").await.unwrap().is_some());
1120 db.delete_session("tok1").await.unwrap();
1121 assert!(db.session_user_with_roots("tok1").await.unwrap().is_none());
1122 }
1123
1124 #[tokio::test]
1125 async fn user_crud_and_roots() {
1126 let (db, _admin) = db_with_admin().await;
1127 let h = crate::auth::hash_password("bobpass1").unwrap();
1128 let bob = db
1129 .create_user("bob", &h, false, &[("docs".into(), Mode::Rw)])
1130 .await
1131 .unwrap();
1132 assert!(!bob.is_admin);
1133 assert!(bob.active);
1134
1135 // Duplicate name (case-insensitive) is rejected.
1136 let h2 = crate::auth::hash_password("carolpass1").unwrap();
1137 assert!(db.create_user("BOB", &h2, false, &[]).await.is_err());
1138 assert!(db.create_user("carol", &h2, false, &[]).await.is_ok());
1139
1140 // Lookup helpers.
1141 assert_eq!(
1142 db.find_user_by_name("Bob").await.unwrap().unwrap().id,
1143 bob.id
1144 );
1145 assert_eq!(
1146 db.find_user_by_id(bob.id).await.unwrap().unwrap().name,
1147 "bob"
1148 );
1149 assert!(db.find_user_by_name("dave").await.unwrap().is_none());
1150 assert_eq!(db.all_users_with_roots().await.unwrap().len(), 3);
1151
1152 // Root replacement semantics.
1153 let roots = db.user_roots(bob.id).await.unwrap();
1154 assert_eq!(roots.len(), 1);
1155 db.update_user(
1156 bob.id,
1157 None,
1158 None,
1159 None,
1160 Some(&[(".".into(), Mode::Ro), ("docs".into(), Mode::Rw)]),
1161 )
1162 .await
1163 .unwrap();
1164 let roots = db.user_roots(bob.id).await.unwrap();
1165 assert_eq!(roots.len(), 2);
1166 assert!(roots.iter().any(|r| r.path == "." && r.mode == Mode::Ro));
1167 db.update_user(bob.id, None, None, None, Some(&[]))
1168 .await
1169 .unwrap();
1170 assert!(db.user_roots(bob.id).await.unwrap().is_empty());
1171
1172 // Password update.
1173 let new_h = crate::auth::hash_password("bobpass2").unwrap();
1174 edit(&db, bob.id, Some(&new_h), None, None).await;
1175 assert!(
1176 db.verify_password("bob", "bobpass1")
1177 .await
1178 .unwrap()
1179 .is_none()
1180 );
1181 assert!(
1182 db.verify_password("bob", "bobpass2")
1183 .await
1184 .unwrap()
1185 .is_some()
1186 );
1187
1188 // Admin flag + count (only active admins count).
1189 edit(&db, bob.id, None, Some(true), None).await;
1190 assert_eq!(db.count_admins().await.unwrap(), 2);
1191 edit(&db, bob.id, None, None, Some(false)).await;
1192 assert_eq!(db.count_admins().await.unwrap(), 1);
1193 edit(&db, bob.id, None, Some(false), None).await;
1194
1195 // Deletion.
1196 assert!(db.delete_user(bob.id).await.unwrap());
1197 assert!(db.find_user_by_id(bob.id).await.unwrap().is_none());
1198 assert!(!db.delete_user(bob.id).await.unwrap());
1199 assert_eq!(db.user_count().await.unwrap(), 2);
1200 }
1201
1202 fn share_row(expires_at: Option<&str>) -> ShareRow {
1203 ShareRow {
1204 id: 1,
1205 token: "t".into(),
1206 creator_id: 1,
1207 target: "docs".into(),
1208 is_file: false,
1209 mode: Mode::Ro,
1210 created_at: "2024-01-01T00:00:00Z".into(),
1211 expires_at: expires_at.map(str::to_string),
1212 password_hash: None,
1213 }
1214 }
1215
1216 #[test]
1217 fn share_expiry_logic() {
1218 assert!(!share_row(None).is_expired());
1219 assert!(!share_row(Some("2999-01-01T00:00:00Z")).is_expired());
1220 assert!(share_row(Some("2000-01-01T00:00:00Z")).is_expired());
1221 // Unparseable expiry → treated as not expired (fail open for reads).
1222 assert!(!share_row(Some("not-a-date")).is_expired());
1223 }
1224
1225 #[tokio::test]
1226 async fn shares_crud() {
1227 let (db, admin) = db_with_admin().await;
1228 let s1 = db
1229 .create_share(admin.id, "tok-a", "docs", false, Mode::Ro, None, None)
1230 .await
1231 .unwrap();
1232 let s2 = db
1233 .create_share(
1234 admin.id,
1235 "tok-b",
1236 "file.txt",
1237 true,
1238 Mode::Rw,
1239 Some("2999-01-01T00:00:00Z"),
1240 None,
1241 )
1242 .await
1243 .unwrap();
1244 assert!(s2.id > s1.id);
1245
1246 let found = db.share_by_token("tok-b").await.unwrap().unwrap();
1247 assert!(found.is_file);
1248 assert_eq!(found.mode, Mode::Rw);
1249 assert!(db.share_by_token("nope").await.unwrap().is_none());
1250
1251 // Listed newest-first.
1252 let list = db.user_shares(admin.id).await.unwrap();
1253 assert_eq!(list.len(), 2);
1254 assert_eq!(list[0].id, s2.id);
1255 // Other users see nothing.
1256 let h = crate::auth::hash_password("bobpass1").unwrap();
1257 let bob = db.create_user("bob", &h, false, &[]).await.unwrap();
1258 assert!(db.user_shares(bob.id).await.unwrap().is_empty());
1259
1260 // Only the creator can delete.
1261 assert!(!db.delete_share(s1.id, bob.id).await.unwrap());
1262 assert!(db.delete_share(s1.id, admin.id).await.unwrap());
1263 assert!(db.share_by_token("tok-a").await.unwrap().is_none());
1264 assert!(!db.delete_share(s1.id, admin.id).await.unwrap());
1265 }
1266
1267 /// The unlock token is what a visitor's cookie carries, so an unlock
1268 /// that opened the wrong share would be a full bypass of the password.
1269 #[tokio::test]
1270 async fn share_unlocks_are_bound_to_one_share() {
1271 let (db, admin) = db_with_admin().await;
1272 let a = db
1273 .create_share(
1274 admin.id,
1275 "tok-a",
1276 "docs",
1277 false,
1278 Mode::Ro,
1279 None,
1280 Some("hash"),
1281 )
1282 .await
1283 .unwrap();
1284 let b = db
1285 .create_share(
1286 admin.id,
1287 "tok-b",
1288 "other",
1289 false,
1290 Mode::Ro,
1291 None,
1292 Some("hash"),
1293 )
1294 .await
1295 .unwrap();
1296 assert_eq!(
1297 db.share_by_token("tok-a")
1298 .await
1299 .unwrap()
1300 .unwrap()
1301 .password_hash,
1302 Some("hash".to_string())
1303 );
1304
1305 let unlock = db.create_share_unlock(a.id).await.unwrap();
1306 assert!(db.share_unlock_valid(&unlock, a.id).await.unwrap());
1307 assert!(!db.share_unlock_valid(&unlock, b.id).await.unwrap());
1308 assert!(!db.share_unlock_valid("nonsense", a.id).await.unwrap());
1309
1310 // Deleting the share takes its unlocks with it, so a re-created
1311 // share that happened to reuse the id could not inherit them.
1312 assert!(db.delete_share(a.id, admin.id).await.unwrap());
1313 assert!(!db.share_unlock_valid(&unlock, a.id).await.unwrap());
1314 }
1315
1316 #[tokio::test]
1317 async fn revoking_a_path_takes_its_descendants_only() {
1318 let (db, admin) = db_with_admin().await;
1319 let mk = async |token: &str, target: &str| {
1320 db.create_share(admin.id, token, target, false, Mode::Ro, None, None)
1321 .await
1322 .unwrap();
1323 };
1324 mk("t-self", "docs").await;
1325 mk("t-child", "docs/a.txt").await;
1326 mk("t-deep", "docs/inner/b.txt").await;
1327 // A sibling whose name merely starts with "docs" must survive.
1328 mk("t-sibling", "docs2/c.txt").await;
1329 mk("t-other", "src").await;
1330 // SQL wildcards in a path are literal characters, not patterns.
1331 mk("t-wild", "do%s/d.txt").await;
1332
1333 assert_eq!(db.revoke_shares_at("docs").await.unwrap(), 3);
1334 for gone in ["t-self", "t-child", "t-deep"] {
1335 assert!(db.share_by_token(gone).await.unwrap().is_none(), "{gone}");
1336 }
1337 for kept in ["t-sibling", "t-other", "t-wild"] {
1338 assert!(db.share_by_token(kept).await.unwrap().is_some(), "{kept}");
1339 }
1340 // Revoking a path nobody shared is a no-op, not an error.
1341 assert_eq!(db.revoke_shares_at("nothing/here").await.unwrap(), 0);
1342 }
1343
1344 #[tokio::test]
1345 async fn settings_round_trip() {
1346 let (db, _admin) = db_with_admin().await;
1347 assert!(!db.allow_writable_shares().await.unwrap());
1348 db.set_allow_writable_shares(true).await.unwrap();
1349 assert!(db.allow_writable_shares().await.unwrap());
1350 // Upsert semantics.
1351 db.set_allow_writable_shares(false).await.unwrap();
1352 assert!(!db.allow_writable_shares().await.unwrap());
1353 // Generic get/set.
1354 db.set_setting("custom", "v").await.unwrap();
1355 assert_eq!(
1356 db.get_setting("custom").await.unwrap().as_deref(),
1357 Some("v")
1358 );
1359 assert_eq!(db.get_setting("missing").await.unwrap(), None);
1360 }
1361}
1362