Apply review findings
Security and correctness: - Rate-limit passwords per client address: 5 per name, 30 per address. An attempt counts before the check, so parallel guesses cannot slip through. --behind-proxy takes the address from the last X-Forwarded-For entry. - Run at most one Argon2 hash per CPU, so parallel logins cannot exhaust memory. - A flood of passkey sign-in starts evicts the oldest instead of blocking everyone. - Uploads skip invalid points instead of rejecting the batch, and no longer return the people list, so a device token sees only its own device. The app drops batches the server refuses with a 4xx. - An admin password reset also revokes the user's device tokens. - Escape usernames in map labels. - Rounded positions also round their times, and duplicate points go. - A past trail range loads once, and late answers for an earlier choice are dropped. Long trails keep the newest points. - The app catches a refused foreground start after reboot. - Sharing again returns the original creation date. Simplifications: two-factor sign-in always starts with the password, device registration by password and `ot login` are gone, smaller HTTP and date helpers, UrlSearchParams for the pairing link. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MREADME.md
@@ -16,9 +16,9 @@ android/ Android app: background tracking, this device's map, see docs/and
cd web && trunk build && cd .. # or `trunk serve`: live reload on :8081, API proxied to :8080
cargo run -p server # http://localhost:8080, the first visit creates the admin account
cargo run -p cli -- login http://localhost:8080 alice
cargo run -p cli -- use-token http://localhost:8080 <token from Settings → Devices>
cargo run -p cli -- simulate --interval 2 --batch 5 48.137 11.575
cargo run -p cli -- people
cargo run -p cli -- position
```
### Android app
@@ -44,6 +44,7 @@ Every flag can also be set by its environment variable. `otserver --help` lists
| `--web-dir` | `OT_WEB_DIR` | `web/dist` | built web UI |
| `--public-url` | `OT_PUBLIC_URL` | | the address browsers use, see below |
| `--retention-days` | `OT_RETENTION_DAYS` | `30` | days to keep points, `0` keeps them forever. Users can choose a shorter time. Each device keeps its newest point, so it stays on the map. |
| `--behind-proxy` | `OT_BEHIND_PROXY` | off | the server is reachable only through one reverse proxy, see below |
`--public-url` matters behind a reverse proxy:
- Passkeys are bound to this address. Without it the server uses the request's Host header and assumes plain HTTP.
@@ -52,14 +53,15 @@ Every flag can also be set by its environment variable. `otserver --help` lists
The server updates the database schema at start. Back up the database file before you upgrade.
`otserver passwd <user>` creates a user or resets a password. A reset also removes all passkeys of the user and turns off two-factor sign-in, so a lost device cannot sign in.
`otserver passwd <user>` creates a user or resets a password. A reset also removes all passkeys of the user, revokes their device tokens and turns off two-factor sign-in, so a lost device cannot sign in. The devices and their history stay. Connect them again with a new token.
## Accounts and sign-in
- The first visit to a server with no users shows a setup form for the admin account. Anyone who reaches the server first can claim it, so set it up before you expose it.
- Admins add and delete users, change their role and reset passwords under Settings → Users. Admins cannot change their own role, so one admin always remains.
- Each user picks a sign-in mode under Settings → Security: password **or** passkey, or password **and** passkey (two-factor). A user with a passkey can remove the password.
- Devices sign in with a token. Create one under Settings → Devices, or register with `ot login` and the password. Two-factor accounts must use a token.
- Each user picks a sign-in mode under Settings → Security: password **or** passkey, or password **and** passkey (two-factor). Two-factor sign-in starts with the password. A user with a passkey can remove the password.
- Wrong passwords are limited per client address: 5 per username and 30 across all usernames, then a 15-minute lockout.
- Devices sign in with a token. The Android app gets one by signing in through the browser. For other clients, create one under Settings → Devices.
## Devices and sharing
@@ -69,7 +71,7 @@ The server updates the database schema at start. Back up the database file befor
- A share chooses what the viewer sees:
- all devices, including ones added later, or only selected devices,
- only the current position, or the trail from now on, since a chosen time, or the full history,
- the exact position, or one rounded to about 100 m, 1 km or 10 km.
- the exact position, or one rounded to about 100 m, 1 km or 10 km. Rounding also rounds the times, so the moment of moving into the next cell does not give the position away.
- Sharing again with the same person replaces the settings.
- A guest link shares with anyone who has the link, without an account. It has the same choices, plus an optional password. The link has the form `https://track.example.com/#l=<token>`. The token stays after the `#`, so it does not reach server or proxy logs when the page loads.
@@ -87,7 +89,9 @@ track.example.com {
}
```
HTTP/3 needs UDP 443 open next to TCP 443. `ot --http3 people` prints `[HTTP/3.0]` when it works.
HTTP/3 needs UDP 443 open next to TCP 443. `ot --http3 position` prints `[HTTP/3.0]` when it works.
Set `OT_BEHIND_PROXY=true`, so the password limits see the real client address. The server then takes the last `X-Forwarded-For` entry, the one the proxy wrote. Clients must not reach the server port directly, or they could set that header themselves. With several proxies in a row, the limits see the outer proxy's address.
## API
@@ -97,7 +101,7 @@ Web endpoints use the session cookie. Devices use `Authorization: Bearer <token>
|---|---|---|
| `GET/POST /api/setup` | none | first-boot admin account |
| `POST /api/login`, `/api/logout` | password | can answer with a passkey challenge (two-factor) |
| `POST /api/passkey/login[/finish]` | none | passkey sign-in. Can ask for the password next (two-factor) |
| `POST /api/passkey/login[/finish]` | none | passkey sign-in. Two-factor accounts start with `/api/login` instead |
| `GET /api/me` | session | |
| `POST/DELETE /api/me/password`, `PUT /api/me/two-factor`, `PUT /api/me/retention` | session | |
| `GET /api/passkeys`, `POST /api/passkeys/register[/finish]`, `DELETE /api/passkeys/{id}` | session | |
@@ -111,8 +115,7 @@ Web endpoints use the session cookie. Devices use `Authorization: Bearer <token>
| `POST /api/guest`, `/api/guest/track` | link token (+ key) | what a guest link shows. 401 means the link needs its password |
| `POST /api/guest/unlock` | link token + password | returns the key for a password-protected link. 5 failures lock the link for 15 minutes |
| `GET/POST /api/users`, `DELETE /api/users/{id}`, `PUT /api/users/{id}/role`, `POST /api/users/{id}/password` | admin | |
| `POST /api/devices/register` | username + password | returns a device token |
| `POST /api/devices/pair/begin` | session | `{challenge, name}` → one-time code for the app, valid 5 minutes |
| `POST /api/devices/pair` | code + verifier | the app exchanges the code and the secret behind the challenge for a device token. Each code works once. |
| `GET /api/device`, `GET /api/device/track?from=&to=` | device token | the app's own position and trail. The web UI's `#device` page uses them inside the app. |
| `POST /api/points` | device token or session | JSON array of points, at most 1000. Returns the visible people. Duplicates (same device and second) are ignored, so a client can retry a batch. A session uploads as the "Web" device. |
| `POST /api/points` | device token or session | JSON array of points, at most 1000. Returns how many were stored and skipped. Invalid points are skipped, so one bad point cannot block a client's queue. Duplicates (same device and second) are ignored, so a client can retry a batch. A session uploads as the "Web" device. |
Mandroid/app/src/main/java/org/opentracker/TrackerService.kt
@@ -81,7 +81,15 @@ class TrackerService : Service() {
stopSelf()
return START_NOT_STICKY
}
startForeground(NOTIFICATION, notification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
try {
startForeground(NOTIFICATION, notification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
} catch (e: RuntimeException) {
// Android refuses a location service started in the background without "Allow all the time",
// for example after a reboot. Then the user has to open the app.
prefs.error = "Tracking stopped. Open the app to start it again."
stopSelf()
return START_NOT_STICKY
}
Watchdog.arm(this)
val action = intent?.action
handler.post {
@@ -208,8 +216,16 @@ class TrackerService : Service() {
updateNotification()
if (outbox.count() > 0) handler.post(upload) else prefs.queued = 0
} catch (e: HttpError) {
if (e.status == 401) return removed()
retry("The server refused the upload: ${e.message}")
when {
e.status == 401 -> return removed()
// The server will refuse this batch again. Keeping it would block every later point.
e.status in 400..499 && e.status != 429 -> {
outbox.removeUpTo(batch.last().first)
prefs.error = "The server refused ${batch.size} points: ${e.message}"
schedule(urgent = true)
}
else -> retry("The server refused the upload: ${e.message}")
}
} catch (e: IOException) {
retry("No connection to the server.")
}
Mcompose.yaml
@@ -5,6 +5,8 @@ services:
- "127.0.0.1:8080:8080"
environment:
OT_PUBLIC_URL: https://track.example.com
# Only the reverse proxy reaches the published port, so its X-Forwarded-For header can be trusted.
OT_BEHIND_PROXY: "true"
OT_RETENTION_DAYS: 30
volumes:
- ./data:/data
Mcrates/api/src/lib.rs
@@ -86,9 +86,6 @@ pub struct Login {
#[serde(default)]
pub username: String,
pub password: String,
/// Set when a passkey sign-in asked for the password as the second step.
#[serde(default)]
pub state_id: Option<String>,
}
/// Reply to a sign-in step. `ok` means the session cookie is set.
@@ -98,9 +95,6 @@ pub struct LoginResult {
/// The password was right. The account also needs a passkey.
#[serde(default)]
pub passkey_challenge: Option<Challenge>,
/// The passkey was right. The account also needs its password, sent with this state_id.
#[serde(default)]
pub password_required: Option<String>,
}
/// The first leg of a WebAuthn ceremony.
@@ -148,13 +142,6 @@ pub struct SetRetention {
pub days: Option<i64>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct RegisterDevice {
pub username: String,
pub password: String,
pub name: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct NewDevice {
pub name: String,
@@ -201,7 +188,8 @@ pub struct Device {
pub struct Uploaded {
/// Points that were new. Duplicates of stored points are ignored.
pub stored: usize,
pub people: Vec<Person>,
/// Invalid points, for example with a timestamp far in the future. They are dropped, so they cannot block a client's queue.
pub skipped: usize,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
Mcrates/cli/src/main.rs
@@ -3,18 +3,16 @@
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use api::{DeviceToken, MAX_BATCH, Person, Point, RegisterDevice, Uploaded};
use api::{MAX_BATCH, Person, Point, Uploaded};
use serde::{Deserialize, Serialize};
const USAGE: &str = "usage: ot [--http3] [--insecure] <command>
login <url> <user> [device-name] register this machine as a device
(password from OT_PASSWORD or stdin)
use-token <url> <token> use a device token created in the web UI
send <lat> <lon> upload one point
simulate [--interval S] [--batch N] [lat lon]
random walk, one point every S seconds (default 2),
uploaded in batches of N (default 5)
people show the positions you can see
position show this device's last position
--http3 use HTTP/3 only. Needs an HTTPS reverse proxy that speaks it.
--insecure accept any TLS certificate, for a local proxy with its own CA.
@@ -40,7 +38,7 @@ fn load_config() -> Config {
let path = config_path();
let text = std::fs::read_to_string(&path).unwrap_or_else(|_| {
fail(&format!(
"no config at {}. Run `ot login` first.",
"no config at {}. Run `ot use-token` first.",
path.display()
))
});
@@ -101,9 +99,16 @@ struct Http {
}
impl Http {
fn get(&self, url: String) -> reqwest::RequestBuilder {
self.version(self.client.get(url))
}
fn post(&self, url: String) -> reqwest::RequestBuilder {
let req = self.client.post(url);
// reqwest picks HTTP/3 per request, not per client.
self.version(self.client.post(url))
}
/// reqwest picks HTTP/3 per request, not per client.
fn version(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if self.http3 {
req.version(reqwest::Version::HTTP_3)
} else {
@@ -132,8 +137,6 @@ async fn main() {
let batch = take_opt(&mut args, "--batch").map_or(5, |s| num::<usize>(&s));
let args: Vec<&str> = args.iter().map(String::as_str).collect();
match args[..] {
["login", url, user] => login(&client, url, user, &default_device_name()).await,
["login", url, user, name] => login(&client, url, user, name).await,
["use-token", url, token] => save_config(&Config {
url: url.trim_end_matches('/').into(),
token: token.into(),
@@ -146,58 +149,25 @@ async fn main() {
let up = upload(&client, &load_config(), &[p])
.await
.unwrap_or_else(|e| fail(&e));
println!("stored {}", up.stored);
println!("stored {}, skipped {}", up.stored, up.skipped);
}
["simulate"] => simulate(&client, 48.1372, 11.5754, interval, batch).await,
["simulate", lat, lon] => simulate(&client, num(lat), num(lon), interval, batch).await,
["people"] => {
let up = upload(&client, &load_config(), &[])
["position"] => {
let cfg = load_config();
let res = client
.get(format!("{}/api/device", cfg.url))
.bearer_auth(&cfg.token)
.send()
.await
.unwrap_or_else(|e| fail(&e));
print_people(&up.people);
.unwrap_or_else(|e| fail(&format!("{e:?}")));
let res = check(res).await.unwrap_or_else(|e| fail(&e));
print_person(&res.json().await.expect("person response"));
}
_ => fail(USAGE),
}
}
fn default_device_name() -> String {
std::fs::read_to_string("/etc/hostname")
.map(|s| s.trim().to_owned())
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "cli".into())
}
async fn login(client: &Http, url: &str, username: &str, name: &str) {
let password = std::env::var("OT_PASSWORD").unwrap_or_else(|_| {
// ponytail: the password echoes on the terminal. Use rpassword if that matters.
eprint!("password for {username}: ");
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.expect("read password");
line.trim_end_matches(['\r', '\n']).to_owned()
});
let url = url.trim_end_matches('/');
let body = RegisterDevice {
username: username.into(),
password,
name: name.into(),
};
let res = client
.post(format!("{url}/api/devices/register"))
.json(&body)
.send()
.await
.unwrap_or_else(|e| fail(&format!("{e:?}")));
let res = check(res).await.unwrap_or_else(|e| fail(&e));
let DeviceToken { token } = res.json().await.expect("token response");
save_config(&Config {
url: url.into(),
token,
});
}
async fn check(res: reqwest::Response) -> Result<reqwest::Response, String> {
if res.status().is_success() {
return Ok(res);
@@ -207,7 +177,6 @@ async fn check(res: reqwest::Response) -> Result<reqwest::Response, String> {
Err(format!("{status} {body}"))
}
/// An empty upload is valid. It returns the visible people without storing anything.
async fn upload(client: &Http, cfg: &Config, points: &[Point]) -> Result<Uploaded, String> {
let res = client
.post(format!("{}/api/points", cfg.url))
@@ -234,8 +203,8 @@ fn point(lat: f64, lon: f64) -> Point {
}
}
fn print_people(people: &[Person]) {
for p in people {
fn print_person(p: &Person) {
{
match p.last() {
Some(d) => {
let l = &d.last;
Mcrates/server/src/auth.rs
@@ -1,13 +1,15 @@
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::sync::{LazyLock, Mutex};
use argon2::Argon2;
use argon2::password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash};
use axum::extract::FromRequestParts;
use axum::http::header;
use axum::extract::{ConnectInfo, FromRequestParts};
use axum::http::request::Parts;
use axum::http::{HeaderMap, header};
use rusqlite::{OptionalExtension, params};
use sha2::{Digest, Sha256};
use tokio::sync::Semaphore;
use crate::{AppState, Error, now};
@@ -60,11 +62,10 @@ pub struct PasswordOk {
/// Checks a username and password. An account without a password always fails.
pub async fn check_password(
state: &AppState,
ip: IpAddr,
username: &str,
password: &str,
) -> Result<PasswordOk, Error> {
let key = username.to_lowercase();
state.limiter.check(&key)?;
let row: Option<(i64, Option<String>, bool)> = state
.db()
.query_row(
@@ -73,9 +74,8 @@ pub async fn check_password(
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()?;
let password = password.to_owned();
let user = tokio::task::spawn_blocking(move || match row {
limited(state, ip, username, move || match row {
Some((id, Some(hash), two_factor)) => {
verify_password(&password, &hash).then_some(PasswordOk { id, two_factor })
}
@@ -86,26 +86,50 @@ pub async fn check_password(
}
})
.await
.map_err(|e| Error::Internal(e.to_string()))?;
}
match user {
Some(user) => {
state.limiter.clear(&key);
Ok(user)
}
None => {
state.limiter.fail(&key);
Err(Error::Unauthorized)
/// Runs a password check for `name` from `ip` under the rate limits. None from `check` means a wrong password.
pub async fn limited<T: Send + 'static>(
state: &AppState,
ip: IpAddr,
name: &str,
check: impl FnOnce() -> Option<T> + Send + 'static,
) -> Result<T, Error> {
let pair = format!("{ip} {}", name.to_lowercase());
let single = ip.to_string();
state
.limiter
.attempt(&[(&pair, MAX_FAILURES), (&single, MAX_IP_FAILURES)])?;
match argon(check).await? {
Some(v) => {
state.limiter.forgive(&pair, &single);
Ok(v)
}
None => Err(Error::Unauthorized),
}
}
pub async fn hash_password_async(password: String) -> Result<String, Error> {
tokio::task::spawn_blocking(move || hash_password(&password))
/// Each Argon2 run takes about 19 MiB. Without a bound, parallel requests could exhaust the memory.
static ARGON: LazyLock<Semaphore> =
LazyLock::new(|| Semaphore::new(std::thread::available_parallelism().map_or(2, |n| n.get())));
/// Runs Argon2 work on the blocking pool, at most one run per CPU at a time.
pub async fn argon<T: Send + 'static>(
work: impl FnOnce() -> T + Send + 'static,
) -> Result<T, Error> {
let _permit = ARGON
.acquire()
.await
.map_err(|e| Error::Internal(e.to_string()))?;
tokio::task::spawn_blocking(work)
.await
.map_err(|e| Error::Internal(e.to_string()))
}
pub async fn hash_password_async(password: String) -> Result<String, Error> {
argon(move || hash_password(&password)).await
}
fn cookie(state: &AppState, value: &str, max_age: i64) -> String {
let secure = if state.https() { "; Secure" } else { "" };
format!(
@@ -136,40 +160,48 @@ pub fn end_other_sessions(state: &AppState, user: &User) -> Result<(), Error> {
Ok(())
}
/// Wrong passwords per address and name before a lockout.
const MAX_FAILURES: u32 = 5;
/// Wrong passwords per address across all names, so guessing one password for many users is limited too.
const MAX_IP_FAILURES: u32 = 30;
const LOCKOUT_SECS: i64 = 15 * 60;
/// Failed login attempts per username.
/// Password attempts per key. An attempt counts before the check, so parallel requests cannot slip past the limit.
#[derive(Default)]
pub struct Limiter(Mutex<HashMap<String, (u32, i64)>>);
impl Limiter {
pub fn check(&self, key: &str) -> Result<(), Error> {
let map = self.0.lock().unwrap();
if let Some(&(failures, since)) = map.get(key)
&& failures >= MAX_FAILURES
&& now() - since < LOCKOUT_SECS
{
/// Counts one attempt for every key, or none if any key is at its limit.
pub fn attempt(&self, keys: &[(&str, u32)]) -> Result<(), Error> {
let now = now();
let mut map = self.0.lock().unwrap();
let locked = keys.iter().any(|(key, max)| {
map.get(*key)
.is_some_and(|&(n, since)| n >= *max && now - since < LOCKOUT_SECS)
});
if locked {
return Err(Error::TooManyRequests);
}
for (key, _) in keys {
let entry = map.entry((*key).to_owned()).or_insert((0, now));
if now - entry.1 >= LOCKOUT_SECS {
*entry = (0, now);
}
entry.0 += 1;
}
Ok(())
}
pub fn fail(&self, key: &str) {
let now = now();
/// The password was right: earlier failures for the pair are forgotten, and this attempt does not count for the address.
pub fn forgive(&self, pair: &str, single: &str) {
let mut map = self.0.lock().unwrap();
let entry = map.entry(key.to_owned()).or_insert((0, now));
if now - entry.1 >= LOCKOUT_SECS {
*entry = (0, now);
map.remove(pair);
if let Some(entry) = map.get_mut(single) {
entry.0 = entry.0.saturating_sub(1);
}
entry.0 += 1;
}
pub fn clear(&self, key: &str) {
self.0.lock().unwrap().remove(key);
}
/// Attackers choose the usernames, so old entries must go.
/// Attackers choose the names, so old entries must go.
pub fn prune(&self) {
let now = now();
self.0
@@ -179,6 +211,43 @@ impl Limiter {
}
}
/// The client's address. With `--behind-proxy`, the last `X-Forwarded-For` entry.
pub struct ClientIp(pub IpAddr);
/// The last `X-Forwarded-For` entry, across all header lines. The proxy appends the address it saw,
/// so earlier entries may come from the client. Falls back to the peer, which then is the proxy.
fn forwarded_ip(peer: IpAddr, headers: &HeaderMap) -> IpAddr {
headers
.get_all("x-forwarded-for")
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(','))
.map(str::trim)
.rfind(|v| !v.is_empty())
.and_then(|v| {
v.parse::<IpAddr>()
.or_else(|_| v.parse::<SocketAddr>().map(|s| s.ip()))
.ok()
})
.unwrap_or(peer)
}
impl FromRequestParts<AppState> for ClientIp {
type Rejection = Error;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
let peer = parts
.extensions
.get::<ConnectInfo<SocketAddr>>()
.map(|c| c.0.ip())
.ok_or_else(|| Error::Internal("no connect info".into()))?;
Ok(ClientIp(match state.behind_proxy {
true => forwarded_ip(peer, &parts.headers),
false => peer,
}))
}
}
/// A logged-in web user, from the session cookie.
pub struct User {
pub id: i64,
@@ -236,7 +305,6 @@ impl FromRequestParts<AppState> for Admin {
/// Who uploads points: a device with its `Authorization: Bearer` token, or the web UI with its session.
pub struct Uploader {
pub user_id: i64,
pub device_id: i64,
}
@@ -246,10 +314,7 @@ impl FromRequestParts<AppState> for Uploader {
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
if parts.headers.contains_key(header::AUTHORIZATION) {
let d = Device::from_request_parts(parts, state).await?;
return Ok(Uploader {
user_id: d.user_id,
device_id: d.id,
});
return Ok(Uploader { device_id: d.id });
}
{
let user = User::from_request_parts(parts, state).await?;
@@ -263,10 +328,7 @@ impl FromRequestParts<AppState> for Uploader {
[user.id],
|r| r.get(0),
)?;
Ok(Uploader {
user_id: user.id,
device_id,
})
Ok(Uploader { device_id })
}
}
}
@@ -307,14 +369,60 @@ mod tests {
#[test]
fn limiter_locks_after_max_failures() {
let l = Limiter::default();
let keys = |pair| [(pair, MAX_FAILURES), ("ip", MAX_IP_FAILURES)];
for _ in 0..MAX_FAILURES {
assert!(l.check("a").is_ok());
l.fail("a");
assert!(l.attempt(&keys("ip a")).is_ok());
}
assert!(matches!(
l.attempt(&keys("ip a")),
Err(Error::TooManyRequests)
));
assert!(l.attempt(&keys("ip b")).is_ok());
l.forgive("ip a", "ip");
assert!(l.attempt(&keys("ip a")).is_ok());
for n in 0..MAX_IP_FAILURES {
let pair = format!("ip {n}");
let _ = l.attempt(&[(&pair, MAX_FAILURES), ("ip", MAX_IP_FAILURES)]);
}
assert!(matches!(l.check("a"), Err(Error::TooManyRequests)));
assert!(l.check("b").is_ok());
l.clear("a");
assert!(l.check("a").is_ok());
assert!(matches!(
l.attempt(&keys("ip new")),
Err(Error::TooManyRequests)
));
}
#[test]
fn forwarded_ip_takes_the_entry_the_proxy_wrote() {
let proxy: IpAddr = "10.0.0.2".parse().unwrap();
let headers = |lines: &[&str]| {
let mut h = HeaderMap::new();
for l in lines {
h.append("x-forwarded-for", l.parse().unwrap());
}
h
};
let ip = |s: &str| s.parse::<IpAddr>().unwrap();
// nginx appends the real address to a value the client sent.
assert_eq!(
forwarded_ip(proxy, &headers(&["6.6.6.6, 203.0.113.7"])),
ip("203.0.113.7")
);
assert_eq!(
forwarded_ip(proxy, &headers(&["6.6.6.6", "203.0.113.7"])),
ip("203.0.113.7")
);
assert_eq!(
forwarded_ip(proxy, &headers(&["[2001:db8::1]:4711"])),
ip("2001:db8::1")
);
assert_eq!(
forwarded_ip(proxy, &headers(&["203.0.113.7,"])),
ip("203.0.113.7")
);
assert_eq!(
forwarded_ip(proxy, &headers(&["203.0.113.7, nonsense"])),
proxy
);
assert_eq!(forwarded_ip(proxy, &HeaderMap::new()), proxy);
}
#[test]
Mcrates/server/src/guest.rs
@@ -76,6 +76,7 @@ pub async fn track(
pub async fn unlock(
State(s): State<AppState>,
auth::ClientIp(ip): auth::ClientIp,
Json(b): Json<GuestUnlock>,
) -> Result<Json<GuestKey>> {
let auth = GuestAuth {
@@ -92,18 +93,11 @@ pub async fn unlock(
)?,
Err(e) => return Err(e),
};
// The prefix keeps link limits apart from username limits.
let limit = format!("\0link {}", auth.token);
s.limiter.check(&limit)?;
let (password, h) = (b.password, hash.clone());
let ok = tokio::task::spawn_blocking(move || auth::verify_password(&password, &h))
.await
.map_err(|e| Error::Internal(e.to_string()))?;
if !ok {
s.limiter.fail(&limit);
return Err(Error::Unauthorized);
}
s.limiter.clear(&limit);
auth::limited(&s, ip, &format!("link {}", auth.token), move || {
auth::verify_password(&password, &h).then_some(())
})
.await?;
Ok(Json(GuestKey {
key: key(&auth.token, &hash),
}))
Mcrates/server/src/main.rs
@@ -221,6 +221,10 @@ struct Cli {
/// Days to keep points. Users can lower this for themselves. 0 keeps points forever.
#[arg(long, env = "OT_RETENTION_DAYS", default_value_t = 30)]
retention_days: i64,
/// Set when one reverse proxy forwards all traffic. Rate limits then use the last X-Forwarded-For entry.
/// Clients must not reach the server port directly, or they can set that header themselves.
#[arg(long, env = "OT_BEHIND_PROXY")]
behind_proxy: bool,
#[command(subcommand)]
command: Option<Command>,
}
@@ -229,7 +233,7 @@ struct Cli {
enum Command {
/// Create a user or reset their password. Reads the password from OT_PASSWORD or stdin.
///
/// A reset also removes all passkeys and turns off two-factor sign-in, so a lost device cannot sign in.
/// A reset also removes all passkeys, revokes device tokens and turns off two-factor sign-in, so a lost device cannot sign in.
Passwd { username: String },
}
@@ -251,6 +255,7 @@ pub struct AppState {
public_url: Option<Url>,
/// 0 means forever.
max_retention_days: i64,
behind_proxy: bool,
}
impl AppState {
@@ -341,8 +346,8 @@ pub fn insert_user(
Ok(db.last_insert_rowid())
}
/// Sets a password and removes every other way in: passkeys, two-factor sign-in and sessions.
/// A reset often follows a lost device, and its passkey must not keep working.
/// Sets a password and removes every other way in: passkeys, two-factor sign-in, sessions and device tokens.
/// A reset often follows a lost device, and its passkey or token must not keep working.
pub fn reset_password(db: &Connection, user_id: i64, pw_hash: &str) -> rusqlite::Result<()> {
db.execute(
"UPDATE users SET pw_hash = ?1, two_factor = 0 WHERE id = ?2",
@@ -350,6 +355,11 @@ pub fn reset_password(db: &Connection, user_id: i64, pw_hash: &str) -> rusqlite:
)?;
db.execute("DELETE FROM passkeys WHERE user_id = ?1", [user_id])?;
db.execute("DELETE FROM sessions WHERE user_id = ?1", [user_id])?;
// A random hash matches no token. The devices and their history stay, and the owner pairs them again.
db.execute(
"UPDATE devices SET token_hash = randomblob(32) WHERE user_id = ?1 AND token_hash IS NOT NULL",
[user_id],
)?;
Ok(())
}
@@ -389,7 +399,9 @@ fn passwd(db: &Connection, username: &str) {
match existing {
Some(id) => {
reset_password(db, id, &hash).expect("update user");
println!("password reset for {username}, passkeys removed, two-factor sign-in off");
println!(
"password reset for {username}: passkeys removed, device tokens revoked, two-factor sign-in off"
);
}
None => {
let username = check_username(username).unwrap_or_else(|e| {
@@ -410,6 +422,7 @@ async fn serve(cli: Cli, db: Connection) {
pairings: Arc::default(),
public_url: cli.public_url,
max_retention_days: cli.retention_days.max(0),
behind_proxy: cli.behind_proxy,
};
tokio::spawn(cleanup(state.clone()));
@@ -430,9 +443,13 @@ async fn serve(cli: Cli, db: Connection) {
{
println!("no users yet: open the web UI to create the admin account");
}
axum::serve(listener, routes::router(state, &cli.web_dir))
.await
.expect("serve");
let app = routes::router(state, &cli.web_dir);
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await
.expect("serve");
}
/// The days of points to keep for a user, or None for forever.
@@ -527,7 +544,8 @@ mod tests {
db.execute_batch(
"UPDATE users SET two_factor = 1;
INSERT INTO passkeys (user_id, cred_id, passkey, name, created_at) VALUES (1, x'01', '{}', 'k', 0);
INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (x'02', 1, 9999999999);",
INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (x'02', 1, 9999999999);
INSERT INTO devices (user_id, name, token_hash, created_at) VALUES (1, 'phone', x'03', 0), (1, 'Web', NULL, 0);",
)
.unwrap();
reset_password(&db, id, "new").unwrap();
@@ -535,6 +553,11 @@ mod tests {
assert_eq!(count("SELECT COUNT(*) FROM passkeys"), 0);
assert_eq!(count("SELECT COUNT(*) FROM sessions"), 0);
assert_eq!(count("SELECT two_factor FROM users"), 0);
assert_eq!(
count("SELECT COUNT(*) FROM devices WHERE token_hash = x'03'"),
0
);
assert_eq!(count("SELECT COUNT(*) FROM devices"), 2);
}
#[test]
Mcrates/server/src/passkeys.rs
@@ -24,6 +24,7 @@ pub const PASSKEY_LIMIT: i64 = 10;
/// How long a browser has to answer a challenge.
const TTL: Duration = Duration::from_secs(300);
/// Anyone can start a passkey sign-in, so their pending challenges need a cap.
/// Past it the oldest goes, so a flood of starts cannot block everyone else's sign-in.
const MAX_ANONYMOUS: usize = 1000;
pub enum Pending {
@@ -37,10 +38,6 @@ pub enum Pending {
user_id: i64,
state: Box<PasskeyAuthentication>,
},
/// A passkey passed. The account also needs its password.
NeedsPassword {
user_id: i64,
},
}
/// WebAuthn takes two requests. This holds what the second one needs, keyed by a handle the client echoes.
@@ -48,18 +45,26 @@ pub enum Pending {
pub struct Ceremonies(Mutex<HashMap<String, (Pending, Instant)>>);
impl Ceremonies {
pub fn put(&self, pending: Pending) -> Result<String, Error> {
pub fn put(&self, pending: Pending) -> String {
let mut map = self.0.lock().unwrap();
map.retain(|_, (_, at)| at.elapsed() < TTL);
let anonymous = |p: &Pending| matches!(p, Pending::SignIn(_));
if anonymous(&pending)
&& map.values().filter(|(p, _)| anonymous(p)).count() >= MAX_ANONYMOUS
{
return Err(Error::TooManyRequests);
if anonymous(&pending) {
let mut started: Vec<(String, Instant)> = map
.iter()
.filter(|(_, (p, _))| anonymous(p))
.map(|(id, (_, at))| (id.clone(), *at))
.collect();
if started.len() >= MAX_ANONYMOUS {
started.sort_by_key(|(_, at)| *at);
for (id, _) in &started[..=started.len() - MAX_ANONYMOUS] {
map.remove(id);
}
}
}
let (id, _) = auth::new_secret();
map.insert(id.clone(), (pending, Instant::now()));
Ok(id)
id
}
/// One handle answers one challenge.
@@ -87,7 +92,7 @@ fn challenge<T: serde::Serialize>(
) -> Result<Challenge, Error> {
let options = serde_json::to_string(options).map_err(|e| Error::Internal(e.to_string()))?;
Ok(Challenge {
state_id: state.ceremonies.put(pending)?,
state_id: state.ceremonies.put(pending),
options,
})
}
@@ -218,7 +223,6 @@ pub fn second_factor(
Ok(Json(LoginResult {
ok: false,
passkey_challenge: Some(ch),
..Default::default()
})
.into_response())
}
@@ -284,13 +288,10 @@ pub async fn login_finish(
)?;
drop(db);
if two_factor && !password_done {
let state_id = s.ceremonies.put(Pending::NeedsPassword { user_id })?;
return Ok(Json(LoginResult {
ok: false,
password_required: Some(state_id),
..Default::default()
})
.into_response());
return Err(Error::BadRequest(
"this account needs its password and a passkey. Sign in with your password first."
.into(),
));
}
sign_in(&s, user_id)
}
Mcrates/server/src/routes.rs
@@ -2,9 +2,9 @@ use std::path::Path;
use api::{
ChangePassword, Credentials, Device, DeviceToken, Login, MAX_BATCH, MAX_PRECISION_M,
MAX_TRACK_SECS, Me, NewDevice, NewShare, NewUser, Person, PersonDevice, Point, RegisterDevice,
ResetPassword, SetRetention, SetRole, SetTwoFactor, SetupStatus, Share, ShareSettings, Shares,
Trail, Uploaded,
MAX_TRACK_SECS, Me, NewDevice, NewShare, NewUser, Person, PersonDevice, Point, ResetPassword,
SetRetention, SetRole, SetTwoFactor, SetupStatus, Share, ShareSettings, Shares, Trail,
Uploaded,
};
use axum::extract::{Path as UrlPath, Query, State};
use axum::http::{HeaderMap, Uri, header};
@@ -15,8 +15,8 @@ use rusqlite::{Connection, OptionalExtension, Row, params};
use serde::Deserialize;
use tower_http::services::ServeDir;
use crate::auth::{self, Admin, User};
use crate::passkeys::{self, Pending};
use crate::auth::{self, Admin, ClientIp, User};
use crate::passkeys;
use crate::{AppState, Error, now};
use crate::{device, guest};
@@ -46,7 +46,6 @@ pub fn router(state: AppState, web_dir: &Path) -> Router {
.route("/api/people", get(people))
.route("/api/people/{id}/track", get(track))
.route("/api/devices", get(list_devices).post(create_device))
.route("/api/devices/register", post(register_device))
.route("/api/devices/pair/begin", post(device::pair_begin))
.route("/api/devices/pair", post(device::pair_finish))
.route("/api/device", get(device::me))
@@ -108,32 +107,16 @@ async fn setup(State(s): State<AppState>, Json(b): Json<Credentials>) -> Result<
async fn login(
State(s): State<AppState>,
ClientIp(ip): ClientIp,
uri: Uri,
headers: HeaderMap,
Json(b): Json<Login>,
) -> Result<Response> {
match &b.state_id {
// The passkey already passed. This is the password step of a two-factor sign-in.
Some(state_id) => {
let Some(Pending::NeedsPassword { user_id }) = s.ceremonies.take(state_id) else {
return Err(passkeys::expired());
};
let username: String =
s.db()
.query_row("SELECT username FROM users WHERE id = ?1", [user_id], |r| {
r.get(0)
})?;
let ok = auth::check_password(&s, &username, &b.password).await?;
passkeys::sign_in(&s, ok.id)
}
None => {
let ok = auth::check_password(&s, b.username.trim(), &b.password).await?;
if ok.two_factor {
return passkeys::second_factor(&s, &uri, &headers, ok.id);
}
passkeys::sign_in(&s, ok.id)
}
let ok = auth::check_password(&s, ip, b.username.trim(), &b.password).await?;
if ok.two_factor {
return passkeys::second_factor(&s, &uri, &headers, ok.id);
}
passkeys::sign_in(&s, ok.id)
}
async fn logout(State(s): State<AppState>, user: User) -> Result<impl IntoResponse> {
@@ -168,6 +151,7 @@ async fn me(State(s): State<AppState>, user: User) -> Result<Json<Me>> {
/// Sets or changes the password. Changing an existing one needs the old one.
async fn change_password(
State(s): State<AppState>,
ClientIp(ip): ClientIp,
user: User,
Json(b): Json<ChangePassword>,
) -> Result<Json<()>> {
@@ -179,7 +163,7 @@ async fn change_password(
)?;
if has_password {
// 400, not 401: the session is still valid, only the old password is wrong.
auth::check_password(&s, &user.username, b.old.as_deref().unwrap_or_default())
auth::check_password(&s, ip, &user.username, b.old.as_deref().unwrap_or_default())
.await
.map_err(|e| match e {
Error::Unauthorized => Error::BadRequest("wrong current password".into()),
@@ -298,6 +282,9 @@ fn coarsen(p: &mut Point, m: u32) {
if m == 0 {
return;
}
// A jump to the next cell shows when the owner crossed the cell edge, and where that edge is.
// Rounding the time to m seconds keeps that crossing about m metres vague at walking speed.
p.ts -= p.ts.rem_euclid(i64::from(m));
let step = f64::from(m) / 111_320.0;
p.lat = ((p.lat / step).round() * step).clamp(-90.0, 90.0);
// A degree of longitude shrinks toward the poles. Using the snapped latitude keeps one grid per row.
@@ -443,10 +430,11 @@ pub fn track_points(
if !allowed {
return Err(Error::NotFound);
}
let points = db
// ponytail: past the limit the oldest points go. Thin the trail evenly if long ranges need all of it.
let mut points: Vec<Point> = db
.prepare_cached(&format!(
"SELECT {POINT_COLS} FROM points WHERE device_id = ?1 AND ts BETWEEN ?2 AND ?3
ORDER BY ts LIMIT {MAX_TRACK_POINTS}"
"SELECT * FROM (SELECT {POINT_COLS} FROM points WHERE device_id = ?1 AND ts BETWEEN ?2 AND ?3
ORDER BY ts DESC LIMIT {MAX_TRACK_POINTS}) ORDER BY ts"
))?
.query_map(params![device, from, to], |r| {
let mut p = point_at(r, 0)?;
@@ -454,6 +442,7 @@ pub fn track_points(
Ok(p)
})?
.collect::<rusqlite::Result<_>>()?;
points.dedup_by(|b, a| (a.ts, a.lat, a.lon) == (b.ts, b.lat, b.lon));
Ok(points)
}
@@ -497,20 +486,6 @@ pub fn insert_device(db: &Connection, user_id: i64, name: &str) -> Result<Device
Ok(DeviceToken { token })
}
/// Registers a device with the account password. Two-factor accounts create device tokens in the web UI.
async fn register_device(
State(s): State<AppState>,
Json(b): Json<RegisterDevice>,
) -> Result<Json<DeviceToken>> {
let ok = auth::check_password(&s, b.username.trim(), &b.password).await?;
if ok.two_factor {
return Err(Error::BadRequest(
"this account needs a passkey to sign in. Create a device token in the web UI.".into(),
));
}
Ok(Json(insert_device(&s.db(), ok.id, &b.name)?))
}
async fn create_device(
State(s): State<AppState>,
user: User,
@@ -561,9 +536,11 @@ async fn upload(
)));
}
let now = now();
for p in &points {
check_point(p, now).map_err(Error::BadRequest)?;
}
let total = points.len();
let points: Vec<Point> = points
.into_iter()
.filter(|p| check_point(p, now).is_ok())
.collect();
let mut db = s.db();
let tx = db.transaction()?;
@@ -591,9 +568,10 @@ async fn upload(
[now, uploader.device_id],
)?;
tx.commit()?;
let people = people_for(&db, uploader.user_id)?;
Ok(Json(Uploaded { stored, people }))
Ok(Json(Uploaded {
stored,
skipped: total - points.len(),
}))
}
fn trail_at(r: &Row, i: usize) -> rusqlite::Result<Trail> {
@@ -696,7 +674,6 @@ async fn create_share(
Json(b): Json<NewShare>,
) -> Result<Json<Share>> {
check_settings(&b.settings, b.expires_at)?;
let now = now();
let mut db = s.db();
let (viewer_id, username): (i64, String) = db
.query_row(
@@ -710,12 +687,12 @@ async fn create_share(
return Err(Error::BadRequest("you cannot share with yourself".into()));
}
let tx = db.transaction()?;
let id = tx.query_row(
let (id, created_at) = tx.query_row(
"INSERT INTO shares (owner_id, viewer_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT (owner_id, viewer_id) DO UPDATE SET expires_at = excluded.expires_at
RETURNING id",
params![user.id, viewer_id, b.expires_at, now],
|r| r.get(0),
RETURNING id, created_at",
params![user.id, viewer_id, b.expires_at, now()],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
save_settings(&tx, id, user.id, &b.settings)?;
tx.commit()?;
@@ -723,7 +700,7 @@ async fn create_share(
id,
username,
expires_at: b.expires_at,
created_at: now,
created_at,
settings: b.settings,
}))
}
@@ -886,7 +863,7 @@ mod tests {
let exact = Point {
acc: Some(5.0),
speed: Some(3.0),
..pt(1, 48.137_15, 11.575_49)
..pt(1_800_000_999, 48.137_15, 11.575_49)
};
let mut p = exact.clone();
coarsen(&mut p, 0);
@@ -900,7 +877,7 @@ mod tests {
dy.abs() <= 500.0 && dx.abs() <= 510.0,
"moved {dy} m, {dx} m"
);
assert_eq!((p.acc, p.speed), (Some(1000.0), None));
assert_eq!((p.acc, p.speed, p.ts), (Some(1000.0), None, 1_800_000_000));
let mut near = pt(1, exact.lat + 0.000_01, exact.lon + 0.000_01);
coarsen(&mut near, 1000);
assert_eq!((near.lat, near.lon), (p.lat, p.lon));
Mweb/Cargo.toml
@@ -13,4 +13,4 @@ serde.workspace = true
serde_json.workspace = true
wasm-bindgen = "0.2.129"
wasm-bindgen-futures = "0.4.79"
web-sys = { version = "0.3.106", features = ["Document", "Element", "HtmlElement", "Location", "Storage", "Window"] }
web-sys = { version = "0.3.106", features = ["Document", "Element", "HtmlElement", "Location", "Storage", "UrlSearchParams", "Window"] }
Mweb/src/app.rs
@@ -33,16 +33,7 @@ pub fn DevicePage() -> impl IntoView {
fn hash_param(key: &str) -> Option<String> {
let hash = window().location().hash().ok()?;
let query = hash.split_once('?')?.1;
query.split('&').find_map(|pair| {
let (k, v) = pair.split_once('=')?;
(k == key)
.then(|| {
js_sys::decode_uri_component(&v.replace('+', " "))
.ok()
.map(String::from)
})
.flatten()
})
web_sys::UrlSearchParams::new_with_str(query).ok()?.get(key)
}
/// Confirms a new device for the signed-in user, then hands a one-time code back to the app.
Mweb/src/http.rs
@@ -1,6 +1,6 @@
use std::fmt;
use gloo_net::http::{Request, Response};
use gloo_net::http::{Request, RequestBuilder, Response};
use serde::Serialize;
use serde::de::DeserializeOwned;
@@ -30,15 +30,19 @@ pub async fn get_bearer<T: DeserializeOwned>(path: &str, token: &str) -> Result<
}
pub async fn post<T: DeserializeOwned>(path: &str, body: &impl Serialize) -> Result<T, Error> {
let req = Request::post(path).json(body).map_err(|e| Error {
status: 0,
msg: e.to_string(),
})?;
read(path, req.send().await).await
send(path, Request::post(path), body).await
}
pub async fn put<T: DeserializeOwned>(path: &str, body: &impl Serialize) -> Result<T, Error> {
let req = Request::put(path).json(body).map_err(|e| Error {
send(path, Request::put(path), body).await
}
async fn send<T: DeserializeOwned>(
path: &str,
req: RequestBuilder,
body: &impl Serialize,
) -> Result<T, Error> {
let req = req.json(body).map_err(|e| Error {
status: 0,
msg: e.to_string(),
})?;
Mweb/src/main.rs
@@ -161,10 +161,8 @@ fn LoginForm() -> impl IntoView {
let password = RwSignal::new(String::new());
let error = RwSignal::new(None::<String>);
let busy = RwSignal::new(false);
// Set when a passkey passed and the account also needs its password.
let password_step = RwSignal::new(None::<String>);
// Follows a sign-in step to its end: a session, a passkey prompt, or a password prompt.
// Follows a sign-in step to its end: a session, or a passkey prompt after the password.
let handle = move |result: Result<LoginResult, String>| {
spawn_local(async move {
let mut result = result;
@@ -177,14 +175,6 @@ fn LoginForm() -> impl IntoView {
}
match result {
Ok(r) if r.ok => account.reload(),
Ok(LoginResult {
password_required: Some(state_id),
..
}) => {
password.set(String::new());
password_step.set(Some(state_id));
error.set(None);
}
Ok(_) => error.set(Some("Sign-in failed.".into())),
Err(e) => error.set(Some(e)),
}
@@ -198,7 +188,6 @@ fn LoginForm() -> impl IntoView {
let body = Login {
username: username.get_untracked(),
password: password.get_untracked(),
state_id: password_step.get_untracked(),
};
spawn_local(async move {
let result = http::post::<LoginResult>("/api/login", &body)
@@ -226,18 +215,13 @@ fn LoginForm() -> impl IntoView {
view! {
<form class="login" on:submit=submit>
<h1>"opentracker"</h1>
<Show
when=move || password_step.get().is_none()
fallback=move || view! { <p>"This account also needs its password."</p> }
>
<label>"Username" <input autocomplete="username webauthn" required bind:value=username /></label>
</Show>
<label>"Username" <input autocomplete="username webauthn" required bind:value=username /></label>
<label>
"Password"
<input type="password" autocomplete="current-password" required bind:value=password />
</label>
<button class="primary" disabled=busy>"Log in"</button>
<Show when=move || passkey::supported() && password_step.get().is_none()>
<Show when=passkey::supported>
<button type="button" disabled=busy on:click=with_passkey>"Sign in with a passkey"</button>
</Show>
<p class="error">{move || error.get()}</p>
Mweb/src/map.rs
@@ -169,6 +169,13 @@ fn add_base_maps(m: &LMap) {
m.on_map("baselayerchange", &save.into_js_value());
}
fn escape(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn latlng(lat: f64, lon: f64) -> Array {
Array::of2(&lat.into(), &lon.into())
}
@@ -224,8 +231,9 @@ fn sync_markers(
("fillOpacity", 1.into()),
]),
);
// Leaflet inserts tooltip text as HTML.
dot.bind_tooltip(
&p.username,
&escape(&p.username),
&obj(&[
("permanent", true.into()),
("direction", "right".into()),
@@ -257,7 +265,7 @@ fn sync_markers(
}
/// `YYYY-MM-DD` and `YYYY-MM-DDTHH:MM` in local time, the formats of date and datetime-local inputs.
fn input_values(d: &js_sys::Date) -> (String, String) {
pub fn input_values(d: &js_sys::Date) -> (String, String) {
let day = format!(
"{:04}-{:02}-{:02}",
d.get_full_year(),
@@ -434,17 +442,27 @@ pub fn MapPage(#[prop(default = Source::Account)] source: Source) -> impl IntoVi
});
});
// Reloads on every poll too, so a current trail grows while you watch.
// The trail on screen, or being loaded: person, device and filter.
let track_key = StoredValue::new(None::<String>);
Effect::new(move |_| {
let (Some(p), Some(dev), Ok((from, to))) = (person(), device(), range()) else {
track_key.set_value(None);
track.set(Vec::new());
return;
};
if p.trail == Trail::None || span.get() == "none" {
track_key.set_value(None);
track.set(Vec::new());
return;
}
let id = p.id;
let key = format!("{id} {dev} {}", filter_key());
// A range that ends now grows with each poll. A past range does not change, so it loads once.
let live = to >= now_secs() - 2 * POLL.as_secs() as i64;
if !live && track_key.get_value().as_deref() == Some(key.as_str()) {
return;
}
track_key.set_value(Some(key.clone()));
spawn_local(async move {
let result = match source.get_value() {
Source::Account => {
@@ -470,6 +488,10 @@ pub fn MapPage(#[prop(default = Source::Account)] source: Source) -> impl IntoVi
http::post::<Vec<Point>>("/api/guest/track", &body).await
}
};
// A slow answer for an earlier choice must not replace the current trail.
if track_key.get_value().as_deref() != Some(key.as_str()) {
return;
}
match result {
Ok(t) => track.set(t),
Err(e) => error.set(Some(e.to_string())),
Mweb/src/settings.rs
@@ -90,7 +90,6 @@ fn Devices() -> impl IntoView {
http::get::<Vec<Device>>("/api/devices")
});
let url = server_url(account);
let username = account.me().map(|m| m.username).unwrap_or_default();
let add = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
@@ -150,9 +149,7 @@ fn Devices() -> impl IntoView {
}
})
}}
<p class="hint">
"Or register with your password: " <code>{format!("ot login {url} {username}")}</code>
</p>
<p class="hint">"The Android app needs no token. Enter " <code>{url}</code> " in the app and sign in."</p>
<p class="error">{move || error.get()}</p>
</section>
<section>
@@ -206,13 +203,7 @@ struct ShareOptions {
impl ShareOptions {
fn new() -> Self {
let d = js_sys::Date::new_0();
let today = format!(
"{:04}-{:02}-{:02}T00:00",
d.get_full_year(),
d.get_month() + 1,
d.get_date()
);
let today = format!("{}T00:00", map::input_values(&js_sys::Date::new_0()).0);
ShareOptions {
duration: RwSignal::new("0".into()),
precision: RwSignal::new("0".into()),
@@ -956,7 +947,7 @@ fn Users() -> impl IntoView {
let reset = move |u: &User| {
let prompt = format!(
"New password for {}. This also removes their passkeys, turns off two-factor sign-in and logs them out.",
"New password for {}. This also removes their passkeys, disconnects their devices, turns off two-factor sign-in and logs them out.",
u.username
);
let Ok(Some(password)) = window().prompt_with_message(&prompt) else {