routes.rs
⎇
Raw
1use std::path::Path;
2
3use api::{
4 ChangePassword, Credentials, Device, DeviceToken, Login, MAX_BATCH, MAX_TRACK_SECS, Me,
5 NewDevice, NewShare, NewUser, PRECISIONS_M, Person, PersonDevice, Point, ResetPassword,
6 SetDefaultDevice, SetDisplayName, SetRetention, SetRole, SetTwoFactor, SetupStatus, Share,
7 ShareSettings, Shares, Uploaded,
8};
9use axum::extract::{Path as UrlPath, Query, State};
10use axum::http::{HeaderMap, HeaderValue, Uri, header};
11use axum::response::{IntoResponse, Response};
12use axum::routing::{delete, get, post, put};
13use axum::{Json, Router};
14use base64::Engine;
15use rusqlite::{Connection, OptionalExtension, Row, ToSql, params};
16use serde::Deserialize;
17use sha2::{Digest, Sha256};
18use tower_http::services::ServeDir;
19
20use crate::auth::{self, Admin, ClientIp, User};
21use crate::passkeys::{self, Pending};
22use crate::{AppState, Error, now};
23use crate::{device, guest};
24
25type Result<T> = std::result::Result<T, Error>;
26
27pub fn router(state: AppState, web_dir: &Path) -> Router {
28 let csp = HeaderValue::from_str(&content_security_policy(web_dir)).expect("CSP is ASCII");
29 Router::new()
30 .route("/api/setup", get(setup_status).post(setup))
31 .route("/api/login", post(login))
32 .route("/api/logout", post(logout))
33 .route("/api/passkey/login", post(passkeys::login_begin))
34 .route("/api/passkey/login/finish", post(passkeys::login_finish))
35 .route("/api/me", get(me))
36 .route(
37 "/api/me/password",
38 post(change_password).delete(delete_password),
39 )
40 .route("/api/me/two-factor", put(set_two_factor))
41 .route("/api/me/retention", put(set_retention))
42 .route("/api/me/display-name", put(set_display_name))
43 .route("/api/me/default-device", put(set_default_device))
44 .route("/api/passkeys", get(passkeys::list))
45 .route("/api/passkeys/register", post(passkeys::register_begin))
46 .route(
47 "/api/passkeys/register/finish",
48 post(passkeys::register_finish),
49 )
50 .route("/api/passkeys/{id}", delete(passkeys::delete))
51 .route("/api/people", get(people))
52 .route("/api/people/{id}/track", get(track))
53 .route("/api/devices", get(list_devices).post(create_device))
54 .route("/api/devices/pair/begin", post(device::pair_begin))
55 .route("/api/devices/pair", post(device::pair_finish))
56 .route("/api/device", get(device::me))
57 .route("/api/device/track", get(device::track))
58 .route("/api/devices/{id}", delete(delete_device))
59 .route("/api/points", post(upload))
60 .route("/api/shares", get(list_shares).post(create_share))
61 .route("/api/shares/{id}", delete(delete_share))
62 .route("/api/usernames", get(usernames))
63 .route("/api/users", get(list_users).post(create_user))
64 .route("/api/users/{id}", delete(delete_user))
65 .route("/api/users/{id}/role", put(set_role))
66 .route("/api/users/{id}/password", post(reset_user_password))
67 .route("/api/links", get(guest::list).post(guest::create))
68 .route("/api/links/{id}", delete(guest::delete))
69 .route("/api/guest", post(guest::view))
70 .route("/api/guest/track", post(guest::track))
71 .route("/api/guest/unlock", post(guest::unlock))
72 .route("/healthz", get(healthz))
73 .fallback_service(ServeDir::new(web_dir))
74 .layer(axum::middleware::map_response(move |mut res: Response| {
75 let csp = csp.clone();
76 async move {
77 let h = res.headers_mut();
78 h.insert(header::CONTENT_SECURITY_POLICY, csp);
79 h.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
80 h.insert(
81 header::X_CONTENT_TYPE_OPTIONS,
82 HeaderValue::from_static("nosniff"),
83 );
84 // Not no-referrer: the OpenStreetMap tile servers require a Referer.
85 h.insert(
86 header::REFERRER_POLICY,
87 HeaderValue::from_static("strict-origin-when-cross-origin"),
88 );
89 res
90 }
91 }))
92 .with_state(state)
93}
94
95/// Allows the inline scripts of the built index.html by hash. Trunk names them anew in each build.
96fn content_security_policy(web_dir: &Path) -> String {
97 let html = std::fs::read_to_string(web_dir.join("index.html")).unwrap_or_default();
98 let hashes: String = html
99 .split("<script")
100 .skip(1)
101 .filter_map(|s| {
102 let (tag, rest) = s.split_once('>')?;
103 let body = rest.split_once("</script>")?.0;
104 let hash = base64::engine::general_purpose::STANDARD.encode(Sha256::digest(body));
105 (!tag.contains("src=")).then(|| format!(" 'sha256-{hash}'"))
106 })
107 .collect();
108 // Keep the tile hosts in sync with web/src/map.rs.
109 format!(
110 "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'{hashes}; style-src 'self' 'unsafe-inline'; \
111 img-src 'self' data: https://tile.openstreetmap.org https://*.tile.openstreetmap.fr \
112 https://*.tile-cyclosm.openstreetmap.fr https://*.tile.opentopomap.org https://server.arcgisonline.com; \
113 connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
114 )
115}
116
117async fn healthz(State(s): State<AppState>) -> Result<&'static str> {
118 s.db().query_row("SELECT 1", [], |_| Ok(()))?;
119 Ok("ok")
120}
121
122async fn setup_status(State(s): State<AppState>) -> Result<Json<SetupStatus>> {
123 Ok(Json(SetupStatus {
124 needed: crate::no_users(&s.db())?,
125 }))
126}
127
128/// Creates the first account, an admin. Only works while no user exists.
129async fn setup(State(s): State<AppState>, Json(b): Json<Credentials>) -> Result<Response> {
130 let username = crate::check_username(&b.username)?.to_owned();
131 auth::check_new_password(&b.password).map_err(|m| Error::BadRequest(m.into()))?;
132 let already = || Error::Conflict("the server is already set up".into());
133 // Checked before hashing, so a request to a set-up server costs no Argon2 work.
134 if !crate::no_users(&s.db())? {
135 return Err(already());
136 }
137 let hash = auth::hash_password_async(b.password).await?;
138 let id = {
139 let db = s.db();
140 // Checked again under the same lock as the insert, so two setups cannot both win.
141 if !crate::no_users(&db)? {
142 return Err(already());
143 }
144 crate::insert_user(&db, &username, &hash, true)?
145 };
146 passkeys::sign_in(&s, id)
147}
148
149async fn login(
150 State(s): State<AppState>,
151 ClientIp(ip): ClientIp,
152 uri: Uri,
153 headers: HeaderMap,
154 Json(b): Json<Login>,
155) -> Result<Response> {
156 match &b.state_id {
157 // The passkey already passed. This is the password step of a two-factor sign-in.
158 Some(state_id) => {
159 let Some(Pending::NeedsPassword { user_id }) = s.ceremonies.take(state_id) else {
160 return Err(passkeys::expired());
161 };
162 let username: String =
163 s.db()
164 .query_row("SELECT username FROM users WHERE id = ?1", [user_id], |r| {
165 r.get(0)
166 })?;
167 let ok = auth::check_password(&s, ip, &username, &b.password).await?;
168 passkeys::sign_in(&s, ok.id)
169 }
170 None => {
171 let ok = auth::check_password(&s, ip, b.username.trim(), &b.password).await?;
172 if ok.two_factor {
173 return passkeys::second_factor(&s, &uri, &headers, ok.id);
174 }
175 passkeys::sign_in(&s, ok.id)
176 }
177 }
178}
179
180async fn logout(State(s): State<AppState>, user: User) -> Result<impl IntoResponse> {
181 s.db().execute(
182 "DELETE FROM sessions WHERE token_hash = ?1",
183 [user.session_hash],
184 )?;
185 Ok(([(header::SET_COOKIE, auth::clear_session(&s))], Json(())))
186}
187
188async fn me(State(s): State<AppState>, user: User) -> Result<Json<Me>> {
189 let (has_password, two_factor, retention_days, display_name, default_device) = s.db().query_row(
190 "SELECT pw_hash IS NOT NULL, two_factor, retention_days, display_name, default_device_id FROM users WHERE id = ?1",
191 [user.id],
192 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
193 )?;
194 Ok(Json(Me {
195 id: user.id,
196 username: user.username,
197 display_name,
198 default_device,
199 is_admin: user.is_admin,
200 has_password,
201 two_factor,
202 retention_days,
203 max_retention_days: (s.max_retention_days > 0).then_some(s.max_retention_days),
204 public_url: s
205 .public_url
206 .as_ref()
207 .map(|u| u.as_str().trim_end_matches('/').to_owned()),
208 }))
209}
210
211/// Sets or changes the password. Changing an existing one needs the old one.
212async fn change_password(
213 State(s): State<AppState>,
214 ClientIp(ip): ClientIp,
215 user: User,
216 Json(b): Json<ChangePassword>,
217) -> Result<Json<()>> {
218 auth::check_new_password(&b.new).map_err(|m| Error::BadRequest(m.into()))?;
219 let has_password: bool = s.db().query_row(
220 "SELECT pw_hash IS NOT NULL FROM users WHERE id = ?1",
221 [user.id],
222 |r| r.get(0),
223 )?;
224 if has_password {
225 // 400, not 401: the session is still valid, only the old password is wrong.
226 auth::check_password(&s, ip, &user.username, b.old.as_deref().unwrap_or_default())
227 .await
228 .map_err(|e| match e {
229 Error::Unauthorized => Error::BadRequest("wrong current password".into()),
230 e => e,
231 })?;
232 } else {
233 user.check_recent()?;
234 }
235 let hash = auth::hash_password_async(b.new).await?;
236 s.db().execute(
237 "UPDATE users SET pw_hash = ?1 WHERE id = ?2",
238 params![hash, user.id],
239 )?;
240 auth::end_other_sessions(&s, &user)?;
241 Ok(Json(()))
242}
243
244/// Leaves the account on passkeys alone.
245async fn delete_password(State(s): State<AppState>, user: User) -> Result<Json<()>> {
246 user.check_recent()?;
247 let db = s.db();
248 if passkeys::count(&db, user.id)? == 0 {
249 return Err(Error::BadRequest(
250 "add a passkey before removing your password".into(),
251 ));
252 }
253 let two_factor: bool = db.query_row(
254 "SELECT two_factor FROM users WHERE id = ?1",
255 [user.id],
256 |r| r.get(0),
257 )?;
258 if two_factor {
259 return Err(Error::BadRequest(
260 "turn off two-factor sign-in before removing your password".into(),
261 ));
262 }
263 db.execute("UPDATE users SET pw_hash = NULL WHERE id = ?1", [user.id])?;
264 drop(db);
265 auth::end_other_sessions(&s, &user)?;
266 Ok(Json(()))
267}
268
269async fn set_two_factor(
270 State(s): State<AppState>,
271 user: User,
272 Json(b): Json<SetTwoFactor>,
273) -> Result<Json<()>> {
274 user.check_recent()?;
275 let db = s.db();
276 if b.enabled {
277 let has_password: bool = db.query_row(
278 "SELECT pw_hash IS NOT NULL FROM users WHERE id = ?1",
279 [user.id],
280 |r| r.get(0),
281 )?;
282 if !has_password {
283 return Err(Error::BadRequest(
284 "set a password before turning on two-factor sign-in".into(),
285 ));
286 }
287 if passkeys::count(&db, user.id)? == 0 {
288 return Err(Error::BadRequest(
289 "add a passkey before turning on two-factor sign-in".into(),
290 ));
291 }
292 }
293 db.execute(
294 "UPDATE users SET two_factor = ?1 WHERE id = ?2",
295 params![b.enabled, user.id],
296 )?;
297 drop(db);
298 auth::end_other_sessions(&s, &user)?;
299 Ok(Json(()))
300}
301
302/// Users can only keep their points for less time than the server allows, never longer.
303async fn set_retention(
304 State(s): State<AppState>,
305 user: User,
306 Json(b): Json<SetRetention>,
307) -> Result<Json<()>> {
308 if let Some(days) = b.days {
309 let max = s.max_retention_days;
310 if days < 1 || (max > 0 && days > max) {
311 let range = if max > 0 {
312 format!("1 to {max}")
313 } else {
314 "at least 1".into()
315 };
316 return Err(Error::BadRequest(format!("retention must be {range} days")));
317 }
318 }
319 let db = s.db();
320 db.execute(
321 "UPDATE users SET retention_days = ?1 WHERE id = ?2",
322 params![b.days, user.id],
323 )?;
324 crate::purge_points(&db, user.id, b.days, s.max_retention_days)?;
325 Ok(Json(()))
326}
327
328async fn set_display_name(
329 State(s): State<AppState>,
330 user: User,
331 Json(b): Json<SetDisplayName>,
332) -> Result<Json<()>> {
333 let name = b.name.as_deref().map(str::trim).filter(|n| !n.is_empty());
334 if name.is_some_and(|n| n.chars().count() > 64) {
335 return Err(Error::BadRequest(
336 "the display name must have at most 64 characters".into(),
337 ));
338 }
339 s.db().execute(
340 "UPDATE users SET display_name = ?1 WHERE id = ?2",
341 params![name, user.id],
342 )?;
343 Ok(Json(()))
344}
345
346async fn set_default_device(
347 State(s): State<AppState>,
348 user: User,
349 Json(b): Json<SetDefaultDevice>,
350) -> Result<Json<()>> {
351 let db = s.db();
352 if let Some(id) = b.device {
353 let mine: bool = db.query_row(
354 "SELECT EXISTS (SELECT 1 FROM devices WHERE id = ?1 AND user_id = ?2)",
355 [id, user.id],
356 |r| r.get(0),
357 )?;
358 if !mine {
359 return Err(Error::NotFound);
360 }
361 }
362 db.execute(
363 "UPDATE users SET default_device_id = ?1 WHERE id = ?2",
364 params![b.device, user.id],
365 )?;
366 Ok(Json(()))
367}
368
369const POINT_COLS: &str = "ts, lat, lon, acc, alt, speed, bearing, battery";
370
371/// POINT_COLS, with the stored cell of `m` metres in place of the exact position.
372fn point_cols(m: u32) -> String {
373 match m {
374 0 => POINT_COLS.into(),
375 m => format!("ts, lat_{m}, lon_{m}, acc, alt, speed, bearing, battery"),
376 }
377}
378
379/// The cell columns of all PRECISIONS_M, in order.
380pub fn cell_cols() -> String {
381 PRECISIONS_M.map(|m| format!("lat_{m}, lon_{m}")).join(", ")
382}
383
384pub type Cells = [(f64, f64); PRECISIONS_M.len()];
385
386/// The cells for a new point of a device, given the cells of its previous point.
387pub fn cells(prev: Option<&Cells>, lat: f64, lon: f64) -> Cells {
388 std::array::from_fn(|i| cell(prev.map(|c| c[i]), lat, lon, PRECISIONS_M[i]))
389}
390
391/// The centre of a grid cell of about `m` metres. Keeps the previous cell until the point is a quarter cell
392/// past its edge. Otherwise GPS noise near an edge flips between two cells and shows where the edge is.
393fn cell(prev: Option<(f64, f64)>, lat: f64, lon: f64, m: u32) -> (f64, f64) {
394 let step = f64::from(m) / 111_320.0;
395 // A degree of longitude shrinks toward the poles. Using the snapped latitude keeps one grid per row.
396 let lon_step = |lat: f64| step / lat.to_radians().cos().max(0.01);
397 if let Some((clat, clon)) = prev
398 && (lat - clat).abs() <= 0.75 * step
399 && (lon - clon).abs() <= 0.75 * lon_step(clat)
400 {
401 return (clat, clon);
402 }
403 let clat = ((lat / step).round() * step).clamp(-90.0, 90.0);
404 let clon = ((lon / lon_step(clat)).round() * lon_step(clat)).clamp(-180.0, 180.0);
405 (clat, clon)
406}
407
408/// Reads the POINT_COLS columns, starting at column `i`.
409fn point_at(r: &Row, i: usize) -> rusqlite::Result<Point> {
410 Ok(Point {
411 ts: r.get(i)?,
412 lat: r.get(i + 1)?,
413 lon: r.get(i + 2)?,
414 acc: r.get(i + 3)?,
415 alt: r.get(i + 4)?,
416 speed: r.get(i + 5)?,
417 bearing: r.get(i + 6)?,
418 battery: r.get(i + 7)?,
419 })
420}
421
422/// Drops what would reveal more than a point read with `point_cols(m)` should. The position is already its cell.
423fn coarsen(p: &mut Point, m: u32) {
424 if m == 0 {
425 return;
426 }
427 // A jump to the next cell shows when the owner crossed the cell edge, and where that edge is.
428 // Rounding the time to m seconds keeps that crossing about m metres vague at walking speed.
429 p.ts -= p.ts.rem_euclid(i64::from(m));
430 p.acc = Some(p.acc.unwrap_or(0.0).max(m as f32));
431 p.alt = None;
432 p.speed = None;
433 p.bearing = None;
434 // The battery drains steadily, so it would date a point within its rounded time.
435 p.battery = None;
436}
437
438/// What a viewer may see of one owner.
439pub struct Access {
440 pub owner: i64,
441 pub username: String,
442 /// None for the viewer's own account.
443 pub share: Option<i64>,
444 pub all_devices: bool,
445 pub trail_since: Option<i64>,
446 pub precision_m: u32,
447}
448
449/// Columns of `shares s` that `access_at` reads, after the owner id and username.
450pub const ACCESS_COLS: &str = "s.id, s.all_devices, s.trail_since, s.precision_m";
451
452/// Reads an owner id, a username and ACCESS_COLS.
453pub fn access_at(r: &Row) -> rusqlite::Result<Access> {
454 Ok(Access {
455 owner: r.get(0)?,
456 username: r.get(1)?,
457 share: r.get(2)?,
458 all_devices: r.get(3)?,
459 trail_since: r.get(4)?,
460 precision_m: r.get(5)?,
461 })
462}
463
464/// Limits `devices d` to the ones an Access allows. Binds ?2 = share, ?3 = all_devices.
465const DEVICE_ALLOWED: &str =
466 "(?3 OR d.id IN (SELECT device_id FROM share_devices WHERE share_id = ?2))";
467
468/// The viewer first, then everyone with an active share to the viewer.
469fn accesses(db: &Connection, viewer: i64) -> rusqlite::Result<Vec<Access>> {
470 let mut list: Vec<Access> = db
471 .prepare_cached(&format!(
472 "SELECT id, username, NULL, 1, 0, 0 FROM users WHERE id = ?1
473 UNION ALL
474 SELECT u.id, u.username, {ACCESS_COLS}
475 FROM shares s JOIN users u ON u.id = s.owner_id
476 WHERE s.viewer_id = ?1 AND (s.expires_at IS NULL OR s.expires_at > ?2)"
477 ))?
478 .query_map(params![viewer, now()], access_at)?
479 .collect::<rusqlite::Result<_>>()?;
480 list.sort_by_key(|a| (a.owner != viewer, a.username.to_lowercase()));
481 Ok(list)
482}
483
484/// The owner's allowed devices with their newest point.
485pub fn person_for(db: &Connection, a: Access) -> rusqlite::Result<Person> {
486 let mut devices: Vec<PersonDevice> = db
487 .prepare_cached(&format!(
488 "SELECT d.id, d.name, {} FROM devices d
489 JOIN points p ON p.device_id = d.id AND p.ts = (SELECT MAX(ts) FROM points WHERE device_id = d.id)
490 WHERE d.user_id = ?1 AND {DEVICE_ALLOWED}",
491 point_cols(a.precision_m)
492 ))?
493 .query_map(params![a.owner, a.share, a.all_devices], |r| {
494 let mut last = point_at(r, 2)?;
495 coarsen(&mut last, a.precision_m);
496 Ok(PersonDevice {
497 id: r.get(0)?,
498 name: r.get(1)?,
499 last,
500 })
501 })?
502 .collect::<rusqlite::Result<_>>()?;
503 // By rounded time, so the order does not tell which device sent last within the same rounded time.
504 devices.sort_by_key(|d| (std::cmp::Reverse(d.last.ts), d.id));
505 let (display_name, default_device): (Option<String>, Option<i64>) = db.query_row(
506 "SELECT display_name, default_device_id FROM users WHERE id = ?1",
507 [a.owner],
508 |r| Ok((r.get(0)?, r.get(1)?)),
509 )?;
510 // A hidden device's id must not reach the viewer.
511 let default_device = default_device.filter(|id| devices.iter().any(|d| d.id == *id));
512 Ok(Person {
513 id: a.owner,
514 username: a.username,
515 display_name,
516 default_device,
517 devices,
518 trail_since: a.trail_since,
519 precision_m: a.precision_m,
520 })
521}
522
523fn people_for(db: &Connection, viewer: i64) -> rusqlite::Result<Vec<Person>> {
524 accesses(db, viewer)?
525 .into_iter()
526 .map(|a| person_for(db, a))
527 .collect()
528}
529
530async fn people(State(s): State<AppState>, user: User) -> Result<Json<Vec<Person>>> {
531 Ok(Json(people_for(&s.db(), user.id)?))
532}
533
534#[derive(Deserialize)]
535struct TrackQuery {
536 from: i64,
537 to: i64,
538 device: i64,
539}
540
541const MAX_TRACK_POINTS: i64 = 50_000;
542
543async fn track(
544 State(s): State<AppState>,
545 user: User,
546 UrlPath(id): UrlPath<i64>,
547 Query(q): Query<TrackQuery>,
548) -> Result<Json<Vec<Point>>> {
549 let db = s.db();
550 let a = accesses(&db, user.id)?
551 .into_iter()
552 .find(|a| a.owner == id)
553 .ok_or(Error::NotFound)?;
554 Ok(Json(track_points(&db, &a, q.device, q.from, q.to)?))
555}
556
557/// One device's points in a time range, as far as the access allows.
558pub fn track_points(
559 db: &Connection,
560 a: &Access,
561 device: i64,
562 from: i64,
563 to: i64,
564) -> Result<Vec<Point>> {
565 if to
566 .checked_sub(from)
567 .is_none_or(|d| !(0..=MAX_TRACK_SECS).contains(&d))
568 {
569 return Err(Error::BadRequest("range must be 0 to 31 days".into()));
570 }
571 let since = a.trail_since.ok_or(Error::Forbidden)?;
572 // Only whole m-second buckets. A bound inside one would split its points by raw time,
573 // and moving the bound would reveal the raw times that the rounding hides.
574 // Saturation only matters far from any stored time.
575 let m = i64::from(a.precision_m.max(1));
576 let floor = |t: i64| t.saturating_sub(t.rem_euclid(m));
577 let from = floor(from).max(floor(since.saturating_add(m - 1)));
578 let to = floor(to).saturating_add(m - 1);
579 let allowed: bool = db.query_row(
580 &format!(
581 "SELECT EXISTS (SELECT 1 FROM devices d WHERE d.id = ?4 AND d.user_id = ?1 AND {DEVICE_ALLOWED})"
582 ),
583 params![a.owner, a.share, a.all_devices, device],
584 |r| r.get(0),
585 )?;
586 if !allowed {
587 return Err(Error::NotFound);
588 }
589 // ponytail: past the limit the oldest points go. Thin the trail evenly if long ranges need all of it.
590 let mut points: Vec<Point> = db
591 .prepare_cached(&format!(
592 "SELECT * FROM (SELECT {} FROM points WHERE device_id = ?1 AND ts BETWEEN ?2 AND ?3
593 ORDER BY ts DESC LIMIT {MAX_TRACK_POINTS}) ORDER BY ts",
594 point_cols(a.precision_m)
595 ))?
596 .query_map(params![device, from, to], |r| {
597 let mut p = point_at(r, 0)?;
598 coarsen(&mut p, a.precision_m);
599 Ok(p)
600 })?
601 .collect::<rusqlite::Result<_>>()?;
602 // The limit can split the oldest bucket, which would show raw times again.
603 if points.len() as i64 == MAX_TRACK_POINTS && a.precision_m > 0 {
604 let oldest = points[0].ts;
605 points.retain(|p| p.ts != oldest);
606 }
607 points.dedup_by(|b, a| (a.ts, a.lat, a.lon) == (b.ts, b.lat, b.lon));
608 Ok(points)
609}
610
611async fn list_devices(State(s): State<AppState>, user: User) -> Result<Json<Vec<Device>>> {
612 let devices = s
613 .db()
614 .prepare_cached(
615 "SELECT id, name, token_hash IS NULL, created_at, last_seen_at FROM devices
616 WHERE user_id = ?1 ORDER BY created_at",
617 )?
618 .query_map([user.id], |r| {
619 Ok(Device {
620 id: r.get(0)?,
621 name: r.get(1)?,
622 web: r.get(2)?,
623 created_at: r.get(3)?,
624 last_seen_at: r.get(4)?,
625 })
626 })?
627 .collect::<rusqlite::Result<_>>()?;
628 Ok(Json(devices))
629}
630
631pub fn check_device_name(name: &str) -> Result<&str> {
632 let name = name.trim();
633 if name.is_empty() || name.chars().count() > 100 {
634 return Err(Error::BadRequest(
635 "device name must have 1 to 100 characters".into(),
636 ));
637 }
638 Ok(name)
639}
640
641pub fn insert_device(db: &Connection, user_id: i64, name: &str) -> Result<DeviceToken> {
642 let name = check_device_name(name)?;
643 let (token, hash) = auth::new_secret();
644 db.execute(
645 "INSERT INTO devices (user_id, name, token_hash, created_at) VALUES (?1, ?2, ?3, ?4)",
646 params![user_id, name, hash, now()],
647 )?;
648 Ok(DeviceToken { token })
649}
650
651async fn create_device(
652 State(s): State<AppState>,
653 user: User,
654 Json(b): Json<NewDevice>,
655) -> Result<Json<DeviceToken>> {
656 Ok(Json(insert_device(&s.db(), user.id, &b.name)?))
657}
658
659async fn delete_device(
660 State(s): State<AppState>,
661 user: User,
662 UrlPath(id): UrlPath<i64>,
663) -> Result<Json<()>> {
664 let n = s.db().execute(
665 "DELETE FROM devices WHERE id = ?1 AND user_id = ?2",
666 [id, user.id],
667 )?;
668 if n == 0 {
669 return Err(Error::NotFound);
670 }
671 Ok(Json(()))
672}
673
674/// Clock skew we accept from a device, so a wrong clock cannot write far into the future.
675const MAX_FUTURE_SECS: i64 = 86400;
676
677fn check_point(p: &Point, now: i64) -> std::result::Result<(), String> {
678 if !(-90.0..=90.0).contains(&p.lat) || !(-180.0..=180.0).contains(&p.lon) {
679 return Err(format!("point {}: coordinates out of range", p.ts));
680 }
681 if p.ts <= 0 || p.ts > now + MAX_FUTURE_SECS {
682 return Err(format!("point {}: timestamp out of range", p.ts));
683 }
684 if p.battery.is_some_and(|b| b > 100) {
685 return Err(format!("point {}: battery above 100", p.ts));
686 }
687 Ok(())
688}
689
690async fn upload(
691 State(s): State<AppState>,
692 uploader: auth::Uploader,
693 Json(points): Json<Vec<Point>>,
694) -> Result<Json<Uploaded>> {
695 if points.len() > MAX_BATCH {
696 return Err(Error::BadRequest(format!(
697 "at most {MAX_BATCH} points per request"
698 )));
699 }
700 let now = now();
701 let total = points.len();
702 let mut points: Vec<Point> = points
703 .into_iter()
704 .filter(|p| check_point(p, now).is_ok())
705 .collect();
706 // Each point's cells follow from the previous point's, so older points go first.
707 points.sort_by_key(|p| p.ts);
708
709 let mut db = s.db();
710 let tx = db.transaction()?;
711 let mut stored = 0;
712 {
713 let cols = cell_cols();
714 let mut insert = tx.prepare_cached(&format!(
715 "INSERT OR IGNORE INTO points (device_id, {POINT_COLS}, {cols}) VALUES ({})",
716 (1..=9 + 2 * PRECISIONS_M.len())
717 .map(|i| format!("?{i}"))
718 .collect::<Vec<_>>()
719 .join(", ")
720 ))?;
721 let mut prev = tx.prepare_cached(&format!(
722 "SELECT {cols} FROM points WHERE device_id = ?1 AND ts < ?2 ORDER BY ts DESC LIMIT 1"
723 ))?;
724 for p in &points {
725 let before: Option<Cells> = prev
726 .query_row(params![uploader.device_id, p.ts], |r| {
727 let mut c = [(0.0, 0.0); PRECISIONS_M.len()];
728 for (i, c) in c.iter_mut().enumerate() {
729 *c = (r.get(2 * i)?, r.get(2 * i + 1)?);
730 }
731 Ok(c)
732 })
733 .optional()?;
734 let cells = cells(before.as_ref(), p.lat, p.lon);
735 let mut values: Vec<&dyn ToSql> = vec![
736 &uploader.device_id,
737 &p.ts,
738 &p.lat,
739 &p.lon,
740 &p.acc,
741 &p.alt,
742 &p.speed,
743 &p.bearing,
744 &p.battery,
745 ];
746 values.extend(cells.iter().flat_map(|(a, b)| [a as &dyn ToSql, b]));
747 stored += insert.execute(values.as_slice())?;
748 }
749 }
750 tx.execute(
751 "UPDATE devices SET last_seen_at = ?1 WHERE id = ?2",
752 [now, uploader.device_id],
753 )?;
754 tx.commit()?;
755 Ok(Json(Uploaded {
756 stored,
757 skipped: total - points.len(),
758 }))
759}
760
761/// Reads ACCESS_COLS from column `i` on.
762pub fn settings_at(db: &Connection, r: &Row, i: usize) -> rusqlite::Result<ShareSettings> {
763 let id: i64 = r.get(i)?;
764 let devices = match r.get::<_, bool>(i + 1)? {
765 true => None,
766 false => Some(
767 db.prepare_cached("SELECT device_id FROM share_devices WHERE share_id = ?1")?
768 .query_map([id], |r| r.get(0))?
769 .collect::<rusqlite::Result<_>>()?,
770 ),
771 };
772 Ok(ShareSettings {
773 devices,
774 trail_since: r.get(i + 2)?,
775 precision_m: r.get(i + 3)?,
776 })
777}
778
779pub fn check_settings(set: &ShareSettings, expires_at: Option<i64>) -> Result<()> {
780 if expires_at.is_some_and(|t| t <= now()) {
781 return Err(Error::BadRequest("expiry must be in the future".into()));
782 }
783 if set.precision_m != 0 && !PRECISIONS_M.contains(&set.precision_m) {
784 return Err(Error::BadRequest(
785 "precision must be 0, 100, 1000, 10000 or 100000 metres".into(),
786 ));
787 }
788 if set.devices.as_ref().is_some_and(Vec::is_empty) {
789 return Err(Error::BadRequest("select at least one device".into()));
790 }
791 Ok(())
792}
793
794/// Writes the settings columns and the device selection of a share or link.
795pub fn save_settings(db: &Connection, id: i64, owner: i64, set: &ShareSettings) -> Result<()> {
796 db.execute(
797 "UPDATE shares SET all_devices = ?2, trail_since = ?3, precision_m = ?4 WHERE id = ?1",
798 params![id, set.devices.is_none(), set.trail_since, set.precision_m],
799 )?;
800 db.execute("DELETE FROM share_devices WHERE share_id = ?1", [id])?;
801 for device in set.devices.iter().flatten() {
802 let added = db.execute(
803 "INSERT OR IGNORE INTO share_devices SELECT ?1, id FROM devices WHERE id = ?2 AND user_id = ?3",
804 [id, *device, owner],
805 )?;
806 if added == 0 {
807 return Err(Error::BadRequest("no such device".into()));
808 }
809 }
810 Ok(())
811}
812
813async fn list_shares(State(s): State<AppState>, user: User) -> Result<Json<Shares>> {
814 let db = s.db();
815 let query = |other: &str, me: &str| -> rusqlite::Result<Vec<Share>> {
816 db.prepare_cached(&format!(
817 "SELECT u.username, s.expires_at, s.created_at, {ACCESS_COLS}
818 FROM shares s JOIN users u ON u.id = s.{other} WHERE s.{me} = ?1 ORDER BY u.username"
819 ))?
820 .query_map([user.id], |r| {
821 Ok(Share {
822 id: r.get(3)?,
823 username: r.get(0)?,
824 expires_at: r.get(1)?,
825 created_at: r.get(2)?,
826 settings: settings_at(&db, r, 3)?,
827 })
828 })?
829 .collect()
830 };
831 Ok(Json(Shares {
832 outgoing: query("viewer_id", "owner_id")?,
833 incoming: query("owner_id", "viewer_id")?,
834 }))
835}
836
837async fn create_share(
838 State(s): State<AppState>,
839 user: User,
840 Json(b): Json<NewShare>,
841) -> Result<Json<Share>> {
842 check_settings(&b.settings, b.expires_at)?;
843 let mut db = s.db();
844 let (viewer_id, username): (i64, String) = db
845 .query_row(
846 "SELECT id, username FROM users WHERE username = ?1",
847 [b.viewer.trim()],
848 |r| Ok((r.get(0)?, r.get(1)?)),
849 )
850 .optional()?
851 .ok_or_else(|| Error::BadRequest("no such user".into()))?;
852 if viewer_id == user.id {
853 return Err(Error::BadRequest("you cannot share with yourself".into()));
854 }
855 let tx = db.transaction()?;
856 let (id, created_at) = tx.query_row(
857 "INSERT INTO shares (owner_id, viewer_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4)
858 ON CONFLICT (owner_id, viewer_id) DO UPDATE SET expires_at = excluded.expires_at
859 RETURNING id, created_at",
860 params![user.id, viewer_id, b.expires_at, now()],
861 |r| Ok((r.get(0)?, r.get(1)?)),
862 )?;
863 save_settings(&tx, id, user.id, &b.settings)?;
864 tx.commit()?;
865 Ok(Json(Share {
866 id,
867 username,
868 expires_at: b.expires_at,
869 created_at,
870 settings: b.settings,
871 }))
872}
873
874/// Either side can end a share. Guest links have their own endpoint.
875async fn delete_share(
876 State(s): State<AppState>,
877 user: User,
878 UrlPath(id): UrlPath<i64>,
879) -> Result<Json<()>> {
880 let n = s.db().execute(
881 "DELETE FROM shares WHERE id = ?1 AND viewer_id IS NOT NULL AND (owner_id = ?2 OR viewer_id = ?2)",
882 [id, user.id],
883 )?;
884 if n == 0 {
885 return Err(Error::NotFound);
886 }
887 Ok(Json(()))
888}
889
890/// Everyone else's username, for picking whom to share with.
891async fn usernames(State(s): State<AppState>, user: User) -> Result<Json<Vec<String>>> {
892 let names = s
893 .db()
894 .prepare_cached("SELECT username FROM users WHERE id <> ?1 ORDER BY username")?
895 .query_map([user.id], |r| r.get(0))?
896 .collect::<rusqlite::Result<_>>()?;
897 Ok(Json(names))
898}
899
900async fn list_users(State(s): State<AppState>, _: Admin) -> Result<Json<Vec<api::User>>> {
901 let users = s
902 .db()
903 .prepare_cached("SELECT id, username, is_admin, created_at FROM users ORDER BY username")?
904 .query_map([], |r| {
905 Ok(api::User {
906 id: r.get(0)?,
907 username: r.get(1)?,
908 is_admin: r.get(2)?,
909 created_at: r.get(3)?,
910 })
911 })?
912 .collect::<rusqlite::Result<_>>()?;
913 Ok(Json(users))
914}
915
916async fn create_user(
917 State(s): State<AppState>,
918 Admin(admin): Admin,
919 Json(b): Json<NewUser>,
920) -> Result<Json<api::User>> {
921 admin.check_recent()?;
922 let username = crate::check_username(&b.username)?.to_owned();
923 auth::check_new_password(&b.password).map_err(|m| Error::BadRequest(m.into()))?;
924 let hash = auth::hash_password_async(b.password).await?;
925 let db = s.db();
926 let id = crate::insert_user(&db, &username, &hash, b.is_admin)?;
927 Ok(Json(api::User {
928 id,
929 username,
930 is_admin: b.is_admin,
931 created_at: now(),
932 }))
933}
934
935/// Admins cannot change their own role, so at least one admin always remains.
936/// The statements check that the caller is still an admin, so two admins cannot demote or delete each other at once.
937async fn set_role(
938 State(s): State<AppState>,
939 Admin(admin): Admin,
940 UrlPath(id): UrlPath<i64>,
941 Json(b): Json<SetRole>,
942) -> Result<Json<()>> {
943 if id == admin.id {
944 return Err(Error::BadRequest("you cannot change your own role".into()));
945 }
946 admin.check_recent()?;
947 if s.db().execute(
948 "UPDATE users SET is_admin = ?1 WHERE id = ?2 AND (SELECT is_admin FROM users WHERE id = ?3)",
949 params![b.is_admin, id, admin.id],
950 )? == 0
951 {
952 return Err(Error::NotFound);
953 }
954 Ok(Json(()))
955}
956
957async fn delete_user(
958 State(s): State<AppState>,
959 Admin(admin): Admin,
960 UrlPath(id): UrlPath<i64>,
961) -> Result<Json<()>> {
962 if id == admin.id {
963 return Err(Error::BadRequest(
964 "you cannot delete your own account".into(),
965 ));
966 }
967 if s.db().execute(
968 "DELETE FROM users WHERE id = ?1 AND (SELECT is_admin FROM users WHERE id = ?2)",
969 [id, admin.id],
970 )? == 0
971 {
972 return Err(Error::NotFound);
973 }
974 Ok(Json(()))
975}
976
977/// The recovery path for a user who lost their password or passkey.
978async fn reset_user_password(
979 State(s): State<AppState>,
980 Admin(admin): Admin,
981 UrlPath(id): UrlPath<i64>,
982 Json(b): Json<ResetPassword>,
983) -> Result<Json<()>> {
984 admin.check_recent()?;
985 auth::check_new_password(&b.password).map_err(|m| Error::BadRequest(m.into()))?;
986 let hash = auth::hash_password_async(b.password).await?;
987 let mut db = s.db();
988 if db
989 .query_row("SELECT 1 FROM users WHERE id = ?1", [id], |_| Ok(()))
990 .optional()?
991 .is_none()
992 {
993 return Err(Error::NotFound);
994 }
995 crate::reset_password(&mut db, id, &hash)?;
996 Ok(Json(()))
997}
998
999#[cfg(test)]
1000mod tests {
1001 use super::*;
1002
1003 #[test]
1004 fn point_validation() {
1005 let now = 1_800_000_000;
1006 let p = |ts, lat, lon| Point {
1007 ts,
1008 lat,
1009 lon,
1010 ..Default::default()
1011 };
1012 assert!(check_point(&p(now, 48.1, 11.5), now).is_ok());
1013 assert!(check_point(&p(now, 91.0, 0.0), now).is_err());
1014 assert!(check_point(&p(now, 0.0, -180.1), now).is_err());
1015 assert!(check_point(&p(now + 2 * MAX_FUTURE_SECS, 0.0, 0.0), now).is_err());
1016 assert!(
1017 check_point(
1018 &Point {
1019 battery: Some(101),
1020 ..p(now, 0.0, 0.0)
1021 },
1022 now
1023 )
1024 .is_err()
1025 );
1026 }
1027
1028 #[test]
1029 fn coarse_points_hide_motion() {
1030 let exact = Point {
1031 ts: 1_800_000_999,
1032 lat: 48.137_15,
1033 lon: 11.575_49,
1034 acc: Some(5.0),
1035 speed: Some(3.0),
1036 battery: Some(80),
1037 ..Default::default()
1038 };
1039 let mut p = exact.clone();
1040 coarsen(&mut p, 0);
1041 assert_eq!(p, exact);
1042 coarsen(&mut p, 1000);
1043 assert_eq!(
1044 (p.acc, p.speed, p.battery, p.ts),
1045 (Some(1000.0), None, None, 1_800_000_000)
1046 );
1047 }
1048
1049 #[test]
1050 fn cells_stay_near_and_stick_through_noise() {
1051 let (lat, lon) = (48.137_15, 11.575_49);
1052 let (clat, clon) = cell(None, lat, lon, 1000);
1053 let (dy, dx) = (
1054 (clat - lat) * 111_320.0,
1055 (clon - lon) * 111_320.0 * lat.to_radians().cos(),
1056 );
1057 assert!(
1058 dy.abs() <= 500.0 && dx.abs() <= 510.0,
1059 "moved {dy} m, {dx} m"
1060 );
1061 // A point 0.1 m past the south edge, then noise of 10 m around the edge.
1062 let edge = clat - 500.0 / 111_320.0;
1063 let first = cell(None, edge - 0.1 / 111_320.0, clon, 1000);
1064 let mut c = first;
1065 for i in 0..20 {
1066 let noise = if i % 2 == 0 { 10.0 } else { -10.0 };
1067 c = cell(Some(c), edge + noise / 111_320.0, clon, 1000);
1068 assert_eq!(c, first);
1069 }
1070 // Clearly in the next cell.
1071 let moved = cell(Some(c), clat, clon, 1000);
1072 assert_eq!(moved, (clat, clon));
1073 }
1074
1075 fn coarse_db() -> Connection {
1076 let db = crate::test_db();
1077 db.execute_batch(
1078 "INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0);
1079 INSERT INTO devices (id, user_id, name, token_hash, created_at) VALUES (1, 1, 'p', x'01', 0);
1080 INSERT INTO points (device_id, ts, lat, lon, lat_100, lon_100) VALUES
1081 (1, 1000, 0, 0, 0, 0), (1, 1042, 1, 1, 1, 1), (1, 1099, 2, 2, 2, 2), (1, 1100, 3, 3, 3, 3);",
1082 )
1083 .unwrap();
1084 db
1085 }
1086
1087 fn coarse(trail_since: i64) -> Access {
1088 Access {
1089 owner: 1,
1090 username: "a".into(),
1091 share: None,
1092 all_devices: true,
1093 trail_since: Some(trail_since),
1094 precision_m: 100,
1095 }
1096 }
1097
1098 #[test]
1099 fn coarse_tracks_cover_whole_buckets_only() {
1100 let db = coarse_db();
1101 let lats = |a: &Access, from, to| -> Vec<f64> {
1102 track_points(&db, a, 1, from, to)
1103 .unwrap()
1104 .iter()
1105 .map(|p| p.lat)
1106 .collect()
1107 };
1108 // Any bound inside a bucket gives the whole bucket, so it cannot split 1042 from 1099.
1109 for to in [1000, 1041, 1042, 1099] {
1110 assert_eq!(lats(&coarse(0), 1000, to), [0.0, 1.0, 2.0], "to {to}");
1111 }
1112 for from in [1001, 1042, 1043, 1099] {
1113 assert_eq!(lats(&coarse(0), from, 1099), [0.0, 1.0, 2.0], "from {from}");
1114 }
1115 // A trail start inside a bucket leaves out the whole bucket.
1116 assert_eq!(lats(&coarse(1042), 0, 2000), [3.0]);
1117 }
1118
1119 #[test]
1120 fn huge_ranges_are_refused() {
1121 let db = coarse_db();
1122 assert!(matches!(
1123 track_points(&db, &coarse(0), 1, i64::MIN, i64::MAX),
1124 Err(Error::BadRequest(_))
1125 ));
1126 assert!(track_points(&db, &coarse(i64::MIN), 1, i64::MAX - 10, i64::MAX).is_ok());
1127 }
1128
1129 async fn admin(s: &AppState, id: i64) -> Result<Json<()>> {
1130 let caller = Admin(User {
1131 id,
1132 username: String::new(),
1133 is_admin: true,
1134 session_hash: vec![],
1135 signed_in_at: now(),
1136 });
1137 set_role(
1138 State(s.clone()),
1139 caller,
1140 UrlPath(3 - id),
1141 Json(SetRole { is_admin: false }),
1142 )
1143 .await
1144 }
1145
1146 #[tokio::test]
1147 async fn two_admins_cannot_demote_each_other() {
1148 let s = crate::test_state();
1149 s.db()
1150 .execute_batch(
1151 "INSERT INTO users (id, username, webauthn_id, is_admin, created_at) VALUES (1, 'a', '1', 1, 0), (2, 'b', '2', 1, 0);",
1152 )
1153 .unwrap();
1154 assert!(admin(&s, 1).await.is_ok());
1155 // User 2's request passed the extractor before user 1 demoted them.
1156 assert!(admin(&s, 2).await.is_err());
1157 let admins: i64 = s
1158 .db()
1159 .query_row("SELECT COUNT(*) FROM users WHERE is_admin", [], |r| {
1160 r.get(0)
1161 })
1162 .unwrap();
1163 assert_eq!(admins, 1);
1164 }
1165
1166 #[test]
1167 fn people_shows_only_shared_devices() {
1168 let db = crate::test_db();
1169 db.execute_batch(
1170 "INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0), (2, 'b', '2', 0);
1171 INSERT INTO devices (id, user_id, name, token_hash, created_at) VALUES (10, 2, 'phone', x'01', 0), (11, 2, 'car', x'02', 0);
1172 INSERT INTO points (device_id, ts, lat, lon) VALUES (10, 100, 1, 1), (11, 200, 2, 2);
1173 INSERT INTO shares (id, owner_id, viewer_id, created_at, all_devices) VALUES (5, 2, 1, 0, 1);",
1174 )
1175 .unwrap();
1176 let devices = |db: &Connection| -> Vec<String> {
1177 people_for(db, 1).unwrap()[1]
1178 .devices
1179 .iter()
1180 .map(|d| d.name.clone())
1181 .collect()
1182 };
1183 let default = |db: &Connection| people_for(db, 1).unwrap()[1].default_device;
1184 db.execute("UPDATE users SET default_device_id = 11 WHERE id = 2", [])
1185 .unwrap();
1186 assert_eq!(devices(&db), ["car", "phone"]);
1187 assert_eq!(default(&db), Some(11));
1188 db.execute_batch(
1189 "UPDATE shares SET all_devices = 0; INSERT INTO share_devices VALUES (5, 10);",
1190 )
1191 .unwrap();
1192 assert_eq!(devices(&db), ["phone"]);
1193 assert_eq!(default(&db), None);
1194 db.execute("DELETE FROM devices WHERE id = 11", []).unwrap();
1195 let cleared: Option<i64> = db
1196 .query_row(
1197 "SELECT default_device_id FROM users WHERE id = 2",
1198 [],
1199 |r| r.get(0),
1200 )
1201 .unwrap();
1202 assert_eq!(cleared, None);
1203 }
1204
1205 #[test]
1206 fn people_respects_share_expiry() {
1207 let db = crate::test_db();
1208 db.execute_batch(
1209 "INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0), (2, 'b', '2', 0), (3, 'c', '3', 0);
1210 INSERT INTO shares (owner_id, viewer_id, expires_at, created_at) VALUES (2, 1, NULL, 0), (3, 1, 1, 0);",
1211 )
1212 .unwrap();
1213 let names: Vec<_> = people_for(&db, 1)
1214 .unwrap()
1215 .into_iter()
1216 .map(|p| p.username)
1217 .collect();
1218 assert_eq!(names, ["a", "b"]);
1219 }
1220}
1221