api.rs
⎇
Raw
1//! The HTTP API.
2//!
3//! There is **no WebSocket and no fan-out hub**. The web UI polls
4//! `GET /api/state` every 5 s while its tab is visible, and that endpoint returns
5//! an `ETag` so an unchanged poll is a 304 with no body. For a handful of users
6//! that costs less than a broadcast hub, per-connection filter tasks, and
7//! lag/resync handling would — and it cannot desynchronise, because there is no
8//! second copy of the state to drift.
9//!
10//! Visibility is resolved in exactly one place, [`visible_user_ids`], used by
11//! every read path. Share expiry is enforced there, at query time, never by a
12//! background job: a stalled job must not be able to leak a position.
13
14use std::net::{IpAddr, SocketAddr};
15use std::sync::Arc;
16
17use axum::extract::{ConnectInfo, Path, Query, Request, State};
18use axum::http::{HeaderMap, StatusCode, header};
19use axum::middleware::{self, Next};
20use axum::response::{IntoResponse, Response};
21use axum::routing::{delete, get, post};
22use axum::{Json, Router};
23use serde::{Deserialize, Serialize};
24use sqlx::SqlitePool;
25use tower_sessions::Session;
26use tracing::warn;
27
28use crate::auth::{self, AuthError, LoginThrottle};
29use crate::config::Config;
30use crate::db::{Db, now};
31use crate::ingest::Ingest;
32use crate::keys::KeyVault;
33use crate::writer::{WriteHandle, WriteOp};
34
35/// Session key holding the authenticated user id.
36const SESSION_USER: &str = "uid";
37
38/// Required on every state-changing request.
39///
40/// The value is never read. A browser cannot set a custom header on a
41/// cross-origin request without a CORS preflight, and this server grants no CORS
42/// at all, so its mere presence proves the request came from our own page.
43/// Together with `SameSite=Lax` on the session cookie that is the entire CSRF
44/// defence: no token to mint, store, rotate, or leak into a log.
45const CSRF_HEADER: &str = "x-ot-csrf";
46
47/// Session key holding the moment the session was created, compared against
48/// `users.pw_changed_at` so a password change invalidates older sessions without
49/// having to enumerate them.
50const SESSION_ISSUED: &str = "iat";
51
52pub struct AppState {
53 pub db: Db,
54 pub cfg: Config,
55 pub vault: KeyVault,
56 pub ingest: Arc<Ingest>,
57 pub writer: WriteHandle,
58 /// Shared with the periodic GC task, which sweeps its expired windows.
59 pub throttle: Arc<LoginThrottle>,
60}
61
62pub type Shared = Arc<AppState>;
63
64// ---------------------------------------------------------------------------
65// Errors
66// ---------------------------------------------------------------------------
67
68#[derive(Debug)]
69pub enum ApiError {
70 Unauthorized,
71 Forbidden,
72 NotFound,
73 BadRequest(String),
74 TooManyRequests(u64),
75 /// Anything unexpected. The detail is logged, never returned: an internal
76 /// error message is a free source of schema and path information.
77 Internal(anyhow::Error),
78}
79
80impl IntoResponse for ApiError {
81 fn into_response(self) -> Response {
82 let (status, message) = match self {
83 Self::Unauthorized => (StatusCode::UNAUTHORIZED, "not signed in".to_string()),
84 Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden".to_string()),
85 Self::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
86 Self::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
87 Self::TooManyRequests(retry) => (
88 StatusCode::TOO_MANY_REQUESTS,
89 format!("too many attempts; try again in {retry}s"),
90 ),
91 Self::Internal(e) => {
92 warn!(error = ?e, "internal error");
93 (
94 StatusCode::INTERNAL_SERVER_ERROR,
95 "internal error".to_string(),
96 )
97 }
98 };
99 (status, Json(ErrorBody { error: message })).into_response()
100 }
101}
102
103impl From<anyhow::Error> for ApiError {
104 fn from(e: anyhow::Error) -> Self {
105 Self::Internal(e)
106 }
107}
108
109impl From<sqlx::Error> for ApiError {
110 fn from(e: sqlx::Error) -> Self {
111 Self::Internal(e.into())
112 }
113}
114
115#[derive(Serialize)]
116struct ErrorBody {
117 error: String,
118}
119
120type ApiResult<T> = Result<T, ApiError>;
121
122// ---------------------------------------------------------------------------
123// Session helpers
124// ---------------------------------------------------------------------------
125
126/// The authenticated user, or [`ApiError::Unauthorized`].
127///
128/// Also checks the session against `pw_changed_at`, which is how a password
129/// change logs out every other browser without keeping a revocation list.
130pub(crate) async fn current_user(state: &Shared, session: &Session) -> ApiResult<i64> {
131 let uid: i64 = session
132 .get(SESSION_USER)
133 .await
134 .map_err(|e| ApiError::Internal(anyhow::anyhow!("session store: {e}")))?
135 .ok_or(ApiError::Unauthorized)?;
136 let issued: i64 = session
137 .get(SESSION_ISSUED)
138 .await
139 .map_err(|e| ApiError::Internal(anyhow::anyhow!("session store: {e}")))?
140 .unwrap_or(0);
141
142 let row: Option<(i64, Option<i64>)> =
143 sqlx::query_as("SELECT pw_changed_at, disabled_at FROM users WHERE id = ?")
144 .bind(uid)
145 .fetch_optional(&state.db.read)
146 .await?;
147 let Some((pw_changed_at, disabled_at)) = row else {
148 let _ = session.flush().await;
149 return Err(ApiError::Unauthorized);
150 };
151 if disabled_at.is_some() || issued < pw_changed_at {
152 let _ = session.flush().await;
153 return Err(ApiError::Unauthorized);
154 }
155 Ok(uid)
156}
157
158async fn require_admin(state: &Shared, session: &Session) -> ApiResult<i64> {
159 let uid = current_user(state, session).await?;
160 let is_admin: i64 = sqlx::query_scalar("SELECT is_admin FROM users WHERE id = ?")
161 .bind(uid)
162 .fetch_one(&state.db.read)
163 .await?;
164 if is_admin == 0 {
165 return Err(ApiError::Forbidden);
166 }
167 Ok(uid)
168}
169
170/// The set of `user_id`s `viewer` may see: themselves, plus anyone reachable
171/// through a live share.
172///
173/// One helper, used by every read path. Per-account positions make this
174/// noticeably simpler than a per-device model would: there is no
175/// `device_id IS NULL` "all my devices" special case in the join.
176async fn visible_user_ids(pool: &SqlitePool, viewer: i64) -> ApiResult<Vec<i64>> {
177 let now = now();
178 let ids: Vec<i64> = sqlx::query_scalar(
179 "SELECT ? AS user_id \
180 UNION \
181 SELECT s.owner_user_id FROM shares s \
182 WHERE s.revoked_at IS NULL \
183 AND (s.expires_at IS NULL OR s.expires_at > ?) \
184 AND s.viewer_user_id = ?",
185 )
186 .bind(viewer)
187 .bind(now)
188 .bind(viewer)
189 .fetch_all(pool)
190 .await?;
191 Ok(ids)
192}
193
194/// Snap a coordinate to a `precision_m` grid, so a share can show a
195/// neighbourhood instead of a doorstep.
196///
197/// Deliberately a pure rounding, with no jitter: a stationary person whose
198/// fuzzed dot wandered on every poll would leak their true position to anyone
199/// who averaged the samples.
200fn snap_e7(lat_e7: i64, lon_e7: i64, precision_m: i64) -> (i64, i64) {
201 if precision_m <= 0 {
202 return (lat_e7, lon_e7);
203 }
204 /// Metres per degree of latitude, and of longitude at the equator.
205 const M_PER_DEG: f64 = 111_320.0;
206
207 let grid = |value: i64, step: f64| ((value as f64 / step).round() * step) as i64;
208 let lat_step = precision_m as f64 / M_PER_DEG * 1e7;
209 let lat = grid(lat_e7, lat_step).clamp(-900_000_000, 900_000_000);
210
211 // The longitude step is derived from the *snapped* latitude, not the real
212 // one, so every point in a cell gets the same grid. Deriving it from the
213 // input would give two neighbours slightly different grids and leak that
214 // they are not in fact at the same place.
215 //
216 // cos(lat) goes to zero at the poles, where a metre of easting is an
217 // unbounded number of degrees. Clamping the divisor keeps the step finite;
218 // it only makes the cell smaller than asked for, never larger.
219 let cos_lat = (lat as f64 / 1e7).to_radians().cos().abs().max(0.01);
220 let lon_step = precision_m as f64 / (M_PER_DEG * cos_lat) * 1e7;
221 (lat, grid(lon_e7, lon_step))
222}
223
224/// `precision_m` per owner for everyone `viewer` can see.
225///
226/// Several live shares could target the same viewer, so the most generous one
227/// wins: a second share must never be able to make an existing one stricter.
228/// Owners with no row (the viewer themselves) are exact.
229async fn share_precision(
230 pool: &SqlitePool,
231 viewer: i64,
232 ids_json: &str,
233) -> ApiResult<std::collections::HashMap<i64, i64>> {
234 let rows: Vec<(i64, i64)> = sqlx::query_as(
235 "SELECT s.owner_user_id, MIN(s.precision_m) FROM shares s \
236 JOIN json_each(?) v ON v.value = s.owner_user_id \
237 WHERE s.viewer_user_id = ? AND s.revoked_at IS NULL \
238 AND (s.expires_at IS NULL OR s.expires_at > ?) \
239 GROUP BY s.owner_user_id",
240 )
241 .bind(ids_json)
242 .bind(viewer)
243 .bind(now())
244 .fetch_all(pool)
245 .await?;
246 Ok(rows.into_iter().collect())
247}
248
249// ---------------------------------------------------------------------------
250// Payloads
251// ---------------------------------------------------------------------------
252
253#[derive(Deserialize)]
254pub struct LoginRequest {
255 pub username: String,
256 pub password: String,
257 /// `"browser"` (default) or `"device"`. A device login additionally mints an
258 /// OTP/1 token.
259 #[serde(default)]
260 pub purpose: Purpose,
261 #[serde(default)]
262 pub device_name: Option<String>,
263 #[serde(default)]
264 pub platform: Option<String>,
265}
266
267#[derive(Deserialize, Default, PartialEq, Eq)]
268#[serde(rename_all = "snake_case")]
269pub enum Purpose {
270 #[default]
271 Browser,
272 Device,
273}
274
275#[derive(Serialize)]
276pub struct LoginResponse {
277 pub user: Me,
278 /// Present only for a device login.
279 #[serde(skip_serializing_if = "Option::is_none")]
280 pub device: Option<DeviceCredentials>,
281}
282
283/// Everything a phone needs, returned exactly once.
284#[derive(Serialize)]
285pub struct DeviceCredentials {
286 pub token_id: u64,
287 /// base64 of 32 bytes. The only time the server emits this in the clear.
288 pub token_key: String,
289 /// base64 of 32 bytes: the key that seals a `REVOKED` notice for this token.
290 ///
291 /// Separate from `token_key` because it must outlive it. `K_up` and `K_down`
292 /// derive from the token key and die with the token's row; this one is
293 /// derived from a server master and the `token_id`, so the server can still
294 /// speak to a device whose row is gone.
295 ///
296 /// It has to be issued at login and cannot be retrofitted: a device that
297 /// never received one can never verify a notice, and the thing that would
298 /// prompt it to log in again is exactly that notice.
299 pub revoke_key: String,
300 pub udp_host: String,
301 pub udp_port: u16,
302 #[serde(skip_serializing_if = "Option::is_none")]
303 pub tls_url: Option<String>,
304 pub config: DeviceConfig,
305}
306
307#[derive(Serialize)]
308pub struct DeviceConfig {
309 pub config_version: u16,
310 pub profile: &'static str,
311}
312
313#[derive(Serialize)]
314pub struct Me {
315 pub id: i64,
316 pub username: String,
317 pub display_name: String,
318 pub is_admin: bool,
319 pub server_time: i64,
320}
321
322#[derive(Serialize)]
323pub struct PersonState {
324 pub user_id: i64,
325 pub display_name: String,
326 /// True for the viewer's own entry.
327 pub is_self: bool,
328 #[serde(skip_serializing_if = "Option::is_none")]
329 pub position: Option<Position>,
330}
331
332#[derive(Serialize)]
333pub struct Position {
334 pub ts: i64,
335 /// Degrees × 1e7. Integers end to end, so there is no float-formatting drift
336 /// between the wire, the database, this JSON, and the map.
337 pub lat_e7: i64,
338 pub lon_e7: i64,
339 pub acc_dm: Option<i64>,
340 pub alt_m: Option<i64>,
341 pub spd_cms: Option<i64>,
342 pub brg_cdeg: Option<i64>,
343 pub bat_pct: Option<i64>,
344 pub flags: i64,
345 pub recv_at: i64,
346}
347
348#[derive(Serialize)]
349pub struct StateResponse {
350 pub server_time: i64,
351 pub people: Vec<PersonState>,
352}
353
354#[derive(Serialize)]
355pub struct TokenInfo {
356 /// A decimal string, not a number. `token_id` is a full 64-bit random value
357 /// and JavaScript's `number` is exact only to 2^53, so a JSON number would
358 /// silently round — and a token id that does not round-trip cannot be
359 /// revoked. The phone's `DeviceCredentials` keeps the numeric form because
360 /// its parser has real 64-bit integers.
361 pub token_id: String,
362 pub name: String,
363 pub platform: String,
364 pub app_version: Option<i64>,
365 pub os_api_level: Option<i64>,
366 pub last_seen_at: Option<i64>,
367 pub last_src_ip: Option<String>,
368 pub last_transport: Option<String>,
369 pub created_at: i64,
370}
371
372#[derive(Deserialize)]
373pub struct TrackQuery {
374 pub from: Option<i64>,
375 pub to: Option<i64>,
376 #[serde(default = "default_max")]
377 pub max: usize,
378}
379
380fn default_max() -> usize {
381 2000
382}
383
384#[derive(Serialize)]
385pub struct TrackResponse {
386 pub user_id: i64,
387 pub from: i64,
388 pub to: i64,
389 /// Google-style encoded polyline at 1e5 precision.
390 pub polyline: String,
391 pub point_count: usize,
392}
393
394#[derive(Deserialize)]
395pub struct PasswordChange {
396 pub current_password: String,
397 pub new_password: String,
398}
399
400#[derive(Deserialize)]
401pub struct CreateUser {
402 pub username: String,
403 pub password: String,
404 #[serde(default)]
405 pub display_name: Option<String>,
406 #[serde(default)]
407 pub is_admin: bool,
408}
409
410#[derive(Serialize)]
411pub struct ShareInfo {
412 pub id: i64,
413 pub viewer_user_id: i64,
414 pub viewer_username: String,
415 pub viewer_display_name: String,
416 pub trail_visible: bool,
417 pub precision_m: i64,
418 pub expires_at: Option<i64>,
419 pub created_at: i64,
420}
421
422#[derive(Deserialize)]
423pub struct CreateShare {
424 /// Resolved server-side. There is deliberately no endpoint that lists or
425 /// searches users: a share is granted to someone you already know the name
426 /// of, and anything else is a user directory for anyone with an account.
427 pub username: String,
428 #[serde(default = "yes")]
429 pub trail_visible: bool,
430 #[serde(default)]
431 pub precision_m: i64,
432 /// Seconds from now. `None` means the share does not expire.
433 #[serde(default)]
434 pub expires_in_s: Option<i64>,
435}
436
437fn yes() -> bool {
438 true
439}
440
441// ---------------------------------------------------------------------------
442// Router
443// ---------------------------------------------------------------------------
444
445/// Rejects a state-changing request that did not come from our own page.
446async fn require_csrf(req: Request, next: Next) -> Response {
447 if req.method().is_safe() || req.headers().contains_key(CSRF_HEADER) {
448 return next.run(req).await;
449 }
450 (
451 StatusCode::FORBIDDEN,
452 Json(ErrorBody {
453 error: format!("missing {CSRF_HEADER} header"),
454 }),
455 )
456 .into_response()
457}
458
459pub fn router(state: Shared) -> Router {
460 Router::new()
461 .route("/api/login", post(login))
462 .route("/api/logout", post(logout))
463 .route("/api/me", get(me))
464 .route("/api/me/password", post(change_password))
465 .route("/api/state", get(state_handler))
466 .route("/api/tokens", get(list_tokens))
467 .route("/api/tokens/{token_id}", delete(revoke_one_token))
468 .route("/api/tokens/revoke-others", post(revoke_others))
469 .route("/api/shares", get(list_shares).post(create_share))
470 .route("/api/shares/{share_id}", delete(revoke_share))
471 .route("/api/users/{user_id}/track", get(track))
472 .route("/api/users", post(create_user))
473 // Only the /api routes above; `route_layer` runs nothing when no route
474 // matches, so the static fallback below is untouched.
475 .route_layer(middleware::from_fn(require_csrf))
476 .route("/healthz", get(healthz))
477 .route("/metrics", get(metrics))
478 // Outside the CSRF layer above deliberately: it is a GET, and Leaflet
479 // loads tiles as plain <img> elements that cannot carry a header.
480 .merge(crate::tiles::router())
481 .with_state(state)
482 // Anything else is the web UI, including deep links it routes itself.
483 .fallback(crate::web::serve)
484}
485
486// ---------------------------------------------------------------------------
487// Handlers
488// ---------------------------------------------------------------------------
489
490async fn healthz(State(state): State<Shared>) -> ApiResult<Json<serde_json::Value>> {
491 // A real query, not a constant: "healthy" has to mean the database answers.
492 let _: i64 = sqlx::query_scalar("SELECT 1")
493 .fetch_one(&state.db.read)
494 .await?;
495 Ok(Json(serde_json::json!({
496 "ok": true,
497 "version": env!("CARGO_PKG_VERSION"),
498 "server_time": now(),
499 "tokens_loaded": state.ingest.active_token_count(),
500 })))
501}
502
503/// Aggregate counters, in Prometheus text format.
504///
505/// Bound to loopback callers only: these numbers say how much abuse the UDP port
506/// is absorbing and how close the writer is to saturation, which is exactly the
507/// reconnaissance an attacker would want. Scrape it through the reverse proxy or
508/// over an SSH tunnel.
509async fn metrics(
510 State(state): State<Shared>,
511 ConnectInfo(peer): ConnectInfo<SocketAddr>,
512) -> ApiResult<String> {
513 if !peer.ip().is_loopback() {
514 return Err(ApiError::Forbidden);
515 }
516 use std::sync::atomic::Ordering::Relaxed;
517 let c = &state.ingest.counters;
518 let mut out = String::new();
519 for (name, value) in [
520 ("otp_datagrams_received", c.received.load(Relaxed)),
521 ("otp_datagrams_malformed", c.malformed.load(Relaxed)),
522 ("otp_unknown_token", c.unknown_token.load(Relaxed)),
523 ("otp_auth_failed", c.auth_failed.load(Relaxed)),
524 ("otp_rate_limited", c.rate_limited.load(Relaxed)),
525 ("otp_throttled", c.throttled.load(Relaxed)),
526 ("otp_points_accepted", c.points_accepted.load(Relaxed)),
527 ("otp_points_rejected", c.points_rejected.load(Relaxed)),
528 ("otp_acks_sent", c.acks_sent.load(Relaxed)),
529 ("otp_nacks_sent", c.nacks_sent.load(Relaxed)),
530 ("otp_revoked_notices", c.revoked_notices_sent.load(Relaxed)),
531 // The reflection budget actually spent. If this is nonzero and climbing,
532 // someone is probing the port with forged token ids.
533 (
534 "otp_unverified_notices",
535 c.unverified_notices_sent.load(Relaxed),
536 ),
537 ("otp_notices_suppressed", c.notices_suppressed.load(Relaxed)),
538 ("otp_silent_drops", c.silent_drops.load(Relaxed)),
539 ] {
540 out.push_str(&format!("# TYPE {name} counter\n{name} {value}\n"));
541 }
542 for (name, value) in [
543 (
544 "otp_tokens_loaded",
545 state.ingest.active_token_count() as u64,
546 ),
547 (
548 "otp_limiter_tracked_ips",
549 state.ingest.limits().tracked_ips() as u64,
550 ),
551 ("otp_writer_capacity_free", state.writer.capacity() as u64),
552 ] {
553 out.push_str(&format!("# TYPE {name} gauge\n{name} {value}\n"));
554 }
555 Ok(out)
556}
557
558async fn login(
559 State(state): State<Shared>,
560 session: Session,
561 ConnectInfo(peer): ConnectInfo<SocketAddr>,
562 Json(req): Json<LoginRequest>,
563) -> ApiResult<Json<LoginResponse>> {
564 let ip = peer.ip();
565 let account = match auth::authenticate(
566 &state.db.read,
567 &state.writer,
568 &state.cfg,
569 &state.throttle,
570 ip,
571 &req.username,
572 req.password,
573 )
574 .await
575 {
576 Ok(a) => a,
577 Err(AuthError::LockedOut { retry_after_s }) => {
578 return Err(ApiError::TooManyRequests(retry_after_s));
579 }
580 Err(AuthError::Invalid) => {
581 audit(&state, None, "login_failed", &req.username, ip).await;
582 return Err(ApiError::Unauthorized);
583 }
584 };
585
586 // Rotate the session id on login, so a fixation attempt cannot survive it.
587 session
588 .cycle_id()
589 .await
590 .map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
591 let issued = now();
592 session
593 .insert(SESSION_USER, account.id)
594 .await
595 .map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
596 session
597 .insert(SESSION_ISSUED, issued)
598 .await
599 .map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
600
601 let device = if req.purpose == Purpose::Device {
602 let name = req.device_name.unwrap_or_else(|| "phone".to_string());
603 let platform = req.platform.unwrap_or_else(|| "android".to_string());
604 let minted = auth::mint_token(
605 &state.db.write,
606 &state.vault,
607 &state.ingest,
608 account.id,
609 &name,
610 &platform,
611 ip,
612 )
613 .await?;
614 audit(&state, Some(account.id), "token_minted", &name, ip).await;
615
616 use base64::Engine as _;
617 Some(DeviceCredentials {
618 token_id: minted.token_id,
619 token_key: base64::engine::general_purpose::STANDARD.encode(minted.token_key),
620 revoke_key: base64::engine::general_purpose::STANDARD
621 .encode(state.vault.revocation_key(minted.token_id)),
622 udp_host: state.cfg.public_udp_host.clone(),
623 udp_port: state.cfg.public_udp_port,
624 tls_url: state.cfg.public_tls_url.clone(),
625 config: DeviceConfig {
626 config_version: minted.config_version,
627 profile: "balanced",
628 },
629 })
630 } else {
631 audit(&state, Some(account.id), "login", "browser", ip).await;
632 None
633 };
634
635 Ok(Json(LoginResponse {
636 user: Me {
637 id: account.id,
638 username: account.username,
639 display_name: account.display_name,
640 is_admin: account.is_admin,
641 server_time: issued,
642 },
643 device,
644 }))
645}
646
647async fn logout(session: Session) -> ApiResult<StatusCode> {
648 session
649 .flush()
650 .await
651 .map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
652 Ok(StatusCode::NO_CONTENT)
653}
654
655async fn me(State(state): State<Shared>, session: Session) -> ApiResult<Json<Me>> {
656 let uid = current_user(&state, &session).await?;
657 let (username, display_name, is_admin): (String, String, i64) =
658 sqlx::query_as("SELECT username, display_name, is_admin FROM users WHERE id = ?")
659 .bind(uid)
660 .fetch_one(&state.db.read)
661 .await?;
662 Ok(Json(Me {
663 id: uid,
664 username,
665 display_name,
666 is_admin: is_admin != 0,
667 server_time: now(),
668 }))
669}
670
671async fn change_password(
672 State(state): State<Shared>,
673 session: Session,
674 ConnectInfo(peer): ConnectInfo<SocketAddr>,
675 Json(req): Json<PasswordChange>,
676) -> ApiResult<StatusCode> {
677 let uid = current_user(&state, &session).await?;
678 if req.new_password.chars().count() < 10 {
679 return Err(ApiError::BadRequest(
680 "the new password must be at least 10 characters".into(),
681 ));
682 }
683
684 let stored: String = sqlx::query_scalar("SELECT pw_hash FROM users WHERE id = ?")
685 .bind(uid)
686 .fetch_one(&state.db.read)
687 .await?;
688 if !auth::verify(&state.cfg, stored, req.current_password)
689 .await?
690 .ok
691 {
692 return Err(ApiError::Unauthorized);
693 }
694
695 let hash = auth::hash_password(&state.cfg, req.new_password).await?;
696 let at = now();
697 sqlx::query("UPDATE users SET pw_hash = ?, pw_changed_at = ? WHERE id = ?")
698 .bind(hash)
699 .bind(at)
700 .bind(uid)
701 .execute(&state.db.write)
702 .await?;
703
704 // A password change logs out every phone and every other browser. Browsers
705 // are handled by the pw_changed_at comparison in current_user; phones need
706 // their tokens actually revoked, since they carry a key rather than a cookie.
707 let revoked = auth::revoke_other_tokens(&state.db.write, &state.ingest, uid, None).await?;
708 session
709 .cycle_id()
710 .await
711 .map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
712 session
713 .insert(SESSION_ISSUED, at)
714 .await
715 .map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
716
717 audit(
718 &state,
719 Some(uid),
720 "password_changed",
721 &format!("{revoked} tokens revoked"),
722 peer.ip(),
723 )
724 .await;
725 Ok(StatusCode::NO_CONTENT)
726}
727
728/// Everything the live view needs, in one call.
729async fn state_handler(
730 State(state): State<Shared>,
731 session: Session,
732 headers: HeaderMap,
733) -> ApiResult<Response> {
734 let uid = current_user(&state, &session).await?;
735 let visible = visible_user_ids(&state.db.read, uid).await?;
736
737 // One query for everyone visible. A LEFT JOIN so a person with no position
738 // yet still appears in the list — otherwise they would silently vanish from
739 // the UI until their first fix, which reads as a bug.
740 //
741 // The id set is passed as a JSON array through `json_each` rather than by
742 // building an `IN (?, ?, ?)` string: the SQL stays a literal, so there is no
743 // interpolation to audit and the statement cache gets one entry instead of
744 // one per group size.
745 let ids_json = serde_json::to_string(&visible).map_err(|e| ApiError::Internal(e.into()))?;
746 let rows = sqlx::query_as::<_, LatestRow>(
747 "SELECT u.id, u.display_name, l.ts, l.lat, l.lon, l.acc_dm, l.alt_m, l.spd_cms, \
748 l.brg_cdeg, l.bat_pct, l.flags, l.recv_at \
749 FROM users u \
750 JOIN json_each(?) v ON v.value = u.id \
751 LEFT JOIN user_latest l ON l.user_id = u.id \
752 ORDER BY u.display_name",
753 )
754 .bind(&ids_json)
755 .fetch_all(&state.db.read)
756 .await?;
757
758 // Your own position is always exact; everyone else's is snapped to whatever
759 // their share allows.
760 let precision = share_precision(&state.db.read, uid, &ids_json).await?;
761 let people: Vec<PersonState> = rows
762 .into_iter()
763 .map(|r| PersonState {
764 user_id: r.id,
765 is_self: r.id == uid,
766 display_name: r.display_name,
767 position: r.ts.map(|ts| {
768 let (lat_e7, lon_e7) = snap_e7(
769 r.lat.unwrap_or(0),
770 r.lon.unwrap_or(0),
771 precision.get(&r.id).copied().unwrap_or(0),
772 );
773 Position {
774 ts,
775 lat_e7,
776 lon_e7,
777 acc_dm: r.acc_dm,
778 alt_m: r.alt_m,
779 spd_cms: r.spd_cms,
780 brg_cdeg: r.brg_cdeg,
781 bat_pct: r.bat_pct,
782 flags: r.flags.unwrap_or(0),
783 recv_at: r.recv_at.unwrap_or(ts),
784 }
785 }),
786 })
787 .collect();
788
789 let body = StateResponse {
790 server_time: now(),
791 people,
792 };
793
794 // ETag over the people list only — deliberately *not* including
795 // `server_time`, which changes every second and would make every poll a 200.
796 let etag = etag_of(&body.people);
797 if headers
798 .get(header::IF_NONE_MATCH)
799 .and_then(|v| v.to_str().ok())
800 .is_some_and(|v| v == etag)
801 {
802 return Ok((StatusCode::NOT_MODIFIED, [(header::ETAG, etag)]).into_response());
803 }
804 Ok((
805 [
806 (header::ETAG, etag),
807 (header::CACHE_CONTROL, "no-store".to_string()),
808 ],
809 Json(body),
810 )
811 .into_response())
812}
813
814#[derive(sqlx::FromRow)]
815struct LatestRow {
816 id: i64,
817 display_name: String,
818 ts: Option<i64>,
819 lat: Option<i64>,
820 lon: Option<i64>,
821 acc_dm: Option<i64>,
822 alt_m: Option<i64>,
823 spd_cms: Option<i64>,
824 brg_cdeg: Option<i64>,
825 bat_pct: Option<i64>,
826 flags: Option<i64>,
827 recv_at: Option<i64>,
828}
829
830/// A weak ETag over the payload's meaningful content.
831fn etag_of(people: &[PersonState]) -> String {
832 use std::hash::{Hash, Hasher};
833 let mut h = std::collections::hash_map::DefaultHasher::new();
834 for p in people {
835 p.user_id.hash(&mut h);
836 p.display_name.hash(&mut h);
837 if let Some(pos) = &p.position {
838 pos.ts.hash(&mut h);
839 pos.lat_e7.hash(&mut h);
840 pos.lon_e7.hash(&mut h);
841 pos.acc_dm.hash(&mut h);
842 pos.bat_pct.hash(&mut h);
843 pos.flags.hash(&mut h);
844 } else {
845 0u8.hash(&mut h);
846 }
847 }
848 format!("W/\"{:x}\"", h.finish())
849}
850
851async fn list_tokens(
852 State(state): State<Shared>,
853 session: Session,
854) -> ApiResult<Json<Vec<TokenInfo>>> {
855 let uid = current_user(&state, &session).await?;
856 let rows: Vec<TokenRow> = sqlx::query_as(
857 "SELECT token_id, name, platform, app_version, os_api_level, last_seen_at, last_src_ip, \
858 last_transport, created_at \
859 FROM tokens WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC",
860 )
861 .bind(uid)
862 .fetch_all(&state.db.read)
863 .await?;
864
865 Ok(Json(
866 rows.into_iter()
867 .map(|r| TokenInfo {
868 token_id: (r.token_id as u64).to_string(),
869 name: r.name,
870 platform: r.platform,
871 app_version: r.app_version,
872 os_api_level: r.os_api_level,
873 last_seen_at: r.last_seen_at,
874 last_src_ip: r.last_src_ip,
875 last_transport: r.last_transport,
876 created_at: r.created_at,
877 })
878 .collect(),
879 ))
880}
881
882#[derive(sqlx::FromRow)]
883struct TokenRow {
884 token_id: i64,
885 name: String,
886 platform: String,
887 app_version: Option<i64>,
888 os_api_level: Option<i64>,
889 last_seen_at: Option<i64>,
890 last_src_ip: Option<String>,
891 last_transport: Option<String>,
892 created_at: i64,
893}
894
895async fn revoke_one_token(
896 State(state): State<Shared>,
897 session: Session,
898 ConnectInfo(peer): ConnectInfo<SocketAddr>,
899 Path(token_id): Path<String>,
900) -> ApiResult<StatusCode> {
901 let uid = current_user(&state, &session).await?;
902 // Parsed as the u64 it is on the wire, then bit-cast: SQLite has no unsigned
903 // integer type, so that cast is how every token id is stored.
904 let token_id = token_id
905 .parse::<u64>()
906 .map_err(|_| ApiError::BadRequest("token id must be a u64".into()))?
907 as i64;
908 // Scope the ownership check into the query: fetching then comparing invites
909 // the check being forgotten on some future path.
910 let owner: Option<i64> = sqlx::query_scalar("SELECT user_id FROM tokens WHERE token_id = ?")
911 .bind(token_id)
912 .fetch_optional(&state.db.read)
913 .await?;
914 match owner {
915 None => return Err(ApiError::NotFound),
916 Some(o) if o != uid => return Err(ApiError::Forbidden),
917 Some(_) => {}
918 }
919
920 if !auth::revoke_token(&state.db.write, &state.ingest, token_id).await? {
921 return Err(ApiError::NotFound);
922 }
923 audit(
924 &state,
925 Some(uid),
926 "token_revoked",
927 &token_id.to_string(),
928 peer.ip(),
929 )
930 .await;
931 Ok(StatusCode::NO_CONTENT)
932}
933
934async fn revoke_others(
935 State(state): State<Shared>,
936 session: Session,
937 ConnectInfo(peer): ConnectInfo<SocketAddr>,
938) -> ApiResult<Json<serde_json::Value>> {
939 let uid = current_user(&state, &session).await?;
940 // From a browser there is no "current token" to keep, so this revokes every
941 // phone. The browser's own session is unaffected.
942 let revoked = auth::revoke_other_tokens(&state.db.write, &state.ingest, uid, None).await?;
943 audit(
944 &state,
945 Some(uid),
946 "tokens_revoked_all",
947 &revoked.to_string(),
948 peer.ip(),
949 )
950 .await;
951 Ok(Json(serde_json::json!({ "revoked": revoked })))
952}
953
954async fn track(
955 State(state): State<Shared>,
956 session: Session,
957 Path(user_id): Path<i64>,
958 Query(q): Query<TrackQuery>,
959) -> ApiResult<Json<TrackResponse>> {
960 let viewer = current_user(&state, &session).await?;
961 let visible = visible_user_ids(&state.db.read, viewer).await?;
962 if !visible.contains(&user_id) {
963 // 403 rather than 404: the caller already knows this user exists if they
964 // saw them in /api/state, and pretending otherwise buys nothing.
965 return Err(ApiError::Forbidden);
966 }
967
968 // Trails are only visible when the share says so. Your own trail is always
969 // yours to see, and always exact. Where several live shares exist the most
970 // generous one wins, so a second share cannot tighten an existing one.
971 let mut precision_m = 0;
972 if user_id != viewer {
973 let (trail_visible, precision): (Option<i64>, Option<i64>) = sqlx::query_as(
974 "SELECT MAX(s.trail_visible), MIN(s.precision_m) FROM shares s \
975 WHERE s.owner_user_id = ? AND s.revoked_at IS NULL \
976 AND (s.expires_at IS NULL OR s.expires_at > ?) \
977 AND s.viewer_user_id = ?",
978 )
979 .bind(user_id)
980 .bind(now())
981 .bind(viewer)
982 .fetch_one(&state.db.read)
983 .await?;
984 if trail_visible.unwrap_or(0) == 0 {
985 return Err(ApiError::Forbidden);
986 }
987 precision_m = precision.unwrap_or(0);
988 }
989
990 let to = q.to.unwrap_or_else(now);
991 let from = q.from.unwrap_or(to - 24 * 3_600);
992 if from >= to {
993 return Err(ApiError::BadRequest("from must be before to".into()));
994 }
995 let max = q.max.clamp(2, 10_000);
996
997 let mut rows: Vec<(i64, i64)> = sqlx::query_as(
998 "SELECT lat, lon FROM points WHERE user_id = ? AND ts >= ? AND ts <= ? ORDER BY ts",
999 )
1000 .bind(user_id)
1001 .bind(from)
1002 .bind(to)
1003 .fetch_all(&state.db.read)
1004 .await?;
1005
1006 // Snap before simplifying: simplifying first would let the exact geometry
1007 // decide which points survive, and the shape of a route is itself a hint.
1008 for p in &mut rows {
1009 (p.0, p.1) = snap_e7(p.0, p.1, precision_m);
1010 }
1011
1012 // Decimate server-side. 2000 points as an encoded polyline is ~10 kB against
1013 // ~60 kB of JSON floats, and the browser has less to draw.
1014 let simplified = crate::polyline::simplify(&rows, max);
1015 let polyline = crate::polyline::encode(&simplified);
1016 Ok(Json(TrackResponse {
1017 user_id,
1018 from,
1019 to,
1020 point_count: simplified.len(),
1021 polyline,
1022 }))
1023}
1024
1025async fn create_user(
1026 State(state): State<Shared>,
1027 session: Session,
1028 ConnectInfo(peer): ConnectInfo<SocketAddr>,
1029 Json(req): Json<CreateUser>,
1030) -> ApiResult<Json<serde_json::Value>> {
1031 let admin = require_admin(&state, &session).await?;
1032 let username = req.username.trim().to_string();
1033 if username.is_empty() || username.chars().count() > 64 {
1034 return Err(ApiError::BadRequest(
1035 "username must be 1..=64 characters".into(),
1036 ));
1037 }
1038 if req.password.chars().count() < 10 {
1039 return Err(ApiError::BadRequest(
1040 "password must be at least 10 characters".into(),
1041 ));
1042 }
1043
1044 let hash = auth::hash_password(&state.cfg, req.password).await?;
1045 let at = now();
1046 let result = sqlx::query(
1047 "INSERT INTO users (username, pw_hash, display_name, is_admin, created_at, pw_changed_at) \
1048 VALUES (?, ?, ?, ?, ?, ?)",
1049 )
1050 .bind(&username)
1051 .bind(hash)
1052 .bind(req.display_name.unwrap_or_else(|| username.clone()))
1053 .bind(i64::from(req.is_admin))
1054 .bind(at)
1055 .bind(at)
1056 .execute(&state.db.write)
1057 .await;
1058
1059 let id = match result {
1060 Ok(r) => r.last_insert_rowid(),
1061 Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
1062 return Err(ApiError::BadRequest("that username is taken".into()));
1063 }
1064 Err(e) => return Err(e.into()),
1065 };
1066 audit(&state, Some(admin), "user_created", &username, peer.ip()).await;
1067 Ok(Json(serde_json::json!({ "id": id, "username": username })))
1068}
1069
1070/// The signed-in user's *outgoing* shares: who can currently see them.
1071async fn list_shares(
1072 State(state): State<Shared>,
1073 session: Session,
1074) -> ApiResult<Json<Vec<ShareInfo>>> {
1075 let uid = current_user(&state, &session).await?;
1076 let rows: Vec<ShareRow> = sqlx::query_as(
1077 "SELECT s.id, s.viewer_user_id, u.username, u.display_name, s.trail_visible, \
1078 s.precision_m, s.expires_at, s.created_at \
1079 FROM shares s JOIN users u ON u.id = s.viewer_user_id \
1080 WHERE s.owner_user_id = ? AND s.revoked_at IS NULL \
1081 AND (s.expires_at IS NULL OR s.expires_at > ?) \
1082 ORDER BY s.created_at DESC",
1083 )
1084 .bind(uid)
1085 .bind(now())
1086 .fetch_all(&state.db.read)
1087 .await?;
1088 Ok(Json(rows.into_iter().map(ShareRow::into_info).collect()))
1089}
1090
1091#[derive(sqlx::FromRow)]
1092struct ShareRow {
1093 id: i64,
1094 viewer_user_id: i64,
1095 username: String,
1096 display_name: String,
1097 trail_visible: i64,
1098 precision_m: i64,
1099 expires_at: Option<i64>,
1100 created_at: i64,
1101}
1102
1103impl ShareRow {
1104 fn into_info(self) -> ShareInfo {
1105 ShareInfo {
1106 id: self.id,
1107 viewer_user_id: self.viewer_user_id,
1108 viewer_username: self.username,
1109 viewer_display_name: self.display_name,
1110 trail_visible: self.trail_visible != 0,
1111 precision_m: self.precision_m,
1112 expires_at: self.expires_at,
1113 created_at: self.created_at,
1114 }
1115 }
1116}
1117
1118async fn create_share(
1119 State(state): State<Shared>,
1120 session: Session,
1121 ConnectInfo(peer): ConnectInfo<SocketAddr>,
1122 Json(req): Json<CreateShare>,
1123) -> ApiResult<(StatusCode, Json<ShareInfo>)> {
1124 let uid = current_user(&state, &session).await?;
1125 if !(0..=100_000).contains(&req.precision_m) {
1126 return Err(ApiError::BadRequest(
1127 "precision_m must be 0..=100000 metres".into(),
1128 ));
1129 }
1130 if req.expires_in_s.is_some_and(|s| s <= 0) {
1131 return Err(ApiError::BadRequest("expires_in_s must be positive".into()));
1132 }
1133
1134 // `username` is COLLATE NOCASE, so this match is case-insensitive for free.
1135 let viewer: Option<(i64, String, String)> = sqlx::query_as(
1136 "SELECT id, username, display_name FROM users \
1137 WHERE username = ? AND disabled_at IS NULL",
1138 )
1139 .bind(req.username.trim())
1140 .fetch_optional(&state.db.read)
1141 .await?;
1142 let Some((viewer_id, username, display_name)) = viewer else {
1143 return Err(ApiError::NotFound);
1144 };
1145 if viewer_id == uid {
1146 return Err(ApiError::BadRequest("you can already see yourself".into()));
1147 }
1148
1149 let at = now();
1150 let expires_at = req.expires_in_s.map(|s| at + s);
1151 // One transaction, because the two statements below are one act. A revoke
1152 // that committed without its replacement would silently drop a share the
1153 // user was in the middle of editing.
1154 let mut tx = state.db.write.begin().await?;
1155 // At most one live row per (owner, viewer): re-sharing with new settings
1156 // replaces the old share rather than adding a more permissive one beside it.
1157 sqlx::query(
1158 "UPDATE shares SET revoked_at = ? \
1159 WHERE owner_user_id = ? AND viewer_user_id = ? AND revoked_at IS NULL",
1160 )
1161 .bind(at)
1162 .bind(uid)
1163 .bind(viewer_id)
1164 .execute(&mut *tx)
1165 .await?;
1166
1167 let id = sqlx::query(
1168 "INSERT INTO shares (owner_user_id, viewer_user_id, trail_visible, precision_m, \
1169 expires_at, created_at) \
1170 VALUES (?, ?, ?, ?, ?, ?)",
1171 )
1172 .bind(uid)
1173 .bind(viewer_id)
1174 .bind(i64::from(req.trail_visible))
1175 .bind(req.precision_m)
1176 .bind(expires_at)
1177 .bind(at)
1178 .execute(&mut *tx)
1179 .await?
1180 .last_insert_rowid();
1181 tx.commit().await?;
1182
1183 audit(&state, Some(uid), "share_created", &username, peer.ip()).await;
1184 Ok((
1185 StatusCode::CREATED,
1186 Json(ShareInfo {
1187 id,
1188 viewer_user_id: viewer_id,
1189 viewer_username: username,
1190 viewer_display_name: display_name,
1191 trail_visible: req.trail_visible,
1192 precision_m: req.precision_m,
1193 expires_at,
1194 created_at: at,
1195 }),
1196 ))
1197}
1198
1199async fn revoke_share(
1200 State(state): State<Shared>,
1201 session: Session,
1202 ConnectInfo(peer): ConnectInfo<SocketAddr>,
1203 Path(share_id): Path<i64>,
1204) -> ApiResult<StatusCode> {
1205 let uid = current_user(&state, &session).await?;
1206 // Ownership is part of the UPDATE, not a fetch-then-compare: the check
1207 // cannot then be forgotten on some future path.
1208 let affected = sqlx::query(
1209 "UPDATE shares SET revoked_at = ? \
1210 WHERE id = ? AND owner_user_id = ? AND revoked_at IS NULL",
1211 )
1212 .bind(now())
1213 .bind(share_id)
1214 .bind(uid)
1215 .execute(&state.db.write)
1216 .await?
1217 .rows_affected();
1218 if affected == 0 {
1219 return Err(ApiError::NotFound);
1220 }
1221 audit(
1222 &state,
1223 Some(uid),
1224 "share_revoked",
1225 &share_id.to_string(),
1226 peer.ip(),
1227 )
1228 .await;
1229 Ok(StatusCode::NO_CONTENT)
1230}
1231
1232async fn audit(state: &Shared, user_id: Option<i64>, action: &str, detail: &str, ip: IpAddr) {
1233 let _ = state
1234 .writer
1235 .send(WriteOp::Audit {
1236 user_id,
1237 at: now(),
1238 action: action.to_string(),
1239 detail: detail.to_string(),
1240 src_ip: Some(ip.to_string()),
1241 })
1242 .await;
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247 use super::*;
1248
1249 /// The web UI's `web/src/api.ts` types are hand-written. This is what stops
1250 /// them drifting: renaming a field here fails `cargo test` instead of
1251 /// producing `undefined` in a browser at runtime.
1252 ///
1253 /// Keys only, not values — the types carry no invariants worth asserting,
1254 /// and a value check would just restate the constructor above it.
1255 fn keys(value: &serde_json::Value) -> Vec<&str> {
1256 let mut k: Vec<&str> = value
1257 .as_object()
1258 .expect("expected a JSON object")
1259 .keys()
1260 .map(String::as_str)
1261 .collect();
1262 k.sort_unstable();
1263 k
1264 }
1265
1266 #[test]
1267 fn the_json_shape_is_the_one_the_web_ui_expects() {
1268 let me = Me {
1269 id: 1,
1270 username: "a".into(),
1271 display_name: "A".into(),
1272 is_admin: false,
1273 server_time: 0,
1274 };
1275 assert_eq!(
1276 keys(&serde_json::to_value(&me).expect("serialize")),
1277 ["display_name", "id", "is_admin", "server_time", "username"]
1278 );
1279
1280 let position = Position {
1281 ts: 0,
1282 lat_e7: 0,
1283 lon_e7: 0,
1284 acc_dm: None,
1285 alt_m: None,
1286 spd_cms: None,
1287 brg_cdeg: None,
1288 bat_pct: None,
1289 flags: 0,
1290 recv_at: 0,
1291 };
1292 assert_eq!(
1293 keys(&serde_json::to_value(&position).expect("serialize")),
1294 [
1295 "acc_dm", "alt_m", "bat_pct", "brg_cdeg", "flags", "lat_e7", "lon_e7", "recv_at",
1296 "spd_cms", "ts"
1297 ]
1298 );
1299
1300 let person = PersonState {
1301 user_id: 1,
1302 display_name: "A".into(),
1303 is_self: true,
1304 position: Some(position),
1305 };
1306 assert_eq!(
1307 keys(&serde_json::to_value(&person).expect("serialize")),
1308 ["display_name", "is_self", "position", "user_id"]
1309 );
1310
1311 // `position` is skipped when absent, which is why the TypeScript field is
1312 // optional rather than nullable.
1313 let no_fix = PersonState {
1314 position: None,
1315 ..person
1316 };
1317 assert_eq!(
1318 keys(&serde_json::to_value(&no_fix).expect("serialize")),
1319 ["display_name", "is_self", "user_id"]
1320 );
1321
1322 let state = StateResponse {
1323 server_time: 0,
1324 people: vec![],
1325 };
1326 assert_eq!(
1327 keys(&serde_json::to_value(&state).expect("serialize")),
1328 ["people", "server_time"]
1329 );
1330
1331 let token = TokenInfo {
1332 token_id: "1".into(),
1333 name: "p".into(),
1334 platform: "android".into(),
1335 app_version: None,
1336 os_api_level: None,
1337 last_seen_at: None,
1338 last_src_ip: None,
1339 last_transport: None,
1340 created_at: 0,
1341 };
1342 assert_eq!(
1343 keys(&serde_json::to_value(&token).expect("serialize")),
1344 [
1345 "app_version",
1346 "created_at",
1347 "last_seen_at",
1348 "last_src_ip",
1349 "last_transport",
1350 "name",
1351 "os_api_level",
1352 "platform",
1353 "token_id"
1354 ]
1355 );
1356
1357 let track = TrackResponse {
1358 user_id: 1,
1359 from: 0,
1360 to: 1,
1361 polyline: String::new(),
1362 point_count: 0,
1363 };
1364 assert_eq!(
1365 keys(&serde_json::to_value(&track).expect("serialize")),
1366 ["from", "point_count", "polyline", "to", "user_id"]
1367 );
1368
1369 let share = ShareInfo {
1370 id: 1,
1371 viewer_user_id: 2,
1372 viewer_username: "b".into(),
1373 viewer_display_name: "B".into(),
1374 trail_visible: true,
1375 precision_m: 0,
1376 expires_at: None,
1377 created_at: 0,
1378 };
1379 assert_eq!(
1380 keys(&serde_json::to_value(&share).expect("serialize")),
1381 [
1382 "created_at",
1383 "expires_at",
1384 "id",
1385 "precision_m",
1386 "trail_visible",
1387 "viewer_display_name",
1388 "viewer_user_id",
1389 "viewer_username"
1390 ]
1391 );
1392
1393 assert_eq!(
1394 keys(&serde_json::to_value(ErrorBody { error: "x".into() }).expect("serialize")),
1395 ["error"]
1396 );
1397 }
1398
1399 /// Metres between two coordinates, good enough to check a grid cell size.
1400 fn metres_between(a: (i64, i64), b: (i64, i64)) -> f64 {
1401 let dlat = (a.0 - b.0) as f64 / 1e7 * 111_320.0;
1402 let cos = (a.0 as f64 / 1e7).to_radians().cos();
1403 let dlon = (a.1 - b.1) as f64 / 1e7 * 111_320.0 * cos;
1404 (dlat * dlat + dlon * dlon).sqrt()
1405 }
1406
1407 #[test]
1408 fn a_precision_of_zero_or_less_leaves_the_position_untouched() {
1409 let exact = (521_234_567, 133_456_789);
1410 for precision in [0, -1, -100_000] {
1411 assert_eq!(snap_e7(exact.0, exact.1, precision), exact);
1412 }
1413 }
1414
1415 /// The property that makes rounding safe where jitter would not be: an
1416 /// attacker polling a stationary person gets the same answer every time, so
1417 /// averaging the samples reveals nothing.
1418 #[test]
1419 fn snapping_is_deterministic() {
1420 let first = snap_e7(521_234_567, 133_456_789, 500);
1421 for _ in 0..10 {
1422 assert_eq!(snap_e7(521_234_567, 133_456_789, 500), first);
1423 }
1424 }
1425
1426 #[test]
1427 fn a_snapped_point_stays_within_roughly_the_requested_precision() {
1428 for lat in [0, 100_000_000, 521_234_567, -335_000_000] {
1429 for lon in [0, 133_456_789, -740_000_000] {
1430 for precision in [10, 100, 1_000, 100_000] {
1431 let snapped = snap_e7(lat, lon, precision);
1432 let moved = metres_between((lat, lon), snapped);
1433 assert!(
1434 moved <= precision as f64,
1435 "moved {moved} m for a {precision} m grid"
1436 );
1437 }
1438 }
1439 }
1440 }
1441
1442 #[test]
1443 fn snapping_collapses_nearby_points_onto_one_cell() {
1444 // Two points ~11 m apart, on a 1 km grid.
1445 let a = snap_e7(521_234_567, 133_456_789, 1_000);
1446 let b = snap_e7(521_235_567, 133_456_789, 1_000);
1447 assert_eq!(a, b);
1448 // The same two points are still distinct when snapped finely.
1449 assert_ne!(
1450 snap_e7(521_234_567, 133_456_789, 1),
1451 snap_e7(521_235_567, 133_456_789, 1)
1452 );
1453 }
1454
1455 /// cos(lat) reaches zero at the poles, where the longitude divisor would be
1456 /// zero and every result a NaN cast to a garbage integer.
1457 #[test]
1458 fn a_point_at_the_pole_does_not_divide_by_zero() {
1459 for lat in [899_999_999, 900_000_000, -900_000_000] {
1460 let (lat_e7, lon_e7) = snap_e7(lat, 123_456_789, 1_000);
1461 assert!((-900_000_000..=900_000_000).contains(&lat_e7), "{lat_e7}");
1462 assert!(
1463 (-1_800_000_000..=1_800_000_000).contains(&lon_e7),
1464 "{lon_e7}"
1465 );
1466 }
1467 }
1468
1469 /// Token ids routinely exceed 2^53, which is why [`TokenInfo::token_id`] is a
1470 /// string. This asserts the reason still holds rather than trusting the
1471 /// comment: if ids ever became small the string could go away.
1472 #[test]
1473 fn token_ids_are_too_large_for_a_javascript_number() {
1474 let big = (0..64)
1475 .map(|_| crate::keys::random_token_id().expect("rng"))
1476 .filter(|id| *id >= (1u64 << 53))
1477 .count();
1478 assert!(
1479 big > 32,
1480 "expected most token ids above 2^53, got {big} of 64"
1481 );
1482 }
1483
1484 /// The exact value a browser must be able to send back and have match.
1485 #[test]
1486 fn a_large_token_id_round_trips_through_its_string_form() {
1487 let id = u64::MAX - 3;
1488 let text = id.to_string();
1489 assert_eq!(text.parse::<u64>().expect("parse") as i64, id as i64);
1490 }
1491}
1492