routes.rs
| 1 | use std::path::Path; |
| 2 | |
| 3 | use api::{ |
| 4 | ChangePassword, Credentials, Device, DeviceToken, Login, MAX_BATCH, MAX_PRECISION_M, |
| 5 | MAX_TRACK_SECS, Me, NewDevice, NewShare, NewUser, Person, PersonDevice, Point, ResetPassword, |
| 6 | SetRetention, SetRole, SetTwoFactor, SetupStatus, Share, ShareSettings, Shares, Uploaded, |
| 7 | }; |
| 8 | use axum::extract::{Path as UrlPath, Query, State}; |
| 9 | use axum::http::{HeaderMap, Uri, header}; |
| 10 | use axum::response::{IntoResponse, Response}; |
| 11 | use axum::routing::{delete, get, post, put}; |
| 12 | use axum::{Json, Router}; |
| 13 | use rusqlite::{Connection, OptionalExtension, Row, params}; |
| 14 | use serde::Deserialize; |
| 15 | use tower_http::services::ServeDir; |
| 16 | |
| 17 | use crate::auth::{self, Admin, ClientIp, User}; |
| 18 | use crate::passkeys::{self, Pending}; |
| 19 | use crate::{AppState, Error, now}; |
| 20 | use crate::{device, guest}; |
| 21 | |
| 22 | type Result<T> = std::result::Result<T, Error>; |
| 23 | |
| 24 | pub fn router(state: AppState, web_dir: &Path) -> Router { |
| 25 | Router::new() |
| 26 | .route("/api/setup", get(setup_status).post(setup)) |
| 27 | .route("/api/login", post(login)) |
| 28 | .route("/api/logout", post(logout)) |
| 29 | .route("/api/passkey/login", post(passkeys::login_begin)) |
| 30 | .route("/api/passkey/login/finish", post(passkeys::login_finish)) |
| 31 | .route("/api/me", get(me)) |
| 32 | .route( |
| 33 | "/api/me/password", |
| 34 | post(change_password).delete(delete_password), |
| 35 | ) |
| 36 | .route("/api/me/two-factor", put(set_two_factor)) |
| 37 | .route("/api/me/retention", put(set_retention)) |
| 38 | .route("/api/passkeys", get(passkeys::list)) |
| 39 | .route("/api/passkeys/register", post(passkeys::register_begin)) |
| 40 | .route( |
| 41 | "/api/passkeys/register/finish", |
| 42 | post(passkeys::register_finish), |
| 43 | ) |
| 44 | .route("/api/passkeys/{id}", delete(passkeys::delete)) |
| 45 | .route("/api/people", get(people)) |
| 46 | .route("/api/people/{id}/track", get(track)) |
| 47 | .route("/api/devices", get(list_devices).post(create_device)) |
| 48 | .route("/api/devices/pair/begin", post(device::pair_begin)) |
| 49 | .route("/api/devices/pair", post(device::pair_finish)) |
| 50 | .route("/api/device", get(device::me)) |
| 51 | .route("/api/device/track", get(device::track)) |
| 52 | .route("/api/devices/{id}", delete(delete_device)) |
| 53 | .route("/api/points", post(upload)) |
| 54 | .route("/api/shares", get(list_shares).post(create_share)) |
| 55 | .route("/api/shares/{id}", delete(delete_share)) |
| 56 | .route("/api/usernames", get(usernames)) |
| 57 | .route("/api/users", get(list_users).post(create_user)) |
| 58 | .route("/api/users/{id}", delete(delete_user)) |
| 59 | .route("/api/users/{id}/role", put(set_role)) |
| 60 | .route("/api/users/{id}/password", post(reset_user_password)) |
| 61 | .route("/api/links", get(guest::list).post(guest::create)) |
| 62 | .route("/api/links/{id}", delete(guest::delete)) |
| 63 | .route("/api/guest", post(guest::view)) |
| 64 | .route("/api/guest/track", post(guest::track)) |
| 65 | .route("/api/guest/unlock", post(guest::unlock)) |
| 66 | .route("/healthz", get(healthz)) |
| 67 | .fallback_service(ServeDir::new(web_dir)) |
| 68 | .with_state(state) |
| 69 | } |
| 70 | |
| 71 | async fn healthz(State(s): State<AppState>) -> Result<&'static str> { |
| 72 | s.db().query_row("SELECT 1", [], |_| Ok(()))?; |
| 73 | Ok("ok") |
| 74 | } |
| 75 | |
| 76 | fn no_users(db: &Connection) -> rusqlite::Result<bool> { |
| 77 | db.query_row("SELECT NOT EXISTS (SELECT 1 FROM users)", [], |r| r.get(0)) |
| 78 | } |
| 79 | |
| 80 | async fn setup_status(State(s): State<AppState>) -> Result<Json<SetupStatus>> { |
| 81 | Ok(Json(SetupStatus { |
| 82 | needed: no_users(&s.db())?, |
| 83 | })) |
| 84 | } |
| 85 | |
| 86 | /// Creates the first account, an admin. Only works while no user exists. |
| 87 | async fn setup(State(s): State<AppState>, Json(b): Json<Credentials>) -> Result<Response> { |
| 88 | let username = crate::check_username(&b.username)?.to_owned(); |
| 89 | auth::check_new_password(&b.password).map_err(|m| Error::BadRequest(m.into()))?; |
| 90 | let already = || Error::Conflict("the server is already set up".into()); |
| 91 | // Checked before hashing, so a request to a set-up server costs no Argon2 work. |
| 92 | if !no_users(&s.db())? { |
| 93 | return Err(already()); |
| 94 | } |
| 95 | let hash = auth::hash_password_async(b.password).await?; |
| 96 | let id = { |
| 97 | let db = s.db(); |
| 98 | // Checked again under the same lock as the insert, so two setups cannot both win. |
| 99 | if !no_users(&db)? { |
| 100 | return Err(already()); |
| 101 | } |
| 102 | crate::insert_user(&db, &username, &hash, true)? |
| 103 | }; |
| 104 | passkeys::sign_in(&s, id) |
| 105 | } |
| 106 | |
| 107 | async fn login( |
| 108 | State(s): State<AppState>, |
| 109 | ClientIp(ip): ClientIp, |
| 110 | uri: Uri, |
| 111 | headers: HeaderMap, |
| 112 | Json(b): Json<Login>, |
| 113 | ) -> Result<Response> { |
| 114 | match &b.state_id { |
| 115 | // The passkey already passed. This is the password step of a two-factor sign-in. |
| 116 | Some(state_id) => { |
| 117 | let Some(Pending::NeedsPassword { user_id }) = s.ceremonies.take(state_id) else { |
| 118 | return Err(passkeys::expired()); |
| 119 | }; |
| 120 | let username: String = |
| 121 | s.db() |
| 122 | .query_row("SELECT username FROM users WHERE id = ?1", [user_id], |r| { |
| 123 | r.get(0) |
| 124 | })?; |
| 125 | let ok = auth::check_password(&s, ip, &username, &b.password).await?; |
| 126 | passkeys::sign_in(&s, ok.id) |
| 127 | } |
| 128 | None => { |
| 129 | let ok = auth::check_password(&s, ip, b.username.trim(), &b.password).await?; |
| 130 | if ok.two_factor { |
| 131 | return passkeys::second_factor(&s, &uri, &headers, ok.id); |
| 132 | } |
| 133 | passkeys::sign_in(&s, ok.id) |
| 134 | } |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | async fn logout(State(s): State<AppState>, user: User) -> Result<impl IntoResponse> { |
| 139 | s.db().execute( |
| 140 | "DELETE FROM sessions WHERE token_hash = ?1", |
| 141 | [user.session_hash], |
| 142 | )?; |
| 143 | Ok(([(header::SET_COOKIE, auth::clear_session(&s))], Json(()))) |
| 144 | } |
| 145 | |
| 146 | async fn me(State(s): State<AppState>, user: User) -> Result<Json<Me>> { |
| 147 | let (has_password, two_factor, retention_days) = s.db().query_row( |
| 148 | "SELECT pw_hash IS NOT NULL, two_factor, retention_days FROM users WHERE id = ?1", |
| 149 | [user.id], |
| 150 | |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), |
| 151 | )?; |
| 152 | Ok(Json(Me { |
| 153 | id: user.id, |
| 154 | username: user.username, |
| 155 | is_admin: user.is_admin, |
| 156 | has_password, |
| 157 | two_factor, |
| 158 | retention_days, |
| 159 | max_retention_days: (s.max_retention_days > 0).then_some(s.max_retention_days), |
| 160 | public_url: s |
| 161 | .public_url |
| 162 | .as_ref() |
| 163 | .map(|u| u.as_str().trim_end_matches('/').to_owned()), |
| 164 | })) |
| 165 | } |
| 166 | |
| 167 | /// Sets or changes the password. Changing an existing one needs the old one. |
| 168 | async fn change_password( |
| 169 | State(s): State<AppState>, |
| 170 | ClientIp(ip): ClientIp, |
| 171 | user: User, |
| 172 | Json(b): Json<ChangePassword>, |
| 173 | ) -> Result<Json<()>> { |
| 174 | auth::check_new_password(&b.new).map_err(|m| Error::BadRequest(m.into()))?; |
| 175 | let has_password: bool = s.db().query_row( |
| 176 | "SELECT pw_hash IS NOT NULL FROM users WHERE id = ?1", |
| 177 | [user.id], |
| 178 | |r| r.get(0), |
| 179 | )?; |
| 180 | if has_password { |
| 181 | // 400, not 401: the session is still valid, only the old password is wrong. |
| 182 | auth::check_password(&s, ip, &user.username, b.old.as_deref().unwrap_or_default()) |
| 183 | .await |
| 184 | .map_err(|e| match e { |
| 185 | Error::Unauthorized => Error::BadRequest("wrong current password".into()), |
| 186 | e => e, |
| 187 | })?; |
| 188 | } |
| 189 | let hash = auth::hash_password_async(b.new).await?; |
| 190 | s.db().execute( |
| 191 | "UPDATE users SET pw_hash = ?1 WHERE id = ?2", |
| 192 | params![hash, user.id], |
| 193 | )?; |
| 194 | auth::end_other_sessions(&s, &user)?; |
| 195 | Ok(Json(())) |
| 196 | } |
| 197 | |
| 198 | /// Leaves the account on passkeys alone. |
| 199 | async fn delete_password(State(s): State<AppState>, user: User) -> Result<Json<()>> { |
| 200 | let db = s.db(); |
| 201 | if passkeys::count(&db, user.id)? == 0 { |
| 202 | return Err(Error::BadRequest( |
| 203 | "add a passkey before removing your password".into(), |
| 204 | )); |
| 205 | } |
| 206 | let two_factor: bool = db.query_row( |
| 207 | "SELECT two_factor FROM users WHERE id = ?1", |
| 208 | [user.id], |
| 209 | |r| r.get(0), |
| 210 | )?; |
| 211 | if two_factor { |
| 212 | return Err(Error::BadRequest( |
| 213 | "turn off two-factor sign-in before removing your password".into(), |
| 214 | )); |
| 215 | } |
| 216 | db.execute("UPDATE users SET pw_hash = NULL WHERE id = ?1", [user.id])?; |
| 217 | drop(db); |
| 218 | auth::end_other_sessions(&s, &user)?; |
| 219 | Ok(Json(())) |
| 220 | } |
| 221 | |
| 222 | async fn set_two_factor( |
| 223 | State(s): State<AppState>, |
| 224 | user: User, |
| 225 | Json(b): Json<SetTwoFactor>, |
| 226 | ) -> Result<Json<()>> { |
| 227 | let db = s.db(); |
| 228 | if b.enabled { |
| 229 | let has_password: bool = db.query_row( |
| 230 | "SELECT pw_hash IS NOT NULL FROM users WHERE id = ?1", |
| 231 | [user.id], |
| 232 | |r| r.get(0), |
| 233 | )?; |
| 234 | if !has_password { |
| 235 | return Err(Error::BadRequest( |
| 236 | "set a password before turning on two-factor sign-in".into(), |
| 237 | )); |
| 238 | } |
| 239 | if passkeys::count(&db, user.id)? == 0 { |
| 240 | return Err(Error::BadRequest( |
| 241 | "add a passkey before turning on two-factor sign-in".into(), |
| 242 | )); |
| 243 | } |
| 244 | } |
| 245 | db.execute( |
| 246 | "UPDATE users SET two_factor = ?1 WHERE id = ?2", |
| 247 | params![b.enabled, user.id], |
| 248 | )?; |
| 249 | drop(db); |
| 250 | auth::end_other_sessions(&s, &user)?; |
| 251 | Ok(Json(())) |
| 252 | } |
| 253 | |
| 254 | /// Users can only keep their points for less time than the server allows, never longer. |
| 255 | async fn set_retention( |
| 256 | State(s): State<AppState>, |
| 257 | user: User, |
| 258 | Json(b): Json<SetRetention>, |
| 259 | ) -> Result<Json<()>> { |
| 260 | if let Some(days) = b.days { |
| 261 | let max = s.max_retention_days; |
| 262 | if days < 1 || (max > 0 && days > max) { |
| 263 | let range = if max > 0 { |
| 264 | format!("1 to {max}") |
| 265 | } else { |
| 266 | "at least 1".into() |
| 267 | }; |
| 268 | return Err(Error::BadRequest(format!("retention must be {range} days"))); |
| 269 | } |
| 270 | } |
| 271 | let db = s.db(); |
| 272 | db.execute( |
| 273 | "UPDATE users SET retention_days = ?1 WHERE id = ?2", |
| 274 | params![b.days, user.id], |
| 275 | )?; |
| 276 | crate::purge_points(&db, user.id, b.days, s.max_retention_days)?; |
| 277 | Ok(Json(())) |
| 278 | } |
| 279 | |
| 280 | const POINT_COLS: &str = "ts, lat, lon, acc, alt, speed, bearing, battery"; |
| 281 | |
| 282 | /// Reads the POINT_COLS columns, starting at column `i`. |
| 283 | fn point_at(r: &Row, i: usize) -> rusqlite::Result<Point> { |
| 284 | Ok(Point { |
| 285 | ts: r.get(i)?, |
| 286 | lat: r.get(i + 1)?, |
| 287 | lon: r.get(i + 2)?, |
| 288 | acc: r.get(i + 3)?, |
| 289 | alt: r.get(i + 4)?, |
| 290 | speed: r.get(i + 5)?, |
| 291 | bearing: r.get(i + 6)?, |
| 292 | battery: r.get(i + 7)?, |
| 293 | }) |
| 294 | } |
| 295 | |
| 296 | /// Snaps a point to a grid of about `m` metres and drops the fields that would reveal more. |
| 297 | fn coarsen(p: &mut Point, m: u32) { |
| 298 | if m == 0 { |
| 299 | return; |
| 300 | } |
| 301 | // A jump to the next cell shows when the owner crossed the cell edge, and where that edge is. |
| 302 | // Rounding the time to m seconds keeps that crossing about m metres vague at walking speed. |
| 303 | p.ts -= p.ts.rem_euclid(i64::from(m)); |
| 304 | let step = f64::from(m) / 111_320.0; |
| 305 | p.lat = ((p.lat / step).round() * step).clamp(-90.0, 90.0); |
| 306 | // A degree of longitude shrinks toward the poles. Using the snapped latitude keeps one grid per row. |
| 307 | let lon_step = step / p.lat.to_radians().cos().max(0.01); |
| 308 | p.lon = ((p.lon / lon_step).round() * lon_step).clamp(-180.0, 180.0); |
| 309 | p.acc = Some(p.acc.unwrap_or(0.0).max(m as f32)); |
| 310 | p.alt = None; |
| 311 | p.speed = None; |
| 312 | p.bearing = None; |
| 313 | } |
| 314 | |
| 315 | /// What a viewer may see of one owner. |
| 316 | pub struct Access { |
| 317 | pub owner: i64, |
| 318 | pub username: String, |
| 319 | /// None for the viewer's own account. |
| 320 | pub share: Option<i64>, |
| 321 | pub all_devices: bool, |
| 322 | pub trail_since: Option<i64>, |
| 323 | pub precision_m: u32, |
| 324 | } |
| 325 | |
| 326 | /// Columns of `shares s` that `access_at` reads, after the owner id and username. |
| 327 | pub const ACCESS_COLS: &str = "s.id, s.all_devices, s.trail_since, s.precision_m"; |
| 328 | |
| 329 | /// Reads an owner id, a username and ACCESS_COLS. |
| 330 | pub fn access_at(r: &Row) -> rusqlite::Result<Access> { |
| 331 | Ok(Access { |
| 332 | owner: r.get(0)?, |
| 333 | username: r.get(1)?, |
| 334 | share: r.get(2)?, |
| 335 | all_devices: r.get(3)?, |
| 336 | trail_since: r.get(4)?, |
| 337 | precision_m: r.get(5)?, |
| 338 | }) |
| 339 | } |
| 340 | |
| 341 | /// Limits `devices d` to the ones an Access allows. Binds ?2 = share, ?3 = all_devices. |
| 342 | const DEVICE_ALLOWED: &str = |
| 343 | "(?3 OR d.id IN (SELECT device_id FROM share_devices WHERE share_id = ?2))"; |
| 344 | |
| 345 | /// The viewer first, then everyone with an active share to the viewer. |
| 346 | fn accesses(db: &Connection, viewer: i64) -> rusqlite::Result<Vec<Access>> { |
| 347 | let mut list: Vec<Access> = db |
| 348 | .prepare_cached(&format!( |
| 349 | "SELECT id, username, NULL, 1, 0, 0 FROM users WHERE id = ?1 |
| 350 | UNION ALL |
| 351 | SELECT u.id, u.username, {ACCESS_COLS} |
| 352 | FROM shares s JOIN users u ON u.id = s.owner_id |
| 353 | WHERE s.viewer_id = ?1 AND (s.expires_at IS NULL OR s.expires_at > ?2)" |
| 354 | ))? |
| 355 | .query_map(params![viewer, now()], access_at)? |
| 356 | .collect::<rusqlite::Result<_>>()?; |
| 357 | list.sort_by_key(|a| (a.owner != viewer, a.username.to_lowercase())); |
| 358 | Ok(list) |
| 359 | } |
| 360 | |
| 361 | /// The owner's allowed devices with their newest point. |
| 362 | pub fn person_for(db: &Connection, a: Access) -> rusqlite::Result<Person> { |
| 363 | let devices = db |
| 364 | .prepare_cached(&format!( |
| 365 | "SELECT d.id, d.name, {POINT_COLS} FROM devices d |
| 366 | JOIN points p ON p.device_id = d.id AND p.ts = (SELECT MAX(ts) FROM points WHERE device_id = d.id) |
| 367 | WHERE d.user_id = ?1 AND {DEVICE_ALLOWED} |
| 368 | ORDER BY p.ts DESC" |
| 369 | ))? |
| 370 | .query_map(params![a.owner, a.share, a.all_devices], |r| { |
| 371 | let mut last = point_at(r, 2)?; |
| 372 | coarsen(&mut last, a.precision_m); |
| 373 | Ok(PersonDevice { |
| 374 | id: r.get(0)?, |
| 375 | name: r.get(1)?, |
| 376 | last, |
| 377 | }) |
| 378 | })? |
| 379 | .collect::<rusqlite::Result<_>>()?; |
| 380 | Ok(Person { |
| 381 | id: a.owner, |
| 382 | username: a.username, |
| 383 | devices, |
| 384 | trail_since: a.trail_since, |
| 385 | precision_m: a.precision_m, |
| 386 | }) |
| 387 | } |
| 388 | |
| 389 | fn people_for(db: &Connection, viewer: i64) -> rusqlite::Result<Vec<Person>> { |
| 390 | accesses(db, viewer)? |
| 391 | .into_iter() |
| 392 | .map(|a| person_for(db, a)) |
| 393 | .collect() |
| 394 | } |
| 395 | |
| 396 | async fn people(State(s): State<AppState>, user: User) -> Result<Json<Vec<Person>>> { |
| 397 | Ok(Json(people_for(&s.db(), user.id)?)) |
| 398 | } |
| 399 | |
| 400 | #[derive(Deserialize)] |
| 401 | struct TrackQuery { |
| 402 | from: i64, |
| 403 | to: i64, |
| 404 | device: i64, |
| 405 | } |
| 406 | |
| 407 | const MAX_TRACK_POINTS: i64 = 50_000; |
| 408 | |
| 409 | async fn track( |
| 410 | State(s): State<AppState>, |
| 411 | user: User, |
| 412 | UrlPath(id): UrlPath<i64>, |
| 413 | Query(q): Query<TrackQuery>, |
| 414 | ) -> Result<Json<Vec<Point>>> { |
| 415 | let db = s.db(); |
| 416 | let a = accesses(&db, user.id)? |
| 417 | .into_iter() |
| 418 | .find(|a| a.owner == id) |
| 419 | .ok_or(Error::NotFound)?; |
| 420 | Ok(Json(track_points(&db, &a, q.device, q.from, q.to)?)) |
| 421 | } |
| 422 | |
| 423 | /// One device's points in a time range, as far as the access allows. |
| 424 | pub fn track_points( |
| 425 | db: &Connection, |
| 426 | a: &Access, |
| 427 | device: i64, |
| 428 | from: i64, |
| 429 | to: i64, |
| 430 | ) -> Result<Vec<Point>> { |
| 431 | if to < from || to - from > MAX_TRACK_SECS { |
| 432 | return Err(Error::BadRequest("range must be 0 to 31 days".into())); |
| 433 | } |
| 434 | let from = from.max(a.trail_since.ok_or(Error::Forbidden)?); |
| 435 | let allowed: bool = db.query_row( |
| 436 | &format!( |
| 437 | "SELECT EXISTS (SELECT 1 FROM devices d WHERE d.id = ?4 AND d.user_id = ?1 AND {DEVICE_ALLOWED})" |
| 438 | ), |
| 439 | params![a.owner, a.share, a.all_devices, device], |
| 440 | |r| r.get(0), |
| 441 | )?; |
| 442 | if !allowed { |
| 443 | return Err(Error::NotFound); |
| 444 | } |
| 445 | // ponytail: past the limit the oldest points go. Thin the trail evenly if long ranges need all of it. |
| 446 | let mut points: Vec<Point> = db |
| 447 | .prepare_cached(&format!( |
| 448 | "SELECT * FROM (SELECT {POINT_COLS} FROM points WHERE device_id = ?1 AND ts BETWEEN ?2 AND ?3 |
| 449 | ORDER BY ts DESC LIMIT {MAX_TRACK_POINTS}) ORDER BY ts" |
| 450 | ))? |
| 451 | .query_map(params![device, from, to], |r| { |
| 452 | let mut p = point_at(r, 0)?; |
| 453 | coarsen(&mut p, a.precision_m); |
| 454 | Ok(p) |
| 455 | })? |
| 456 | .collect::<rusqlite::Result<_>>()?; |
| 457 | points.dedup_by(|b, a| (a.ts, a.lat, a.lon) == (b.ts, b.lat, b.lon)); |
| 458 | Ok(points) |
| 459 | } |
| 460 | |
| 461 | async fn list_devices(State(s): State<AppState>, user: User) -> Result<Json<Vec<Device>>> { |
| 462 | let devices = s |
| 463 | .db() |
| 464 | .prepare_cached( |
| 465 | "SELECT id, name, token_hash IS NULL, created_at, last_seen_at FROM devices |
| 466 | WHERE user_id = ?1 ORDER BY created_at", |
| 467 | )? |
| 468 | .query_map([user.id], |r| { |
| 469 | Ok(Device { |
| 470 | id: r.get(0)?, |
| 471 | name: r.get(1)?, |
| 472 | web: r.get(2)?, |
| 473 | created_at: r.get(3)?, |
| 474 | last_seen_at: r.get(4)?, |
| 475 | }) |
| 476 | })? |
| 477 | .collect::<rusqlite::Result<_>>()?; |
| 478 | Ok(Json(devices)) |
| 479 | } |
| 480 | |
| 481 | pub fn check_device_name(name: &str) -> Result<&str> { |
| 482 | let name = name.trim(); |
| 483 | if name.is_empty() || name.chars().count() > 100 { |
| 484 | return Err(Error::BadRequest( |
| 485 | "device name must have 1 to 100 characters".into(), |
| 486 | )); |
| 487 | } |
| 488 | Ok(name) |
| 489 | } |
| 490 | |
| 491 | pub fn insert_device(db: &Connection, user_id: i64, name: &str) -> Result<DeviceToken> { |
| 492 | let name = check_device_name(name)?; |
| 493 | let (token, hash) = auth::new_secret(); |
| 494 | db.execute( |
| 495 | "INSERT INTO devices (user_id, name, token_hash, created_at) VALUES (?1, ?2, ?3, ?4)", |
| 496 | params![user_id, name, hash, now()], |
| 497 | )?; |
| 498 | Ok(DeviceToken { token }) |
| 499 | } |
| 500 | |
| 501 | async fn create_device( |
| 502 | State(s): State<AppState>, |
| 503 | user: User, |
| 504 | Json(b): Json<NewDevice>, |
| 505 | ) -> Result<Json<DeviceToken>> { |
| 506 | Ok(Json(insert_device(&s.db(), user.id, &b.name)?)) |
| 507 | } |
| 508 | |
| 509 | async fn delete_device( |
| 510 | State(s): State<AppState>, |
| 511 | user: User, |
| 512 | UrlPath(id): UrlPath<i64>, |
| 513 | ) -> Result<Json<()>> { |
| 514 | let n = s.db().execute( |
| 515 | "DELETE FROM devices WHERE id = ?1 AND user_id = ?2", |
| 516 | [id, user.id], |
| 517 | )?; |
| 518 | if n == 0 { |
| 519 | return Err(Error::NotFound); |
| 520 | } |
| 521 | Ok(Json(())) |
| 522 | } |
| 523 | |
| 524 | /// Clock skew we accept from a device, so a wrong clock cannot write far into the future. |
| 525 | const MAX_FUTURE_SECS: i64 = 86400; |
| 526 | |
| 527 | fn check_point(p: &Point, now: i64) -> std::result::Result<(), String> { |
| 528 | if !(-90.0..=90.0).contains(&p.lat) || !(-180.0..=180.0).contains(&p.lon) { |
| 529 | return Err(format!("point {}: coordinates out of range", p.ts)); |
| 530 | } |
| 531 | if p.ts <= 0 || p.ts > now + MAX_FUTURE_SECS { |
| 532 | return Err(format!("point {}: timestamp out of range", p.ts)); |
| 533 | } |
| 534 | if p.battery.is_some_and(|b| b > 100) { |
| 535 | return Err(format!("point {}: battery above 100", p.ts)); |
| 536 | } |
| 537 | Ok(()) |
| 538 | } |
| 539 | |
| 540 | async fn upload( |
| 541 | State(s): State<AppState>, |
| 542 | uploader: auth::Uploader, |
| 543 | Json(points): Json<Vec<Point>>, |
| 544 | ) -> Result<Json<Uploaded>> { |
| 545 | if points.len() > MAX_BATCH { |
| 546 | return Err(Error::BadRequest(format!( |
| 547 | "at most {MAX_BATCH} points per request" |
| 548 | ))); |
| 549 | } |
| 550 | let now = now(); |
| 551 | let total = points.len(); |
| 552 | let points: Vec<Point> = points |
| 553 | .into_iter() |
| 554 | .filter(|p| check_point(p, now).is_ok()) |
| 555 | .collect(); |
| 556 | |
| 557 | let mut db = s.db(); |
| 558 | let tx = db.transaction()?; |
| 559 | let mut stored = 0; |
| 560 | { |
| 561 | let mut insert = tx.prepare_cached(&format!( |
| 562 | "INSERT OR IGNORE INTO points (device_id, {POINT_COLS}) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)" |
| 563 | ))?; |
| 564 | for p in &points { |
| 565 | stored += insert.execute(params![ |
| 566 | uploader.device_id, |
| 567 | p.ts, |
| 568 | p.lat, |
| 569 | p.lon, |
| 570 | p.acc, |
| 571 | p.alt, |
| 572 | p.speed, |
| 573 | p.bearing, |
| 574 | p.battery |
| 575 | ])?; |
| 576 | } |
| 577 | } |
| 578 | tx.execute( |
| 579 | "UPDATE devices SET last_seen_at = ?1 WHERE id = ?2", |
| 580 | [now, uploader.device_id], |
| 581 | )?; |
| 582 | tx.commit()?; |
| 583 | Ok(Json(Uploaded { |
| 584 | stored, |
| 585 | skipped: total - points.len(), |
| 586 | })) |
| 587 | } |
| 588 | |
| 589 | /// Reads ACCESS_COLS from column `i` on. |
| 590 | pub fn settings_at(db: &Connection, r: &Row, i: usize) -> rusqlite::Result<ShareSettings> { |
| 591 | let id: i64 = r.get(i)?; |
| 592 | let devices = match r.get::<_, bool>(i + 1)? { |
| 593 | true => None, |
| 594 | false => Some( |
| 595 | db.prepare_cached("SELECT device_id FROM share_devices WHERE share_id = ?1")? |
| 596 | .query_map([id], |r| r.get(0))? |
| 597 | .collect::<rusqlite::Result<_>>()?, |
| 598 | ), |
| 599 | }; |
| 600 | Ok(ShareSettings { |
| 601 | devices, |
| 602 | trail_since: r.get(i + 2)?, |
| 603 | precision_m: r.get(i + 3)?, |
| 604 | }) |
| 605 | } |
| 606 | |
| 607 | pub fn check_settings(set: &ShareSettings, expires_at: Option<i64>) -> Result<()> { |
| 608 | if expires_at.is_some_and(|t| t <= now()) { |
| 609 | return Err(Error::BadRequest("expiry must be in the future".into())); |
| 610 | } |
| 611 | if set.precision_m > MAX_PRECISION_M { |
| 612 | return Err(Error::BadRequest(format!( |
| 613 | "precision must be at most {MAX_PRECISION_M} metres" |
| 614 | ))); |
| 615 | } |
| 616 | if set.devices.as_ref().is_some_and(Vec::is_empty) { |
| 617 | return Err(Error::BadRequest("select at least one device".into())); |
| 618 | } |
| 619 | Ok(()) |
| 620 | } |
| 621 | |
| 622 | /// Writes the settings columns and the device selection of a share or link. |
| 623 | pub fn save_settings(db: &Connection, id: i64, owner: i64, set: &ShareSettings) -> Result<()> { |
| 624 | db.execute( |
| 625 | "UPDATE shares SET all_devices = ?2, trail_since = ?3, precision_m = ?4 WHERE id = ?1", |
| 626 | params![id, set.devices.is_none(), set.trail_since, set.precision_m], |
| 627 | )?; |
| 628 | db.execute("DELETE FROM share_devices WHERE share_id = ?1", [id])?; |
| 629 | for device in set.devices.iter().flatten() { |
| 630 | let added = db.execute( |
| 631 | "INSERT OR IGNORE INTO share_devices SELECT ?1, id FROM devices WHERE id = ?2 AND user_id = ?3", |
| 632 | [id, *device, owner], |
| 633 | )?; |
| 634 | if added == 0 { |
| 635 | return Err(Error::BadRequest("no such device".into())); |
| 636 | } |
| 637 | } |
| 638 | Ok(()) |
| 639 | } |
| 640 | |
| 641 | async fn list_shares(State(s): State<AppState>, user: User) -> Result<Json<Shares>> { |
| 642 | let db = s.db(); |
| 643 | let query = |other: &str, me: &str| -> rusqlite::Result<Vec<Share>> { |
| 644 | db.prepare_cached(&format!( |
| 645 | "SELECT u.username, s.expires_at, s.created_at, {ACCESS_COLS} |
| 646 | FROM shares s JOIN users u ON u.id = s.{other} WHERE s.{me} = ?1 ORDER BY u.username" |
| 647 | ))? |
| 648 | .query_map([user.id], |r| { |
| 649 | Ok(Share { |
| 650 | id: r.get(3)?, |
| 651 | username: r.get(0)?, |
| 652 | expires_at: r.get(1)?, |
| 653 | created_at: r.get(2)?, |
| 654 | settings: settings_at(&db, r, 3)?, |
| 655 | }) |
| 656 | })? |
| 657 | .collect() |
| 658 | }; |
| 659 | Ok(Json(Shares { |
| 660 | outgoing: query("viewer_id", "owner_id")?, |
| 661 | incoming: query("owner_id", "viewer_id")?, |
| 662 | })) |
| 663 | } |
| 664 | |
| 665 | async fn create_share( |
| 666 | State(s): State<AppState>, |
| 667 | user: User, |
| 668 | Json(b): Json<NewShare>, |
| 669 | ) -> Result<Json<Share>> { |
| 670 | check_settings(&b.settings, b.expires_at)?; |
| 671 | let mut db = s.db(); |
| 672 | let (viewer_id, username): (i64, String) = db |
| 673 | .query_row( |
| 674 | "SELECT id, username FROM users WHERE username = ?1", |
| 675 | [b.viewer.trim()], |
| 676 | |r| Ok((r.get(0)?, r.get(1)?)), |
| 677 | ) |
| 678 | .optional()? |
| 679 | .ok_or_else(|| Error::BadRequest("no such user".into()))?; |
| 680 | if viewer_id == user.id { |
| 681 | return Err(Error::BadRequest("you cannot share with yourself".into())); |
| 682 | } |
| 683 | let tx = db.transaction()?; |
| 684 | let (id, created_at) = tx.query_row( |
| 685 | "INSERT INTO shares (owner_id, viewer_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4) |
| 686 | ON CONFLICT (owner_id, viewer_id) DO UPDATE SET expires_at = excluded.expires_at |
| 687 | RETURNING id, created_at", |
| 688 | params![user.id, viewer_id, b.expires_at, now()], |
| 689 | |r| Ok((r.get(0)?, r.get(1)?)), |
| 690 | )?; |
| 691 | save_settings(&tx, id, user.id, &b.settings)?; |
| 692 | tx.commit()?; |
| 693 | Ok(Json(Share { |
| 694 | id, |
| 695 | username, |
| 696 | expires_at: b.expires_at, |
| 697 | created_at, |
| 698 | settings: b.settings, |
| 699 | })) |
| 700 | } |
| 701 | |
| 702 | /// Either side can end a share. Guest links have their own endpoint. |
| 703 | async fn delete_share( |
| 704 | State(s): State<AppState>, |
| 705 | user: User, |
| 706 | UrlPath(id): UrlPath<i64>, |
| 707 | ) -> Result<Json<()>> { |
| 708 | let n = s.db().execute( |
| 709 | "DELETE FROM shares WHERE id = ?1 AND viewer_id IS NOT NULL AND (owner_id = ?2 OR viewer_id = ?2)", |
| 710 | [id, user.id], |
| 711 | )?; |
| 712 | if n == 0 { |
| 713 | return Err(Error::NotFound); |
| 714 | } |
| 715 | Ok(Json(())) |
| 716 | } |
| 717 | |
| 718 | /// Everyone else's username, for picking whom to share with. |
| 719 | async fn usernames(State(s): State<AppState>, user: User) -> Result<Json<Vec<String>>> { |
| 720 | let names = s |
| 721 | .db() |
| 722 | .prepare_cached("SELECT username FROM users WHERE id <> ?1 ORDER BY username")? |
| 723 | .query_map([user.id], |r| r.get(0))? |
| 724 | .collect::<rusqlite::Result<_>>()?; |
| 725 | Ok(Json(names)) |
| 726 | } |
| 727 | |
| 728 | async fn list_users(State(s): State<AppState>, _: Admin) -> Result<Json<Vec<api::User>>> { |
| 729 | let users = s |
| 730 | .db() |
| 731 | .prepare_cached("SELECT id, username, is_admin, created_at FROM users ORDER BY username")? |
| 732 | .query_map([], |r| { |
| 733 | Ok(api::User { |
| 734 | id: r.get(0)?, |
| 735 | username: r.get(1)?, |
| 736 | is_admin: r.get(2)?, |
| 737 | created_at: r.get(3)?, |
| 738 | }) |
| 739 | })? |
| 740 | .collect::<rusqlite::Result<_>>()?; |
| 741 | Ok(Json(users)) |
| 742 | } |
| 743 | |
| 744 | async fn create_user( |
| 745 | State(s): State<AppState>, |
| 746 | _: Admin, |
| 747 | Json(b): Json<NewUser>, |
| 748 | ) -> Result<Json<api::User>> { |
| 749 | let username = crate::check_username(&b.username)?.to_owned(); |
| 750 | auth::check_new_password(&b.password).map_err(|m| Error::BadRequest(m.into()))?; |
| 751 | let hash = auth::hash_password_async(b.password).await?; |
| 752 | let db = s.db(); |
| 753 | let id = crate::insert_user(&db, &username, &hash, b.is_admin)?; |
| 754 | Ok(Json(api::User { |
| 755 | id, |
| 756 | username, |
| 757 | is_admin: b.is_admin, |
| 758 | created_at: now(), |
| 759 | })) |
| 760 | } |
| 761 | |
| 762 | /// Admins cannot change their own role, so at least one admin always remains. |
| 763 | async fn set_role( |
| 764 | State(s): State<AppState>, |
| 765 | Admin(admin): Admin, |
| 766 | UrlPath(id): UrlPath<i64>, |
| 767 | Json(b): Json<SetRole>, |
| 768 | ) -> Result<Json<()>> { |
| 769 | if id == admin.id { |
| 770 | return Err(Error::BadRequest("you cannot change your own role".into())); |
| 771 | } |
| 772 | if s.db().execute( |
| 773 | "UPDATE users SET is_admin = ?1 WHERE id = ?2", |
| 774 | params![b.is_admin, id], |
| 775 | )? == 0 |
| 776 | { |
| 777 | return Err(Error::NotFound); |
| 778 | } |
| 779 | Ok(Json(())) |
| 780 | } |
| 781 | |
| 782 | async fn delete_user( |
| 783 | State(s): State<AppState>, |
| 784 | Admin(admin): Admin, |
| 785 | UrlPath(id): UrlPath<i64>, |
| 786 | ) -> Result<Json<()>> { |
| 787 | if id == admin.id { |
| 788 | return Err(Error::BadRequest( |
| 789 | "you cannot delete your own account".into(), |
| 790 | )); |
| 791 | } |
| 792 | if s.db().execute("DELETE FROM users WHERE id = ?1", [id])? == 0 { |
| 793 | return Err(Error::NotFound); |
| 794 | } |
| 795 | Ok(Json(())) |
| 796 | } |
| 797 | |
| 798 | /// The recovery path for a user who lost their password or passkey. |
| 799 | async fn reset_user_password( |
| 800 | State(s): State<AppState>, |
| 801 | _: Admin, |
| 802 | UrlPath(id): UrlPath<i64>, |
| 803 | Json(b): Json<ResetPassword>, |
| 804 | ) -> Result<Json<()>> { |
| 805 | auth::check_new_password(&b.password).map_err(|m| Error::BadRequest(m.into()))?; |
| 806 | let hash = auth::hash_password_async(b.password).await?; |
| 807 | let db = s.db(); |
| 808 | if db |
| 809 | .query_row("SELECT 1 FROM users WHERE id = ?1", [id], |_| Ok(())) |
| 810 | .optional()? |
| 811 | .is_none() |
| 812 | { |
| 813 | return Err(Error::NotFound); |
| 814 | } |
| 815 | crate::reset_password(&db, id, &hash)?; |
| 816 | Ok(Json(())) |
| 817 | } |
| 818 | |
| 819 | #[cfg(test)] |
| 820 | mod tests { |
| 821 | use super::*; |
| 822 | |
| 823 | fn pt(ts: i64, lat: f64, lon: f64) -> Point { |
| 824 | Point { |
| 825 | ts, |
| 826 | lat, |
| 827 | lon, |
| 828 | acc: None, |
| 829 | alt: None, |
| 830 | speed: None, |
| 831 | bearing: None, |
| 832 | battery: None, |
| 833 | } |
| 834 | } |
| 835 | |
| 836 | #[test] |
| 837 | fn point_validation() { |
| 838 | let now = 1_800_000_000; |
| 839 | assert!(check_point(&pt(now, 48.1, 11.5), now).is_ok()); |
| 840 | assert!(check_point(&pt(now, 91.0, 0.0), now).is_err()); |
| 841 | assert!(check_point(&pt(now, 0.0, -180.1), now).is_err()); |
| 842 | assert!(check_point(&pt(now + 2 * MAX_FUTURE_SECS, 0.0, 0.0), now).is_err()); |
| 843 | assert!( |
| 844 | check_point( |
| 845 | &Point { |
| 846 | battery: Some(101), |
| 847 | ..pt(now, 0.0, 0.0) |
| 848 | }, |
| 849 | now |
| 850 | ) |
| 851 | .is_err() |
| 852 | ); |
| 853 | } |
| 854 | |
| 855 | #[test] |
| 856 | fn coarse_points_stay_near_and_hide_motion() { |
| 857 | let exact = Point { |
| 858 | acc: Some(5.0), |
| 859 | speed: Some(3.0), |
| 860 | ..pt(1_800_000_999, 48.137_15, 11.575_49) |
| 861 | }; |
| 862 | let mut p = exact.clone(); |
| 863 | coarsen(&mut p, 0); |
| 864 | assert_eq!(p, exact); |
| 865 | coarsen(&mut p, 1000); |
| 866 | let (dy, dx) = ( |
| 867 | (p.lat - exact.lat) * 111_320.0, |
| 868 | (p.lon - exact.lon) * 111_320.0 * exact.lat.to_radians().cos(), |
| 869 | ); |
| 870 | assert!( |
| 871 | dy.abs() <= 500.0 && dx.abs() <= 510.0, |
| 872 | "moved {dy} m, {dx} m" |
| 873 | ); |
| 874 | assert_eq!((p.acc, p.speed, p.ts), (Some(1000.0), None, 1_800_000_000)); |
| 875 | let mut near = pt(1, exact.lat + 0.000_01, exact.lon + 0.000_01); |
| 876 | coarsen(&mut near, 1000); |
| 877 | assert_eq!((near.lat, near.lon), (p.lat, p.lon)); |
| 878 | } |
| 879 | |
| 880 | #[test] |
| 881 | fn people_shows_only_shared_devices() { |
| 882 | let db = crate::test_db(); |
| 883 | db.execute_batch( |
| 884 | "INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0), (2, 'b', '2', 0); |
| 885 | INSERT INTO devices (id, user_id, name, token_hash, created_at) VALUES (10, 2, 'phone', x'01', 0), (11, 2, 'car', x'02', 0); |
| 886 | INSERT INTO points (device_id, ts, lat, lon) VALUES (10, 100, 1, 1), (11, 200, 2, 2); |
| 887 | INSERT INTO shares (id, owner_id, viewer_id, created_at, all_devices) VALUES (5, 2, 1, 0, 1);", |
| 888 | ) |
| 889 | .unwrap(); |
| 890 | let devices = |db: &Connection| -> Vec<String> { |
| 891 | people_for(db, 1).unwrap()[1] |
| 892 | .devices |
| 893 | .iter() |
| 894 | .map(|d| d.name.clone()) |
| 895 | .collect() |
| 896 | }; |
| 897 | assert_eq!(devices(&db), ["car", "phone"]); |
| 898 | db.execute_batch( |
| 899 | "UPDATE shares SET all_devices = 0; INSERT INTO share_devices VALUES (5, 10);", |
| 900 | ) |
| 901 | .unwrap(); |
| 902 | assert_eq!(devices(&db), ["phone"]); |
| 903 | } |
| 904 | |
| 905 | #[test] |
| 906 | fn people_respects_share_expiry() { |
| 907 | let db = crate::test_db(); |
| 908 | db.execute_batch( |
| 909 | "INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0), (2, 'b', '2', 0), (3, 'c', '3', 0); |
| 910 | INSERT INTO shares (owner_id, viewer_id, expires_at, created_at) VALUES (2, 1, NULL, 0), (3, 1, 1, 0);", |
| 911 | ) |
| 912 | .unwrap(); |
| 913 | let names: Vec<_> = people_for(&db, 1) |
| 914 | .unwrap() |
| 915 | .into_iter() |
| 916 | .map(|p| p.username) |
| 917 | .collect(); |
| 918 | assert_eq!(names, ["a", "b"]); |
| 919 | } |
| 920 | } |
| 921 |