step 14: shares, precision_m and the OSM tile proxy
Groups are gone: a share targets exactly one user. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mcrates/otserver/migrations/0001_init.sql
@@ -104,28 +104,12 @@ CREATE TABLE user_latest (
src_token_id INTEGER
) STRICT;
CREATE TABLE groups (
id INTEGER PRIMARY KEY,
owner_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE (owner_user_id, name)
) STRICT;
CREATE TABLE group_members (
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
added_at INTEGER NOT NULL,
PRIMARY KEY (group_id, user_id)
) STRICT, WITHOUT ROWID;
-- You share *yourself*, not a phone: there is no per-device dimension here.
-- You share *yourself*, not a phone: there is no per-device dimension here, and
-- a share targets exactly one other account.
CREATE TABLE shares (
id INTEGER PRIMARY KEY,
owner_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Exactly one of these two.
viewer_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
viewer_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
viewer_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
trail_visible INTEGER NOT NULL DEFAULT 1 CHECK (trail_visible IN (0, 1)),
-- Metres to round the position to before showing it. 0 = exact.
precision_m INTEGER NOT NULL DEFAULT 0,
@@ -135,13 +119,11 @@ CREATE TABLE shares (
revoked_at INTEGER,
created_at INTEGER NOT NULL,
CHECK ((viewer_user_id IS NULL) <> (viewer_group_id IS NULL)),
CHECK (owner_user_id <> viewer_user_id)
) STRICT;
CREATE INDEX shares_owner ON shares(owner_user_id);
CREATE INDEX shares_viewer_user ON shares(viewer_user_id);
CREATE INDEX shares_viewer_group ON shares(viewer_group_id);
-- OSM tile proxy cache metadata. The bytes live on the filesystem at
-- {cache_dir}/tiles/{z}/{x}/{y}.png; this table is the index and the accounting
Mcrates/otserver/src/api.rs
@@ -127,7 +127,7 @@ type ApiResult<T> = Result<T, ApiError>;
///
/// Also checks the session against `pw_changed_at`, which is how a password
/// change logs out every other browser without keeping a revocation list.
async fn current_user(state: &Shared, session: &Session) -> ApiResult<i64> {
pub(crate) async fn current_user(state: &Shared, session: &Session) -> ApiResult<i64> {
let uid: i64 = session
.get(SESSION_USER)
.await
@@ -181,18 +181,71 @@ async fn visible_user_ids(pool: &SqlitePool, viewer: i64) -> ApiResult<Vec<i64>>
SELECT s.owner_user_id FROM shares s \
WHERE s.revoked_at IS NULL \
AND (s.expires_at IS NULL OR s.expires_at > ?) \
AND ( s.viewer_user_id = ? \
OR s.viewer_group_id IN (SELECT group_id FROM group_members WHERE user_id = ?) )",
AND s.viewer_user_id = ?",
)
.bind(viewer)
.bind(now)
.bind(viewer)
.bind(viewer)
.fetch_all(pool)
.await?;
Ok(ids)
}
/// Snap a coordinate to a `precision_m` grid, so a share can show a
/// neighbourhood instead of a doorstep.
///
/// Deliberately a pure rounding, with no jitter: a stationary person whose
/// fuzzed dot wandered on every poll would leak their true position to anyone
/// who averaged the samples.
fn snap_e7(lat_e7: i64, lon_e7: i64, precision_m: i64) -> (i64, i64) {
if precision_m <= 0 {
return (lat_e7, lon_e7);
}
/// Metres per degree of latitude, and of longitude at the equator.
const M_PER_DEG: f64 = 111_320.0;
let grid = |value: i64, step: f64| ((value as f64 / step).round() * step) as i64;
let lat_step = precision_m as f64 / M_PER_DEG * 1e7;
let lat = grid(lat_e7, lat_step).clamp(-900_000_000, 900_000_000);
// The longitude step is derived from the *snapped* latitude, not the real
// one, so every point in a cell gets the same grid. Deriving it from the
// input would give two neighbours slightly different grids and leak that
// they are not in fact at the same place.
//
// cos(lat) goes to zero at the poles, where a metre of easting is an
// unbounded number of degrees. Clamping the divisor keeps the step finite;
// it only makes the cell smaller than asked for, never larger.
let cos_lat = (lat as f64 / 1e7).to_radians().cos().abs().max(0.01);
let lon_step = precision_m as f64 / (M_PER_DEG * cos_lat) * 1e7;
(lat, grid(lon_e7, lon_step))
}
/// `precision_m` per owner for everyone `viewer` can see.
///
/// Several live shares could target the same viewer, so the most generous one
/// wins: a second share must never be able to make an existing one stricter.
/// Owners with no row (the viewer themselves) are exact.
async fn share_precision(
pool: &SqlitePool,
viewer: i64,
ids_json: &str,
) -> ApiResult<std::collections::HashMap<i64, i64>> {
let rows: Vec<(i64, i64)> = sqlx::query_as(
"SELECT s.owner_user_id, MIN(s.precision_m) FROM shares s \
JOIN json_each(?) v ON v.value = s.owner_user_id \
WHERE s.viewer_user_id = ? AND s.revoked_at IS NULL \
AND (s.expires_at IS NULL OR s.expires_at > ?) \
GROUP BY s.owner_user_id",
)
.bind(ids_json)
.bind(viewer)
.bind(now())
.fetch_all(pool)
.await?;
Ok(rows.into_iter().collect())
}
// ---------------------------------------------------------------------------
// Payloads
// ---------------------------------------------------------------------------
@@ -354,6 +407,37 @@ pub struct CreateUser {
pub is_admin: bool,
}
#[derive(Serialize)]
pub struct ShareInfo {
pub id: i64,
pub viewer_user_id: i64,
pub viewer_username: String,
pub viewer_display_name: String,
pub trail_visible: bool,
pub precision_m: i64,
pub expires_at: Option<i64>,
pub created_at: i64,
}
#[derive(Deserialize)]
pub struct CreateShare {
/// Resolved server-side. There is deliberately no endpoint that lists or
/// searches users: a share is granted to someone you already know the name
/// of, and anything else is a user directory for anyone with an account.
pub username: String,
#[serde(default = "yes")]
pub trail_visible: bool,
#[serde(default)]
pub precision_m: i64,
/// Seconds from now. `None` means the share does not expire.
#[serde(default)]
pub expires_in_s: Option<i64>,
}
fn yes() -> bool {
true
}
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
@@ -382,6 +466,8 @@ pub fn router(state: Shared) -> Router {
.route("/api/tokens", get(list_tokens))
.route("/api/tokens/{token_id}", delete(revoke_one_token))
.route("/api/tokens/revoke-others", post(revoke_others))
.route("/api/shares", get(list_shares).post(create_share))
.route("/api/shares/{share_id}", delete(revoke_share))
.route("/api/users/{user_id}/track", get(track))
.route("/api/users", post(create_user))
// Only the /api routes above; `route_layer` runs nothing when no route
@@ -389,6 +475,9 @@ pub fn router(state: Shared) -> Router {
.route_layer(middleware::from_fn(require_csrf))
.route("/healthz", get(healthz))
.route("/metrics", get(metrics))
// Outside the CSRF layer above deliberately: it is a GET, and Leaflet
// loads tiles as plain <img> elements that cannot carry a header.
.merge(crate::tiles::router())
.with_state(state)
// Anything else is the web UI, including deep links it routes itself.
.fallback(crate::web::serve)
@@ -666,23 +755,33 @@ async fn state_handler(
.fetch_all(&state.db.read)
.await?;
// Your own position is always exact; everyone else's is snapped to whatever
// their share allows.
let precision = share_precision(&state.db.read, uid, &ids_json).await?;
let people: Vec<PersonState> = rows
.into_iter()
.map(|r| PersonState {
user_id: r.id,
is_self: r.id == uid,
display_name: r.display_name,
position: r.ts.map(|ts| Position {
ts,
lat_e7: r.lat.unwrap_or(0),
lon_e7: r.lon.unwrap_or(0),
acc_dm: r.acc_dm,
alt_m: r.alt_m,
spd_cms: r.spd_cms,
brg_cdeg: r.brg_cdeg,
bat_pct: r.bat_pct,
flags: r.flags.unwrap_or(0),
recv_at: r.recv_at.unwrap_or(ts),
position: r.ts.map(|ts| {
let (lat_e7, lon_e7) = snap_e7(
r.lat.unwrap_or(0),
r.lon.unwrap_or(0),
precision.get(&r.id).copied().unwrap_or(0),
);
Position {
ts,
lat_e7,
lon_e7,
acc_dm: r.acc_dm,
alt_m: r.alt_m,
spd_cms: r.spd_cms,
brg_cdeg: r.brg_cdeg,
bat_pct: r.bat_pct,
flags: r.flags.unwrap_or(0),
recv_at: r.recv_at.unwrap_or(ts),
}
}),
})
.collect();
@@ -867,24 +966,25 @@ async fn track(
}
// Trails are only visible when the share says so. Your own trail is always
// yours to see.
// yours to see, and always exact. Where several live shares exist the most
// generous one wins, so a second share cannot tighten an existing one.
let mut precision_m = 0;
if user_id != viewer {
let trail_visible: Option<i64> = sqlx::query_scalar(
"SELECT MAX(s.trail_visible) FROM shares s \
let (trail_visible, precision): (Option<i64>, Option<i64>) = sqlx::query_as(
"SELECT MAX(s.trail_visible), MIN(s.precision_m) FROM shares s \
WHERE s.owner_user_id = ? AND s.revoked_at IS NULL \
AND (s.expires_at IS NULL OR s.expires_at > ?) \
AND ( s.viewer_user_id = ? \
OR s.viewer_group_id IN (SELECT group_id FROM group_members WHERE user_id = ?) )",
AND s.viewer_user_id = ?",
)
.bind(user_id)
.bind(now())
.bind(viewer)
.bind(viewer)
.fetch_one(&state.db.read)
.await?;
if trail_visible.unwrap_or(0) == 0 {
return Err(ApiError::Forbidden);
}
precision_m = precision.unwrap_or(0);
}
let to = q.to.unwrap_or_else(now);
@@ -894,7 +994,7 @@ async fn track(
}
let max = q.max.clamp(2, 10_000);
let rows: Vec<(i64, i64)> = sqlx::query_as(
let mut rows: Vec<(i64, i64)> = sqlx::query_as(
"SELECT lat, lon FROM points WHERE user_id = ? AND ts >= ? AND ts <= ? ORDER BY ts",
)
.bind(user_id)
@@ -903,6 +1003,12 @@ async fn track(
.fetch_all(&state.db.read)
.await?;
// Snap before simplifying: simplifying first would let the exact geometry
// decide which points survive, and the shape of a route is itself a hint.
for p in &mut rows {
(p.0, p.1) = snap_e7(p.0, p.1, precision_m);
}
// Decimate server-side. 2000 points as an encoded polyline is ~10 kB against
// ~60 kB of JSON floats, and the browser has less to draw.
let simplified = crate::polyline::simplify(&rows, max);
@@ -961,6 +1067,168 @@ async fn create_user(
Ok(Json(serde_json::json!({ "id": id, "username": username })))
}
/// The signed-in user's *outgoing* shares: who can currently see them.
async fn list_shares(
State(state): State<Shared>,
session: Session,
) -> ApiResult<Json<Vec<ShareInfo>>> {
let uid = current_user(&state, &session).await?;
let rows: Vec<ShareRow> = sqlx::query_as(
"SELECT s.id, s.viewer_user_id, u.username, u.display_name, s.trail_visible, \
s.precision_m, s.expires_at, s.created_at \
FROM shares s JOIN users u ON u.id = s.viewer_user_id \
WHERE s.owner_user_id = ? AND s.revoked_at IS NULL \
AND (s.expires_at IS NULL OR s.expires_at > ?) \
ORDER BY s.created_at DESC",
)
.bind(uid)
.bind(now())
.fetch_all(&state.db.read)
.await?;
Ok(Json(rows.into_iter().map(ShareRow::into_info).collect()))
}
#[derive(sqlx::FromRow)]
struct ShareRow {
id: i64,
viewer_user_id: i64,
username: String,
display_name: String,
trail_visible: i64,
precision_m: i64,
expires_at: Option<i64>,
created_at: i64,
}
impl ShareRow {
fn into_info(self) -> ShareInfo {
ShareInfo {
id: self.id,
viewer_user_id: self.viewer_user_id,
viewer_username: self.username,
viewer_display_name: self.display_name,
trail_visible: self.trail_visible != 0,
precision_m: self.precision_m,
expires_at: self.expires_at,
created_at: self.created_at,
}
}
}
async fn create_share(
State(state): State<Shared>,
session: Session,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Json(req): Json<CreateShare>,
) -> ApiResult<(StatusCode, Json<ShareInfo>)> {
let uid = current_user(&state, &session).await?;
if !(0..=100_000).contains(&req.precision_m) {
return Err(ApiError::BadRequest(
"precision_m must be 0..=100000 metres".into(),
));
}
if req.expires_in_s.is_some_and(|s| s <= 0) {
return Err(ApiError::BadRequest("expires_in_s must be positive".into()));
}
// `username` is COLLATE NOCASE, so this match is case-insensitive for free.
let viewer: Option<(i64, String, String)> = sqlx::query_as(
"SELECT id, username, display_name FROM users \
WHERE username = ? AND disabled_at IS NULL",
)
.bind(req.username.trim())
.fetch_optional(&state.db.read)
.await?;
let Some((viewer_id, username, display_name)) = viewer else {
return Err(ApiError::NotFound);
};
if viewer_id == uid {
return Err(ApiError::BadRequest("you can already see yourself".into()));
}
let at = now();
let expires_at = req.expires_in_s.map(|s| at + s);
// One transaction, because the two statements below are one act. A revoke
// that committed without its replacement would silently drop a share the
// user was in the middle of editing.
let mut tx = state.db.write.begin().await?;
// At most one live row per (owner, viewer): re-sharing with new settings
// replaces the old share rather than adding a more permissive one beside it.
sqlx::query(
"UPDATE shares SET revoked_at = ? \
WHERE owner_user_id = ? AND viewer_user_id = ? AND revoked_at IS NULL",
)
.bind(at)
.bind(uid)
.bind(viewer_id)
.execute(&mut *tx)
.await?;
let id = sqlx::query(
"INSERT INTO shares (owner_user_id, viewer_user_id, trail_visible, precision_m, \
expires_at, created_at) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(uid)
.bind(viewer_id)
.bind(i64::from(req.trail_visible))
.bind(req.precision_m)
.bind(expires_at)
.bind(at)
.execute(&mut *tx)
.await?
.last_insert_rowid();
tx.commit().await?;
audit(&state, Some(uid), "share_created", &username, peer.ip()).await;
Ok((
StatusCode::CREATED,
Json(ShareInfo {
id,
viewer_user_id: viewer_id,
viewer_username: username,
viewer_display_name: display_name,
trail_visible: req.trail_visible,
precision_m: req.precision_m,
expires_at,
created_at: at,
}),
))
}
async fn revoke_share(
State(state): State<Shared>,
session: Session,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Path(share_id): Path<i64>,
) -> ApiResult<StatusCode> {
let uid = current_user(&state, &session).await?;
// Ownership is part of the UPDATE, not a fetch-then-compare: the check
// cannot then be forgotten on some future path.
let affected = sqlx::query(
"UPDATE shares SET revoked_at = ? \
WHERE id = ? AND owner_user_id = ? AND revoked_at IS NULL",
)
.bind(now())
.bind(share_id)
.bind(uid)
.execute(&state.db.write)
.await?
.rows_affected();
if affected == 0 {
return Err(ApiError::NotFound);
}
audit(
&state,
Some(uid),
"share_revoked",
&share_id.to_string(),
peer.ip(),
)
.await;
Ok(StatusCode::NO_CONTENT)
}
async fn audit(state: &Shared, user_id: Option<i64>, action: &str, detail: &str, ip: IpAddr) {
let _ = state
.writer
@@ -1098,12 +1366,106 @@ mod tests {
["from", "point_count", "polyline", "to", "user_id"]
);
let share = ShareInfo {
id: 1,
viewer_user_id: 2,
viewer_username: "b".into(),
viewer_display_name: "B".into(),
trail_visible: true,
precision_m: 0,
expires_at: None,
created_at: 0,
};
assert_eq!(
keys(&serde_json::to_value(&share).expect("serialize")),
[
"created_at",
"expires_at",
"id",
"precision_m",
"trail_visible",
"viewer_display_name",
"viewer_user_id",
"viewer_username"
]
);
assert_eq!(
keys(&serde_json::to_value(ErrorBody { error: "x".into() }).expect("serialize")),
["error"]
);
}
/// Metres between two coordinates, good enough to check a grid cell size.
fn metres_between(a: (i64, i64), b: (i64, i64)) -> f64 {
let dlat = (a.0 - b.0) as f64 / 1e7 * 111_320.0;
let cos = (a.0 as f64 / 1e7).to_radians().cos();
let dlon = (a.1 - b.1) as f64 / 1e7 * 111_320.0 * cos;
(dlat * dlat + dlon * dlon).sqrt()
}
#[test]
fn a_precision_of_zero_or_less_leaves_the_position_untouched() {
let exact = (521_234_567, 133_456_789);
for precision in [0, -1, -100_000] {
assert_eq!(snap_e7(exact.0, exact.1, precision), exact);
}
}
/// The property that makes rounding safe where jitter would not be: an
/// attacker polling a stationary person gets the same answer every time, so
/// averaging the samples reveals nothing.
#[test]
fn snapping_is_deterministic() {
let first = snap_e7(521_234_567, 133_456_789, 500);
for _ in 0..10 {
assert_eq!(snap_e7(521_234_567, 133_456_789, 500), first);
}
}
#[test]
fn a_snapped_point_stays_within_roughly_the_requested_precision() {
for lat in [0, 100_000_000, 521_234_567, -335_000_000] {
for lon in [0, 133_456_789, -740_000_000] {
for precision in [10, 100, 1_000, 100_000] {
let snapped = snap_e7(lat, lon, precision);
let moved = metres_between((lat, lon), snapped);
assert!(
moved <= precision as f64,
"moved {moved} m for a {precision} m grid"
);
}
}
}
}
#[test]
fn snapping_collapses_nearby_points_onto_one_cell() {
// Two points ~11 m apart, on a 1 km grid.
let a = snap_e7(521_234_567, 133_456_789, 1_000);
let b = snap_e7(521_235_567, 133_456_789, 1_000);
assert_eq!(a, b);
// The same two points are still distinct when snapped finely.
assert_ne!(
snap_e7(521_234_567, 133_456_789, 1),
snap_e7(521_235_567, 133_456_789, 1)
);
}
/// cos(lat) reaches zero at the poles, where the longitude divisor would be
/// zero and every result a NaN cast to a garbage integer.
#[test]
fn a_point_at_the_pole_does_not_divide_by_zero() {
for lat in [899_999_999, 900_000_000, -900_000_000] {
let (lat_e7, lon_e7) = snap_e7(lat, 123_456_789, 1_000);
assert!((-900_000_000..=900_000_000).contains(&lat_e7), "{lat_e7}");
assert!(
(-1_800_000_000..=1_800_000_000).contains(&lon_e7),
"{lon_e7}"
);
}
}
/// Token ids routinely exceed 2^53, which is why [`TokenInfo::token_id`] is a
/// string. This asserts the reason still holds rather than trusting the
/// comment: if ids ever became small the string could go away.
Mcrates/otserver/src/config.rs
@@ -204,10 +204,8 @@ impl Config {
/// `User-Agent` for upstream tile requests. The policy prohibits library
/// defaults, so this is deliberately specific and contactable.
///
/// Used by the tile proxy, which lands in a later step; kept here now because
/// [`Config::validate`] already refuses to start without the contact address
/// it embeds, and the two belong together.
#[allow(dead_code)]
/// Paired with [`Config::validate`], which refuses to start without the
/// contact address this embeds.
pub fn tile_user_agent(&self) -> String {
format!(
"opentracker/{} (self-hosted; +{}; contact: {})",
Mcrates/otserver/src/db.rs
@@ -237,7 +237,7 @@ mod tests {
}
#[tokio::test]
async fn a_share_must_target_exactly_one_of_user_or_group() {
async fn a_share_must_target_one_other_account() {
let (db, _dir) = test_db().await;
for (id, username) in [(1, "a"), (2, "b")] {
sqlx::query(
@@ -251,7 +251,7 @@ mod tests {
.expect("user");
}
// Neither target.
// No viewer at all.
assert!(
sqlx::query("INSERT INTO shares (owner_user_id, created_at) VALUES (1, 0)")
.execute(&db.write)
Mcrates/otserver/src/main.rs
@@ -22,6 +22,7 @@ mod limits;
mod polyline;
mod retention;
mod simulate;
mod tiles;
mod udp;
mod web;
mod writer;
@@ -203,6 +204,11 @@ async fn main() -> Result<()> {
}
.spawn();
let limits_task = retention::spawn_limits_gc(Arc::clone(&ingest), throttle);
let eviction_task = tiles::spawn_eviction(
state.db.write.clone(),
state.cfg.cache_dir.clone(),
state.cfg.max_cache_bytes,
);
let app = api::router(Arc::clone(&state)).layer(session_layer);
@@ -236,6 +242,7 @@ async fn main() -> Result<()> {
info!("shutting down");
retention_task.abort();
limits_task.abort();
eviction_task.abort();
for task in udp_tasks {
task.abort();
}
Acrates/otserver/src/tiles.rs
@@ -0,0 +1,712 @@
//! An OSM tile caching proxy.
//!
//! The web UI never talks to `tile.openstreetmap.org` directly. Two reasons, and
//! only the second is about performance: the browser would leak every viewer's IP
//! and viewport to a third party, and a small deployment re-requesting the same
//! city block all day is exactly the traffic the OSM tile usage policy asks
//! proxies to absorb.
//!
//! Bytes live at `{cache_dir}/tiles/{z}/{x}/{y}.png`. The `tiles` table is the
//! index and the byte accounting; the filesystem alone could not answer "what is
//! the least recently used tile" without walking it.
use std::path::{Path as FsPath, PathBuf};
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use anyhow::{Context, Result};
use axum::Router;
use axum::extract::{Path, State};
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use sqlx::SqlitePool;
use tokio::io::AsyncWriteExt;
use tower_sessions::Session;
use tracing::{debug, warn};
use crate::api::{ApiError, Shared, current_user};
use crate::db::now;
/// Deepest zoom the proxy will fetch. OSM itself stops at 19, and accepting more
/// only lets a caller mint cache entries upstream will never satisfy.
const MAX_ZOOM: u8 = 19;
/// Upper bound on an accepted response body. A PNG tile is a few tens of KiB; a
/// hostile or misconfigured upstream must not be able to fill the disk with one
/// response.
const MAX_TILE_BYTES: usize = 256 * 1024;
/// Used when upstream sends no `Cache-Control: max-age`.
///
/// This deliberately overrides a bare `no-cache`, which is what
/// `tile.openstreetmap.org` answers with. Honouring it would send every single
/// tile request upstream and make the proxy worse than useless to the very
/// servers it exists to spare. A rendered tile changes on the order of days.
const DEFAULT_TTL_S: i64 = 7 * 86_400;
/// How long the *browser* may reuse a tile without asking us again. Tiles are
/// immutable in practice, so this is the cheapest hit of all: no request at all.
const BROWSER_CACHE_CONTROL: &str = "public, max-age=86400";
const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(10);
/// Merged into the main router by [`crate::api::router`].
///
/// No `.png` suffix on the route: axum allows one parameter per path segment, so
/// `{y}.png` is rejected at startup. Leaflet does not care about the extension,
/// and the `Content-Type` header is what a browser actually reads.
pub fn router() -> Router<Shared> {
Router::new().route("/tiles/{z}/{x}/{y}", get(tile))
}
/// One client for the whole process, so connections to the tile server are kept
/// alive across requests instead of paying a TLS handshake per tile.
fn client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.timeout(UPSTREAM_TIMEOUT)
.build()
// The builder only fails on a broken TLS backend, and a default
// client still works — refusing to serve any map at all would be a
// worse answer than one without keep-alive tuning.
.unwrap_or_default()
})
}
#[derive(sqlx::FromRow)]
struct TileRow {
etag: Option<String>,
last_modified: Option<String>,
expires_at: i64,
}
/// Serve one tile, from cache when possible.
///
/// Requires a signed-in session, like every other read path. An unauthenticated
/// `/tiles` endpoint is an open proxy: strangers would burn this deployment's OSM
/// quota and get its IP blocked. Leaflet loads tiles as same-origin `<img>`
/// requests, so the session cookie rides along without any JavaScript help.
async fn tile(
State(state): State<Shared>,
session: Session,
Path((z, x, y)): Path<(u8, u32, u32)>,
) -> Result<Response, ApiError> {
current_user(&state, &session).await?;
// Before anything touches the filesystem: the extractor guarantees these are
// integers, not that they name a tile that can exist. Without this a garbage
// request creates a directory tree.
if !in_range(z, x, y) {
return Err(ApiError::BadRequest(format!(
"no such tile: z must be 0..={MAX_ZOOM} and x, y must be below 2^z"
)));
}
let path = tile_path(&state.cfg.cache_dir, z, x, y);
let row: Option<TileRow> = sqlx::query_as(
"SELECT etag, last_modified, expires_at FROM tiles WHERE z = ? AND x = ? AND y = ?",
)
.bind(i64::from(z))
.bind(i64::from(x))
.bind(i64::from(y))
.fetch_optional(&state.db.read)
.await?;
// The row and the file can disagree — a manually cleared cache directory, a
// half-restored backup. The bytes are the truth; a row without them is a miss.
let cached = match &row {
Some(_) => tokio::fs::read(&path).await.ok(),
None => None,
};
if row.is_some() && cached.is_none() {
delete_row(&state.db.write, z, x, y).await?;
}
let at = now();
if let (Some(meta), Some(bytes)) = (&row, &cached)
&& meta.expires_at > at
{
touch(&state.db.write, z, x, y, at, None).await?;
return Ok(png(bytes.clone()));
}
// Only when we hold the bytes a 304 would refer to. Sending a validator we
// cannot honour would turn every request into an empty response.
let conditional = row
.as_ref()
.filter(|_| cached.is_some())
.map(|m| (m.etag.clone(), m.last_modified.clone()));
match fetch(&state.cfg, z, x, y, conditional).await {
// The case that actually keeps the OSM quota happy: a revalidation costs
// a few hundred bytes and refreshes a tile we already hold.
Ok(Fetched::NotModified { expires_at }) => match cached {
Some(bytes) => {
touch(&state.db.write, z, x, y, at, Some(expires_at)).await?;
Ok(png(bytes))
}
// Upstream answered 304 to a request that carried no validator.
// Serving the zero bytes we hold would render a broken image.
None => Err(ApiError::Internal(anyhow::anyhow!(
"tile upstream sent 304 for a tile we do not have"
))),
},
Ok(Fetched::Body {
bytes,
etag,
last_modified,
expires_at,
}) => {
write_tile(&path, &bytes).await?;
upsert(
&state.db.write,
z,
x,
y,
&etag,
&last_modified,
at,
expires_at,
bytes.len() as i64,
)
.await?;
Ok(png(bytes))
}
Err(e) => match cached {
// A stale tile is a correct-looking map. An error is a grey square in
// the middle of one, which reads as a broken deployment.
Some(bytes) => {
debug!(error = %e, z, x, y, "serving a stale tile; upstream is unavailable");
Ok(png(bytes))
}
None => Err(ApiError::Internal(e)),
},
}
}
fn in_range(z: u8, x: u32, y: u32) -> bool {
z <= MAX_ZOOM && u64::from(x) < 1u64 << z && u64::from(y) < 1u64 << z
}
fn tile_path(cache_dir: &FsPath, z: u8, x: u32, y: u32) -> PathBuf {
cache_dir.join(format!("tiles/{z}/{x}/{y}.png"))
}
fn upstream_url(template: &str, z: u8, x: u32, y: u32) -> String {
template
.replace("{z}", &z.to_string())
.replace("{x}", &x.to_string())
.replace("{y}", &y.to_string())
}
fn png(bytes: Vec<u8>) -> Response {
(
StatusCode::OK,
[
(header::CONTENT_TYPE, "image/png"),
(header::CACHE_CONTROL, BROWSER_CACHE_CONTROL),
],
bytes,
)
.into_response()
}
enum Fetched {
NotModified {
expires_at: i64,
},
Body {
bytes: Vec<u8>,
etag: Option<String>,
last_modified: Option<String>,
expires_at: i64,
},
}
/// One upstream GET, conditional when we already hold a copy.
///
/// The `User-Agent` is not decoration: the OSM tile usage policy prohibits
/// library defaults and blocks unidentified proxies without notice, which is why
/// [`crate::config::Config::validate`] refuses to start without a contact address.
async fn fetch(
cfg: &crate::config::Config,
z: u8,
x: u32,
y: u32,
conditional: Option<(Option<String>, Option<String>)>,
) -> Result<Fetched> {
let url = upstream_url(&cfg.tile_upstream_url, z, x, y);
let mut req = client()
.get(&url)
.header(header::USER_AGENT, cfg.tile_user_agent());
if let Some((etag, last_modified)) = conditional {
if let Some(etag) = etag {
req = req.header(header::IF_NONE_MATCH, etag);
}
if let Some(lm) = last_modified {
req = req.header(header::IF_MODIFIED_SINCE, lm);
}
}
let resp = req.send().await.with_context(|| format!("GET {url}"))?;
let status = resp.status();
let expires_at = now() + max_age_of(resp.headers()).unwrap_or(DEFAULT_TTL_S);
if status == reqwest::StatusCode::NOT_MODIFIED {
return Ok(Fetched::NotModified { expires_at });
}
if !status.is_success() {
anyhow::bail!("tile upstream answered {status} for {url}");
}
let etag = header_string(resp.headers(), header::ETAG);
let last_modified = header_string(resp.headers(), header::LAST_MODIFIED);
let bytes = read_capped(resp)
.await
.with_context(|| format!("body of {url}"))?;
Ok(Fetched::Body {
bytes,
etag,
last_modified,
expires_at,
})
}
/// Read the body chunk by chunk, refusing to buffer more than
/// [`MAX_TILE_BYTES`]. `Response::bytes` would happily allocate whatever the
/// server sends, and `Content-Length` is the sender's claim, not a bound.
async fn read_capped(mut resp: reqwest::Response) -> Result<Vec<u8>> {
let mut out = Vec::new();
while let Some(chunk) = resp.chunk().await? {
if out.len() + chunk.len() > MAX_TILE_BYTES {
anyhow::bail!("tile body exceeds {MAX_TILE_BYTES} bytes");
}
out.extend_from_slice(&chunk);
}
Ok(out)
}
fn header_string(headers: &reqwest::header::HeaderMap, name: header::HeaderName) -> Option<String> {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
}
/// `max-age` from a `Cache-Control` header, in seconds.
fn max_age_of(headers: &reqwest::header::HeaderMap) -> Option<i64> {
let value = headers.get(header::CACHE_CONTROL)?.to_str().ok()?;
value
.split(',')
.filter_map(|part| part.trim().strip_prefix("max-age="))
.find_map(|n| n.trim().parse::<i64>().ok())
.filter(|n| *n > 0)
}
/// Write the bytes, then rename into place.
///
/// The rename is the point: a concurrent reader either sees the previous file or
/// the complete new one, never a half-written PNG.
async fn write_tile(path: &FsPath, bytes: &[u8]) -> Result<()> {
let dir = path.parent().context("tile path has no parent")?;
tokio::fs::create_dir_all(dir)
.await
.with_context(|| format!("creating {}", dir.display()))?;
// In the same directory, so the rename stays within one filesystem.
static SEQ: AtomicU64 = AtomicU64::new(0);
let temp = dir.join(format!(
".{}.{}.tmp",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
let mut file = tokio::fs::File::create(&temp)
.await
.with_context(|| format!("creating {}", temp.display()))?;
let written = async {
file.write_all(bytes).await?;
file.sync_all().await
}
.await;
if let Err(e) = written {
let _ = tokio::fs::remove_file(&temp).await;
return Err(anyhow::Error::new(e).context("writing a tile"));
}
tokio::fs::rename(&temp, path)
.await
.with_context(|| format!("renaming into {}", path.display()))?;
Ok(())
}
async fn touch(
pool: &SqlitePool,
z: u8,
x: u32,
y: u32,
at: i64,
expires_at: Option<i64>,
) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE tiles SET last_access = ?, expires_at = COALESCE(?, expires_at) \
WHERE z = ? AND x = ? AND y = ?",
)
.bind(at)
.bind(expires_at)
.bind(i64::from(z))
.bind(i64::from(x))
.bind(i64::from(y))
.execute(pool)
.await
.map(|_| ())
}
async fn delete_row(pool: &SqlitePool, z: u8, x: u32, y: u32) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM tiles WHERE z = ? AND x = ? AND y = ?")
.bind(i64::from(z))
.bind(i64::from(x))
.bind(i64::from(y))
.execute(pool)
.await
.map(|_| ())
}
#[allow(clippy::too_many_arguments)]
async fn upsert(
pool: &SqlitePool,
z: u8,
x: u32,
y: u32,
etag: &Option<String>,
last_modified: &Option<String>,
at: i64,
expires_at: i64,
bytes: i64,
) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO tiles (z, x, y, etag, last_modified, fetched_at, expires_at, bytes, last_access) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT (z, x, y) DO UPDATE SET \
etag = excluded.etag, last_modified = excluded.last_modified, \
fetched_at = excluded.fetched_at, expires_at = excluded.expires_at, \
bytes = excluded.bytes, last_access = excluded.last_access",
)
.bind(i64::from(z))
.bind(i64::from(x))
.bind(i64::from(y))
.bind(etag)
.bind(last_modified)
.bind(at)
.bind(expires_at)
.bind(bytes)
.bind(at)
.execute(pool)
.await
.map(|_| ())
}
// ---------------------------------------------------------------------------
// Eviction
// ---------------------------------------------------------------------------
/// How often the cache is measured against its ceiling.
const EVICT_INTERVAL: Duration = Duration::from_secs(10 * 60);
/// Rows deleted per pass, so the write lock is never held for long — the same
/// reasoning as [`crate::retention`]'s batches.
const EVICT_BATCH: i64 = 500;
/// Eviction stops here rather than at the ceiling, so a cache sitting exactly at
/// the limit does not evict a tile on every single fetch.
fn low_water(max_bytes: u64) -> i64 {
(max_bytes / 10 * 9) as i64
}
/// Enforce `max_cache_bytes`, oldest access first.
///
/// On a timer rather than on the request path: eviction is a whole-table sum and
/// a batch of deletes, and making a map pan pay for that would be felt.
pub fn spawn_eviction(
pool: SqlitePool,
cache_dir: PathBuf,
max_bytes: u64,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(EVICT_INTERVAL);
// Sleep first: startup already has enough to do.
ticker.tick().await;
loop {
ticker.tick().await;
if let Err(e) = evict_once(&pool, &cache_dir, max_bytes).await {
warn!(error = %e, "tile eviction failed; will retry next tick");
}
}
})
}
/// Delete least-recently-used tiles until the cache is under [`low_water`].
///
/// Returns the number of tiles removed.
async fn evict_once(pool: &SqlitePool, cache_dir: &FsPath, max_bytes: u64) -> Result<u64> {
let total: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(bytes), 0) FROM tiles")
.fetch_one(pool)
.await
.context("summing the tile cache")?;
if total <= max_bytes as i64 {
return Ok(0);
}
let mut remaining = total;
let target = low_water(max_bytes);
let mut removed = 0;
// ponytail: one row deleted per statement, driven by the tiles_last_access
// index. Fine up to the ~100k rows a 1 GiB cache holds; if a deployment runs
// a much larger ceiling, delete by a last_access cutoff in one statement.
while remaining > target {
let batch: Vec<(i64, i64, i64, i64)> =
sqlx::query_as("SELECT z, x, y, bytes FROM tiles ORDER BY last_access LIMIT ?")
.bind(EVICT_BATCH)
.fetch_all(pool)
.await
.context("listing the least recently used tiles")?;
if batch.is_empty() {
break;
}
for (z, x, y, bytes) in batch {
sqlx::query("DELETE FROM tiles WHERE z = ? AND x = ? AND y = ?")
.bind(z)
.bind(x)
.bind(y)
.execute(pool)
.await
.context("evicting a tile row")?;
// A leftover file is only wasted space, and the next fetch of that
// tile overwrites it — so a failed unlink must not abort the sweep.
let path = tile_path(cache_dir, z as u8, x as u32, y as u32);
let _ = tokio::fs::remove_file(&path).await;
remaining -= bytes;
removed += 1;
if remaining <= target {
break;
}
}
}
debug!(removed, total, target, "tile cache eviction");
Ok(removed)
}
#[cfg(test)]
mod tests {
use super::*;
/// A throwaway database, migrated and ready. Deliberately a local copy of
/// `db::tests::test_db`: that module is private to `db.rs`, so it is not
/// reachable from here even under `cfg(test)`.
async fn test_db() -> (crate::db::Db, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir");
let db = crate::db::Db::open(&dir.path().join("test.db"))
.await
.expect("open");
(db, dir)
}
#[test]
fn tiles_outside_the_pyramid_are_rejected() {
assert!(in_range(0, 0, 0));
assert!(in_range(1, 1, 1));
assert!(in_range(19, (1 << 19) - 1, (1 << 19) - 1));
assert!(!in_range(20, 0, 0), "zoom beyond what OSM serves");
assert!(!in_range(1, 2, 0), "x must be below 2^z");
assert!(!in_range(1, 0, 2), "y must be below 2^z");
assert!(!in_range(0, 1, 0));
assert!(!in_range(19, 1 << 19, 0));
assert!(!in_range(3, u32::MAX, u32::MAX));
}
#[test]
fn the_upstream_url_is_built_from_the_template() {
assert_eq!(
upstream_url("https://tile.openstreetmap.org/{z}/{x}/{y}.png", 7, 66, 44),
"https://tile.openstreetmap.org/7/66/44.png"
);
// Subdomain-style templates put the placeholders elsewhere; the
// substitution must not care where they are.
assert_eq!(
upstream_url("https://t.example/{x}-{y}@{z}", 3, 1, 2),
"https://t.example/1-2@3"
);
}
#[test]
fn the_cache_path_mirrors_the_request_path() {
assert_eq!(
tile_path(FsPath::new("/var/cache/ot"), 7, 66, 44),
PathBuf::from("/var/cache/ot/tiles/7/66/44.png")
);
}
#[test]
fn an_upstream_max_age_sets_the_expiry() {
let mut headers = reqwest::header::HeaderMap::new();
assert_eq!(
max_age_of(&headers),
None,
"no header means the default TTL"
);
headers.insert(
header::CACHE_CONTROL,
"public, max-age=604800".parse().expect("literal"),
);
assert_eq!(max_age_of(&headers), Some(604_800));
// `no-cache` carries no max-age, so the default applies rather than a
// zero TTL that would revalidate on every single request.
headers.insert(header::CACHE_CONTROL, "no-cache".parse().expect("literal"));
assert_eq!(max_age_of(&headers), None);
}
#[tokio::test]
async fn a_tile_is_renamed_into_place_rather_than_written_in_pieces() {
let dir = tempfile::tempdir().expect("temp dir");
let path = tile_path(dir.path(), 4, 1, 2);
write_tile(&path, b"\x89PNG").await.expect("write");
assert_eq!(
tokio::fs::read(&path).await.expect("read"),
b"\x89PNG",
"the file must exist with its full contents"
);
// No temp file survives a successful write.
let leftovers: Vec<_> = std::fs::read_dir(path.parent().expect("parent"))
.expect("read_dir")
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
.collect();
assert!(leftovers.is_empty(), "a temp file was left behind");
}
/// Inserts `n` tiles of `bytes` each, oldest access first.
async fn seed(pool: &SqlitePool, dir: &FsPath, n: u32, bytes: i64) {
for i in 0..n {
let path = tile_path(dir, 1, i, 0);
write_tile(&path, &vec![0u8; bytes as usize])
.await
.expect("tile file");
upsert(pool, 1, i, 0, &None, &None, i64::from(i), 0, bytes)
.await
.expect("row");
}
}
#[tokio::test]
async fn eviction_removes_the_least_recently_used_tiles_down_to_the_low_water_mark() {
let (db, _db_dir) = test_db().await;
let cache = tempfile::tempdir().expect("temp dir");
// 10 tiles of 100 bytes against a 500-byte ceiling: 1000 bytes cached,
// and eviction must stop at 450, not at 500.
seed(&db.write, cache.path(), 10, 100).await;
let removed = evict_once(&db.write, cache.path(), 500)
.await
.expect("evict");
assert_eq!(removed, 6, "1000 bytes down to 450 needs six tiles gone");
let survivors: Vec<i64> = sqlx::query_scalar("SELECT x FROM tiles ORDER BY x")
.fetch_all(&db.read)
.await
.expect("rows");
assert_eq!(
survivors,
vec![6, 7, 8, 9],
"the oldest accesses must go first"
);
assert!(
!tile_path(cache.path(), 1, 0, 0).exists(),
"an evicted row must take its file with it, or the accounting lies"
);
assert!(tile_path(cache.path(), 1, 9, 0).exists());
}
#[tokio::test]
async fn a_cache_under_its_ceiling_is_left_alone() {
let (db, _db_dir) = test_db().await;
let cache = tempfile::tempdir().expect("temp dir");
seed(&db.write, cache.path(), 4, 100).await;
assert_eq!(
evict_once(&db.write, cache.path(), 1_000)
.await
.expect("evict"),
0
);
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tiles")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(count, 4);
}
/// A fresh access must move a tile to the back of the eviction queue, which
/// is the entire reason `last_access` is written on a cache hit.
#[tokio::test]
async fn a_recently_served_tile_outlives_an_older_one() {
let (db, _db_dir) = test_db().await;
let cache = tempfile::tempdir().expect("temp dir");
seed(&db.write, cache.path(), 4, 100).await;
touch(&db.write, 1, 0, 0, 9_999, None).await.expect("touch");
evict_once(&db.write, cache.path(), 200)
.await
.expect("evict");
let survivors: Vec<i64> = sqlx::query_scalar("SELECT x FROM tiles ORDER BY x")
.fetch_all(&db.read)
.await
.expect("rows");
assert!(
survivors.contains(&0),
"tile 0 was just served and must not be the first evicted, got {survivors:?}"
);
}
#[tokio::test]
async fn a_refreshed_tile_keeps_one_row_rather_than_accumulating() {
let (db, _db_dir) = test_db().await;
upsert(&db.write, 5, 1, 2, &None, &None, 1, 100, 10)
.await
.expect("insert");
upsert(
&db.write,
5,
1,
2,
&Some("\"abc\"".into()),
&None,
2,
200,
20,
)
.await
.expect("update");
let rows: Vec<(i64, Option<String>, i64)> =
sqlx::query_as("SELECT bytes, etag, expires_at FROM tiles")
.fetch_all(&db.read)
.await
.expect("rows");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].0, 20, "byte accounting must follow the new body");
assert_eq!(rows[0].1.as_deref(), Some("\"abc\""));
assert_eq!(rows[0].2, 200);
}
}
// ponytail: two simultaneous requests for the same missing tile both fetch it.
// A duplicated upstream GET is cheap and the atomic rename keeps the file
// consistent, so this is not worth a single-flight map. If the same viewport
// ever gets opened by enough people at once to matter, key a
// `DashMap<(z, x, y), broadcast::Sender<_>>` on the coordinates and have the
// second caller await the first.
Mweb/src/Map.tsx
@@ -3,10 +3,12 @@ import L from "leaflet";
import "leaflet/dist/leaflet.css";
import type { PersonState } from "./api";
// Straight to OSM for now. The caching proxy at /tiles/{z}/{x}/{y}.png lands in
// step 14; swapping this string is the whole migration. Attribution is not
// removable either way — it is a condition of the tile policy.
const TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
// Tiles go through our own caching proxy, not straight to OSM. Same origin, so
// the session cookie rides along and no browser ever talks to tile.openstreetmap
// .org — the upstream sees one server, not every user's IP. Attribution stays:
// proxying changes who fetches the tiles, not who made them, and showing it is a
// condition of the OSM tile usage policy.
const TILE_URL = "/tiles/{z}/{x}/{y}";
const ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
interface Props {
Mweb/src/Settings.tsx
@@ -1,6 +1,6 @@
import { createResource, createSignal, For, Show } from "solid-js";
import { api, type TokenInfo } from "./api";
import { ago } from "./live";
import { api, type ShareInfo, type TokenInfo } from "./api";
import { ago, until } from "./live";
import { useSession } from "./session";
/**
@@ -60,10 +60,122 @@ export default function Settings() {
</For>
<button onClick={revokeOthers}>log out all devices</button>
</section>
<Sharing />
</main>
);
}
const PRECISIONS = [
{ m: 0, label: "exact" },
{ m: 100, label: "100 m" },
{ m: 500, label: "500 m" },
{ m: 1000, label: "1 km" },
{ m: 5000, label: "5 km" },
];
const EXPIRIES = [
{ s: null, label: "never" },
{ s: 3600, label: "1 hour" },
{ s: 8 * 3600, label: "8 hours" },
{ s: 24 * 3600, label: "24 hours" },
{ s: 7 * 86400, label: "7 days" },
];
function precisionWords(m: number): string {
if (m === 0) return "exact";
return m < 1000 ? `rounded to ${m} m` : `rounded to ${m / 1000} km`;
}
/**
* Outgoing shares: who can see this account, and how much.
*
* The whole grant is on one row because the three settings only mean anything
* together — "bob, live position only, rounded to 1 km" is the sentence a user
* has to be able to check at a glance before trusting it.
*/
function Sharing() {
const [shares, { refetch }] = createResource(() => api.shares());
const [username, setUsername] = createSignal("");
const [trailVisible, setTrailVisible] = createSignal(true);
const [precision, setPrecision] = createSignal(0);
const [expiresIn, setExpiresIn] = createSignal<number | null>(null);
const [error, setError] = createSignal<string | null>(null);
const now = Math.floor(Date.now() / 1000);
async function create(e: Event) {
e.preventDefault();
setError(null);
try {
await api.createShare({
username: username(),
trail_visible: trailVisible(),
precision_m: precision(),
expires_in_s: expiresIn(),
});
setUsername("");
setTrailVisible(true);
setPrecision(0);
setExpiresIn(null);
void refetch();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}
async function revoke(sh: ShareInfo) {
if (!confirm(`Stop sharing with ${sh.viewer_display_name}?`)) return;
await api.revokeShare(sh.id);
void refetch();
}
return (
<section class="card">
<h2>sharing</h2>
<For each={shares()} fallback={<p class="muted">you are not sharing with anyone</p>}>
{(sh) => (
<div class="share">
<div>
<strong>{sh.viewer_display_name}</strong> <span class="muted">{sh.viewer_username}</span>
<div class="muted">
{sh.trail_visible ? "trail visible" : "live position only"}
{` · ${precisionWords(sh.precision_m)}`}
{sh.expires_at === null ? "" : ` · expires in ${until(sh.expires_at, now)}`}
</div>
</div>
<button onClick={() => revoke(sh)}>stop</button>
</div>
)}
</For>
<form class="share-form" onSubmit={create}>
<input placeholder="username to share with" autocomplete="off" value={username()} onInput={(e) => setUsername(e.currentTarget.value)} />
<label>
<input type="checkbox" checked={trailVisible()} onChange={(e) => setTrailVisible(e.currentTarget.checked)} />
show my trail, not just where I am now
</label>
<label>
precision
<select value={precision()} onChange={(e) => setPrecision(Number(e.currentTarget.value))}>
<For each={PRECISIONS}>{(p) => <option value={p.m}>{p.label}</option>}</For>
</select>
</label>
<p class="muted">precision blurs your position before they see it, so they get the area and not the address.</p>
<label>
expires
{/* A select cannot produce an out-of-range value, so the server's 400
for a bad precision or expiry is unreachable from this form. */}
<select value={String(expiresIn())} onChange={(e) => setExpiresIn(e.currentTarget.value === "null" ? null : Number(e.currentTarget.value))}>
<For each={EXPIRIES}>{(x) => <option value={String(x.s)}>{x.label}</option>}</For>
</select>
</label>
<Show when={error()}>{(msg) => <p class="error">{msg()}</p>}</Show>
<button>share</button>
</form>
</section>
);
}
function PasswordForm() {
const [current, setCurrent] = createSignal("");
const [next, setNext] = createSignal("");
Mweb/src/api.ts
@@ -60,6 +60,25 @@ export interface TrackResponse {
point_count: number;
}
export interface ShareInfo {
id: number;
viewer_user_id: number;
viewer_username: string;
viewer_display_name: string;
trail_visible: boolean;
precision_m: number;
expires_at: number | null;
created_at: number;
}
export interface CreateShare {
username: string;
trail_visible: boolean;
precision_m: number;
/** Seconds from now. `null` means the share does not expire. */
expires_in_s: number | null;
}
/** Point flag bits, matching `otproto::Point`. */
export const FLAG_CHARGING = 1;
export const FLAG_NETWORK_FIX = 2;
@@ -121,6 +140,10 @@ export const api = {
tokens: () => json<TokenInfo[]>("GET", "/api/tokens"),
revokeToken: (id: string) => json<void>("DELETE", `/api/tokens/${id}`),
revokeOthers: () => json<{ revoked: number }>("POST", "/api/tokens/revoke-others"),
/** Outgoing, still-live shares only, newest first. */
shares: () => json<ShareInfo[]>("GET", "/api/shares"),
createShare: (body: CreateShare) => json<ShareInfo>("POST", "/api/shares", body),
revokeShare: (id: number) => json<void>("DELETE", `/api/shares/${id}`),
track: (userId: number, from: number, to: number, max = 2000) =>
json<TrackResponse>("GET", `/api/users/${userId}/track?from=${from}&to=${to}&max=${max}`),
changePassword: (current_password: string, new_password: string) =>
Mweb/src/live.ts
@@ -85,6 +85,12 @@ export function ago(ts: number, now: number): string {
return `${Math.floor(d / 86400)}d`;
}
/** Same buckets as `ago`, for a timestamp in the future. Separate function
because a flag on `ago` would put the tense at the call site, not in the name. */
export function until(ts: number, now: number): string {
return ago(now, ts);
}
/** Fresh under 15 min, amber to an hour, red beyond. */
export function staleness(ts: number, now: number): "fresh" | "stale" | "old" {
const d = now - ts;
Mweb/src/styles.css
@@ -80,6 +80,23 @@ aside { border-right: 1px solid var(--line); overflow-y: auto; padding: 8px; dis
.token { display: flex; align-items: center; gap: 12px; justify-content: space-between; padding: 8px 0; border-top: 1px solid var(--line); }
.password { display: grid; gap: 8px; margin-top: 12px; }
/* Shares reuse the token row's shape: same list, same border, same stop button. */
.share { display: flex; align-items: center; gap: 12px; justify-content: space-between; padding: 8px 0; border-top: 1px solid var(--line); }
.share-form { display: grid; gap: 8px; margin-top: 12px; justify-items: start; }
.share-form input:not([type="checkbox"]) { width: 100%; }
.share-form label { display: flex; align-items: center; gap: 8px; color: var(--muted); }
.share-form p { margin: 0; font-size: 12px; }
/* Only the share form has selects; matching the button/input rule keeps the
native dropdown behaviour and still looks like the rest of the form. */
select {
font: inherit;
color: inherit;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 6px;
padding: 6px 10px;
}
/* Leaflet's own attribution must stay legible in dark mode; it is not optional. */
.leaflet-container { background: var(--bg); }
.leaflet-control-attribution { background: #ffffffcc !important; color: #16181d !important; }