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.
130async 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 OR s.viewer_group_id IN (SELECT group_id FROM group_members WHERE user_id = ?) )",
186 )
187 .bind(viewer)
188 .bind(now)
189 .bind(viewer)
190 .bind(viewer)
191 .fetch_all(pool)
192 .await?;
193 Ok(ids)
194}
195
196// ---------------------------------------------------------------------------
197// Payloads
198// ---------------------------------------------------------------------------
199
200#[derive(Deserialize)]
201pub struct LoginRequest {
202 pub username: String,
203 pub password: String,
204 /// `"browser"` (default) or `"device"`. A device login additionally mints an
205 /// OTP/1 token.
206 #[serde(default)]
207 pub purpose: Purpose,
208 #[serde(default)]
209 pub device_name: Option<String>,
210 #[serde(default)]
211 pub platform: Option<String>,
212}
213
214#[derive(Deserialize, Default, PartialEq, Eq)]
215#[serde(rename_all = "snake_case")]
216pub enum Purpose {
217 #[default]
218 Browser,
219 Device,
220}
221
222#[derive(Serialize)]
223pub struct LoginResponse {
224 pub user: Me,
225 /// Present only for a device login.
226 #[serde(skip_serializing_if = "Option::is_none")]
227 pub device: Option<DeviceCredentials>,
228}
229
230/// Everything a phone needs, returned exactly once.
231#[derive(Serialize)]
232pub struct DeviceCredentials {
233 pub token_id: u64,
234 /// base64 of 32 bytes. The only time the server emits this in the clear.
235 pub token_key: String,
236 /// base64 of 32 bytes: the key that seals a `REVOKED` notice for this token.
237 ///
238 /// Separate from `token_key` because it must outlive it. `K_up` and `K_down`
239 /// derive from the token key and die with the token's row; this one is
240 /// derived from a server master and the `token_id`, so the server can still
241 /// speak to a device whose row is gone.
242 ///
243 /// It has to be issued at login and cannot be retrofitted: a device that
244 /// never received one can never verify a notice, and the thing that would
245 /// prompt it to log in again is exactly that notice.
246 pub revoke_key: String,
247 pub udp_host: String,
248 pub udp_port: u16,
249 #[serde(skip_serializing_if = "Option::is_none")]
250 pub tls_url: Option<String>,
251 pub config: DeviceConfig,
252}
253
254#[derive(Serialize)]
255pub struct DeviceConfig {
256 pub config_version: u16,
257 pub profile: &'static str,
258}
259
260#[derive(Serialize)]
261pub struct Me {
262 pub id: i64,
263 pub username: String,
264 pub display_name: String,
265 pub is_admin: bool,
266 pub server_time: i64,
267}
268
269#[derive(Serialize)]
270pub struct PersonState {
271 pub user_id: i64,
272 pub display_name: String,
273 /// True for the viewer's own entry.
274 pub is_self: bool,
275 #[serde(skip_serializing_if = "Option::is_none")]
276 pub position: Option<Position>,
277}
278
279#[derive(Serialize)]
280pub struct Position {
281 pub ts: i64,
282 /// Degrees × 1e7. Integers end to end, so there is no float-formatting drift
283 /// between the wire, the database, this JSON, and the map.
284 pub lat_e7: i64,
285 pub lon_e7: i64,
286 pub acc_dm: Option<i64>,
287 pub alt_m: Option<i64>,
288 pub spd_cms: Option<i64>,
289 pub brg_cdeg: Option<i64>,
290 pub bat_pct: Option<i64>,
291 pub flags: i64,
292 pub recv_at: i64,
293}
294
295#[derive(Serialize)]
296pub struct StateResponse {
297 pub server_time: i64,
298 pub people: Vec<PersonState>,
299}
300
301#[derive(Serialize)]
302pub struct TokenInfo {
303 /// A decimal string, not a number. `token_id` is a full 64-bit random value
304 /// and JavaScript's `number` is exact only to 2^53, so a JSON number would
305 /// silently round — and a token id that does not round-trip cannot be
306 /// revoked. The phone's `DeviceCredentials` keeps the numeric form because
307 /// its parser has real 64-bit integers.
308 pub token_id: String,
309 pub name: String,
310 pub platform: String,
311 pub app_version: Option<i64>,
312 pub os_api_level: Option<i64>,
313 pub last_seen_at: Option<i64>,
314 pub last_src_ip: Option<String>,
315 pub last_transport: Option<String>,
316 pub created_at: i64,
317}
318
319#[derive(Deserialize)]
320pub struct TrackQuery {
321 pub from: Option<i64>,
322 pub to: Option<i64>,
323 #[serde(default = "default_max")]
324 pub max: usize,
325}
326
327fn default_max() -> usize {
328 2000
329}
330
331#[derive(Serialize)]
332pub struct TrackResponse {
333 pub user_id: i64,
334 pub from: i64,
335 pub to: i64,
336 /// Google-style encoded polyline at 1e5 precision.
337 pub polyline: String,
338 pub point_count: usize,
339}
340
341#[derive(Deserialize)]
342pub struct PasswordChange {
343 pub current_password: String,
344 pub new_password: String,
345}
346
347#[derive(Deserialize)]
348pub struct CreateUser {
349 pub username: String,
350 pub password: String,
351 #[serde(default)]
352 pub display_name: Option<String>,
353 #[serde(default)]
354 pub is_admin: bool,
355}
356
357// ---------------------------------------------------------------------------
358// Router
359// ---------------------------------------------------------------------------
360
361/// Rejects a state-changing request that did not come from our own page.
362async fn require_csrf(req: Request, next: Next) -> Response {
363 if req.method().is_safe() || req.headers().contains_key(CSRF_HEADER) {
364 return next.run(req).await;
365 }
366 (
367 StatusCode::FORBIDDEN,
368 Json(ErrorBody {
369 error: format!("missing {CSRF_HEADER} header"),
370 }),
371 )
372 .into_response()
373}
374
375pub fn router(state: Shared) -> Router {
376 Router::new()
377 .route("/api/login", post(login))
378 .route("/api/logout", post(logout))
379 .route("/api/me", get(me))
380 .route("/api/me/password", post(change_password))
381 .route("/api/state", get(state_handler))
382 .route("/api/tokens", get(list_tokens))
383 .route("/api/tokens/{token_id}", delete(revoke_one_token))
384 .route("/api/tokens/revoke-others", post(revoke_others))
385 .route("/api/users/{user_id}/track", get(track))
386 .route("/api/users", post(create_user))
387 // Only the /api routes above; `route_layer` runs nothing when no route
388 // matches, so the static fallback below is untouched.
389 .route_layer(middleware::from_fn(require_csrf))
390 .route("/healthz", get(healthz))
391 .route("/metrics", get(metrics))
392 .with_state(state)
393 // Anything else is the web UI, including deep links it routes itself.
394 .fallback(crate::web::serve)
395}
396
397// ---------------------------------------------------------------------------
398// Handlers
399// ---------------------------------------------------------------------------
400
401async fn healthz(State(state): State<Shared>) -> ApiResult<Json<serde_json::Value>> {
402 // A real query, not a constant: "healthy" has to mean the database answers.
403 let _: i64 = sqlx::query_scalar("SELECT 1")
404 .fetch_one(&state.db.read)
405 .await?;
406 Ok(Json(serde_json::json!({
407 "ok": true,
408 "version": env!("CARGO_PKG_VERSION"),
409 "server_time": now(),
410 "tokens_loaded": state.ingest.active_token_count(),
411 })))
412}
413
414/// Aggregate counters, in Prometheus text format.
415///
416/// Bound to loopback callers only: these numbers say how much abuse the UDP port
417/// is absorbing and how close the writer is to saturation, which is exactly the
418/// reconnaissance an attacker would want. Scrape it through the reverse proxy or
419/// over an SSH tunnel.
420async fn metrics(
421 State(state): State<Shared>,
422 ConnectInfo(peer): ConnectInfo<SocketAddr>,
423) -> ApiResult<String> {
424 if !peer.ip().is_loopback() {
425 return Err(ApiError::Forbidden);
426 }
427 use std::sync::atomic::Ordering::Relaxed;
428 let c = &state.ingest.counters;
429 let mut out = String::new();
430 for (name, value) in [
431 ("otp_datagrams_received", c.received.load(Relaxed)),
432 ("otp_datagrams_malformed", c.malformed.load(Relaxed)),
433 ("otp_unknown_token", c.unknown_token.load(Relaxed)),
434 ("otp_auth_failed", c.auth_failed.load(Relaxed)),
435 ("otp_rate_limited", c.rate_limited.load(Relaxed)),
436 ("otp_throttled", c.throttled.load(Relaxed)),
437 ("otp_points_accepted", c.points_accepted.load(Relaxed)),
438 ("otp_points_rejected", c.points_rejected.load(Relaxed)),
439 ("otp_acks_sent", c.acks_sent.load(Relaxed)),
440 ("otp_nacks_sent", c.nacks_sent.load(Relaxed)),
441 ("otp_revoked_notices", c.revoked_notices_sent.load(Relaxed)),
442 // The reflection budget actually spent. If this is nonzero and climbing,
443 // someone is probing the port with forged token ids.
444 (
445 "otp_unverified_notices",
446 c.unverified_notices_sent.load(Relaxed),
447 ),
448 ("otp_notices_suppressed", c.notices_suppressed.load(Relaxed)),
449 ("otp_silent_drops", c.silent_drops.load(Relaxed)),
450 ] {
451 out.push_str(&format!("# TYPE {name} counter\n{name} {value}\n"));
452 }
453 for (name, value) in [
454 (
455 "otp_tokens_loaded",
456 state.ingest.active_token_count() as u64,
457 ),
458 (
459 "otp_limiter_tracked_ips",
460 state.ingest.limits().tracked_ips() as u64,
461 ),
462 ("otp_writer_capacity_free", state.writer.capacity() as u64),
463 ] {
464 out.push_str(&format!("# TYPE {name} gauge\n{name} {value}\n"));
465 }
466 Ok(out)
467}
468
469async fn login(
470 State(state): State<Shared>,
471 session: Session,
472 ConnectInfo(peer): ConnectInfo<SocketAddr>,
473 Json(req): Json<LoginRequest>,
474) -> ApiResult<Json<LoginResponse>> {
475 let ip = peer.ip();
476 let account = match auth::authenticate(
477 &state.db.read,
478 &state.writer,
479 &state.cfg,
480 &state.throttle,
481 ip,
482 &req.username,
483 req.password,
484 )
485 .await
486 {
487 Ok(a) => a,
488 Err(AuthError::LockedOut { retry_after_s }) => {
489 return Err(ApiError::TooManyRequests(retry_after_s));
490 }
491 Err(AuthError::Invalid) => {
492 audit(&state, None, "login_failed", &req.username, ip).await;
493 return Err(ApiError::Unauthorized);
494 }
495 };
496
497 // Rotate the session id on login, so a fixation attempt cannot survive it.
498 session
499 .cycle_id()
500 .await
501 .map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
502 let issued = now();
503 session
504 .insert(SESSION_USER, account.id)
505 .await
506 .map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
507 session
508 .insert(SESSION_ISSUED, issued)
509 .await
510 .map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
511
512 let device = if req.purpose == Purpose::Device {
513 let name = req.device_name.unwrap_or_else(|| "phone".to_string());
514 let platform = req.platform.unwrap_or_else(|| "android".to_string());
515 let minted = auth::mint_token(
516 &state.db.write,
517 &state.vault,
518 &state.ingest,
519 account.id,
520 &name,
521 &platform,
522 ip,
523 )
524 .await?;
525 audit(&state, Some(account.id), "token_minted", &name, ip).await;
526
527 use base64::Engine as _;
528 Some(DeviceCredentials {
529 token_id: minted.token_id,
530 token_key: base64::engine::general_purpose::STANDARD.encode(minted.token_key),
531 revoke_key: base64::engine::general_purpose::STANDARD
532 .encode(state.vault.revocation_key(minted.token_id)),
533 udp_host: state.cfg.public_udp_host.clone(),
534 udp_port: state.cfg.public_udp_port,
535 tls_url: state.cfg.public_tls_url.clone(),
536 config: DeviceConfig {
537 config_version: minted.config_version,
538 profile: "balanced",
539 },
540 })
541 } else {
542 audit(&state, Some(account.id), "login", "browser", ip).await;
543 None
544 };
545
546 Ok(Json(LoginResponse {
547 user: Me {
548 id: account.id,
549 username: account.username,
550 display_name: account.display_name,
551 is_admin: account.is_admin,
552 server_time: issued,
553 },
554 device,
555 }))
556}
557
558async fn logout(session: Session) -> ApiResult<StatusCode> {
559 session
560 .flush()
561 .await
562 .map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
563 Ok(StatusCode::NO_CONTENT)
564}
565
566async fn me(State(state): State<Shared>, session: Session) -> ApiResult<Json<Me>> {
567 let uid = current_user(&state, &session).await?;
568 let (username, display_name, is_admin): (String, String, i64) =
569 sqlx::query_as("SELECT username, display_name, is_admin FROM users WHERE id = ?")
570 .bind(uid)
571 .fetch_one(&state.db.read)
572 .await?;
573 Ok(Json(Me {
574 id: uid,
575 username,
576 display_name,
577 is_admin: is_admin != 0,
578 server_time: now(),
579 }))
580}
581
582async fn change_password(
583 State(state): State<Shared>,
584 session: Session,
585 ConnectInfo(peer): ConnectInfo<SocketAddr>,
586 Json(req): Json<PasswordChange>,
587) -> ApiResult<StatusCode> {
588 let uid = current_user(&state, &session).await?;
589 if req.new_password.chars().count() < 10 {
590 return Err(ApiError::BadRequest(
591 "the new password must be at least 10 characters".into(),
592 ));
593 }
594
595 let stored: String = sqlx::query_scalar("SELECT pw_hash FROM users WHERE id = ?")
596 .bind(uid)
597 .fetch_one(&state.db.read)
598 .await?;
599 if !auth::verify(&state.cfg, stored, req.current_password)
600 .await?
601 .ok
602 {
603 return Err(ApiError::Unauthorized);
604 }
605
606 let hash = auth::hash_password(&state.cfg, req.new_password).await?;
607 let at = now();
608 sqlx::query("UPDATE users SET pw_hash = ?, pw_changed_at = ? WHERE id = ?")
609 .bind(hash)
610 .bind(at)
611 .bind(uid)
612 .execute(&state.db.write)
613 .await?;
614
615 // A password change logs out every phone and every other browser. Browsers
616 // are handled by the pw_changed_at comparison in current_user; phones need
617 // their tokens actually revoked, since they carry a key rather than a cookie.
618 let revoked = auth::revoke_other_tokens(&state.db.write, &state.ingest, uid, None).await?;
619 session
620 .cycle_id()
621 .await
622 .map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
623 session
624 .insert(SESSION_ISSUED, at)
625 .await
626 .map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
627
628 audit(
629 &state,
630 Some(uid),
631 "password_changed",
632 &format!("{revoked} tokens revoked"),
633 peer.ip(),
634 )
635 .await;
636 Ok(StatusCode::NO_CONTENT)
637}
638
639/// Everything the live view needs, in one call.
640async fn state_handler(
641 State(state): State<Shared>,
642 session: Session,
643 headers: HeaderMap,
644) -> ApiResult<Response> {
645 let uid = current_user(&state, &session).await?;
646 let visible = visible_user_ids(&state.db.read, uid).await?;
647
648 // One query for everyone visible. A LEFT JOIN so a person with no position
649 // yet still appears in the list — otherwise they would silently vanish from
650 // the UI until their first fix, which reads as a bug.
651 //
652 // The id set is passed as a JSON array through `json_each` rather than by
653 // building an `IN (?, ?, ?)` string: the SQL stays a literal, so there is no
654 // interpolation to audit and the statement cache gets one entry instead of
655 // one per group size.
656 let ids_json = serde_json::to_string(&visible).map_err(|e| ApiError::Internal(e.into()))?;
657 let rows = sqlx::query_as::<_, LatestRow>(
658 "SELECT u.id, u.display_name, l.ts, l.lat, l.lon, l.acc_dm, l.alt_m, l.spd_cms, \
659 l.brg_cdeg, l.bat_pct, l.flags, l.recv_at \
660 FROM users u \
661 JOIN json_each(?) v ON v.value = u.id \
662 LEFT JOIN user_latest l ON l.user_id = u.id \
663 ORDER BY u.display_name",
664 )
665 .bind(&ids_json)
666 .fetch_all(&state.db.read)
667 .await?;
668
669 let people: Vec<PersonState> = rows
670 .into_iter()
671 .map(|r| PersonState {
672 user_id: r.id,
673 is_self: r.id == uid,
674 display_name: r.display_name,
675 position: r.ts.map(|ts| Position {
676 ts,
677 lat_e7: r.lat.unwrap_or(0),
678 lon_e7: r.lon.unwrap_or(0),
679 acc_dm: r.acc_dm,
680 alt_m: r.alt_m,
681 spd_cms: r.spd_cms,
682 brg_cdeg: r.brg_cdeg,
683 bat_pct: r.bat_pct,
684 flags: r.flags.unwrap_or(0),
685 recv_at: r.recv_at.unwrap_or(ts),
686 }),
687 })
688 .collect();
689
690 let body = StateResponse {
691 server_time: now(),
692 people,
693 };
694
695 // ETag over the people list only — deliberately *not* including
696 // `server_time`, which changes every second and would make every poll a 200.
697 let etag = etag_of(&body.people);
698 if headers
699 .get(header::IF_NONE_MATCH)
700 .and_then(|v| v.to_str().ok())
701 .is_some_and(|v| v == etag)
702 {
703 return Ok((StatusCode::NOT_MODIFIED, [(header::ETAG, etag)]).into_response());
704 }
705 Ok((
706 [
707 (header::ETAG, etag),
708 (header::CACHE_CONTROL, "no-store".to_string()),
709 ],
710 Json(body),
711 )
712 .into_response())
713}
714
715#[derive(sqlx::FromRow)]
716struct LatestRow {
717 id: i64,
718 display_name: String,
719 ts: Option<i64>,
720 lat: Option<i64>,
721 lon: Option<i64>,
722 acc_dm: Option<i64>,
723 alt_m: Option<i64>,
724 spd_cms: Option<i64>,
725 brg_cdeg: Option<i64>,
726 bat_pct: Option<i64>,
727 flags: Option<i64>,
728 recv_at: Option<i64>,
729}
730
731/// A weak ETag over the payload's meaningful content.
732fn etag_of(people: &[PersonState]) -> String {
733 use std::hash::{Hash, Hasher};
734 let mut h = std::collections::hash_map::DefaultHasher::new();
735 for p in people {
736 p.user_id.hash(&mut h);
737 p.display_name.hash(&mut h);
738 if let Some(pos) = &p.position {
739 pos.ts.hash(&mut h);
740 pos.lat_e7.hash(&mut h);
741 pos.lon_e7.hash(&mut h);
742 pos.acc_dm.hash(&mut h);
743 pos.bat_pct.hash(&mut h);
744 pos.flags.hash(&mut h);
745 } else {
746 0u8.hash(&mut h);
747 }
748 }
749 format!("W/\"{:x}\"", h.finish())
750}
751
752async fn list_tokens(
753 State(state): State<Shared>,
754 session: Session,
755) -> ApiResult<Json<Vec<TokenInfo>>> {
756 let uid = current_user(&state, &session).await?;
757 let rows: Vec<TokenRow> = sqlx::query_as(
758 "SELECT token_id, name, platform, app_version, os_api_level, last_seen_at, last_src_ip, \
759 last_transport, created_at \
760 FROM tokens WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC",
761 )
762 .bind(uid)
763 .fetch_all(&state.db.read)
764 .await?;
765
766 Ok(Json(
767 rows.into_iter()
768 .map(|r| TokenInfo {
769 token_id: (r.token_id as u64).to_string(),
770 name: r.name,
771 platform: r.platform,
772 app_version: r.app_version,
773 os_api_level: r.os_api_level,
774 last_seen_at: r.last_seen_at,
775 last_src_ip: r.last_src_ip,
776 last_transport: r.last_transport,
777 created_at: r.created_at,
778 })
779 .collect(),
780 ))
781}
782
783#[derive(sqlx::FromRow)]
784struct TokenRow {
785 token_id: i64,
786 name: String,
787 platform: String,
788 app_version: Option<i64>,
789 os_api_level: Option<i64>,
790 last_seen_at: Option<i64>,
791 last_src_ip: Option<String>,
792 last_transport: Option<String>,
793 created_at: i64,
794}
795
796async fn revoke_one_token(
797 State(state): State<Shared>,
798 session: Session,
799 ConnectInfo(peer): ConnectInfo<SocketAddr>,
800 Path(token_id): Path<String>,
801) -> ApiResult<StatusCode> {
802 let uid = current_user(&state, &session).await?;
803 // Parsed as the u64 it is on the wire, then bit-cast: SQLite has no unsigned
804 // integer type, so that cast is how every token id is stored.
805 let token_id = token_id
806 .parse::<u64>()
807 .map_err(|_| ApiError::BadRequest("token id must be a u64".into()))?
808 as i64;
809 // Scope the ownership check into the query: fetching then comparing invites
810 // the check being forgotten on some future path.
811 let owner: Option<i64> = sqlx::query_scalar("SELECT user_id FROM tokens WHERE token_id = ?")
812 .bind(token_id)
813 .fetch_optional(&state.db.read)
814 .await?;
815 match owner {
816 None => return Err(ApiError::NotFound),
817 Some(o) if o != uid => return Err(ApiError::Forbidden),
818 Some(_) => {}
819 }
820
821 if !auth::revoke_token(&state.db.write, &state.ingest, token_id).await? {
822 return Err(ApiError::NotFound);
823 }
824 audit(
825 &state,
826 Some(uid),
827 "token_revoked",
828 &token_id.to_string(),
829 peer.ip(),
830 )
831 .await;
832 Ok(StatusCode::NO_CONTENT)
833}
834
835async fn revoke_others(
836 State(state): State<Shared>,
837 session: Session,
838 ConnectInfo(peer): ConnectInfo<SocketAddr>,
839) -> ApiResult<Json<serde_json::Value>> {
840 let uid = current_user(&state, &session).await?;
841 // From a browser there is no "current token" to keep, so this revokes every
842 // phone. The browser's own session is unaffected.
843 let revoked = auth::revoke_other_tokens(&state.db.write, &state.ingest, uid, None).await?;
844 audit(
845 &state,
846 Some(uid),
847 "tokens_revoked_all",
848 &revoked.to_string(),
849 peer.ip(),
850 )
851 .await;
852 Ok(Json(serde_json::json!({ "revoked": revoked })))
853}
854
855async fn track(
856 State(state): State<Shared>,
857 session: Session,
858 Path(user_id): Path<i64>,
859 Query(q): Query<TrackQuery>,
860) -> ApiResult<Json<TrackResponse>> {
861 let viewer = current_user(&state, &session).await?;
862 let visible = visible_user_ids(&state.db.read, viewer).await?;
863 if !visible.contains(&user_id) {
864 // 403 rather than 404: the caller already knows this user exists if they
865 // saw them in /api/state, and pretending otherwise buys nothing.
866 return Err(ApiError::Forbidden);
867 }
868
869 // Trails are only visible when the share says so. Your own trail is always
870 // yours to see.
871 if user_id != viewer {
872 let trail_visible: Option<i64> = sqlx::query_scalar(
873 "SELECT MAX(s.trail_visible) FROM shares s \
874 WHERE s.owner_user_id = ? AND s.revoked_at IS NULL \
875 AND (s.expires_at IS NULL OR s.expires_at > ?) \
876 AND ( s.viewer_user_id = ? \
877 OR s.viewer_group_id IN (SELECT group_id FROM group_members WHERE user_id = ?) )",
878 )
879 .bind(user_id)
880 .bind(now())
881 .bind(viewer)
882 .bind(viewer)
883 .fetch_one(&state.db.read)
884 .await?;
885 if trail_visible.unwrap_or(0) == 0 {
886 return Err(ApiError::Forbidden);
887 }
888 }
889
890 let to = q.to.unwrap_or_else(now);
891 let from = q.from.unwrap_or(to - 24 * 3_600);
892 if from >= to {
893 return Err(ApiError::BadRequest("from must be before to".into()));
894 }
895 let max = q.max.clamp(2, 10_000);
896
897 let rows: Vec<(i64, i64)> = sqlx::query_as(
898 "SELECT lat, lon FROM points WHERE user_id = ? AND ts >= ? AND ts <= ? ORDER BY ts",
899 )
900 .bind(user_id)
901 .bind(from)
902 .bind(to)
903 .fetch_all(&state.db.read)
904 .await?;
905
906 // Decimate server-side. 2000 points as an encoded polyline is ~10 kB against
907 // ~60 kB of JSON floats, and the browser has less to draw.
908 let simplified = crate::polyline::simplify(&rows, max);
909 let polyline = crate::polyline::encode(&simplified);
910 Ok(Json(TrackResponse {
911 user_id,
912 from,
913 to,
914 point_count: simplified.len(),
915 polyline,
916 }))
917}
918
919async fn create_user(
920 State(state): State<Shared>,
921 session: Session,
922 ConnectInfo(peer): ConnectInfo<SocketAddr>,
923 Json(req): Json<CreateUser>,
924) -> ApiResult<Json<serde_json::Value>> {
925 let admin = require_admin(&state, &session).await?;
926 let username = req.username.trim().to_string();
927 if username.is_empty() || username.chars().count() > 64 {
928 return Err(ApiError::BadRequest(
929 "username must be 1..=64 characters".into(),
930 ));
931 }
932 if req.password.chars().count() < 10 {
933 return Err(ApiError::BadRequest(
934 "password must be at least 10 characters".into(),
935 ));
936 }
937
938 let hash = auth::hash_password(&state.cfg, req.password).await?;
939 let at = now();
940 let result = sqlx::query(
941 "INSERT INTO users (username, pw_hash, display_name, is_admin, created_at, pw_changed_at) \
942 VALUES (?, ?, ?, ?, ?, ?)",
943 )
944 .bind(&username)
945 .bind(hash)
946 .bind(req.display_name.unwrap_or_else(|| username.clone()))
947 .bind(i64::from(req.is_admin))
948 .bind(at)
949 .bind(at)
950 .execute(&state.db.write)
951 .await;
952
953 let id = match result {
954 Ok(r) => r.last_insert_rowid(),
955 Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
956 return Err(ApiError::BadRequest("that username is taken".into()));
957 }
958 Err(e) => return Err(e.into()),
959 };
960 audit(&state, Some(admin), "user_created", &username, peer.ip()).await;
961 Ok(Json(serde_json::json!({ "id": id, "username": username })))
962}
963
964async fn audit(state: &Shared, user_id: Option<i64>, action: &str, detail: &str, ip: IpAddr) {
965 let _ = state
966 .writer
967 .send(WriteOp::Audit {
968 user_id,
969 at: now(),
970 action: action.to_string(),
971 detail: detail.to_string(),
972 src_ip: Some(ip.to_string()),
973 })
974 .await;
975}
976
977#[cfg(test)]
978mod tests {
979 use super::*;
980
981 /// The web UI's `web/src/api.ts` types are hand-written. This is what stops
982 /// them drifting: renaming a field here fails `cargo test` instead of
983 /// producing `undefined` in a browser at runtime.
984 ///
985 /// Keys only, not values — the types carry no invariants worth asserting,
986 /// and a value check would just restate the constructor above it.
987 fn keys(value: &serde_json::Value) -> Vec<&str> {
988 let mut k: Vec<&str> = value
989 .as_object()
990 .expect("expected a JSON object")
991 .keys()
992 .map(String::as_str)
993 .collect();
994 k.sort_unstable();
995 k
996 }
997
998 #[test]
999 fn the_json_shape_is_the_one_the_web_ui_expects() {
1000 let me = Me {
1001 id: 1,
1002 username: "a".into(),
1003 display_name: "A".into(),
1004 is_admin: false,
1005 server_time: 0,
1006 };
1007 assert_eq!(
1008 keys(&serde_json::to_value(&me).expect("serialize")),
1009 ["display_name", "id", "is_admin", "server_time", "username"]
1010 );
1011
1012 let position = Position {
1013 ts: 0,
1014 lat_e7: 0,
1015 lon_e7: 0,
1016 acc_dm: None,
1017 alt_m: None,
1018 spd_cms: None,
1019 brg_cdeg: None,
1020 bat_pct: None,
1021 flags: 0,
1022 recv_at: 0,
1023 };
1024 assert_eq!(
1025 keys(&serde_json::to_value(&position).expect("serialize")),
1026 [
1027 "acc_dm", "alt_m", "bat_pct", "brg_cdeg", "flags", "lat_e7", "lon_e7", "recv_at",
1028 "spd_cms", "ts"
1029 ]
1030 );
1031
1032 let person = PersonState {
1033 user_id: 1,
1034 display_name: "A".into(),
1035 is_self: true,
1036 position: Some(position),
1037 };
1038 assert_eq!(
1039 keys(&serde_json::to_value(&person).expect("serialize")),
1040 ["display_name", "is_self", "position", "user_id"]
1041 );
1042
1043 // `position` is skipped when absent, which is why the TypeScript field is
1044 // optional rather than nullable.
1045 let no_fix = PersonState {
1046 position: None,
1047 ..person
1048 };
1049 assert_eq!(
1050 keys(&serde_json::to_value(&no_fix).expect("serialize")),
1051 ["display_name", "is_self", "user_id"]
1052 );
1053
1054 let state = StateResponse {
1055 server_time: 0,
1056 people: vec![],
1057 };
1058 assert_eq!(
1059 keys(&serde_json::to_value(&state).expect("serialize")),
1060 ["people", "server_time"]
1061 );
1062
1063 let token = TokenInfo {
1064 token_id: "1".into(),
1065 name: "p".into(),
1066 platform: "android".into(),
1067 app_version: None,
1068 os_api_level: None,
1069 last_seen_at: None,
1070 last_src_ip: None,
1071 last_transport: None,
1072 created_at: 0,
1073 };
1074 assert_eq!(
1075 keys(&serde_json::to_value(&token).expect("serialize")),
1076 [
1077 "app_version",
1078 "created_at",
1079 "last_seen_at",
1080 "last_src_ip",
1081 "last_transport",
1082 "name",
1083 "os_api_level",
1084 "platform",
1085 "token_id"
1086 ]
1087 );
1088
1089 let track = TrackResponse {
1090 user_id: 1,
1091 from: 0,
1092 to: 1,
1093 polyline: String::new(),
1094 point_count: 0,
1095 };
1096 assert_eq!(
1097 keys(&serde_json::to_value(&track).expect("serialize")),
1098 ["from", "point_count", "polyline", "to", "user_id"]
1099 );
1100
1101 assert_eq!(
1102 keys(&serde_json::to_value(ErrorBody { error: "x".into() }).expect("serialize")),
1103 ["error"]
1104 );
1105 }
1106
1107 /// Token ids routinely exceed 2^53, which is why [`TokenInfo::token_id`] is a
1108 /// string. This asserts the reason still holds rather than trusting the
1109 /// comment: if ids ever became small the string could go away.
1110 #[test]
1111 fn token_ids_are_too_large_for_a_javascript_number() {
1112 let big = (0..64)
1113 .map(|_| crate::keys::random_token_id().expect("rng"))
1114 .filter(|id| *id >= (1u64 << 53))
1115 .count();
1116 assert!(
1117 big > 32,
1118 "expected most token ids above 2^53, got {big} of 64"
1119 );
1120 }
1121
1122 /// The exact value a browser must be able to send back and have match.
1123 #[test]
1124 fn a_large_token_id_round_trips_through_its_string_form() {
1125 let id = u64::MAX - 3;
1126 let text = id.to_string();
1127 assert_eq!(text.parse::<u64>().expect("parse") as i64, id as i64);
1128 }
1129}
1130