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 // The name others see, and the device the map selects first.
194 "
195ALTER TABLE users ADD COLUMN display_name TEXT;
196ALTER TABLE users ADD COLUMN default_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL;
197",
198];
199
200/// The migration that adds the cell columns.
201const CELLS_MIGRATION: usize = 5;
202
203/// Opens the database and brings its schema up to date.
204pub fn open(path: &std::path::Path) -> Result<Connection, Box<dyn std::error::Error>> {
205 let mut db = Connection::open(path)?;
206 db.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")?;
207 migrate(&mut db)?;
208 Ok(db)
209}
210
211fn migrate(db: &mut Connection) -> Result<(), Box<dyn std::error::Error>> {
212 let version: i64 = db.pragma_query_value(None, "user_version", |r| r.get(0))?;
213 let version = usize::try_from(version)?;
214 if version > MIGRATIONS.len() {
215 return Err(format!(
216 "the database has schema version {version}, newer than this server knows"
217 )
218 .into());
219 }
220 // Table rebuilds drop tables that others reference. SQLite ignores this pragma inside a transaction.
221 db.pragma_update(None, "foreign_keys", false)?;
222 for (i, sql) in MIGRATIONS.iter().enumerate().skip(version) {
223 let tx = db.transaction()?;
224 tx.execute_batch(sql)?;
225 if i + 1 == CELLS_MIGRATION {
226 fill_cells(&tx)?;
227 }
228 let broken: bool = tx.query_row(
229 "SELECT EXISTS (SELECT 1 FROM pragma_foreign_key_check)",
230 [],
231 |r| r.get(0),
232 )?;
233 if broken {
234 return Err(format!("migration {} breaks a foreign key", i + 1).into());
235 }
236 tx.pragma_update(None, "user_version", i as i64 + 1)?;
237 tx.commit()?;
238 }
239 db.pragma_update(None, "foreign_keys", true)?;
240 Ok(())
241}
242
243/// Computes the cells of every point, in time order per device, as an upload would have.
244fn fill_cells(db: &Connection) -> rusqlite::Result<()> {
245 let devices: Vec<i64> = db
246 .prepare("SELECT id FROM devices")?
247 .query_map([], |r| r.get(0))?
248 .collect::<rusqlite::Result<_>>()?;
249 let mut update = db.prepare(&format!(
250 "UPDATE points SET ({}) = ({}) WHERE device_id = ?1 AND ts = ?2",
251 routes::cell_cols(),
252 (3..3 + 2 * api::PRECISIONS_M.len())
253 .map(|i| format!("?{i}"))
254 .collect::<Vec<_>>()
255 .join(", ")
256 ))?;
257 for device in devices {
258 let points: Vec<(i64, f64, f64)> = db
259 .prepare_cached("SELECT ts, lat, lon FROM points WHERE device_id = ?1 ORDER BY ts")?
260 .query_map([device], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
261 .collect::<rusqlite::Result<_>>()?;
262 let mut prev = None;
263 for (ts, lat, lon) in points {
264 let cells = routes::cells(prev.as_ref(), lat, lon);
265 let mut values: Vec<&dyn rusqlite::ToSql> = vec![&device, &ts];
266 values.extend(
267 cells
268 .iter()
269 .flat_map(|(a, b)| [a as &dyn rusqlite::ToSql, b]),
270 );
271 update.execute(values.as_slice())?;
272 prev = Some(cells);
273 }
274 }
275 Ok(())
276}
277
278#[derive(Parser)]
279#[command(about = "opentracker server")]
280struct Cli {
281 #[arg(long, env = "OT_ADDR", default_value = "127.0.0.1:8080")]
282 addr: String,
283 #[arg(long, env = "OT_DB", default_value = "ot.db")]
284 db: PathBuf,
285 /// The built web UI.
286 #[arg(long, env = "OT_WEB_DIR", default_value = "web/dist")]
287 web_dir: PathBuf,
288 /// The address browsers use, for example https://track.example.com.
289 /// Passkeys need it behind a reverse proxy. An https URL also marks the session cookie Secure.
290 #[arg(long, env = "OT_PUBLIC_URL", value_parser = parse_public_url)]
291 public_url: Option<Url>,
292 /// Days to keep points. Users can lower this for themselves. 0 keeps points forever.
293 #[arg(long, env = "OT_RETENTION_DAYS", default_value_t = 30)]
294 retention_days: i64,
295 /// Set when one reverse proxy forwards all traffic. Rate limits then use the last X-Forwarded-For entry.
296 /// Clients must not reach the server port directly, or they can set that header themselves.
297 #[arg(long, env = "OT_TRUSTED_PROXY")]
298 trusted_proxy: bool,
299 #[command(subcommand)]
300 command: Option<Command>,
301}
302
303#[derive(Subcommand)]
304enum Command {
305 /// Create a user or reset their password. Reads the password from OT_PASSWORD or stdin.
306 ///
307 /// A reset also removes all passkeys, revokes device tokens and turns off two-factor sign-in, so a lost device cannot sign in.
308 Passwd { username: String },
309}
310
311fn parse_public_url(s: &str) -> Result<Url, String> {
312 let url = Url::parse(s).map_err(|e| e.to_string())?;
313 if !matches!(url.scheme(), "http" | "https") || url.path() != "/" {
314 return Err("must be http(s)://host[:port] without a path".into());
315 }
316 Ok(url)
317}
318
319#[derive(Clone)]
320pub struct AppState {
321 // ponytail: one connection behind a global lock. Fine for a few users; use a pool if requests queue up.
322 db: Arc<Mutex<Connection>>,
323 limiter: Arc<auth::Limiter>,
324 ceremonies: Arc<passkeys::Ceremonies>,
325 pairings: Arc<device::Pairings>,
326 public_url: Option<Url>,
327 /// 0 means forever.
328 max_retention_days: i64,
329 trusted_proxy: bool,
330}
331
332impl AppState {
333 pub fn db(&self) -> MutexGuard<'_, Connection> {
334 self.db.lock().unwrap_or_else(PoisonError::into_inner)
335 }
336
337 pub fn https(&self) -> bool {
338 self.public_url
339 .as_ref()
340 .is_some_and(|u| u.scheme() == "https")
341 }
342}
343
344#[derive(Debug)]
345pub enum Error {
346 BadRequest(String),
347 Unauthorized,
348 Forbidden,
349 NotFound,
350 Conflict(String),
351 TooManyRequests,
352 Internal(String),
353}
354
355impl IntoResponse for Error {
356 fn into_response(self) -> Response {
357 match self {
358 Error::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
359 Error::Unauthorized => StatusCode::UNAUTHORIZED.into_response(),
360 Error::Forbidden => StatusCode::FORBIDDEN.into_response(),
361 Error::NotFound => StatusCode::NOT_FOUND.into_response(),
362 Error::Conflict(msg) => (StatusCode::CONFLICT, msg).into_response(),
363 Error::TooManyRequests => StatusCode::TOO_MANY_REQUESTS.into_response(),
364 Error::Internal(msg) => {
365 eprintln!("internal error: {msg}");
366 StatusCode::INTERNAL_SERVER_ERROR.into_response()
367 }
368 }
369 }
370}
371
372impl From<rusqlite::Error> for Error {
373 fn from(e: rusqlite::Error) -> Self {
374 Error::Internal(e.to_string())
375 }
376}
377
378pub fn now() -> i64 {
379 SystemTime::now()
380 .duration_since(UNIX_EPOCH)
381 .unwrap()
382 .as_secs() as i64
383}
384
385#[tokio::main]
386async fn main() {
387 let cli = Cli::parse();
388 let mut db = open(&cli.db).unwrap_or_else(|e| {
389 eprintln!("cannot open {}: {e}", cli.db.display());
390 std::process::exit(1);
391 });
392
393 match &cli.command {
394 Some(Command::Passwd { username }) => passwd(&mut db, username),
395 None => serve(cli, db).await,
396 }
397}
398
399pub fn no_users(db: &Connection) -> rusqlite::Result<bool> {
400 db.query_row("SELECT NOT EXISTS (SELECT 1 FROM users)", [], |r| r.get(0))
401}
402
403/// Turns a UNIQUE violation into a 409 with `msg`.
404pub fn taken(msg: &str) -> impl FnOnce(rusqlite::Error) -> Error + '_ {
405 move |e| match e {
406 rusqlite::Error::SqliteFailure(f, _)
407 if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE =>
408 {
409 Error::Conflict(msg.into())
410 }
411 e => e.into(),
412 }
413}
414
415/// Inserts a user. The first user ever becomes the admin.
416pub fn insert_user(
417 db: &Connection,
418 username: &str,
419 pw_hash: &str,
420 is_admin: bool,
421) -> Result<i64, Error> {
422 let first = no_users(db)?;
423 db.execute(
424 "INSERT INTO users (username, pw_hash, is_admin, webauthn_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
425 params![username, pw_hash, is_admin || first, webauthn_rs::prelude::Uuid::new_v4().to_string(), now()],
426 )
427 .map_err(taken("that username is taken"))?;
428 Ok(db.last_insert_rowid())
429}
430
431/// Sets a password and removes every other way in: passkeys, two-factor sign-in, sessions and device tokens.
432/// A reset often follows a lost device, and its passkey or token must not keep working.
433/// Pairing codes die with the sessions that began them.
434pub fn reset_password(db: &mut Connection, user_id: i64, pw_hash: &str) -> rusqlite::Result<()> {
435 let tx = db.transaction()?;
436 tx.execute(
437 "UPDATE users SET pw_hash = ?1, two_factor = 0 WHERE id = ?2",
438 params![pw_hash, user_id],
439 )?;
440 tx.execute("DELETE FROM passkeys WHERE user_id = ?1", [user_id])?;
441 tx.execute("DELETE FROM sessions WHERE user_id = ?1", [user_id])?;
442 // A random hash matches no token. The devices and their history stay, and the owner pairs them again.
443 tx.execute(
444 "UPDATE devices SET token_hash = randomblob(32) WHERE user_id = ?1 AND token_hash IS NOT NULL",
445 [user_id],
446 )?;
447 tx.commit()
448}
449
450pub fn check_username(name: &str) -> Result<&str, Error> {
451 let name = name.trim();
452 if name.is_empty() || name.chars().count() > 64 {
453 return Err(Error::BadRequest(
454 "username must have 1 to 64 characters".into(),
455 ));
456 }
457 Ok(name)
458}
459
460fn passwd(db: &mut Connection, username: &str) {
461 let password = std::env::var("OT_PASSWORD").unwrap_or_else(|_| {
462 // ponytail: the password echoes on the terminal. Use rpassword if that matters.
463 eprint!("password for {username}: ");
464 let mut line = String::new();
465 std::io::stdin()
466 .read_line(&mut line)
467 .expect("read password");
468 line.trim_end_matches(['\r', '\n']).to_owned()
469 });
470 if let Err(msg) = auth::check_new_password(&password) {
471 eprintln!("{msg}");
472 std::process::exit(1);
473 }
474 let hash = auth::hash_password(&password);
475 let existing: Option<i64> = db
476 .query_row(
477 "SELECT id FROM users WHERE username = ?1",
478 [username],
479 |r| r.get(0),
480 )
481 .optional()
482 .expect("query user");
483 match existing {
484 Some(id) => {
485 reset_password(db, id, &hash).expect("update user");
486 println!(
487 "password reset for {username}: passkeys removed, device tokens revoked, two-factor sign-in off"
488 );
489 }
490 None => {
491 let username = check_username(username).unwrap_or_else(|e| {
492 eprintln!("{e:?}");
493 std::process::exit(1);
494 });
495 insert_user(db, username, &hash, false).expect("insert user");
496 println!("created user {username}");
497 }
498 }
499}
500
501async fn serve(cli: Cli, db: Connection) {
502 let state = AppState {
503 db: Arc::new(Mutex::new(db)),
504 limiter: Arc::default(),
505 ceremonies: Arc::default(),
506 pairings: Arc::default(),
507 public_url: cli.public_url,
508 max_retention_days: cli.retention_days.max(0),
509 trusted_proxy: cli.trusted_proxy,
510 };
511 tokio::spawn(cleanup(state.clone()));
512
513 let listener = tokio::net::TcpListener::bind(&cli.addr)
514 .await
515 .expect("bind");
516 println!(
517 "listening on http://{}, serving {}",
518 cli.addr,
519 cli.web_dir.display()
520 );
521 if no_users(&state.db()).unwrap_or(false) {
522 println!("no users yet: open the web UI to create the admin account");
523 }
524 let app = routes::router(state, &cli.web_dir);
525 axum::serve(
526 listener,
527 app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
528 )
529 .with_graceful_shutdown(shutdown())
530 .await
531 .expect("serve");
532}
533
534/// In a container the server is process 1, which gets no default signal handling. Without this, `podman stop` waits 10 s and kills it.
535async fn shutdown() {
536 let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
537 .expect("SIGTERM handler");
538 tokio::select! {
539 _ = term.recv() => {}
540 _ = tokio::signal::ctrl_c() => {}
541 }
542}
543
544/// The days of points to keep for a user, or None for forever.
545pub fn effective_retention(user_days: Option<i64>, max_days: i64) -> Option<i64> {
546 match (user_days, max_days) {
547 (None, 0) => None,
548 (None, max) => Some(max),
549 (Some(days), 0) => Some(days),
550 (Some(days), max) => Some(days.min(max)),
551 }
552}
553
554/// Deletes points older than the user's retention. Each device keeps its newest point, so it stays on the map.
555pub fn purge_points(
556 db: &Connection,
557 user_id: i64,
558 user_days: Option<i64>,
559 max_days: i64,
560) -> rusqlite::Result<usize> {
561 match effective_retention(user_days, max_days) {
562 None => Ok(0),
563 Some(days) => db.execute(
564 "DELETE FROM points
565 WHERE device_id IN (SELECT id FROM devices WHERE user_id = ?1) AND ts < ?2
566 AND ts < (SELECT MAX(ts) FROM points p WHERE p.device_id = points.device_id)",
567 [user_id, now().saturating_sub(days.saturating_mul(86400))],
568 ),
569 }
570}
571
572async fn cleanup(state: AppState) {
573 let mut tick = tokio::time::interval(Duration::from_secs(3600));
574 loop {
575 tick.tick().await;
576 let now = now();
577 let db = state.db();
578 let result = db
579 .execute_batch(&format!(
580 "DELETE FROM sessions WHERE expires_at <= {now};
581 DELETE FROM shares WHERE expires_at <= {now};"
582 ))
583 .and_then(|()| {
584 let users: Vec<(i64, Option<i64>)> = db
585 .prepare("SELECT id, retention_days FROM users")?
586 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
587 .collect::<rusqlite::Result<_>>()?;
588 for (id, days) in users {
589 purge_points(&db, id, days, state.max_retention_days)?;
590 }
591 Ok(())
592 });
593 if let Err(e) = result {
594 eprintln!("cleanup failed: {e}");
595 }
596 drop(db);
597 state.limiter.prune();
598 }
599}
600
601#[cfg(test)]
602pub fn test_db() -> Connection {
603 let mut db = Connection::open_in_memory().unwrap();
604 migrate(&mut db).unwrap();
605 db
606}
607
608#[cfg(test)]
609pub fn test_state() -> AppState {
610 AppState {
611 db: Arc::new(Mutex::new(test_db())),
612 limiter: Arc::default(),
613 ceremonies: Arc::default(),
614 pairings: Arc::default(),
615 public_url: None,
616 max_retention_days: 0,
617 trusted_proxy: false,
618 }
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624
625 #[test]
626 fn retention_never_exceeds_the_server_limit() {
627 assert_eq!(effective_retention(None, 0), None);
628 assert_eq!(effective_retention(None, 30), Some(30));
629 assert_eq!(effective_retention(Some(7), 0), Some(7));
630 assert_eq!(effective_retention(Some(7), 30), Some(7));
631 assert_eq!(effective_retention(Some(90), 30), Some(30));
632 }
633
634 #[test]
635 fn public_url_is_an_origin() {
636 assert!(parse_public_url("https://track.example.com").is_ok());
637 assert!(parse_public_url("http://localhost:8080/").is_ok());
638 assert!(parse_public_url("https://example.com/track").is_err());
639 assert!(parse_public_url("ftp://example.com").is_err());
640 }
641
642 #[test]
643 fn reset_removes_every_other_way_in() {
644 let mut db = test_db();
645 let id = insert_user(&db, "a", "old", false).unwrap();
646 db.execute_batch(
647 "UPDATE users SET two_factor = 1;
648 INSERT INTO passkeys (user_id, cred_id, passkey, name, created_at) VALUES (1, x'01', '{}', 'k', 0);
649 INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (x'02', 1, 9999999999);
650 INSERT INTO devices (user_id, name, token_hash, created_at) VALUES (1, 'phone', x'03', 0), (1, 'Web', NULL, 0);",
651 )
652 .unwrap();
653 reset_password(&mut db, id, "new").unwrap();
654 let count = |sql: &str| -> i64 { db.query_row(sql, [], |r| r.get(0)).unwrap() };
655 assert_eq!(count("SELECT COUNT(*) FROM passkeys"), 0);
656 assert_eq!(count("SELECT COUNT(*) FROM sessions"), 0);
657 assert_eq!(count("SELECT two_factor FROM users"), 0);
658 assert_eq!(
659 count("SELECT COUNT(*) FROM devices WHERE token_hash = x'03'"),
660 0
661 );
662 assert_eq!(count("SELECT COUNT(*) FROM devices"), 2);
663 }
664
665 #[test]
666 fn migrations_keep_old_points() {
667 let mut db = Connection::open_in_memory().unwrap();
668 db.execute_batch(MIGRATIONS[0]).unwrap();
669 db.execute_batch(
670 "INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0), (2, 'b', '2', 0);
671 INSERT INTO devices (id, user_id, name, token_hash, created_at, last_seen_at)
672 VALUES (7, 1, 'old', x'01', 0, 5), (8, 1, 'new', x'02', 0, 9);
673 INSERT INTO points (user_id, ts, lat, lon) VALUES (1, 100, 1, 1), (2, 100, 2, 2);",
674 )
675 .unwrap();
676 migrate(&mut db).unwrap();
677 migrate(&mut db).unwrap();
678 let owners: Vec<(i64, String)> = db
679 .prepare("SELECT d.user_id, d.name FROM points p JOIN devices d ON d.id = p.device_id ORDER BY d.user_id")
680 .unwrap()
681 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
682 .unwrap()
683 .collect::<rusqlite::Result<_>>()
684 .unwrap();
685 assert_eq!(owners, [(1, "new".into()), (2, "Web".into())]);
686 }
687
688 #[test]
689 fn trail_columns_merge_into_one() {
690 let mut db = Connection::open_in_memory().unwrap();
691 for sql in &MIGRATIONS[..3] {
692 db.execute_batch(sql).unwrap();
693 }
694 db.pragma_update(None, "user_version", 3).unwrap();
695 db.execute_batch(
696 "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);
697 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);",
698 )
699 .unwrap();
700 migrate(&mut db).unwrap();
701 let since: Vec<Option<i64>> = db
702 .prepare("SELECT trail_since FROM shares ORDER BY viewer_id")
703 .unwrap()
704 .query_map([], |r| r.get(0))
705 .unwrap()
706 .collect::<rusqlite::Result<_>>()
707 .unwrap();
708 assert_eq!(since, [None, Some(0), Some(99)]);
709 }
710
711 #[test]
712 fn cells_fill_in_time_order_and_precisions_round_up() {
713 let mut db = Connection::open_in_memory().unwrap();
714 for sql in &MIGRATIONS[..CELLS_MIGRATION - 1] {
715 db.execute_batch(sql).unwrap();
716 }
717 db.pragma_update(None, "user_version", CELLS_MIGRATION as i64 - 1)
718 .unwrap();
719 db.execute_batch(
720 "INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0), (2, 'b', '2', 0);
721 INSERT INTO devices (id, user_id, name, token_hash, created_at) VALUES (1, 1, 'p', x'01', 0);
722 INSERT INTO points (device_id, ts, lat, lon) VALUES (1, 20, 0.0049, 0), (1, 10, 0.0044, 0);
723 INSERT INTO shares (owner_id, viewer_id, created_at, precision_m) VALUES (1, 2, 0, 500);",
724 )
725 .unwrap();
726 migrate(&mut db).unwrap();
727 let first = routes::cells(None, 0.0044, 0.0);
728 let second = routes::cells(Some(&first), 0.0049, 0.0);
729 // The later point sticks to the earlier point's 1 km cell, though alone it would round to the next.
730 assert_ne!(routes::cells(None, 0.0049, 0.0)[1], second[1]);
731 let stored: (f64, i64) = db
732 .query_row(
733 "SELECT lat_1000, (SELECT precision_m FROM shares) FROM points WHERE ts = 20",
734 [],
735 |r| Ok((r.get(0)?, r.get(1)?)),
736 )
737 .unwrap();
738 assert_eq!(stored, (second[1].0, 1000));
739 }
740
741 #[test]
742 fn retention_keeps_each_devices_newest_point() {
743 let db = test_db();
744 db.execute_batch(
745 "INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0);
746 INSERT INTO devices (id, user_id, name, token_hash, created_at) VALUES (1, 1, 'p', x'01', 0), (2, 1, 'q', x'02', 0);
747 INSERT INTO points (device_id, ts, lat, lon) VALUES (1, 10, 0, 0), (1, 20, 0, 0), (2, 15, 0, 0);",
748 )
749 .unwrap();
750 purge_points(&db, 1, Some(1), 30).unwrap();
751 let left: Vec<(i64, i64)> = db
752 .prepare("SELECT device_id, ts FROM points ORDER BY device_id")
753 .unwrap()
754 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
755 .unwrap()
756 .collect::<rusqlite::Result<_>>()
757 .unwrap();
758 assert_eq!(left, [(1, 20), (2, 15)]);
759 }
760
761 #[test]
762 fn first_user_is_admin() {
763 let db = test_db();
764 let a = insert_user(&db, "a", "", false).unwrap();
765 let b = insert_user(&db, "b", "", false).unwrap();
766 let admin = |id: i64| -> bool {
767 db.query_row("SELECT is_admin FROM users WHERE id = ?1", [id], |r| {
768 r.get(0)
769 })
770 .unwrap()
771 };
772 assert!(admin(a));
773 assert!(!admin(b));
774 assert!(matches!(
775 insert_user(&db, "A", "", false),
776 Err(Error::Conflict(_))
777 ));
778 }
779}
780