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