main.rs
⎇
Raw
1//! opentracker server: one binary, one SQLite file.
2
3mod auth;
4mod device;
5mod guest;
6mod passkeys;
7mod routes;
8
9use std::path::PathBuf;
10use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12
13use axum::http::StatusCode;
14use axum::response::{IntoResponse, Response};
15use clap::{Parser, Subcommand};
16use rusqlite::{Connection, OptionalExtension, params};
17use webauthn_rs::prelude::Url;
18
19/// Schema steps, applied in order. `PRAGMA user_version` counts the applied ones.
20/// Never edit a released step. Append a new one.
21const MIGRATIONS: &[&str] = &[
22 // IF NOT EXISTS: databases from before migrations have these tables at version 0.
23 "
24CREATE TABLE IF NOT EXISTS users (
25 id INTEGER PRIMARY KEY,
26 username TEXT NOT NULL UNIQUE COLLATE NOCASE,
27 -- NULL when the account signs in with passkeys only.
28 pw_hash TEXT,
29 is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0, 1)),
30 two_factor INTEGER NOT NULL DEFAULT 0 CHECK (two_factor IN (0, 1)),
31 -- The WebAuthn user handle. Random, so a passkey does not reveal the user id.
32 webauthn_id TEXT NOT NULL UNIQUE,
33 retention_days INTEGER CHECK (retention_days > 0),
34 created_at INTEGER NOT NULL
35) STRICT;
36
37CREATE TABLE IF NOT EXISTS sessions (
38 token_hash BLOB PRIMARY KEY,
39 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
40 expires_at INTEGER NOT NULL
41) STRICT;
42
43CREATE TABLE IF NOT EXISTS passkeys (
44 id INTEGER PRIMARY KEY,
45 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
46 cred_id BLOB NOT NULL UNIQUE,
47 -- webauthn_rs::prelude::Passkey as JSON.
48 passkey TEXT NOT NULL,
49 name TEXT NOT NULL,
50 created_at INTEGER NOT NULL,
51 last_used_at INTEGER
52) STRICT;
53
54CREATE TABLE IF NOT EXISTS devices (
55 id INTEGER PRIMARY KEY,
56 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
57 name TEXT NOT NULL,
58 token_hash BLOB NOT NULL UNIQUE,
59 created_at INTEGER NOT NULL,
60 last_seen_at INTEGER
61) STRICT;
62
63-- The key makes uploads idempotent: a retried batch collapses into the rows already there.
64CREATE TABLE IF NOT EXISTS points (
65 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
66 ts INTEGER NOT NULL,
67 lat REAL NOT NULL,
68 lon REAL NOT NULL,
69 acc REAL,
70 alt REAL,
71 speed REAL,
72 bearing REAL,
73 battery INTEGER,
74 PRIMARY KEY (user_id, ts)
75) STRICT, WITHOUT ROWID;
76
77CREATE INDEX IF NOT EXISTS points_ts ON points(ts);
78
79CREATE TABLE IF NOT EXISTS shares (
80 id INTEGER PRIMARY KEY,
81 owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
82 viewer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
83 expires_at INTEGER,
84 created_at INTEGER NOT NULL,
85 UNIQUE (owner_id, viewer_id),
86 CHECK (owner_id <> viewer_id)
87) STRICT;
88",
89 // Points belong to a device. The web UI uploads as one device per user, which has no token.
90 "
91CREATE TABLE devices_new (
92 id INTEGER PRIMARY KEY,
93 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
94 name TEXT NOT NULL,
95 -- NULL for the web device.
96 token_hash BLOB UNIQUE,
97 created_at INTEGER NOT NULL,
98 last_seen_at INTEGER
99) STRICT;
100INSERT INTO devices_new SELECT * FROM devices;
101DROP TABLE devices;
102ALTER TABLE devices_new RENAME TO devices;
103CREATE UNIQUE INDEX devices_web ON devices(user_id) WHERE token_hash IS NULL;
104
105-- Older points go to the device that uploaded last, or to a new web device.
106INSERT INTO devices (user_id, name, created_at)
107SELECT DISTINCT user_id, 'Web', unixepoch() FROM points
108WHERE user_id NOT IN (SELECT user_id FROM devices);
109
110-- The key makes uploads idempotent: a retried batch collapses into the rows already there.
111CREATE TABLE points_new (
112 device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
113 ts INTEGER NOT NULL,
114 lat REAL NOT NULL,
115 lon REAL NOT NULL,
116 acc REAL,
117 alt REAL,
118 speed REAL,
119 bearing REAL,
120 battery INTEGER,
121 PRIMARY KEY (device_id, ts)
122) STRICT, WITHOUT ROWID;
123INSERT INTO points_new
124SELECT (SELECT d.id FROM devices d WHERE d.user_id = p.user_id
125 ORDER BY d.last_seen_at DESC NULLS LAST, d.id LIMIT 1),
126 ts, lat, lon, acc, alt, speed, bearing, battery
127FROM points p;
128DROP TABLE points;
129ALTER TABLE points_new RENAME TO points;
130CREATE INDEX points_ts ON points(ts);
131
132ALTER TABLE shares ADD COLUMN all_devices INTEGER NOT NULL DEFAULT 1 CHECK (all_devices IN (0, 1));
133ALTER TABLE shares ADD COLUMN trail INTEGER NOT NULL DEFAULT 1 CHECK (trail IN (0, 1));
134ALTER TABLE shares ADD COLUMN precision_m INTEGER NOT NULL DEFAULT 0 CHECK (precision_m >= 0);
135-- Used only when all_devices is 0.
136CREATE TABLE share_devices (
137 share_id INTEGER NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
138 device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
139 PRIMARY KEY (share_id, device_id)
140) STRICT, WITHOUT ROWID;
141",
142 // Trails can start at a time. Guest links are shares without a viewer.
143 "
144CREATE TABLE shares_new (
145 id INTEGER PRIMARY KEY,
146 owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
147 -- NULL for a guest link.
148 viewer_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
149 expires_at INTEGER,
150 created_at INTEGER NOT NULL,
151 all_devices INTEGER NOT NULL DEFAULT 1 CHECK (all_devices IN (0, 1)),
152 trail INTEGER NOT NULL DEFAULT 1 CHECK (trail IN (0, 1)),
153 trail_since INTEGER,
154 precision_m INTEGER NOT NULL DEFAULT 0 CHECK (precision_m >= 0),
155 -- Guest links only. Kept readable, so the owner can copy the link again.
156 name TEXT,
157 token TEXT UNIQUE,
158 pw_hash TEXT,
159 UNIQUE (owner_id, viewer_id),
160 CHECK (owner_id <> viewer_id),
161 CHECK ((viewer_id IS NULL) = (token IS NOT NULL))
162) STRICT;
163INSERT INTO shares_new (id, owner_id, viewer_id, expires_at, created_at, all_devices, trail, precision_m)
164SELECT id, owner_id, viewer_id, expires_at, created_at, all_devices, trail, precision_m FROM shares;
165DROP TABLE shares;
166ALTER TABLE shares_new RENAME TO shares;
167",
168 // One column for the trail: NULL shows only the current position, 0 the full history.
169 "
170UPDATE shares SET trail_since = CASE WHEN trail = 0 THEN NULL ELSE COALESCE(trail_since, 0) END;
171ALTER TABLE shares DROP COLUMN trail;
172",
173 // Credential changes need a recent sign-in. 0: existing sessions must sign in again first.
174 // Each point stores its cell for every precision in PRECISIONS_M. `fill_cells` computes them for existing points.
175 "
176ALTER TABLE sessions ADD COLUMN signed_in_at INTEGER NOT NULL DEFAULT 0;
177ALTER TABLE points ADD COLUMN lat_100 REAL;
178ALTER TABLE points ADD COLUMN lon_100 REAL;
179ALTER TABLE points ADD COLUMN lat_1000 REAL;
180ALTER TABLE points ADD COLUMN lon_1000 REAL;
181ALTER TABLE points ADD COLUMN lat_10000 REAL;
182ALTER TABLE points ADD COLUMN lon_10000 REAL;
183ALTER TABLE points ADD COLUMN lat_100000 REAL;
184ALTER TABLE points ADD COLUMN lon_100000 REAL;
185-- Other precisions round up to the next coarser one, so no share shows more than before.
186UPDATE shares SET precision_m = CASE
187 WHEN precision_m = 0 THEN 0
188 WHEN precision_m <= 100 THEN 100
189 WHEN precision_m <= 1000 THEN 1000
190 WHEN precision_m <= 10000 THEN 10000
191 ELSE 100000 END;
192",
193];
194
195/// The migration that adds the cell columns.
196const CELLS_MIGRATION: usize = 5;
197
198/// Opens the database and brings its schema up to date.
199pub fn open(path: &std::path::Path) -> Result<Connection, Box<dyn std::error::Error>> {
200 let mut db = Connection::open(path)?;
201 db.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")?;
202 migrate(&mut db)?;
203 Ok(db)
204}
205
206fn migrate(db: &mut Connection) -> Result<(), Box<dyn std::error::Error>> {
207 let version: i64 = db.pragma_query_value(None, "user_version", |r| r.get(0))?;
208 let version = usize::try_from(version)?;
209 if version > MIGRATIONS.len() {
210 return Err(format!(
211 "the database has schema version {version}, newer than this server knows"
212 )
213 .into());
214 }
215 // Table rebuilds drop tables that others reference. SQLite ignores this pragma inside a transaction.
216 db.pragma_update(None, "foreign_keys", false)?;
217 for (i, sql) in MIGRATIONS.iter().enumerate().skip(version) {
218 let tx = db.transaction()?;
219 tx.execute_batch(sql)?;
220 if i + 1 == CELLS_MIGRATION {
221 fill_cells(&tx)?;
222 }
223 let broken: bool = tx.query_row(
224 "SELECT EXISTS (SELECT 1 FROM pragma_foreign_key_check)",
225 [],
226 |r| r.get(0),
227 )?;
228 if broken {
229 return Err(format!("migration {} breaks a foreign key", i + 1).into());
230 }
231 tx.pragma_update(None, "user_version", i as i64 + 1)?;
232 tx.commit()?;
233 }
234 db.pragma_update(None, "foreign_keys", true)?;
235 Ok(())
236}
237
238/// Computes the cells of every point, in time order per device, as an upload would have.
239fn fill_cells(db: &Connection) -> rusqlite::Result<()> {
240 let devices: Vec<i64> = db
241 .prepare("SELECT id FROM devices")?
242 .query_map([], |r| r.get(0))?
243 .collect::<rusqlite::Result<_>>()?;
244 let mut update = db.prepare(&format!(
245 "UPDATE points SET ({}) = ({}) WHERE device_id = ?1 AND ts = ?2",
246 routes::cell_cols(),
247 (3..3 + 2 * api::PRECISIONS_M.len())
248 .map(|i| format!("?{i}"))
249 .collect::<Vec<_>>()
250 .join(", ")
251 ))?;
252 for device in devices {
253 let points: Vec<(i64, f64, f64)> = db
254 .prepare_cached("SELECT ts, lat, lon FROM points WHERE device_id = ?1 ORDER BY ts")?
255 .query_map([device], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
256 .collect::<rusqlite::Result<_>>()?;
257 let mut prev = None;
258 for (ts, lat, lon) in points {
259 let cells = routes::cells(prev.as_ref(), lat, lon);
260 let mut values: Vec<&dyn rusqlite::ToSql> = vec![&device, &ts];
261 values.extend(
262 cells
263 .iter()
264 .flat_map(|(a, b)| [a as &dyn rusqlite::ToSql, b]),
265 );
266 update.execute(values.as_slice())?;
267 prev = Some(cells);
268 }
269 }
270 Ok(())
271}
272
273#[derive(Parser)]
274#[command(about = "opentracker server")]
275struct Cli {
276 #[arg(long, env = "OT_ADDR", default_value = "127.0.0.1:8080")]
277 addr: String,
278 #[arg(long, env = "OT_DB", default_value = "ot.db")]
279 db: PathBuf,
280 /// The built web UI.
281 #[arg(long, env = "OT_WEB_DIR", default_value = "web/dist")]
282 web_dir: PathBuf,
283 /// The address browsers use, for example https://track.example.com.
284 /// Passkeys need it behind a reverse proxy. An https URL also marks the session cookie Secure.
285 #[arg(long, env = "OT_PUBLIC_URL", value_parser = parse_public_url)]
286 public_url: Option<Url>,
287 /// Days to keep points. Users can lower this for themselves. 0 keeps points forever.
288 #[arg(long, env = "OT_RETENTION_DAYS", default_value_t = 30)]
289 retention_days: i64,
290 /// Set when one reverse proxy forwards all traffic. Rate limits then use the last X-Forwarded-For entry.
291 /// Clients must not reach the server port directly, or they can set that header themselves.
292 #[arg(long, env = "OT_TRUSTED_PROXY")]
293 trusted_proxy: bool,
294 #[command(subcommand)]
295 command: Option<Command>,
296}
297
298#[derive(Subcommand)]
299enum Command {
300 /// Create a user or reset their password. Reads the password from OT_PASSWORD or stdin.
301 ///
302 /// A reset also removes all passkeys, revokes device tokens and turns off two-factor sign-in, so a lost device cannot sign in.
303 Passwd { username: String },
304}
305
306fn parse_public_url(s: &str) -> Result<Url, String> {
307 let url = Url::parse(s).map_err(|e| e.to_string())?;
308 if !matches!(url.scheme(), "http" | "https") || url.path() != "/" {
309 return Err("must be http(s)://host[:port] without a path".into());
310 }
311 Ok(url)
312}
313
314#[derive(Clone)]
315pub struct AppState {
316 // ponytail: one connection behind a global lock. Fine for a few users; use a pool if requests queue up.
317 db: Arc<Mutex<Connection>>,
318 limiter: Arc<auth::Limiter>,
319 ceremonies: Arc<passkeys::Ceremonies>,
320 pairings: Arc<device::Pairings>,
321 public_url: Option<Url>,
322 /// 0 means forever.
323 max_retention_days: i64,
324 trusted_proxy: bool,
325}
326
327impl AppState {
328 pub fn db(&self) -> MutexGuard<'_, Connection> {
329 self.db.lock().unwrap_or_else(PoisonError::into_inner)
330 }
331
332 pub fn https(&self) -> bool {
333 self.public_url
334 .as_ref()
335 .is_some_and(|u| u.scheme() == "https")
336 }
337}
338
339#[derive(Debug)]
340pub enum Error {
341 BadRequest(String),
342 Unauthorized,
343 Forbidden,
344 NotFound,
345 Conflict(String),
346 TooManyRequests,
347 Internal(String),
348}
349
350impl IntoResponse for Error {
351 fn into_response(self) -> Response {
352 match self {
353 Error::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
354 Error::Unauthorized => StatusCode::UNAUTHORIZED.into_response(),
355 Error::Forbidden => StatusCode::FORBIDDEN.into_response(),
356 Error::NotFound => StatusCode::NOT_FOUND.into_response(),
357 Error::Conflict(msg) => (StatusCode::CONFLICT, msg).into_response(),
358 Error::TooManyRequests => StatusCode::TOO_MANY_REQUESTS.into_response(),
359 Error::Internal(msg) => {
360 eprintln!("internal error: {msg}");
361 StatusCode::INTERNAL_SERVER_ERROR.into_response()
362 }
363 }
364 }
365}
366
367impl From<rusqlite::Error> for Error {
368 fn from(e: rusqlite::Error) -> Self {
369 Error::Internal(e.to_string())
370 }
371}
372
373pub fn now() -> i64 {
374 SystemTime::now()
375 .duration_since(UNIX_EPOCH)
376 .unwrap()
377 .as_secs() as i64
378}
379
380#[tokio::main]
381async fn main() {
382 let cli = Cli::parse();
383 let mut db = open(&cli.db).unwrap_or_else(|e| {
384 eprintln!("cannot open {}: {e}", cli.db.display());
385 std::process::exit(1);
386 });
387
388 match &cli.command {
389 Some(Command::Passwd { username }) => passwd(&mut db, username),
390 None => serve(cli, db).await,
391 }
392}
393
394pub fn no_users(db: &Connection) -> rusqlite::Result<bool> {
395 db.query_row("SELECT NOT EXISTS (SELECT 1 FROM users)", [], |r| r.get(0))
396}
397
398/// Turns a UNIQUE violation into a 409 with `msg`.
399pub fn taken(msg: &str) -> impl FnOnce(rusqlite::Error) -> Error + '_ {
400 move |e| match e {
401 rusqlite::Error::SqliteFailure(f, _)
402 if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE =>
403 {
404 Error::Conflict(msg.into())
405 }
406 e => e.into(),
407 }
408}
409
410/// Inserts a user. The first user ever becomes the admin.
411pub fn insert_user(
412 db: &Connection,
413 username: &str,
414 pw_hash: &str,
415 is_admin: bool,
416) -> Result<i64, Error> {
417 let first = no_users(db)?;
418 db.execute(
419 "INSERT INTO users (username, pw_hash, is_admin, webauthn_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
420 params![username, pw_hash, is_admin || first, webauthn_rs::prelude::Uuid::new_v4().to_string(), now()],
421 )
422 .map_err(taken("that username is taken"))?;
423 Ok(db.last_insert_rowid())
424}
425
426/// Sets a password and removes every other way in: passkeys, two-factor sign-in, sessions and device tokens.
427/// A reset often follows a lost device, and its passkey or token must not keep working.
428/// Pairing codes die with the sessions that began them.
429pub fn reset_password(db: &mut Connection, user_id: i64, pw_hash: &str) -> rusqlite::Result<()> {
430 let tx = db.transaction()?;
431 tx.execute(
432 "UPDATE users SET pw_hash = ?1, two_factor = 0 WHERE id = ?2",
433 params![pw_hash, user_id],
434 )?;
435 tx.execute("DELETE FROM passkeys WHERE user_id = ?1", [user_id])?;
436 tx.execute("DELETE FROM sessions WHERE user_id = ?1", [user_id])?;
437 // A random hash matches no token. The devices and their history stay, and the owner pairs them again.
438 tx.execute(
439 "UPDATE devices SET token_hash = randomblob(32) WHERE user_id = ?1 AND token_hash IS NOT NULL",
440 [user_id],
441 )?;
442 tx.commit()
443}
444
445pub fn check_username(name: &str) -> Result<&str, Error> {
446 let name = name.trim();
447 if name.is_empty() || name.chars().count() > 64 {
448 return Err(Error::BadRequest(
449 "username must have 1 to 64 characters".into(),
450 ));
451 }
452 Ok(name)
453}
454
455fn passwd(db: &mut Connection, username: &str) {
456 let password = std::env::var("OT_PASSWORD").unwrap_or_else(|_| {
457 // ponytail: the password echoes on the terminal. Use rpassword if that matters.
458 eprint!("password for {username}: ");
459 let mut line = String::new();
460 std::io::stdin()
461 .read_line(&mut line)
462 .expect("read password");
463 line.trim_end_matches(['\r', '\n']).to_owned()
464 });
465 if let Err(msg) = auth::check_new_password(&password) {
466 eprintln!("{msg}");
467 std::process::exit(1);
468 }
469 let hash = auth::hash_password(&password);
470 let existing: Option<i64> = db
471 .query_row(
472 "SELECT id FROM users WHERE username = ?1",
473 [username],
474 |r| r.get(0),
475 )
476 .optional()
477 .expect("query user");
478 match existing {
479 Some(id) => {
480 reset_password(db, id, &hash).expect("update user");
481 println!(
482 "password reset for {username}: passkeys removed, device tokens revoked, two-factor sign-in off"
483 );
484 }
485 None => {
486 let username = check_username(username).unwrap_or_else(|e| {
487 eprintln!("{e:?}");
488 std::process::exit(1);
489 });
490 insert_user(db, username, &hash, false).expect("insert user");
491 println!("created user {username}");
492 }
493 }
494}
495
496async fn serve(cli: Cli, db: Connection) {
497 let state = AppState {
498 db: Arc::new(Mutex::new(db)),
499 limiter: Arc::default(),
500 ceremonies: Arc::default(),
501 pairings: Arc::default(),
502 public_url: cli.public_url,
503 max_retention_days: cli.retention_days.max(0),
504 trusted_proxy: cli.trusted_proxy,
505 };
506 tokio::spawn(cleanup(state.clone()));
507
508 let listener = tokio::net::TcpListener::bind(&cli.addr)
509 .await
510 .expect("bind");
511 println!(
512 "listening on http://{}, serving {}",
513 cli.addr,
514 cli.web_dir.display()
515 );
516 if no_users(&state.db()).unwrap_or(false) {
517 println!("no users yet: open the web UI to create the admin account");
518 }
519 let app = routes::router(state, &cli.web_dir);
520 axum::serve(
521 listener,
522 app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
523 )
524 .with_graceful_shutdown(shutdown())
525 .await
526 .expect("serve");
527}
528
529/// In a container the server is process 1, which gets no default signal handling. Without this, `podman stop` waits 10 s and kills it.
530async fn shutdown() {
531 let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
532 .expect("SIGTERM handler");
533 tokio::select! {
534 _ = term.recv() => {}
535 _ = tokio::signal::ctrl_c() => {}
536 }
537}
538
539/// The days of points to keep for a user, or None for forever.
540pub fn effective_retention(user_days: Option<i64>, max_days: i64) -> Option<i64> {
541 match (user_days, max_days) {
542 (None, 0) => None,
543 (None, max) => Some(max),
544 (Some(days), 0) => Some(days),
545 (Some(days), max) => Some(days.min(max)),
546 }
547}
548
549/// Deletes points older than the user's retention. Each device keeps its newest point, so it stays on the map.
550pub fn purge_points(
551 db: &Connection,
552 user_id: i64,
553 user_days: Option<i64>,
554 max_days: i64,
555) -> rusqlite::Result<usize> {
556 match effective_retention(user_days, max_days) {
557 None => Ok(0),
558 Some(days) => db.execute(
559 "DELETE FROM points
560 WHERE device_id IN (SELECT id FROM devices WHERE user_id = ?1) AND ts < ?2
561 AND ts < (SELECT MAX(ts) FROM points p WHERE p.device_id = points.device_id)",
562 [user_id, now().saturating_sub(days.saturating_mul(86400))],
563 ),
564 }
565}
566
567async fn cleanup(state: AppState) {
568 let mut tick = tokio::time::interval(Duration::from_secs(3600));
569 loop {
570 tick.tick().await;
571 let now = now();
572 let db = state.db();
573 let result = db
574 .execute_batch(&format!(
575 "DELETE FROM sessions WHERE expires_at <= {now};
576 DELETE FROM shares WHERE expires_at <= {now};"
577 ))
578 .and_then(|()| {
579 let users: Vec<(i64, Option<i64>)> = db
580 .prepare("SELECT id, retention_days FROM users")?
581 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
582 .collect::<rusqlite::Result<_>>()?;
583 for (id, days) in users {
584 purge_points(&db, id, days, state.max_retention_days)?;
585 }
586 Ok(())
587 });
588 if let Err(e) = result {
589 eprintln!("cleanup failed: {e}");
590 }
591 drop(db);
592 state.limiter.prune();
593 }
594}
595
596#[cfg(test)]
597pub fn test_db() -> Connection {
598 let mut db = Connection::open_in_memory().unwrap();
599 migrate(&mut db).unwrap();
600 db
601}
602
603#[cfg(test)]
604pub fn test_state() -> AppState {
605 AppState {
606 db: Arc::new(Mutex::new(test_db())),
607 limiter: Arc::default(),
608 ceremonies: Arc::default(),
609 pairings: Arc::default(),
610 public_url: None,
611 max_retention_days: 0,
612 trusted_proxy: false,
613 }
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 #[test]
621 fn retention_never_exceeds_the_server_limit() {
622 assert_eq!(effective_retention(None, 0), None);
623 assert_eq!(effective_retention(None, 30), Some(30));
624 assert_eq!(effective_retention(Some(7), 0), Some(7));
625 assert_eq!(effective_retention(Some(7), 30), Some(7));
626 assert_eq!(effective_retention(Some(90), 30), Some(30));
627 }
628
629 #[test]
630 fn public_url_is_an_origin() {
631 assert!(parse_public_url("https://track.example.com").is_ok());
632 assert!(parse_public_url("http://localhost:8080/").is_ok());
633 assert!(parse_public_url("https://example.com/track").is_err());
634 assert!(parse_public_url("ftp://example.com").is_err());
635 }
636
637 #[test]
638 fn reset_removes_every_other_way_in() {
639 let mut db = test_db();
640 let id = insert_user(&db, "a", "old", false).unwrap();
641 db.execute_batch(
642 "UPDATE users SET two_factor = 1;
643 INSERT INTO passkeys (user_id, cred_id, passkey, name, created_at) VALUES (1, x'01', '{}', 'k', 0);
644 INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (x'02', 1, 9999999999);
645 INSERT INTO devices (user_id, name, token_hash, created_at) VALUES (1, 'phone', x'03', 0), (1, 'Web', NULL, 0);",
646 )
647 .unwrap();
648 reset_password(&mut db, id, "new").unwrap();
649 let count = |sql: &str| -> i64 { db.query_row(sql, [], |r| r.get(0)).unwrap() };
650 assert_eq!(count("SELECT COUNT(*) FROM passkeys"), 0);
651 assert_eq!(count("SELECT COUNT(*) FROM sessions"), 0);
652 assert_eq!(count("SELECT two_factor FROM users"), 0);
653 assert_eq!(
654 count("SELECT COUNT(*) FROM devices WHERE token_hash = x'03'"),
655 0
656 );
657 assert_eq!(count("SELECT COUNT(*) FROM devices"), 2);
658 }
659
660 #[test]
661 fn migrations_keep_old_points() {
662 let mut db = Connection::open_in_memory().unwrap();
663 db.execute_batch(MIGRATIONS[0]).unwrap();
664 db.execute_batch(
665 "INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0), (2, 'b', '2', 0);
666 INSERT INTO devices (id, user_id, name, token_hash, created_at, last_seen_at)
667 VALUES (7, 1, 'old', x'01', 0, 5), (8, 1, 'new', x'02', 0, 9);
668 INSERT INTO points (user_id, ts, lat, lon) VALUES (1, 100, 1, 1), (2, 100, 2, 2);",
669 )
670 .unwrap();
671 migrate(&mut db).unwrap();
672 migrate(&mut db).unwrap();
673 let owners: Vec<(i64, String)> = db
674 .prepare("SELECT d.user_id, d.name FROM points p JOIN devices d ON d.id = p.device_id ORDER BY d.user_id")
675 .unwrap()
676 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
677 .unwrap()
678 .collect::<rusqlite::Result<_>>()
679 .unwrap();
680 assert_eq!(owners, [(1, "new".into()), (2, "Web".into())]);
681 }
682
683 #[test]
684 fn trail_columns_merge_into_one() {
685 let mut db = Connection::open_in_memory().unwrap();
686 for sql in &MIGRATIONS[..3] {
687 db.execute_batch(sql).unwrap();
688 }
689 db.pragma_update(None, "user_version", 3).unwrap();
690 db.execute_batch(
691 "INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0), (2, 'b', '2', 0), (3, 'c', '3', 0), (4, 'd', '4', 0);
692 INSERT INTO shares (owner_id, viewer_id, created_at, trail, trail_since) VALUES (1, 2, 0, 0, NULL), (1, 3, 0, 1, NULL), (1, 4, 0, 1, 99);",
693 )
694 .unwrap();
695 migrate(&mut db).unwrap();
696 let since: Vec<Option<i64>> = db
697 .prepare("SELECT trail_since FROM shares ORDER BY viewer_id")
698 .unwrap()
699 .query_map([], |r| r.get(0))
700 .unwrap()
701 .collect::<rusqlite::Result<_>>()
702 .unwrap();
703 assert_eq!(since, [None, Some(0), Some(99)]);
704 }
705
706 #[test]
707 fn cells_fill_in_time_order_and_precisions_round_up() {
708 let mut db = Connection::open_in_memory().unwrap();
709 for sql in &MIGRATIONS[..CELLS_MIGRATION - 1] {
710 db.execute_batch(sql).unwrap();
711 }
712 db.pragma_update(None, "user_version", CELLS_MIGRATION as i64 - 1)
713 .unwrap();
714 db.execute_batch(
715 "INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0), (2, 'b', '2', 0);
716 INSERT INTO devices (id, user_id, name, token_hash, created_at) VALUES (1, 1, 'p', x'01', 0);
717 INSERT INTO points (device_id, ts, lat, lon) VALUES (1, 20, 0.0049, 0), (1, 10, 0.0044, 0);
718 INSERT INTO shares (owner_id, viewer_id, created_at, precision_m) VALUES (1, 2, 0, 500);",
719 )
720 .unwrap();
721 migrate(&mut db).unwrap();
722 let first = routes::cells(None, 0.0044, 0.0);
723 let second = routes::cells(Some(&first), 0.0049, 0.0);
724 // The later point sticks to the earlier point's 1 km cell, though alone it would round to the next.
725 assert_ne!(routes::cells(None, 0.0049, 0.0)[1], second[1]);
726 let stored: (f64, i64) = db
727 .query_row(
728 "SELECT lat_1000, (SELECT precision_m FROM shares) FROM points WHERE ts = 20",
729 [],
730 |r| Ok((r.get(0)?, r.get(1)?)),
731 )
732 .unwrap();
733 assert_eq!(stored, (second[1].0, 1000));
734 }
735
736 #[test]
737 fn retention_keeps_each_devices_newest_point() {
738 let db = test_db();
739 db.execute_batch(
740 "INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0);
741 INSERT INTO devices (id, user_id, name, token_hash, created_at) VALUES (1, 1, 'p', x'01', 0), (2, 1, 'q', x'02', 0);
742 INSERT INTO points (device_id, ts, lat, lon) VALUES (1, 10, 0, 0), (1, 20, 0, 0), (2, 15, 0, 0);",
743 )
744 .unwrap();
745 purge_points(&db, 1, Some(1), 30).unwrap();
746 let left: Vec<(i64, i64)> = db
747 .prepare("SELECT device_id, ts FROM points ORDER BY device_id")
748 .unwrap()
749 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
750 .unwrap()
751 .collect::<rusqlite::Result<_>>()
752 .unwrap();
753 assert_eq!(left, [(1, 20), (2, 15)]);
754 }
755
756 #[test]
757 fn first_user_is_admin() {
758 let db = test_db();
759 let a = insert_user(&db, "a", "", false).unwrap();
760 let b = insert_user(&db, "b", "", false).unwrap();
761 let admin = |id: i64| -> bool {
762 db.query_row("SELECT is_admin FROM users WHERE id = ?1", [id], |r| {
763 r.get(0)
764 })
765 .unwrap()
766 };
767 assert!(admin(a));
768 assert!(!admin(b));
769 assert!(matches!(
770 insert_user(&db, "A", "", false),
771 Err(Error::Conflict(_))
772 ));
773 }
774}
775