Restart as a simple HTTP + SQLite rewrite
Replace the UDP protocol, Android app and Solid web UI with a small workspace: an axum + SQLite server, a CLI test client, and a Leptos + Leaflet web UI. - First-boot admin setup, users with admin and user roles - Passwords and passkeys, as alternatives or as two-factor sign-in - Several devices per person, plus the web app as one "Web" device - Shares and guest links with device selection, trail start, rounded positions and optional link passwords - Server and per-user retention that keeps each device's newest point - Schema migrations through PRAGMA user_version - Sending the browser's position once or on a timer - Tabbed settings, theme toggle, mobile layout Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A.cargo/config.toml
@@ -0,0 +1,3 @@
# reqwest gates HTTP/3 (used by the CLI) behind this cfg.
[target.'cfg(not(target_arch = "wasm32"))']
rustflags = ["--cfg", "reqwest_unstable"]
M.containerignore
@@ -1,10 +1,4 @@
target/
web/node_modules/
web/dist/
android/
dev/
crates/otproto/fuzz/target/
.git/
*.db
*.db-wal
*.db-shm
target
web/dist
*.db*
.git
M.gitignore
@@ -1,26 +1,6 @@
/target
**/*.db
**/*.db-wal
**/*.db-shm
/cache
/dev
/secret.key
opentracker.toml
# Android
/android/.gradle
/android/build
/android/app/build
/android/local.properties
/android/keystore.properties
/android/*.jks
# Web
/web/node_modules
/web/dist
!/web/dist/.gitkeep
# cargo-fuzz
/crates/otproto/fuzz/target
/crates/otproto/fuzz/corpus
/crates/otproto/fuzz/artifacts
*.db
*.db-wal
*.db-shm
server.log
MCargo.lock
@@ -3,35 +3,74 @@
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "aead"
version = "0.5.2"
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"crypto-common 0.1.7",
"generic-array",
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"memchr",
"utf8parse",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "any_spawner"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
checksum = "1384d3fe1eecb464229fcf6eebb72306591c56bf27b373561489458a7c73027d"
dependencies = [
"futures",
"thiserror 2.0.21",
"wasm-bindgen-futures",
]
[[package]]
name = "anyhow"
@@ -39,48 +78,90 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "api"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "argon2"
version = "0.5.3"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
dependencies = [
"base64ct",
"blake2",
"cpufeatures 0.2.17",
"cpufeatures 0.3.1",
"password-hash",
]
[[package]]
name = "async-compression"
version = "0.4.42"
name = "asn1-rs"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
dependencies = [
"compression-codecs",
"compression-core",
"pin-project-lite",
"tokio",
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom",
"num-traits",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]]
name = "async-trait"
version = "0.1.91"
name = "asn1-rs-derive"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 2.0.119",
"synstructure 0.13.2",
]
[[package]]
name = "atoi"
version = "2.0.0"
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"num-traits",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-once-cell"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a"
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
@@ -89,12 +170,65 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "attribute-derive"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77"
dependencies = [
"attribute-derive-macro",
"derive-where",
"manyhow",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "attribute-derive-macro"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61"
dependencies = [
"collection_literals",
"interpolator",
"manyhow",
"proc-macro-utils",
"proc-macro2",
"quote",
"quote-use",
"syn 2.0.119",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
name = "axum"
version = "0.8.9"
@@ -147,12 +281,30 @@ dependencies = [
"tracing",
]
[[package]]
name = "base16"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8"
[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.8.3"
@@ -160,36 +312,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit-set"
version = "0.8.0"
name = "base64urlsafedata"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c"
dependencies = [
"bit-vec",
"base64 0.21.7",
"pastey",
"serde",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "2.13.1"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
dependencies = [
"serde_core",
]
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "blake2"
version = "0.10.6"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
dependencies = [
"digest 0.10.7",
"digest 0.11.3",
]
[[package]]
@@ -216,33 +361,35 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "camino"
version = "1.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591"
[[package]]
name = "cc"
version = "1.2.64"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
@@ -252,72 +399,137 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.9.1"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
[[package]]
name = "chacha20"
version = "0.10.1"
name = "clap"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
"clap_builder",
"clap_derive",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
name = "clap_builder"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"aead",
"chacha20 0.9.1",
"cipher",
"poly1305",
"zeroize",
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "cipher"
version = "0.4.4"
name = "clap_derive"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"crypto-common 0.1.7",
"inout",
"zeroize",
"heck",
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "clap_lex"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "cli"
version = "0.1.0"
dependencies = [
"api",
"getrandom 0.4.3",
"reqwest",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "codee"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9dbbdc4b4d349732bc6690de10a9de952bd39ba6a065c586e26600b6b0b91f5"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "compression-codecs"
version = "0.4.38"
name = "collection_literals"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
version = "4.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
dependencies = [
"compression-core",
"flate2",
"bytes",
"memchr",
]
[[package]]
name = "compression-core"
version = "0.4.32"
name = "config"
version = "0.15.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
checksum = "38e9fbc53afcec54441422f1c596cd054655b32e70e1cbac956651b7b175a404"
dependencies = [
"convert_case 0.6.0",
"pathdiff",
"serde_core",
"toml",
"winnow",
]
[[package]]
name = "const-oid"
version = "0.9.6"
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "const-oid"
@@ -326,72 +538,104 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cookie"
version = "0.18.1"
name = "const-str"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f"
[[package]]
name = "const_format"
version = "0.2.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e"
dependencies = [
"percent-encoding",
"time",
"version_check",
"const_format_proc_macros",
"konst",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
name = "const_format_proc_macros"
version = "0.2.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744"
dependencies = [
"libc",
"proc-macro2",
"quote",
"unicode-xid",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
name = "const_str_slice_concat"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b"
[[package]]
name = "convert_case"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
dependencies = [
"libc",
"unicode-segmentation",
]
[[package]]
name = "crc"
version = "3.4.0"
name = "convert_case"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
dependencies = [
"crc-catalog",
"unicode-segmentation",
]
[[package]]
name = "crc-catalog"
version = "2.5.0"
name = "convert_case_extras"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
checksum = "589c70f0faf8aa9d17787557d5eae854d7755cac50f5c3d12c81d3d57661cebb"
dependencies = [
"convert_case 0.11.0",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"cfg-if",
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.13"
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"crossbeam-utils",
"libc",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.22"
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
@@ -400,7 +644,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@@ -414,28 +657,32 @@ dependencies = [
]
[[package]]
name = "dashmap"
version = "6.2.1"
name = "ctutils"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
"cmov",
]
[[package]]
name = "der"
version = "0.7.10"
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "der-parser"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
dependencies = [
"const-oid 0.9.6",
"pem-rfc7468",
"zeroize",
"asn1-rs",
"displaydoc",
"nom",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
@@ -443,9 +690,17 @@ name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"serde_core",
]
[[package]]
name = "derive-where"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "digest"
@@ -454,9 +709,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"const-oid 0.9.6",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
@@ -466,34 +719,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid 0.10.2",
"const-oid",
"crypto-common 0.2.2",
"ctutils",
]
[[package]]
name = "displaydoc"
version = "0.2.6"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.6",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
name = "drain_filter_polyfill"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.17.0"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "either_of"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5060e0a4cbf26a87550792688ade88e6b8aec9208613631a7a363bda7bc2d4cd"
dependencies = [
"serde",
"paste",
"pin-project-lite",
]
[[package]]
@@ -502,6 +769,12 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erased"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1731451909bde27714eacba19c2566362a7f35224f52b153d3f42cf60f72472"
[[package]]
name = "errno"
version = "0.3.14"
@@ -513,58 +786,48 @@ dependencies = [
]
[[package]]
name = "etcetera"
version = "0.8.0"
name = "event-listener"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"cfg-if",
"home",
"windows-sys 0.48.0",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener"
version = "5.4.2"
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"parking",
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.5.0"
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "find-msvc-tools"
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "flate2"
version = "1.1.9"
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "flume"
version = "0.11.1"
name = "find-msvc-tools"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
dependencies = [
"futures-core",
"futures-sink",
"spin",
]
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fnv"
@@ -574,9 +837,24 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
@@ -587,14 +865,21 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
@@ -603,9 +888,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
"futures-sink",
@@ -613,73 +898,57 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-executor"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-intrusive"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
dependencies = [
"futures-core",
"lock_api",
"parking_lot",
]
[[package]]
name = "futures-io"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-macro"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.6",
]
[[package]]
name = "futures-sink"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
[[package]]
name = "futures-timer"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -720,11 +989,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -742,33 +1009,50 @@ dependencies = [
]
[[package]]
name = "governor"
version = "0.8.1"
name = "gloo-net"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be93b4ec2e4710b04d9264c0c7350cdd62a8c20e5e4ac732552ebb8f0debe8eb"
checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580"
dependencies = [
"cfg-if",
"dashmap",
"futures-channel",
"futures-core",
"futures-sink",
"futures-timer",
"futures-util",
"getrandom 0.3.4",
"no-std-compat",
"nonzero_ext",
"parking_lot",
"portable-atomic",
"quanta",
"rand 0.9.5",
"smallvec",
"spinning_top",
"web-time",
"gloo-utils",
"http",
"js-sys",
"pin-project",
"serde",
"serde_json",
"thiserror 1.0.69",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "gloo-utils"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
dependencies = [
"js-sys",
"serde",
"serde_json",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "guardian"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f"
[[package]]
name = "h2"
version = "0.4.15"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
@@ -784,19 +1068,50 @@ dependencies = [
]
[[package]]
name = "hashbrown"
version = "0.14.5"
name = "h3"
version = "0.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
checksum = "10872b55cfb02a821b69dc7cf8dc6a71d6af25eb9a79662bec4a9d016056b3be"
dependencies = [
"bytes",
"fastrand",
"futures-util",
"http",
"pin-project-lite",
"tokio",
]
[[package]]
name = "h3-quinn"
version = "0.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2e732c8d91a74731663ac8479ab505042fbf547b9a207213ab7fbcbfc4f8b4"
dependencies = [
"bytes",
"futures",
"h3",
"quinn",
"tokio",
"tokio-util",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
@@ -805,14 +1120,17 @@ name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "hashlink"
version = "0.10.0"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
dependencies = [
"hashbrown 0.15.5",
"hashbrown 0.17.1",
]
[[package]]
@@ -828,37 +1146,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.4"
name = "html-escape"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest 0.10.7",
]
[[package]]
name = "home"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
dependencies = [
"windows-sys 0.61.2",
]
checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5"
[[package]]
name = "http"
version = "1.4.2"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
@@ -876,9 +1173,9 @@ dependencies = [
[[package]]
name = "http-body-util"
version = "0.1.4"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
dependencies = [
"bytes",
"futures-core",
@@ -907,18 +1204,31 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.13"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "hydration_context"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bbbeb23ee808258cef2c5585ff0dc8e41da21a8dde943f6b290da153a042a96"
dependencies = [
"futures",
"or_poisoned",
"pin-project-lite",
"serde",
"throw_error",
]
[[package]]
name = "hyper"
version = "1.11.0"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
dependencies = [
"atomic-waker",
"bytes",
@@ -938,9 +1248,9 @@ dependencies = [
[[package]]
name = "hyper-rustls"
version = "0.27.9"
version = "0.27.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
dependencies = [
"http",
"hyper",
@@ -949,21 +1259,21 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
dependencies = [
"base64",
"base64 0.23.1",
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"httparse",
"hyper",
"ipnet",
"libc",
@@ -977,9 +1287,9 @@ dependencies = [
[[package]]
name = "icu_collections"
version = "2.2.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
@@ -991,9 +1301,9 @@ dependencies = [
[[package]]
name = "icu_locale_core"
version = "2.2.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
@@ -1004,9 +1314,9 @@ dependencies = [
[[package]]
name = "icu_normalizer"
version = "2.2.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
dependencies = [
"icu_collections",
"icu_normalizer_data",
@@ -1018,16 +1328,17 @@ dependencies = [
[[package]]
name = "icu_normalizer_data"
version = "2.2.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
[[package]]
name = "icu_properties"
version = "2.2.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
dependencies = [
"displaydoc",
"icu_collections",
"icu_locale_core",
"icu_properties_data",
@@ -1038,15 +1349,15 @@ dependencies = [
[[package]]
name = "icu_properties_data"
version = "2.2.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
version = "2.2.0"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
@@ -1080,28 +1391,40 @@ dependencies = [
[[package]]
name = "indexmap"
version = "2.14.0"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
]
[[package]]
name = "inout"
version = "0.1.4"
name = "interpolator"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8"
[[package]]
name = "ipnet"
version = "2.12.0"
version = "2.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
@@ -1109,72 +1432,248 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys",
"log",
"simd_cesu8",
"thiserror 2.0.21",
"walkdir",
"windows-link",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.119",
]
[[package]]
name = "jobserver"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom 0.4.3",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.103"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "konst"
version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb"
dependencies = [
"konst_macro_rules",
]
[[package]]
name = "konst_macro_rules"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37"
[[package]]
name = "lazy_static"
version = "1.5.0"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]]
name = "leptos"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
checksum = "ae0b16d491eff5674a34a99bdba7cc9190c596c08dd6d99c89d01cd582f040b1"
dependencies = [
"spin",
"any_spawner",
"cfg-if",
"either_of",
"futures",
"getrandom 0.4.3",
"hydration_context",
"leptos_config",
"leptos_dom",
"leptos_hot_reload",
"leptos_macro",
"leptos_server",
"oco_ref",
"or_poisoned",
"paste",
"reactive_graph",
"rustc-hash",
"rustc_version",
"send_wrapper",
"serde",
"serde_json",
"serde_qs",
"server_fn",
"slotmap",
"tachys",
"thiserror 2.0.21",
"throw_error",
"typed-builder",
"typed-builder-macro",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm_split_helpers",
"web-sys",
]
[[package]]
name = "libc"
version = "0.2.189"
name = "leptos_config"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
checksum = "0c06f751315bccc0d193fab302ac01d25bcfcd97474d4676440e7e3250dc3fc3"
dependencies = [
"config",
"regex",
"serde",
"thiserror 2.0.21",
"typed-builder",
]
[[package]]
name = "leptos_dom"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35742e9ed8f8aaf9e549b454c68a7ac0992536e06856365639b111f72ab07884"
dependencies = [
"js-sys",
"or_poisoned",
"reactive_graph",
"send_wrapper",
"tachys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "libm"
version = "0.2.16"
name = "leptos_hot_reload"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
checksum = "9d2a0f220c8a5ef3c51199dfb9cdd702bc0eb80d52fbe70c7890adfaaae8a4b1"
dependencies = [
"anyhow",
"camino",
"indexmap",
"or_poisoned",
"proc-macro2",
"quote",
"rstml",
"serde",
"syn 2.0.119",
"walkdir",
]
[[package]]
name = "libredox"
version = "0.1.18"
name = "leptos_macro"
version = "0.8.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
checksum = "4cfefedc5b4bd150b2669b9d3169b5795008b1cca8147ef1a6591eda0d57c94c"
dependencies = [
"bitflags",
"libc",
"plain",
"redox_syscall 0.9.0",
"attribute-derive",
"cfg-if",
"convert_case 0.11.0",
"convert_case_extras",
"html-escape",
"itertools",
"leptos_hot_reload",
"prettyplease",
"proc-macro2",
"quote",
"rstml",
"rustc_version",
"server_fn_macro",
"syn 2.0.119",
"uuid",
]
[[package]]
name = "leptos_server"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202b2d1261c22d886f307934760a6bb566444f68f39b24f6f10defdc23633049"
dependencies = [
"any_spawner",
"base64 0.22.1",
"codee",
"futures",
"hydration_context",
"or_poisoned",
"reactive_graph",
"send_wrapper",
"serde",
"serde_json",
"server_fn",
"tachys",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "lock_api"
@@ -1183,45 +1682,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
"serde",
]
[[package]]
name = "log"
version = "0.4.33"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lru-slab"
version = "0.1.2"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
[[package]]
name = "matchers"
version = "0.2.0"
name = "manyhow"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587"
dependencies = [
"regex-automata",
"manyhow-macros",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "matchit"
version = "0.8.4"
name = "manyhow-macros"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495"
dependencies = [
"proc-macro-utils",
"proc-macro2",
"quote",
]
[[package]]
name = "md-5"
version = "0.10.6"
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest 0.10.7",
]
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
@@ -1246,20 +1748,16 @@ dependencies = [
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "mio"
version = "1.2.2"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"wasi",
@@ -1267,40 +1765,29 @@ dependencies = [
]
[[package]]
name = "no-std-compat"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
[[package]]
name = "nonzero_ext"
version = "0.3.0"
name = "next_tuple"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"windows-sys 0.61.2",
"memchr",
"minimal-lexical",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"lazy_static",
"libm",
"num-integer",
"num-iter",
"num-traits",
"rand 0.8.7",
"smallvec",
"zeroize",
]
[[package]]
@@ -1311,31 +1798,39 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.46"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"num-integer",
"num-traits",
"autocfg",
]
[[package]]
name = "num-traits"
version = "0.2.19"
name = "oco_ref"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
checksum = "ed0423ff9973dea4d6bd075934fdda86ebb8c05bdf9d6b0507067d4a1226371d"
dependencies = [
"autocfg",
"libm",
"serde",
"thiserror 2.0.21",
]
[[package]]
name = "oid-registry"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
dependencies = [
"asn1-rs",
]
[[package]]
@@ -1345,61 +1840,60 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "opaque-debug"
version = "0.3.1"
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "otproto"
version = "0.1.0"
name = "openssl"
version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [
"chacha20poly1305",
"hex",
"hkdf",
"proptest",
"serde",
"serde_json",
"sha2 0.10.9",
"thiserror 2.0.19",
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "otserver"
version = "0.1.0"
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"anyhow",
"argon2",
"axum",
"base64",
"chacha20poly1305",
"dashmap",
"governor",
"hex",
"hkdf",
"mime_guess",
"otproto",
"rand 0.9.5",
"reqwest",
"rust-embed",
"serde",
"serde_json",
"sha2 0.10.9",
"socket2",
"sqlx",
"tempfile",
"thiserror 2.0.19",
"time",
"tokio",
"toml",
"tower",
"tower-http",
"tower-sessions",
"tower-sessions-sqlx-store",
"tracing",
"tracing-subscriber",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "or_poisoned"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd"
[[package]]
name = "parking"
version = "2.2.1"
@@ -1424,98 +1918,93 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall 0.5.18",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
"getrandom 0.4.3",
"phc",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "percent-encoding"
version = "2.3.2"
name = "pastey"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pkcs1"
version = "0.7.5"
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
"der",
"pkcs8",
"spki",
]
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pkcs8"
version = "0.10.2"
name = "phc"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892"
dependencies = [
"der",
"spki",
"base64ct",
"ctutils",
"getrandom 0.4.3",
]
[[package]]
name = "pkg-config"
version = "0.3.33"
name = "pin-project"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "plain"
version = "0.2.3"
name = "pin-project-internal"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "poly1305"
version = "0.8.0"
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "portable-atomic"
version = "1.14.0"
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "potential_utf"
version = "0.1.5"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"zerovec",
]
@@ -1530,75 +2019,91 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.119",
]
[[package]]
name = "proc-macro-error-attr2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
dependencies = [
"zerocopy",
"proc-macro2",
"quote",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
name = "proc-macro-error2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
dependencies = [
"unicode-ident",
"proc-macro-error-attr2",
"proc-macro2",
"quote",
]
[[package]]
name = "proptest"
version = "1.11.0"
name = "proc-macro-utils"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071"
dependencies = [
"bit-set",
"bit-vec",
"bitflags",
"num-traits",
"rand 0.9.5",
"rand_chacha 0.9.0",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
"tempfile",
"unarray",
"proc-macro2",
"quote",
"smallvec",
]
[[package]]
name = "quanta"
version = "0.12.6"
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi",
"web-sys",
"winapi",
"unicode-ident",
]
[[package]]
name = "quick-error"
version = "1.2.3"
name = "proc-macro2-diagnostics"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"version_check",
"yansi",
]
[[package]]
name = "quinn"
version = "0.11.11"
version = "0.11.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
dependencies = [
"bytes",
"cfg_aliases",
"futures-io",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.19",
"thiserror 2.0.21",
"tokio",
"tracing",
"web-time",
@@ -1606,21 +2111,22 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.16"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand 0.10.3",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.19",
"thiserror 2.0.21",
"tinyvec",
"tracing",
"web-time",
@@ -1628,9 +2134,9 @@ dependencies = [
[[package]]
name = "quinn-udp"
version = "0.5.15"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
dependencies = [
"cfg_aliases",
"libc",
@@ -1649,6 +2155,28 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "quote-use"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e"
dependencies = [
"quote",
"quote-use-macros",
]
[[package]]
name = "quote-use-macros"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35"
dependencies = [
"proc-macro-utils",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "r-efi"
version = "5.3.0"
@@ -1661,48 +2189,27 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha 0.9.0",
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.2"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20 0.10.1",
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
@@ -1713,15 +2220,6 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
@@ -1747,21 +2245,56 @@ dependencies = [
]
[[package]]
name = "rand_xorshift"
version = "0.4.0"
name = "reactive_graph"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
checksum = "79f11fac67625645add76313dab8dae510fca0654d96ef98fd8f961a7d2e22c1"
dependencies = [
"rand_core 0.9.5",
"any_spawner",
"async-lock",
"futures",
"guardian",
"hydration_context",
"indexmap",
"or_poisoned",
"paste",
"pin-project-lite",
"rustc-hash",
"rustc_version",
"send_wrapper",
"serde",
"slotmap",
"thiserror 2.0.21",
"web-sys",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
name = "reactive_stores"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
checksum = "a29e469b556f25486678e9f5f65fa21a0351d98affde9a7eea9fee286bf8ebd6"
dependencies = [
"bitflags",
"guardian",
"indexmap",
"itertools",
"or_poisoned",
"paste",
"reactive_graph",
"reactive_stores_macro",
"rustc-hash",
"send_wrapper",
]
[[package]]
name = "reactive_stores_macro"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d11f1b82859d94577892b8037c35f8a4f066cbb3864583e22e68def0af49a88"
dependencies = [
"convert_case 0.11.0",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
@@ -1774,19 +2307,22 @@ dependencies = [
]
[[package]]
name = "redox_syscall"
version = "0.9.0"
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"bitflags",
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
@@ -1801,14 +2337,16 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
dependencies = [
"base64",
"base64 0.23.1",
"bytes",
"futures-core",
"h2",
"h3",
"h3-quinn",
"http",
"http-body",
"http-body-util",
@@ -1822,20 +2360,19 @@ dependencies = [
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-http 0.6.11",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
@@ -1853,77 +2390,43 @@ dependencies = [
]
[[package]]
name = "rmp"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
dependencies = [
"num-traits",
]
[[package]]
name = "rmp-serde"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
dependencies = [
"rmp",
"serde",
]
[[package]]
name = "rsa"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [
"const-oid 0.9.6",
"digest 0.10.7",
"num-bigint-dig",
"num-integer",
"num-traits",
"pkcs1",
"pkcs8",
"rand_core 0.6.4",
"signature",
"spki",
"subtle",
"zeroize",
]
[[package]]
name = "rust-embed"
version = "8.12.0"
name = "rsqlite-vfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
"hashbrown 0.16.1",
"thiserror 2.0.21",
]
[[package]]
name = "rust-embed-impl"
version = "8.12.0"
name = "rstml"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097"
checksum = "61cf4616de7499fc5164570d40ca4e1b24d231c6833a88bff0fe00725080fd56"
dependencies = [
"mime_guess",
"derive-where",
"proc-macro2",
"proc-macro2-diagnostics",
"quote",
"rust-embed-utils",
"syn 2.0.119",
"walkdir",
"syn_derive",
"thiserror 2.0.21",
]
[[package]]
name = "rust-embed-utils"
version = "8.12.0"
name = "rusqlite"
version = "0.40.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0"
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
dependencies = [
"sha2 0.11.0",
"walkdir",
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]]
@@ -1933,32 +2436,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustix"
version = "1.1.4"
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"semver",
]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom",
]
[[package]]
name = "rustls"
version = "0.23.42"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"aws-lc-rs",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
@@ -1969,12 +2489,40 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@@ -1986,18 +2534,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "rusty-fork"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
"fnv",
"quick-error",
"tempfile",
"wait-timeout",
]
[[package]]
name = "ryu"
version = "1.0.23"
@@ -2013,12 +2549,59 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "send_wrapper"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
dependencies = [
"futures-core",
]
[[package]]
name = "serde"
version = "1.0.229"
@@ -2029,6 +2612,16 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde_cbor_2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c"
dependencies = [
"half",
"serde",
]
[[package]]
name = "serde_core"
version = "1.0.229"
@@ -2046,7 +2639,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.6",
]
[[package]]
@@ -2074,35 +2667,114 @@ dependencies = [
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
name = "serde_qs"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352"
dependencies = [
"percent-encoding",
"serde",
"thiserror 2.0.21",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "server"
version = "0.1.0"
dependencies = [
"api",
"argon2",
"axum",
"clap",
"getrandom 0.4.3",
"hex",
"rusqlite",
"serde",
"serde_json",
"sha2 0.11.0",
"tokio",
"tower-http 0.7.1",
"uuid",
"webauthn-rs",
"webauthn-rs-proto",
]
[[package]]
name = "server_fn"
version = "0.8.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
checksum = "be8559dd05af1b5b7e363a150616589d5a88af5187273f7f331ba0dae8922812"
dependencies = [
"serde_core",
"base64 0.22.1",
"bytes",
"const-str",
"const_format",
"futures",
"gloo-net",
"http",
"js-sys",
"or_poisoned",
"pin-project-lite",
"rustc_version",
"rustversion",
"send_wrapper",
"serde",
"serde_json",
"serde_qs",
"server_fn_macro_default",
"thiserror 2.0.21",
"throw_error",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"xxhash-rust",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
name = "server_fn_macro"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
checksum = "b4ef7e82840fddcd17e15a62640b6d7d3b3a905e65b07854a903393db2aced76"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
"const_format",
"convert_case 0.11.0",
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.119",
"xxhash-rust",
]
[[package]]
name = "sha1"
version = "0.10.7"
name = "server_fn_macro_default"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
checksum = "63eb08f80db903d3c42f64e60ebb3875e0305be502bdc064ec0a0eab42207f00"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
"server_fn_macro",
"syn 2.0.119",
]
[[package]]
@@ -2123,19 +2795,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"cpufeatures 0.3.1",
"digest 0.11.3",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
version = "2.0.1"
@@ -2153,20 +2816,20 @@ dependencies = [
]
[[package]]
name = "signature"
version = "2.2.0"
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"digest 0.10.7",
"rand_core 0.6.4",
"rustc_version",
"simdutf8",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "slab"
@@ -2175,14 +2838,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
name = "slotmap"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
dependencies = [
"serde",
"version_check",
]
[[package]]
name = "smallvec"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
[[package]]
name = "socket2"
version = "0.6.5"
@@ -2194,223 +2863,15 @@ dependencies = [
]
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
dependencies = [
"lock_api",
]
[[package]]
name = "spinning_top"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
dependencies = [
"lock_api",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "sqlx"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
dependencies = [
"sqlx-core",
"sqlx-macros",
"sqlx-mysql",
"sqlx-postgres",
"sqlx-sqlite",
]
[[package]]
name = "sqlx-core"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [
"base64",
"bytes",
"crc",
"crossbeam-queue",
"either",
"event-listener",
"futures-core",
"futures-intrusive",
"futures-io",
"futures-util",
"hashbrown 0.15.5",
"hashlink",
"indexmap",
"log",
"memchr",
"once_cell",
"percent-encoding",
"serde",
"serde_json",
"sha2 0.10.9",
"smallvec",
"thiserror 2.0.19",
"time",
"tokio",
"tokio-stream",
"tracing",
"url",
]
[[package]]
name = "sqlx-macros"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
dependencies = [
"proc-macro2",
"quote",
"sqlx-core",
"sqlx-macros-core",
"syn 2.0.119",
]
[[package]]
name = "sqlx-macros-core"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
dependencies = [
"dotenvy",
"either",
"heck",
"hex",
"once_cell",
"proc-macro2",
"quote",
"serde",
"serde_json",
"sha2 0.10.9",
"sqlx-core",
"sqlx-mysql",
"sqlx-postgres",
"sqlx-sqlite",
"syn 2.0.119",
"tokio",
"url",
]
[[package]]
name = "sqlx-mysql"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64",
"bitflags",
"byteorder",
"bytes",
"crc",
"digest 0.10.7",
"dotenvy",
"either",
"futures-channel",
"futures-core",
"futures-io",
"futures-util",
"generic-array",
"hex",
"hkdf",
"hmac",
"itoa",
"log",
"md-5",
"memchr",
"once_cell",
"percent-encoding",
"rand 0.8.7",
"rsa",
"serde",
"sha1",
"sha2 0.10.9",
"smallvec",
"sqlx-core",
"stringprep",
"thiserror 2.0.19",
"time",
"tracing",
"whoami",
]
[[package]]
name = "sqlx-postgres"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64",
"bitflags",
"byteorder",
"crc",
"dotenvy",
"etcetera",
"futures-channel",
"futures-core",
"futures-util",
"hex",
"hkdf",
"hmac",
"home",
"itoa",
"log",
"md-5",
"memchr",
"once_cell",
"rand 0.8.7",
"serde",
"serde_json",
"sha2 0.10.9",
"smallvec",
"sqlx-core",
"stringprep",
"thiserror 2.0.19",
"time",
"tracing",
"whoami",
]
[[package]]
name = "sqlx-sqlite"
version = "0.8.6"
name = "sqlite-wasm-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"atoi",
"flume",
"futures-channel",
"futures-core",
"futures-executor",
"futures-intrusive",
"futures-util",
"libsqlite3-sys",
"log",
"percent-encoding",
"serde",
"serde_urlencoded",
"sqlx-core",
"thiserror 2.0.19",
"time",
"tracing",
"url",
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]]
@@ -2420,15 +2881,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stringprep"
version = "0.1.5"
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
@@ -2449,15 +2905,27 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.3"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn_derive"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb066a04799e45f5d582e8fc6ec8e6d6896040d00898eb4e6a835196815b219"
dependencies = [
"proc-macro-error2",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
@@ -2479,16 +2947,46 @@ dependencies = [
]
[[package]]
name = "tempfile"
version = "3.27.0"
name = "synstructure"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "tachys"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e47e2220bb9d2a3be976a22a0fb7685a0b84499bb11ea5cb3bbc82907675ac68"
dependencies = [
"any_spawner",
"async-trait",
"const_str_slice_concat",
"drain_filter_polyfill",
"either_of",
"erased",
"futures",
"html-escape",
"indexmap",
"itertools",
"js-sys",
"next_tuple",
"oco_ref",
"or_poisoned",
"paste",
"reactive_graph",
"reactive_stores",
"rustc-hash",
"rustc_version",
"send_wrapper",
"slotmap",
"throw_error",
"wasm-bindgen",
"web-sys",
]
[[package]]
@@ -2502,11 +3000,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.19"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl 2.0.19",
"thiserror-impl 2.0.21",
]
[[package]]
@@ -2522,29 +3020,29 @@ dependencies = [
[[package]]
name = "thiserror-impl"
version = "2.0.19"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.6",
]
[[package]]
name = "thread_local"
version = "1.1.10"
name = "throw_error"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
checksum = "dc0ed6038fcbc0795aca7c92963ddda636573b956679204e044492d2b13c8f64"
dependencies = [
"cfg-if",
"pin-project-lite",
]
[[package]]
name = "time"
version = "0.3.54"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
@@ -2572,9 +3070,9 @@ dependencies = [
[[package]]
name = "tinystr"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"zerovec",
@@ -2582,18 +3080,9 @@ dependencies = [
[[package]]
name = "tinyvec"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
@@ -2614,54 +3103,44 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.7.1"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.6",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"libc",
"pin-project-lite",
"tokio",
]
[[package]]
name = "toml"
version = "1.1.3+spec-1.1.0"
version = "1.1.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
dependencies = [
"indexmap",
"serde_core",
@@ -2713,28 +3192,29 @@ dependencies = [
]
[[package]]
name = "tower-cookies"
version = "0.11.0"
name = "tower-http"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"axum-core",
"cookie",
"bitflags",
"bytes",
"futures-util",
"http",
"parking_lot",
"http-body",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
"url",
]
[[package]]
name = "tower-http"
version = "0.6.11"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
dependencies = [
"async-compression",
"bitflags",
"bytes",
"futures-core",
@@ -2750,11 +3230,8 @@ dependencies = [
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
"tracing",
"url",
]
[[package]]
@@ -2769,71 +3246,6 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tower-sessions"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a05911f23e8fae446005fe9b7b97e66d95b6db589dc1c4d59f6a2d4d4927d3"
dependencies = [
"async-trait",
"http",
"time",
"tokio",
"tower-cookies",
"tower-layer",
"tower-service",
"tower-sessions-core",
"tower-sessions-memory-store",
"tracing",
]
[[package]]
name = "tower-sessions-core"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce8cce604865576b7751b7a6bc3058f754569a60d689328bb74c52b1d87e355b"
dependencies = [
"async-trait",
"axum-core",
"base64",
"futures",
"http",
"parking_lot",
"rand 0.8.7",
"serde",
"serde_json",
"thiserror 2.0.19",
"time",
"tokio",
"tracing",
]
[[package]]
name = "tower-sessions-memory-store"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb05909f2e1420135a831dd5df9f5596d69196d0a64c3499ca474c4bd3d33242"
dependencies = [
"async-trait",
"time",
"tokio",
"tower-sessions-core",
]
[[package]]
name = "tower-sessions-sqlx-store"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e054622079f57fc1a7d6a6089c9334f963d62028fe21dc9eddd58af9a78480b3"
dependencies = [
"async-trait",
"rmp-serde",
"sqlx",
"thiserror 1.0.69",
"time",
"tower-sessions-core",
]
[[package]]
name = "tracing"
version = "0.1.44"
@@ -2864,43 +3276,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
name = "typed-builder"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
"typed-builder-macro",
]
[[package]]
name = "try-lock"
version = "0.2.5"
name = "typed-builder-macro"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "typenum"
@@ -2908,54 +3310,29 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-ident"
version = "1.0.24"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-properties"
version = "0.1.4"
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "universal-hash"
version = "0.5.1"
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common 0.1.7",
"subtle",
]
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
@@ -2973,6 +3350,7 @@ dependencies = [
"idna",
"percent-encoding",
"serde",
"serde_derive",
]
[[package]]
@@ -2982,10 +3360,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "valuable"
version = "0.1.1"
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
dependencies = [
"getrandom 0.4.3",
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
@@ -2999,15 +3389,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -3042,17 +3423,11 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.126"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
"cfg-if",
"once_cell",
@@ -3063,19 +3438,20 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.76"
version = "0.4.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
dependencies = [
"js-sys",
"tokio",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.126"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -3083,31 +3459,82 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.126"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.6",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.126"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasm_split_helpers"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab578aae2fe2916edaea06843187d50f87b0965622da0ceef648edca27b385ba"
dependencies = [
"async-once-cell",
"wasm_split_macros",
]
[[package]]
name = "wasm_split_macros"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e653af7ee4a9ef0fce481a9ec6f43cb78de20d0cdb4f4f5862e1dc6e407e6c8"
dependencies = [
"base16",
"quote",
"sha2 0.10.9",
"syn 2.0.119",
]
[[package]]
name = "web"
version = "0.1.0"
dependencies = [
"api",
"console_error_panic_hook",
"gloo-net",
"js-sys",
"leptos",
"serde",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "web-sys"
version = "0.3.103"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -3124,39 +3551,81 @@ dependencies = [
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
name = "webauthn-attestation-ca"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
checksum = "6475c0bbd1a3f04afaa3e98880408c5be61680c5e6bd3c6f8c250990d5d3e18e"
dependencies = [
"rustls-pki-types",
"base64urlsafedata",
"openssl",
"openssl-sys",
"serde",
"tracing",
"uuid",
]
[[package]]
name = "webauthn-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c548915e0e92ee946bbf2aecf01ea21bef53d974b0793cc6732ba81a03fc422"
dependencies = [
"base64urlsafedata",
"serde",
"tracing",
"url",
"uuid",
"webauthn-rs-core",
]
[[package]]
name = "whoami"
version = "1.6.1"
name = "webauthn-rs-core"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
checksum = "296d2d501feb715d80b8e186fb88bab1073bca17f460303a1013d17b673bea6a"
dependencies = [
"libredox",
"wasite",
"base64 0.21.7",
"base64urlsafedata",
"der-parser",
"hex",
"nom",
"openssl",
"openssl-sys",
"rand 0.9.5",
"rand_chacha",
"serde",
"serde_cbor_2",
"serde_json",
"thiserror 1.0.69",
"tracing",
"url",
"uuid",
"webauthn-attestation-ca",
"webauthn-rs-proto",
"x509-parser",
]
[[package]]
name = "winapi"
version = "0.3.9"
name = "webauthn-rs-proto"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
checksum = "c37393beac9c1ed1ca6dbb30b1e01783fb316ab3a45d90ecd48c99052dd7ef1e"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
"base64 0.21.7",
"base64urlsafedata",
"serde",
"serde_json",
"url",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "winapi-util"
@@ -3167,34 +3636,19 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
"windows-targets",
]
[[package]]
@@ -3206,67 +3660,34 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -3279,48 +3700,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -3332,6 +3729,9 @@ name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
@@ -3341,9 +3741,38 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "writeable"
version = "0.6.3"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "x509-parser"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom",
"oid-registry",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]]
name = "xxhash-rust"
version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
[[package]]
name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "yoke"
@@ -3358,30 +3787,30 @@ dependencies = [
[[package]]
name = "yoke-derive"
version = "0.8.2"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
"syn 3.0.6",
"synstructure 0.14.0",
]
[[package]]
name = "zerocopy"
version = "0.8.55"
version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.55"
version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
dependencies = [
"proc-macro2",
"quote",
@@ -3399,14 +3828,14 @@ dependencies = [
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
"syn 3.0.6",
"synstructure 0.14.0",
]
[[package]]
@@ -3417,9 +3846,9 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.4"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
"yoke",
@@ -3428,9 +3857,9 @@ dependencies = [
[[package]]
name = "zerovec"
version = "0.11.6"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"yoke",
"zerofrom",
@@ -3439,13 +3868,13 @@ dependencies = [
[[package]]
name = "zerovec-derive"
version = "0.11.3"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.6",
]
[[package]]
MCargo.toml
@@ -1,25 +1,16 @@
[workspace]
members = ["crates/otproto", "crates/otserver"]
# cargo-fuzz builds its crates with their own nightly flags; keeping them out of
# the workspace stops a stable `cargo build` from trying to compile them.
exclude = ["crates/otproto/fuzz"]
members = ["crates/api", "crates/server", "crates/cli", "web"]
resolver = "3"
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.90"
license = "AGPL-3.0-or-later"
repository = "https://github.com/schulze/opentracker"
[workspace.dependencies]
otproto = { path = "crates/otproto" }
thiserror = "2"
api = { path = "crates/api" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
hex = "0.4"
[profile.release]
lto = "thin"
codegen-units = 1
strip = true
MContainerfile
@@ -1,46 +1,21 @@
# One image: the Rust binary with the web UI compiled into it.
#
# Named Containerfile, so podman finds it without -f. The just recipes pass -f
# anyway, because the docker CLI only looks for Dockerfile.
#
# `rust-embed` pulls web/dist into the binary in release mode, so the runtime
# stage carries no assets and no web server — just the one executable.
FROM oven/bun:1.4 AS web
WORKDIR /web
COPY web/package.json web/bun.lock ./
RUN bun install --frozen-lockfile
COPY web/ ./
RUN bun run build
FROM rust:1-slim-trixie AS server
FROM docker.io/library/rust:1-trixie AS build
ARG TRUNK_VERSION=0.21.14
RUN rustup target add wasm32-unknown-unknown \
&& curl -sSfL https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz \
| tar -xz -C /usr/local/bin
WORKDIR /src
# libsqlite3-sys is vendored, so the build needs a C toolchain but no dev headers.
RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev && rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/
COPY --from=web /web/dist/ web/dist/
RUN cargo build --release -p otserver
FROM debian:trixie-slim
# ca-certificates is not optional: the tile proxy fetches over HTTPS.
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 --home /data opentracker \
&& mkdir -p /data && chown opentracker /data
COPY --from=server /src/target/release/otserver /usr/local/bin/otserver
COPY . .
RUN cd web && trunk build --release
RUN cargo build --release -p server
USER opentracker
WORKDIR /data
ENV OT_HTTP_ADDR=0.0.0.0:7372 \
OT_UDP_ADDR=0.0.0.0:7373 \
OT_DB_PATH=/data/opentracker.db \
OT_CACHE_DIR=/data/cache
FROM docker.io/library/debian:trixie-slim
# webauthn-rs links OpenSSL.
RUN apt-get update && apt-get install -y --no-install-recommends libssl3t64 && rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 ot && mkdir /data && chown ot /data
COPY --from=build /src/target/release/otserver /usr/local/bin/
COPY --from=build /src/web/dist /srv/web
ENV OT_ADDR=0.0.0.0:8080 OT_DB=/data/ot.db OT_WEB_DIR=/srv/web
USER ot
VOLUME /data
# THE TRAP: 7373 is UDP and HTTP reverse proxies do not forward it. It needs its
# own published port and its own firewall rule, or every phone silently falls
# back to TLS-over-TCP and the whole point of the protocol is lost.
EXPOSE 7372/tcp 7373/udp
EXPOSE 8080
ENTRYPOINT ["otserver"]
AREADME.md
@@ -0,0 +1,102 @@
# opentracker
Self-hosted location sharing. Devices upload positions over HTTPS. A web map shows your position and the positions others share with you.
```
crates/api JSON types shared by server, CLI and web
crates/server otserver: axum + SQLite, serves the API and web/dist
crates/cli ot: test client (register a device, send points, simulate a walk)
web/ Leptos + Leaflet, built with Trunk
```
## Development
```sh
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 -- simulate --interval 2 --batch 5 48.137 11.575
cargo run -p cli -- people
```
## Configuration
Every flag can also be set by its environment variable. `otserver --help` lists them.
| Flag | Variable | Default | |
|---|---|---|---|
| `--addr` | `OT_ADDR` | `127.0.0.1:8080` | listen address |
| `--db` | `OT_DB` | `ot.db` | SQLite file |
| `--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. |
`--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.
- An `https://` URL marks the session cookie `Secure`.
- The web UI shows it in the device setup commands.
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.
## 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.
## Devices and sharing
- A person can upload from several devices at once. Each device keeps its own trail. The map shows one dot per person, at the newest position of any device.
- The web app counts as one device, "Web", in every browser.
- Removing a device deletes its points.
- 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.
- 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.
## Deployment
```sh
OT_PUBLIC_URL=https://track.example.com docker compose up -d
```
The container listens on `127.0.0.1:8080`. Put a TLS reverse proxy in front of it. A proxy with HTTP/3 is recommended: phones connect faster after sleeping, and the connection survives network changes. Caddy does HTTP/3 by default:
```
track.example.com {
reverse_proxy 127.0.0.1:8080
}
```
HTTP/3 needs UDP 443 open next to TCP 443. `ot --http3 people` prints `[HTTP/3.0]` when it works.
## API
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) |
| `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 | |
| `GET /healthz` | none | `ok` while the database answers |
| `GET /api/people` | session | you plus everyone who shares with you, with each visible device and its latest point |
| `GET /api/people/{id}/track?from=&to=&device=` | session | one device's points in a time range, at most 31 days |
| `GET/POST /api/devices`, `DELETE /api/devices/{id}` | session | POST creates a device token |
| `GET/POST /api/shares`, `DELETE /api/shares/{id}` | session | POST with an existing viewer replaces that share |
| `GET /api/usernames` | session | all other usernames, for the share form |
| `GET/POST /api/links`, `DELETE /api/links/{id}` | session | guest links |
| `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/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. |
Dandroid/app/build.gradle.kts-148
@@ -1,148 +0,0 @@
import java.util.Properties
// The only build file in the project. AGP 9 ships Kotlin built in, so there is
// no `org.jetbrains.kotlin.android` line; and at one module a root build file and
// a version catalog would both be pure ceremony. Versions are pinned exactly —
// no `+`, no version ranges — because reproducible release builds are a goal from
// day one.
//
// The Compose compiler plugin is *not* implied by AGP's built-in Kotlin, despite
// what the "built-in Kotlin" framing suggests: enabling `buildFeatures.compose`
// without it fails configuration outright. Its version must equal the KGP version
// AGP bundles — 2.2.10 for AGP 9.2.1. Verify after any AGP bump with:
// ./gradlew :app:buildEnvironment | grep kotlin-gradle-plugin
plugins {
id("com.android.application") version "9.2.1"
id("org.jetbrains.kotlin.plugin.compose") version "2.2.10"
}
android {
namespace = "net.lexcom.opentracker"
compileSdk = 36
defaultConfig {
applicationId = "net.lexcom.opentracker"
// 29 is the first API with the 3-arg startForeground() overload, which
// is what lets us run a location foreground service without any
// compat library.
minSdk = 29
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
buildFeatures {
compose = true
// Off by default since AGP 8; we read FLAG_DEBUGGABLE instead of
// generating a class for one boolean.
buildConfig = false
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
buildTypes {
debug {
// So a debug build can sit next to a release build on the same
// device. Referenced by the adb commands in the README.
applicationIdSuffix = ".debug"
}
release {
// No reflection anywhere in this app, and Compose/AndroidX ship
// consumer ProGuard rules, so minification can be turned on later
// without ever creating a proguard-rules.pro. Costs a few MB of APK.
isMinifyEnabled = false
signingConfig = signingConfigs.findByName("release")
}
}
signingConfigs {
// Release signing is configured only when the (gitignored) properties
// file exists, so a fresh clone can still build debug.
val props = rootProject.file("keystore.properties")
if (props.exists()) {
create("release") {
val p = Properties().apply { props.inputStream().use(::load) }
storeFile = rootProject.file(p.getProperty("storeFile"))
storePassword = p.getProperty("storePassword")
keyAlias = p.getProperty("keyAlias")
keyPassword = p.getProperty("keyPassword")
// v1 signatures are only needed below API 24. Schemes v2/v3 are
// enough at minSdk 29 and keep the APK's zip entries untouched.
enableV1Signing = false
enableV2Signing = true
enableV3Signing = true
}
}
}
lint {
disable += setOf(
// There is no res/values/strings.xml on purpose: this app is not
// localized, and UI strings live as Kotlin constants next to the
// code that uses them.
"HardcodedText",
"MissingDefaultResource",
// "a newer version is available" checks. Every version here is
// pinned deliberately, for reproducible builds; being told daily
// that a newer one exists is noise, and with warningsAsErrors it
// would break the build the moment Google publishes anything.
// Upgrades are a decision, not a lint finding.
"AndroidGradlePluginVersion",
"GradleDependency",
"NewerVersionAvailable",
"OldTargetApi",
// Suggests replacing android:allowBackup="false" with a
// dataExtractionRules XML resource. There is nothing to configure:
// this app's data directory holds the device's token key, and
// backup/transfer of it is exactly what must not happen. Following
// the advice would add a res/ file that says "back up nothing".
"DataExtractionRules",
)
// A lint failure should stop the build rather than scroll past.
warningsAsErrors = true
abortOnError = true
}
packaging {
resources.excludes += setOf(
"META-INF/{AL2.0,LGPL2.1}",
"DebugProbesKt.bin",
)
}
testOptions {
unitTests.isReturnDefaultValues = true
}
sourceSets["test"].resources.srcDir("../../crates/otproto/tests")
}
dependencies {
implementation(platform("androidx.compose:compose-bom:2026.06.01"))
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.13.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
// The map. FOSS, no Play Services, raster tiles, points straight at our own
// /tiles proxy. Hosted in Compose via AndroidView.
implementation("org.osmdroid:osmdroid-android:6.1.20")
// Everything non-trivial in this app (sampling policy, frame codec, queue,
// AEAD) is pure Kotlin and tested on the JVM. No androidTest/, no emulator
// in the loop.
// Spelled out rather than `kotlin("test")`: AGP's built-in Kotlin does not
// apply the Kotlin Gradle plugin's version-inferring `kotlin()` helper, so
// that form resolves to a versionless coordinate and silently contributes
// nothing. The `-junit` variant brings the JUnit 4 runner AGP's unit tests
// expect. Version must track the KGP that AGP bundles.
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:2.2.10")
// Test-only. The app itself parses its one JSON response (the login reply)
// with the framework's org.json, but unit tests run against android.jar
// stubs where those methods return defaults, so the golden-vector test needs
// a real parser. Never reaches the APK.
testImplementation("org.json:json:20260719")
}
Dandroid/app/src/debug/AndroidManifest.xml-24
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Debug-only overlay. The one thing it changes is cleartext HTTP.
The main manifest sets android:usesCleartextTraffic="false" on purpose, and
that stays true for every release build. But the emulator reaches a server
running on the developer's own machine as http://10.0.2.2:7372, and with the
flag off the platform blocks that connection outright, before any code runs.
There is no certificate to install and no hostname to except, so the flag has
to be flipped for the debug build.
tools:replace is required: manifest merger keeps the stricter value from the
main manifest otherwise, and reports a conflict rather than overriding it.
This cannot leak into a release: the merger only reads src/debug for the debug
build type, which also carries its own applicationIdSuffix.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:usesCleartextTraffic="true"
tools:replace="android:usesCleartextTraffic" />
</manifest>
Dandroid/app/src/main/AndroidManifest.xml-87
@@ -1,87 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The one file with no Gradle equivalent. Everything here is either a permission,
a component declaration, or an <application> attribute that replaces a whole
res/ file:
label replaces values/strings.xml
allowBackup=false replaces backup_rules.xml + data_extraction_rules.xml
usesCleartextTraffic replaces network_security_config.xml
allowBackup="false" is a deliberate security decision, not a shortcut: the data
directory holds this device's token key, and restoring it onto a second device
would silently clone a credential.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- COARSE must be requested alongside FINE: asking for FINE alone on
API 31+ can be silently downgraded to COARSE by the system dialog. -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- Mandatory from API 34: without it startForeground(TYPE_LOCATION) throws
SecurityException. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Deliberately NOT SCHEDULE_EXACT_ALARM: the watchdog uses inexact
setAndAllowWhileIdle, which needs no permission and is why the heartbeat
is 15 minutes rather than 5. -->
<uses-feature
android:name="android.hardware.location.gps"
android:required="false" />
<application
android:allowBackup="false"
android:icon="@android:drawable/ic_menu_mylocation"
android:label="opentracker"
android:supportsRtl="true"
android:theme="@style/Theme.OpenTracker"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- stopWithTask=false: swiping the app away must not stop sharing.
location has no FGS runtime timeout (unlike dataSync) and is exempt
from the API 35 restriction on services started from
BOOT_COMPLETED — which is exactly why it is the right type. -->
<service
android:name=".TrackerService"
android:exported="false"
android:foregroundServiceType="location"
android:stopWithTask="false" />
<!-- MY_PACKAGE_REPLACED covers `adb install -r`, which otherwise leaves
tracking silently stopped after every reinstall. -->
<receiver
android:name=".BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<receiver
android:name=".WatchdogReceiver"
android:exported="false" />
</application>
</manifest>
Dandroid/app/src/main/java/net/lexcom/opentracker/BootReceiver.kt-94
@@ -1,94 +0,0 @@
package net.lexcom.opentracker
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import net.lexcom.opentracker.store.Prefs
/**
* Restarts tracking after a reboot or an app update.
*
* `MY_PACKAGE_REPLACED` matters as much as `BOOT_COMPLETED`: it covers
* `adb install -r`, which otherwise leaves tracking silently stopped after every
* reinstall. Starting a `location` foreground service from here is explicitly
* legal — the API 35 restriction on boot-started foreground services covers
* `dataSync`, `camera`, `mediaPlayback`, `phoneCall`, `mediaProjection` and
* `microphone`, and not `location`.
*
* The decision itself lives in [shouldRestartTracking] so it can be tested on
* the JVM. Everything in this class is framework wiring around it.
*/
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// The manifest filter already narrows this, but a receiver is exported
// and anyone may send it an Intent with any action.
if (intent.action != Intent.ACTION_BOOT_COMPLETED &&
intent.action != Intent.ACTION_MY_PACKAGE_REPLACED
) {
return
}
val prefs = Prefs(context)
val enabled = prefs.isTrackingEnabled()
val hasCredentials = prefs.load() != null
val restart = shouldRestartTracking(
enabled = enabled,
hasCredentials = hasCredentials,
hasBackgroundLocation = PermissionGate.hasBackgroundLocation(context),
)
if (restart) {
Log.i(TAG, "resuming tracking after ${intent.action}")
Watchdog.arm(context)
TrackerService.start(context)
return
}
// Only worth a notification when the user actually wanted tracking on.
// Anything else is the normal state of an app nobody has logged into.
if (enabled && hasCredentials) {
Log.w(TAG, "not resuming tracking: background location is missing")
alert(context)
}
}
/** Same shape as `TrackerService.postAlert`: without the grant the framework
* drops `notify` anyway, so asking first saves a pointless call. */
private fun alert(context: Context) {
if (!PermissionGate.canPostNotifications(context)) return
Notifications.ensureChannels(context)
context.getSystemService(NotificationManager::class.java)
.notify(Notifications.ID_ALERT, Notifications.alert(context, ALERT_TITLE, ALERT_TEXT))
}
private companion object {
const val TAG = "OpenTracker"
const val ALERT_TITLE = "Tracking did not resume"
const val ALERT_TEXT =
"opentracker may only start sharing in the background with the " +
"\"Allow all the time\" location permission. Open opentracker " +
"and grant it, then start sharing again."
}
}
/**
* Whether a restart without the app on screen will actually produce positions.
*
* All three conditions must hold, and the third is the subtle one. A foreground
* service started while the app is not visible only receives fixes if
* `ACCESS_BACKGROUND_LOCATION` is granted. Starting without it yields a service
* that runs, holds a wake lock, drains the battery and reports nothing, which is
* worse for the user than not starting at all. See
* [PermissionGate.hasBackgroundLocation].
*
* Pure and `internal` so it can be tested on the JVM, where every framework
* getter returns a default.
*/
internal fun shouldRestartTracking(
enabled: Boolean,
hasCredentials: Boolean,
hasBackgroundLocation: Boolean,
): Boolean = enabled && hasCredentials && hasBackgroundLocation
Dandroid/app/src/main/java/net/lexcom/opentracker/MainActivity.kt-133
@@ -1,133 +0,0 @@
package net.lexcom.opentracker
import android.Manifest
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import net.lexcom.opentracker.store.Prefs
import net.lexcom.opentracker.ui.HomeScreen
import net.lexcom.opentracker.ui.LoginScreen
/**
* The app's single activity. It owns the two things only an Activity can do:
* launch the permission prompt, and decide which screen is on screen.
*
* Which screen that is comes from [Prefs]: no credentials means login, anything
* else means home. It is held as Compose state rather than re-read on every
* resume, so signing in and signing out swap the screen without an Activity
* restart and without a flash of the wrong one.
*
* There is no ViewModel. The only state that outlives this Activity is
* [TrackerState], which the service writes and which survives a rotation on its
* own because it is a process-wide object.
*/
class MainActivity : ComponentActivity() {
/** Recomputed after every launcher result so the home banner can update. */
private var missing by mutableStateOf(emptyList<String>())
private val permissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
// The result map is ignored on purpose. It reports the permissions
// that were asked for, and what the UI needs is what is still
// missing overall, which includes the ones this round never asked.
missing = PermissionGate.missing(this)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
missing = PermissionGate.missing(this)
// Captured once. Inside setContent every composable lambda brings its
// own receiver, so a bare `this` there means BoxScope, not the Activity.
val activity = this
setContent {
// One read per sign-in state change, not one per recomposition.
// Holding the Credentials rather than a boolean also gives
// HomeScreen its server address without a second load().
var credentials by remember { mutableStateOf(Prefs(activity).load()) }
// Local copy so the null check smart-casts; a delegated var cannot.
val current = credentials
MaterialTheme {
// fillMaxSize so the theme's background covers the whole window
// and not just the height of the content. safeDrawingPadding on
// the inner Box rather than on the Surface: from targetSdk 35 the
// window is edge to edge by default, so without it the title sits
// under the status bar clock, and padding the Surface instead
// would leave the bars unpainted.
Surface(Modifier.fillMaxSize()) {
Box(Modifier.safeDrawingPadding()) {
if (current == null) {
LoginScreen(onLoggedIn = { credentials = Prefs(activity).load() })
} else {
val state by TrackerState.state.collectAsState()
HomeScreen(
state = state,
missingPermissions = missing,
serverHost = "${current.udpHost}:${current.udpPort}",
onGrantPermissions = ::requestNextPermissions,
// The flag is what BootReceiver and
// WatchdogReceiver read to tell a user stop from
// a system kill. It is always written before the
// service call, because TrackerService.onDestroy
// reads it to decide whether to leave the
// watchdog alarm armed.
onStart = {
Prefs(activity).setTrackingEnabled(true)
TrackerService.start(activity)
},
onStop = {
Prefs(activity).setTrackingEnabled(false)
TrackerService.stop(activity)
},
onSignOut = {
// Stop first. Clearing the credentials under a
// running service leaves it sending with a token
// the user just gave up.
Prefs(activity).setTrackingEnabled(false)
TrackerService.stop(activity)
Prefs(activity).clear()
credentials = null
},
)
}
}
}
}
}
}
/**
* Requests the first group of missing permissions, never all of them.
*
* `ACCESS_BACKGROUND_LOCATION` must be asked for alone and only after
* foreground location is already granted. Android drops it silently when it
* arrives in the same request as foreground location, so the user is never
* prompted and the grant never appears. See [PermissionGate.missing], which
* puts it last for exactly this reason.
*
* The result callback recomputes the list, so a second tap on the same
* button asks for the next group.
*/
private fun requestNextPermissions() {
val pending = missing
if (pending.isEmpty()) return
// Everything before background location, or background location alone
// when it is all that is left.
val group = pending.takeWhile { it != Manifest.permission.ACCESS_BACKGROUND_LOCATION }
.ifEmpty { listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION) }
permissionLauncher.launch(group.toTypedArray())
}
}
Dandroid/app/src/main/java/net/lexcom/opentracker/Notifications.kt-101
@@ -1,101 +0,0 @@
package net.lexcom.opentracker
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
/**
* The app's two notification channels and the notifications posted on them.
*
* This file only builds. Nothing here posts, because the one notification that
* matters is handed to `startForeground()` as an object, not looked up after
* being posted.
*
* The split into two channels exists so the user can silence one without
* silencing the other. The tracking notification is a status line that stays up
* for the whole trip, so it must never make a sound while the user drives. A
* revoked token is the opposite: nothing works until the user acts, so it is
* allowed to interrupt.
*
* Strings are Kotlin constants because this app has no res/values/strings.xml
* and is not localized. See the lint block in build.gradle.kts.
*/
object Notifications {
const val CHANNEL_TRACKING = "tracking"
const val CHANNEL_ALERTS = "alerts"
const val ID_TRACKING = 1
const val ID_ALERT = 2
private const val TRACKING_NAME = "Location tracking"
private const val TRACKING_DESC =
"The permanent notice shown while your location is being shared. " +
"Silent by design. Turning it off does not stop tracking."
private const val ALERTS_NAME = "Alerts"
private const val ALERTS_DESC =
"Problems that need you, such as being signed out and having to log in again."
private const val TRACKING_TITLE = "opentracker"
/**
* Creates both channels. Safe to call on every service start: the framework
* treats creating an existing channel as a no-op, and it never resets an
* importance the user has changed by hand.
*/
fun ensureChannels(context: Context) {
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
// IMPORTANCE_LOW: visible in the shade, no sound, no heads-up.
NotificationChannel(CHANNEL_TRACKING, TRACKING_NAME, NotificationManager.IMPORTANCE_LOW)
.apply { description = TRACKING_DESC },
)
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ALERTS, ALERTS_NAME, NotificationManager.IMPORTANCE_DEFAULT)
.apply { description = ALERTS_DESC },
)
}
/**
* The ongoing foreground-service notification. [text] is the live status the
* service rewrites as it runs, for example the motion mode and queue depth.
*/
fun tracking(context: Context, text: String): Notification =
Notification.Builder(context, CHANNEL_TRACKING)
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
.setContentTitle(TRACKING_TITLE)
.setContentText(text)
.setContentIntent(openApp(context))
.setOngoing(true)
// The channel is already IMPORTANCE_LOW, so this is silent anyway.
// It matters because the user can raise the channel: then this keeps
// a status-text update from buzzing every few seconds.
// (setSilent() is NotificationCompat only; the framework builder has
// no equivalent that is not deprecated.)
.setOnlyAlertOnce(true)
.build()
/** A one-off the user can swipe away, for example "you were signed out". */
fun alert(context: Context, title: String, text: String): Notification =
Notification.Builder(context, CHANNEL_ALERTS)
.setSmallIcon(android.R.drawable.ic_dialog_alert)
.setContentTitle(title)
.setContentText(text)
.setStyle(Notification.BigTextStyle().bigText(text))
.setContentIntent(openApp(context))
.setAutoCancel(true)
.build()
// FLAG_IMMUTABLE is mandatory from API 31 and lint fails the build without
// it. We never fill this intent in later, so immutable is also correct.
private fun openApp(context: Context): PendingIntent =
PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
Dandroid/app/src/main/java/net/lexcom/opentracker/PermissionGate.kt-70
@@ -1,70 +0,0 @@
package net.lexcom.opentracker
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
/**
* Answers "may we do this yet?" for the three permissions that decide whether
* tracking works.
*
* This object only reads state. It shows no dialog and holds no Activity, so
* the service and any composable can call it. Requesting the permissions is
* MainActivity's job, because only an Activity can launch the system prompt.
*/
object PermissionGate {
/** The one the service cannot run without. No fix, nothing to send. */
fun hasLocation(context: Context): Boolean =
granted(context, Manifest.permission.ACCESS_FINE_LOCATION)
/**
* Granted separately from [hasLocation], and from API 30 only through a
* second trip into system settings. The distinction that confuses everyone:
* a foreground service started while the app is visible keeps getting fixes
* without this permission, even after the user leaves the app. What needs it
* is *starting* to track while the app is not visible, which is exactly what
* BootReceiver and WatchdogReceiver do.
*/
fun hasBackgroundLocation(context: Context): Boolean =
granted(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION)
/**
* Runtime-granted from API 33, implicitly granted below it.
*
* Denial is not fatal and the service must not treat it as such. The
* foreground service still starts and still tracks; its notification is
* simply never drawn. The user then has no way to see or stop tracking from
* the shade, which is a reason to ask again, not a reason to refuse to run.
*/
fun canPostNotifications(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
granted(context, Manifest.permission.POST_NOTIFICATIONS)
/**
* The permissions still missing, in the order they must be requested.
*
* The order is the point of this function. Android rejects the background
* location prompt outright until foreground location is granted, so asking
* for both at once loses the background one silently.
*/
fun missing(context: Context): List<String> = buildList {
if (!hasLocation(context)) {
// COARSE goes with FINE: asking for FINE alone lets the system
// dialog downgrade the grant to COARSE. See AndroidManifest.xml.
add(Manifest.permission.ACCESS_COARSE_LOCATION)
add(Manifest.permission.ACCESS_FINE_LOCATION)
}
if (!canPostNotifications(context)) {
add(Manifest.permission.POST_NOTIFICATIONS)
}
if (!hasBackgroundLocation(context)) {
add(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
}
}
// context.checkSelfPermission is API 23+, so at minSdk 29 ContextCompat
// would only add an import around the same call.
private fun granted(context: Context, permission: String): Boolean =
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
}
Dandroid/app/src/main/java/net/lexcom/opentracker/TrackerService.kt-486
@@ -1,486 +0,0 @@
package net.lexcom.opentracker
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ServiceInfo
import android.os.BatteryManager
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.IBinder
import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import net.lexcom.opentracker.loc.AospLocationSource
import net.lexcom.opentracker.loc.Fix
import net.lexcom.opentracker.loc.Motion
import net.lexcom.opentracker.loc.MotionDetector
import net.lexcom.opentracker.loc.Request
import net.lexcom.opentracker.loc.SamplingPolicy
import net.lexcom.opentracker.net.NetWatcher
import net.lexcom.opentracker.net.Round
import net.lexcom.opentracker.net.Transport
import net.lexcom.opentracker.net.UdpTransport
import net.lexcom.opentracker.net.Uplink
import net.lexcom.opentracker.queue.PointQueue
import net.lexcom.opentracker.store.Credentials
import net.lexcom.opentracker.store.Prefs
import net.lexcom.opentracker.wire.Point
import net.lexcom.opentracker.wire.PointFlags
import java.io.File
/**
* The `location` foreground service: the one thing that keeps this app alive.
*
* It owns no logic of its own. It wires the five pieces that already exist and
* are already tested on the JVM:
*
* ```text
* AospLocationSource -> SamplingPolicy -> PointQueue -> Uplink -> UdpTransport
* ```
*
* One [HandlerThread] named "tracker" owns all of it. [PointQueue] and [Uplink]
* are both documented as single-threaded and they share the peek/ack pairing, so
* they must run on the same thread. [AospLocationSource] is built with that
* thread's `Looper`, so fixes already arrive there. [NetWatcher] is the one
* callback that does not: it fires on a framework thread and is posted across.
*
* Every durable input comes from [Prefs], never from an Intent extra. The system
* recreates a `START_STICKY` service with a null Intent, so an extra would be
* lost exactly when the service is restarted after a low-memory kill.
*/
class TrackerService : Service() {
private lateinit var thread: HandlerThread
private lateinit var handler: Handler
private lateinit var queue: PointQueue
private lateinit var transport: Transport
private lateinit var uplink: Uplink
private lateinit var source: AospLocationSource
private lateinit var detector: MotionDetector
private lateinit var wakeLock: PowerManager.WakeLock
private var watcher: NetWatcher? = null
private val policy = SamplingPolicy()
private var running = false
/** Sent once after start and once after every network change, never per round. */
private var helloPending = true
private var battery = Battery(null, false)
private var batteryReadAtMs = Long.MIN_VALUE
private val pump = Runnable { round() }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Notifications.ensureChannels(this)
if (running) {
// A second start from the UI, the boot receiver or the watchdog.
// Everything is already wired; re-wiring would leak the old socket.
//
// It is not a no-op though: it kicks a round. That is what the
// watchdog alarm is for. scheduleIn uses postDelayed, which counts
// uptimeMillis and does not advance in deep sleep, so the 60 s idle
// pump can stall for a whole doze window. The alarm is the only
// thing that still fires, and this line is what it buys.
scheduleIn(0)
return START_STICKY
}
// Checked before startForeground on purpose. From API 34 the framework
// throws SecurityException if a `location` foreground service starts
// without the location grant, and a crash loop is worse than an alert.
// Reaching stopSelf without ever calling startForeground is safe: the
// five-second deadline from startForegroundService is cancelled when the
// service is destroyed.
if (!PermissionGate.hasLocation(this)) {
return refuse(startId, ALERT_NO_LOCATION)
}
val credentials = Prefs(this).load() ?: return refuse(startId, ALERT_NO_LOGIN)
startForeground(
Notifications.ID_TRACKING,
Notifications.tracking(this, STATUS_STARTING),
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION,
)
startTracking(credentials)
return START_STICKY
}
override fun onDestroy() {
// Only a user stop takes the heartbeat down with it. A low-memory kill
// also lands here, and there the alarm is the one thing that can bring
// tracking back, so it has to stay armed.
if (!Prefs(this).isTrackingEnabled()) Watchdog.cancel(this)
if (!running) return
running = false
TrackerState.reportStopped()
// Before anything else: a callback left registered outlives the service
// and goes on waking the process on every network change.
watcher?.stop()
watcher = null
handler.removeCallbacks(pump)
// Closing on the owning thread, because the queue and the socket are
// not thread-safe and a round may be in flight right now. quitSafely
// runs what is already queued, including this, and then stops.
handler.post {
// Disarmed on the tracker thread, which is the only thread that
// ever arms it. A trigger left registered outlives the service.
detector.disarm()
source.stop()
transport.close()
queue.close()
}
thread.quitSafely()
}
override fun onBind(intent: Intent?): IBinder? = null
/** Say what is missing, then stop. Not sticky: a restart would find the same
* missing thing and spin. */
private fun refuse(startId: Int, reason: String): Int {
Log.w(TAG, "cannot track: $reason")
// Clear the flag before stopping, so onDestroy takes the watchdog alarm
// down with it. Leaving it armed would wake the device every 15 minutes
// to refuse again, and re-post this alert each time. Nothing here fixes
// itself; the user has to grant something or log in, and both paths go
// through the Start button, which sets the flag again.
Prefs(this).setTrackingEnabled(false)
TrackerState.reportStopped()
postAlert(ALERT_TITLE_STOPPED, reason)
stopSelf(startId)
return START_NOT_STICKY
}
// -- wiring --------------------------------------------------------------
@SuppressLint("MissingPermission") // PermissionGate.hasLocation was checked above.
private fun startTracking(credentials: Credentials) {
thread = HandlerThread("tracker").apply { start() }
handler = Handler(thread.looper)
queue = PointQueue(File(filesDir, QUEUE_FILE), QUEUE_CAPACITY)
transport = UdpTransport(credentials.udpHost, credentials.udpPort)
// elapsedRealtime, not currentTimeMillis: this clock only paces backoff,
// and a wall clock that jumps backwards would stall the uplink for as
// long as the jump. A point's own timestamp comes from the fix.
uplink = Uplink(transport, queue, credentials, SystemClock::elapsedRealtime)
source = AospLocationSource(this, thread.looper)
detector = MotionDetector(this)
wakeLock = getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG)
// Armed here rather than in the UI, so a service the system restarts by
// itself also gets its heartbeat back.
Watchdog.arm(this)
running = true
// Before the first round, so the UI's start/stop button is right as soon
// as this returns. It matters after a low-memory kill: the system
// restarts the service in a fresh process where TrackerState is back at
// its defaults, and without this the button would offer to start a
// service that is already running until the first round reports in.
TrackerState.report(STATUS_STARTING, policy.motion, queue.size)
handler.post {
source.start(reconcileMotion(), ::onFix)
round()
}
watcher = NetWatcher(this).apply {
start {
handler.post {
// The backoff was earned by an outage that has just ended.
uplink.clearBackOff()
helloPending = true
scheduleIn(0)
}
}
}
}
// -- sampling ------------------------------------------------------------
/** Runs on the tracker thread: [AospLocationSource] was built with its looper. */
@SuppressLint("MissingPermission") // Same grant as startTracking; the service stops if it is lost.
private fun onFix(fix: Fix) {
val decision = policy.offer(fix, SystemClock.elapsedRealtime())
decision.keep?.let {
val point = withBattery(it)
queue.append(point)
TrackerState.reportFix(point)
scheduleIn(0)
}
if (decision.requestChanged) source.update(reconcileMotion())
}
/**
* Match the motion trigger to the mode, and answer what the source should
* actually be asked for.
*
* The trigger only earns its keep in STATIONARY, where GNSS is off and
* nothing else can notice a departure. In every faster mode GNSS is already
* running and its fixes are better evidence, so the trigger is cancelled.
*/
private fun reconcileMotion(): Request {
if (policy.motion != Motion.STATIONARY) {
detector.disarm()
return policy.request
}
if (detector.arm(::onMotion)) return policy.request
// No significant-motion sensor on this device. Nothing would ever wake
// the phone, so it would sit in STATIONARY with GNSS off forever and
// never report that it moved. Keep GNSS registered instead: the old,
// more expensive behaviour is the only acceptable way to degrade. A
// tracker that costs battery is a complaint; one that silently stops
// reporting movement is a failure.
Log.i(TAG, "no significant-motion sensor; keeping GNSS on while stationary")
return policy.request.copy(useGnss = true)
}
/**
* The motion trigger fired, on a sensor thread.
*
* Posted across at once: [SamplingPolicy], [PointQueue] and [Uplink] are all
* single-threaded and owned by the tracker thread.
*/
@SuppressLint("MissingPermission") // Same grant as startTracking.
private fun onMotion() {
handler.post {
if (policy.wake(SystemClock.elapsedRealtime())) source.update(reconcileMotion())
}
}
/**
* Fills the two fields [SamplingPolicy] deliberately leaves unset.
*
* The sticky `ACTION_BATTERY_CHANGED` Intent is readable with no receiver
* and no permission. It is still a binder round trip, so the answer is held
* for [BATTERY_TTL_MS]. A battery does not move a whole percent in a minute,
* and in VEHICLE mode a fix arrives every five seconds.
*/
private fun withBattery(point: Point): Point {
val now = SystemClock.elapsedRealtime()
if (now - batteryReadAtMs >= BATTERY_TTL_MS) {
val sticky = registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
battery = if (sticky == null) {
Battery(null, false)
} else {
batteryOf(
level = sticky.getIntExtra(BatteryManager.EXTRA_LEVEL, -1),
scale = sticky.getIntExtra(BatteryManager.EXTRA_SCALE, -1),
status = sticky.getIntExtra(BatteryManager.EXTRA_STATUS, -1),
)
}
batteryReadAtMs = now
}
return point.copy(
batPct = battery.pct,
flags = if (battery.charging) point.flags or PointFlags.CHARGING else point.flags,
)
}
// -- uplink --------------------------------------------------------------
/**
* One round, on the tracker thread.
*
* ponytail: `sendRound` blocks up to `Uplink.REPLY_TIMEOUT_MS` waiting for a
* reply, and location callbacks queue behind it for that long. The ceiling
* is two seconds of delayed appends, which costs nothing because a point's
* timestamp comes from the fix and not from when it was queued. Upgrade path
* if that ever stops being true: move the uplink onto a second thread and
* put a lock around the queue.
*
* The wake lock is what makes a round survive doze. A foreground service
* does not hold the CPU awake, and the wake lock the system grants a
* broadcast expires the moment `onReceive` returns, so without this the
* device can fall asleep halfway through a send and the reply never arrives.
*
* ponytail: the acquire has a timeout as a safety net, so no bug in here can
* pin the CPU on for the rest of the day. The ceiling is a round that
* legitimately takes longer than [WAKE_LOCK_MS], which then finishes with the
* device free to sleep again. At a two-second reply timeout that is 30x of
* headroom. Upgrade path if the uplink ever grows a long operation: pass the
* expected duration in instead of one constant.
*/
private fun round() {
val hello = helloPending
wakeLock.acquire(WAKE_LOCK_MS)
val result = try {
if (hello) {
uplink.hello(appVersionCode(), Build.VERSION.SDK_INT, firstLaunch = false)
} else {
uplink.sendRound()
}
} finally {
if (wakeLock.isHeld) wakeLock.release()
}
// BackOff means nothing was sent, so the announcement still owes a try.
// Any other outcome means it went out; it is best effort, not a retry loop.
if (hello && result !is Round.BackOff) helloPending = false
when (result) {
is Round.Acked -> {
updateNotification(STATUS_SHARING)
scheduleNext()
}
// Nothing queued. Sending again at once would just be a busy loop;
// the next kept fix schedules a round itself.
Round.Idle -> {
updateNotification(STATUS_SHARING)
scheduleIn(IDLE_MS)
}
// Silent data loss, so it gets a notification and not a log line.
// The server refused points it will never accept, and the usual
// cause is a device clock outside the server's +/-30-day window.
is Round.Dropped -> {
Log.w(TAG, "server rejected ${result.count} points as malformed")
postAlert(ALERT_TITLE_DROPPED, ALERT_DROPPED)
scheduleNext()
}
// The uplink already decided when it may speak again. Polling faster
// than it asked is how a saturated server stays saturated.
//
// The status text matters here. This notification is the only place
// the user can see whether sharing actually works, and leaving it
// reading "Starting" through an outage is how they find out too late.
is Round.NoReply -> {
updateNotification(STATUS_OFFLINE)
scheduleAt(result.retryAtMs)
}
is Round.BackOff -> scheduleAt(result.retryAtMs)
is Round.Throttled -> {
updateNotification(STATUS_BUSY)
scheduleAt(result.retryAtMs)
}
// The credentials on disk are worthless. Keeping them would leave a
// dead token key on the device, and retrying would only be refused.
is Round.TokenDead -> {
Log.w(TAG, "token is dead; clearing credentials and stopping")
Prefs(this).clear()
postAlert(ALERT_TITLE_STOPPED, ALERT_TOKEN_DEAD)
stopSelf()
}
}
}
/** More waiting means keep going; an empty queue means wait for a fix. */
private fun scheduleNext() = scheduleIn(if (queue.size > 0) 0 else IDLE_MS)
private fun scheduleAt(atMs: Long) = scheduleIn(atMs - SystemClock.elapsedRealtime())
private fun scheduleIn(delayMs: Long) {
if (!running) return
handler.removeCallbacks(pump)
handler.postDelayed(pump, delayMs.coerceIn(0, IDLE_MS))
}
// -- notifications -------------------------------------------------------
private fun updateNotification(state: String) {
val text = "$state - ${policy.motion.name.lowercase()} - ${queue.size} queued"
notifier().notify(Notifications.ID_TRACKING, Notifications.tracking(this, text))
// Same three facts, same moment, for the UI. Every path that changes what
// the user sees already goes through here, so there is nothing else to hook.
TrackerState.report(state, policy.motion, queue.size)
}
private fun postAlert(title: String, text: String) {
// Without the grant `notify` is dropped by the framework anyway. The
// service still runs; see PermissionGate.canPostNotifications.
if (!PermissionGate.canPostNotifications(this)) return
notifier().notify(Notifications.ID_ALERT, Notifications.alert(this, title, text))
}
private fun notifier(): NotificationManager =
getSystemService(NotificationManager::class.java)
/** `buildConfig = false`, so there is no generated `VERSION_CODE` to read. */
private fun appVersionCode(): Int =
packageManager.getPackageInfo(packageName, 0).longVersionCode.toInt()
companion object {
private const val TAG = "OpenTracker"
private const val QUEUE_FILE = "points.queue"
/**
* 8192 slots of 32 bytes: 256 KiB, preallocated once.
*
* VEHICLE samples every 5 s, so at most 720 points an hour, and the ring
* covers about 11 hours of continuous driving with no network. In
* STATIONARY the heartbeat keeps at most 12 points an hour, which is
* roughly four weeks. Both are far longer than any outage worth
* surviving, and 256 KiB is nothing next to the APK.
*/
private const val QUEUE_CAPACITY = 8192
/** Nothing to send. Long enough to be free, short enough to recover from
* a missed wakeup before the user notices. */
private const val IDLE_MS = 60_000L
private const val BATTERY_TTL_MS = 60_000L
/** Namespaced tag, as the platform asks for: it shows up in battery
* stats and in `dumpsys power`, where an unprefixed name is anonymous. */
private const val WAKE_LOCK_TAG = "opentracker:round"
/** Safety net only. A round is a datagram and a reply, so the real cost
* is milliseconds; see the ceiling noted on `round`. */
private const val WAKE_LOCK_MS = 60_000L
private const val STATUS_STARTING = "Starting"
private const val STATUS_SHARING = "Sharing"
private const val STATUS_OFFLINE = "Offline"
private const val STATUS_BUSY = "Server busy"
private const val ALERT_TITLE_STOPPED = "Tracking stopped"
private const val ALERT_TITLE_DROPPED = "Some positions were lost"
private const val ALERT_NO_LOGIN = "Not signed in. Open opentracker and log in."
private const val ALERT_NO_LOCATION =
"Location permission is missing. Open opentracker and grant it."
private const val ALERT_TOKEN_DEAD =
"This device was signed out. Open opentracker and log in again."
private const val ALERT_DROPPED =
"The server refused some positions and they were discarded. " +
"The usual cause is a wrong date or time on this phone."
/** One place that builds the Intent. BootReceiver, the watchdog and the
* UI all start the service, and three copies would drift apart. */
fun start(context: Context) {
context.startForegroundService(Intent(context, TrackerService::class.java))
}
fun stop(context: Context) {
context.stopService(Intent(context, TrackerService::class.java))
}
}
}
/** What the sticky battery Intent says, once it is no longer an Intent. */
internal data class Battery(val pct: Int?, val charging: Boolean)
/**
* The battery Intent's three extras as a percentage and a charging flag.
*
* Takes Ints and not an Intent so it can be tested on the JVM, where every
* framework getter returns a default. A missing or zero scale yields a null
* percentage, which the wire encodes as "unknown"; inventing 100 would put a
* wrong number in front of the user.
*/
internal fun batteryOf(level: Int, scale: Int, status: Int): Battery = Battery(
pct = if (level < 0 || scale <= 0) null else (level * 100 / scale).coerceIn(0, 100),
charging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL,
)
Dandroid/app/src/main/java/net/lexcom/opentracker/TrackerState.kt-59
@@ -1,59 +0,0 @@
package net.lexcom.opentracker
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import net.lexcom.opentracker.loc.Motion
import net.lexcom.opentracker.wire.Point
/**
* What the service is doing right now, for the UI to read.
*
* A process-wide singleton, not a binder. The UI and the service live in the
* same process, so a bound service would add a connection lifecycle, an
* interface and a reconnect path to move four fields across a boundary that is
* not there.
*
* This is display state only. Nothing here is durable and nothing reads it back
* to make a decision: the service's own state lives in [store.Prefs] and in the
* point queue. If the process dies, this resets to "not sharing", which is what
* a fresh UI should show anyway until the service reports again.
*
* Written from the tracker thread, read from the main thread. [MutableStateFlow]
* is safe for that.
*/
object TrackerState {
data class Snapshot(
val running: Boolean = false,
/** The same short string the ongoing notification shows. */
val status: String = STATUS_STOPPED,
val motion: Motion = Motion.STATIONARY,
val queued: Int = 0,
/** The last point actually kept for sending, or null since process start. */
val last: Point? = null,
)
const val STATUS_STOPPED = "Not sharing"
private val _state = MutableStateFlow(Snapshot())
val state: StateFlow<Snapshot> = _state.asStateFlow()
internal fun report(status: String, motion: Motion, queued: Int) {
_state.value = _state.value.copy(
running = true,
status = status,
motion = motion,
queued = queued,
)
}
/** The last kept point is left in place: it is still the best position known. */
internal fun reportStopped() {
_state.value = _state.value.copy(running = false, status = STATUS_STOPPED, queued = 0)
}
internal fun reportFix(point: Point) {
_state.value = _state.value.copy(last = point)
}
}
Dandroid/app/src/main/java/net/lexcom/opentracker/Watchdog.kt-68
@@ -1,68 +0,0 @@
package net.lexcom.opentracker
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.SystemClock
/**
* The heartbeat that unsticks the service after the device has slept.
*
* [TrackerService] paces itself with `Handler.postDelayed`, which counts
* `uptimeMillis`. That clock stops in deep sleep, so the 60 s idle pump can stall
* for the whole length of a doze window. An alarm is the only thing the system
* guarantees still fires, so it is what wakes the service back up.
*
* `setAndAllowWhileIdle` is inexact and needs no permission. See
* [WatchdogReceiver] for why the interval is 15 minutes and why
* `SCHEDULE_EXACT_ALARM` is not requested.
*/
object Watchdog {
/** Matches the "roughly every 15 minutes" the receiver's KDoc promises. */
private const val INTERVAL_MS = 15 * 60 * 1000L
/** Stable, so [arm] replaces its own alarm instead of stacking a new one. */
private const val REQUEST_CODE = 1
/**
* Schedules the next single wakeup, replacing any pending one.
*
* One shot and re-armed by the receiver, not `setRepeating`: a repeating
* alarm is inexact *and* does not fire in doze at all, which is exactly the
* case this exists for.
*
* ELAPSED_REALTIME_WAKEUP and not RTC: a wall-clock correction, a timezone
* change or an NTP jump must not move the heartbeat. WAKEUP because a
* heartbeat that waits for the user to pick up the phone is not one.
*/
fun arm(context: Context) {
alarmManager(context).setAndAllowWhileIdle(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + INTERVAL_MS,
pendingIntent(context),
)
}
/** Cancels the alarm and the PendingIntent, so nothing is left to fire. */
fun cancel(context: Context) {
val pending = pendingIntent(context)
alarmManager(context).cancel(pending)
pending.cancel()
}
private fun alarmManager(context: Context): AlarmManager =
context.getSystemService(AlarmManager::class.java)
// Explicit Intent: an implicit broadcast would be delivered to whoever else
// declares the action. FLAG_IMMUTABLE is mandatory from API 31 and correct
// here, because nothing ever fills this Intent in later.
private fun pendingIntent(context: Context): PendingIntent =
PendingIntent.getBroadcast(
context,
REQUEST_CODE,
Intent(context, WatchdogReceiver::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
Dandroid/app/src/main/java/net/lexcom/opentracker/WatchdogReceiver.kt-46
@@ -1,46 +0,0 @@
package net.lexcom.opentracker
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import net.lexcom.opentracker.store.Prefs
/**
* Target of an inexact `setAndAllowWhileIdle` alarm, roughly every 15 minutes:
* liveness check, queue flush, and the stationary heartbeat. Re-arms itself,
* because `setRepeating` is both inexact *and* does not fire in doze.
*
* 15 minutes rather than 5 because inexact alarms in doze fire at most about
* once per 9 minutes, and `setExactAndAllowWhileIdle` would require
* `SCHEDULE_EXACT_ALARM` from API 31 — a permission this app deliberately does
* not request.
*/
class WatchdogReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// The broadcast wake lock expires when onReceive returns, so any async
// work here must take its own PARTIAL_WAKE_LOCK first. TrackerService
// does exactly that around a round; nothing here runs after the return.
if (!Prefs(context).isTrackingEnabled()) {
// A leftover alarm from a session the user has since stopped. Left
// armed it would wake the device every 15 minutes forever.
Log.i(TAG, "watchdog fired but tracking is off; cancelling")
Watchdog.cancel(context)
return
}
// Re-arm first. If the start below throws, the next heartbeat is already
// scheduled and the watchdog recovers on its own.
Watchdog.arm(context)
// A start on an already-running service is not a no-op: it kicks a round.
// That is the whole point of this alarm, because the service's own timer
// does not advance while the device sleeps.
TrackerService.start(context)
}
private companion object {
const val TAG = "OpenTracker"
}
}
Dandroid/app/src/main/java/net/lexcom/opentracker/crypto/Sealer.kt-261
@@ -1,261 +0,0 @@
package net.lexcom.opentracker.crypto
import net.lexcom.opentracker.wire.HEADER_LEN
import net.lexcom.opentracker.wire.Header
import net.lexcom.opentracker.wire.MAX_DATAGRAM
import net.lexcom.opentracker.wire.Message
import net.lexcom.opentracker.wire.NONCE_LEN
import net.lexcom.opentracker.wire.TAG_LEN
import net.lexcom.opentracker.wire.WireFormatException
import net.lexcom.opentracker.wire.datagramLen
import java.security.GeneralSecurityException
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* ChaCha20-Poly1305 sealing and the HKDF key schedule.
*
* The point of this file is that it has **no dependencies**. Android's platform
* Conscrypt exposes `ChaCha20/Poly1305/NoPadding`, and Conscrypt is a Mainline
* module on Android 10+, so there is no Tink, no libsodium, no BouncyCastle, no
* JNI and no NDK anywhere in this build — which also means no per-ABI `.so`
* files and roughly 1.5 MB less APK.
*
* Two provider quirks are worth knowing:
* - The transform is spelled `ChaCha20/Poly1305/NoPadding` by Conscrypt but
* `ChaCha20-Poly1305` by the JDK's SunJCE (JEP 329), so JVM unit tests and
* the phone need different names. [transform] resolves whichever is present.
* - The nonce goes in an [IvParameterSpec], **not** a `GCMParameterSpec`.
* Passing the latter throws, and the error message does not say why.
*/
object Sealer {
const val KEY_LEN = 32
private val TRANSFORMS = listOf("ChaCha20/Poly1305/NoPadding", "ChaCha20-Poly1305")
/** Resolved once per process. */
val transform: String by lazy {
TRANSFORMS.firstOrNull { name ->
runCatching { Cipher.getInstance(name) }.isSuccess
} ?: throw GeneralSecurityException(
"no ChaCha20-Poly1305 provider; tried ${TRANSFORMS.joinToString()}",
)
}
val providerName: String by lazy { Cipher.getInstance(transform).provider.name }
private val rng = SecureRandom()
/** 12 fresh random bytes: an OTP/1 nonce, which is also a message id. */
fun newNonce(): ByteArray = ByteArray(NONCE_LEN).also(rng::nextBytes)
// -- key schedule --------------------------------------------------------
/**
* HKDF-Expand with SHA-256. Expand only, no extract: the token key is
* already 32 uniformly random bytes from the server's CSPRNG, so there is no
* entropy to condition.
*/
fun hkdfExpand(prk: ByteArray, info: ByteArray, length: Int): ByteArray {
require(length in 1..255 * 32) { "HKDF output length $length out of range" }
val mac = Mac.getInstance("HmacSHA256")
val out = ByteArray(length)
var previous = ByteArray(0)
var offset = 0
var counter = 1
while (offset < length) {
mac.init(SecretKeySpec(prk, "HmacSHA256"))
mac.update(previous)
mac.update(info)
mac.update(counter.toByte())
previous = mac.doFinal()
val take = minOf(previous.size, length - offset)
previous.copyInto(out, offset, 0, take)
offset += take
counter++
}
return out
}
/** `K_up = HKDF-Expand(token_key, "otp/1/up", 32)` — device to server. */
fun deriveUp(tokenKey: ByteArray): ByteArray =
hkdfExpand(tokenKey, "otp/1/up".toByteArray(), KEY_LEN)
/** `K_down = HKDF-Expand(token_key, "otp/1/down", 32)` — server to device. */
fun deriveDown(tokenKey: ByteArray): ByteArray =
hkdfExpand(tokenKey, "otp/1/down".toByteArray(), KEY_LEN)
/**
* `K_rev = HKDF-Expand(master, "otp/1/revoke" || token_id, 32)`.
*
* The app never derives this in production — the server issues it at login
* and it is stored alongside the token key. This exists so the golden vectors
* can prove the derivation matches, and so a test can construct a notice for
* a *different* token id and confirm it is rejected.
*/
fun deriveRevocation(master: ByteArray, tokenId: Long): ByteArray {
val info = ByteArray(12 + 8)
"otp/1/revoke".toByteArray().copyInto(info)
for (i in 0 until 8) {
info[12 + i] = (tokenId ushr (56 - 8 * i)).toByte()
}
return hkdfExpand(master, info, KEY_LEN)
}
// -- seal / open ---------------------------------------------------------
/**
* `header || ChaCha20Poly1305(key, nonce, payload, aad = header)`.
*
* The nonce comes from [header], so callers must have obtained it from
* [newNonce]. Reusing one under the same key is catastrophic for
* ChaCha20-Poly1305, which is why nothing here silently invents one.
*/
fun seal(key: ByteArray, header: Header, payload: ByteArray): ByteArray {
require(key.size == KEY_LEN) { "key must be $KEY_LEN bytes" }
val total = datagramLen(payload.size)
require(total <= MAX_DATAGRAM) { "datagram would be $total bytes, over the budget" }
val aad = header.toBytes()
val cipher = Cipher.getInstance(transform)
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "ChaCha20"), IvParameterSpec(header.nonce))
cipher.updateAAD(aad)
val sealed = cipher.doFinal(payload) // ciphertext || tag
val out = ByteArray(total)
aad.copyInto(out, 0)
sealed.copyInto(out, HEADER_LEN)
return out
}
fun sealMessage(key: ByteArray, tokenId: Long, nonce: ByteArray, msg: Message): ByteArray =
seal(key, Header(msg.type, tokenId, nonce), msg.encodePayload())
/**
* Verify and decrypt. Throws [GeneralSecurityException] on a bad tag.
*
* A device that cannot open a datagram simply drops it. Never answer one —
* a reply would turn the socket into a forgery oracle.
*/
fun open(key: ByteArray, datagram: ByteArray): Pair<Header, ByteArray> {
require(key.size == KEY_LEN) { "key must be $KEY_LEN bytes" }
val header = Header.peek(datagram)
if (datagram.size < HEADER_LEN + TAG_LEN) {
throw WireFormatException("datagram too short to hold a tag")
}
val cipher = Cipher.getInstance(transform)
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "ChaCha20"), IvParameterSpec(header.nonce))
cipher.updateAAD(datagram, 0, HEADER_LEN)
val payload = cipher.doFinal(datagram, HEADER_LEN, datagram.size - HEADER_LEN)
return header to payload
}
fun openMessage(key: ByteArray, datagram: ByteArray): Pair<Header, Message> {
val (header, payload) = open(key, datagram)
return header to Message.decodePayload(header.type, payload)
}
// -- self test -----------------------------------------------------------
data class SelfTestReport(
val ok: Boolean,
val transform: String?,
val provider: String?,
val detail: String,
) {
val summary: String
get() = buildString {
append(if (ok) "OK" else "FAILED")
if (transform != null) append("\ntransform: $transform")
if (provider != null) append("\nprovider: $provider")
append("\n")
append(detail)
}
}
/**
* Seals a known vector and checks it byte-for-byte, then opens it again.
*
* Cheap insurance against an OEM shipping a mangled provider set, and it
* turns "does API 29 really have ChaCha20-Poly1305?" into a fact visible in
* the log pane rather than a claim in a design document. The vector is the
* `loc_single` case from `crates/otproto/tests/vectors.json`; the full set is
* checked on the JVM by `VectorsTest`.
*/
fun selfTest(): SelfTestReport {
val tokenKey = hexToBytes("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val expectedUp = "52c8535360382dd1d2b9d4b5d605f7c46f8a69fd4b5d62dfd900ca10b8ac6196"
val expectedDatagram = "110123456789abcdef000102030405060708090a0bee7fe4db683bd4169d85" +
"b517d4e14fceed13336f3437fe90f2c162c7b9a8073cfefc686999c6fa2a83"
val t = runCatching { transform }.getOrElse { e ->
return SelfTestReport(false, null, null, "no provider: ${e.message}")
}
val p = runCatching { providerName }.getOrNull()
return try {
val kUp = deriveUp(tokenKey)
if (bytesToHex(kUp) != expectedUp) {
return SelfTestReport(false, t, p, "HKDF mismatch: got ${bytesToHex(kUp)}")
}
val msg = Message.Loc(
listOf(
net.lexcom.opentracker.wire.Point(
ts = 1_785_000_042L,
latE7 = 525_200_080,
lonE7 = 134_050_000,
accDm = 80,
altM = 34,
spdCms = 450,
brgCdeg = 21_400,
batPct = 76,
flags = net.lexcom.opentracker.wire.PointFlags.NETWORK_FIX,
),
),
)
val nonce = hexToBytes("000102030405060708090a0b")
val sealed = sealMessage(kUp, 0x0123456789abcdefL, nonce, msg)
if (bytesToHex(sealed) != expectedDatagram) {
return SelfTestReport(
false, t, p,
"datagram mismatch\n got ${bytesToHex(sealed)}\nwant $expectedDatagram",
)
}
val (header, decoded) = openMessage(kUp, sealed)
if (decoded != msg) {
return SelfTestReport(false, t, p, "round trip changed the message")
}
// A tampered tag must be rejected.
val tampered = sealed.copyOf().also { it[it.size - 1] = (it[it.size - 1].toInt() xor 1).toByte() }
val rejected = runCatching { open(kUp, tampered) }.isFailure
if (!rejected) {
return SelfTestReport(false, t, p, "tampered datagram was accepted")
}
SelfTestReport(
true, t, p,
"HKDF, seal, open and tamper detection all match the Rust vectors " +
"(token 0x${header.tokenId.toString(16)}, ${sealed.size} B datagram).",
)
} catch (e: GeneralSecurityException) {
SelfTestReport(false, t, p, "crypto error: $e")
}
}
internal fun hexToBytes(s: String): ByteArray {
require(s.length % 2 == 0) { "odd-length hex string" }
return ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
}
internal fun bytesToHex(b: ByteArray): String =
StringBuilder(b.size * 2).apply {
b.forEach { append("%02x".format(it)) }
}.toString()
}
Dandroid/app/src/main/java/net/lexcom/opentracker/loc/AospLocationSource.kt-144
@@ -1,144 +0,0 @@
package net.lexcom.opentracker.loc
import android.Manifest
import android.content.Context
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
import android.os.Looper
import android.util.Log
import androidx.annotation.RequiresPermission
/**
* The device implementation of [LocationSource], on plain
* `android.location.LocationManager`.
*
* No Play Services and no AndroidX location: this app must run on a de-Googled
* phone, and the fused provider is the one thing that would prevent it.
*
* The class is deliberately dumb. It hands every fix from every enabled
* provider to the callback and filters nothing. [SamplingPolicy] already
* decides what is accurate enough and what has moved far enough, and a second
* copy of that decision here would drift out of step with it.
*
* Not thread-safe. Every method must be called from the thread that owns
* [looper], which is also the thread the callback runs on.
*
* The caller guarantees `ACCESS_FINE_LOCATION` is granted. Step 9 owns the
* runtime permission gate.
*/
class AospLocationSource(context: Context, private val looper: Looper) : LocationSource {
private val manager = context.getSystemService(LocationManager::class.java)
private var onFix: ((Fix) -> Unit)? = null
private val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
// A reading with no usable coordinate is dropped rather than
// substituted. See toE7.
val latE7 = toE7(location.latitude, LAT_MAX_E7) ?: return
val lonE7 = toE7(location.longitude, LON_MAX_E7) ?: return
onFix?.invoke(
fixFrom(
tsMs = location.time,
latE7 = latE7,
lonE7 = lonE7,
accuracyM = if (location.hasAccuracy()) location.accuracy else null,
speedMps = if (location.hasSpeed()) location.speed else null,
altM = if (location.hasAltitude()) location.altitude.toFloat() else null,
bearingDeg = if (location.hasBearing()) location.bearing else null,
fromNetwork = location.provider == LocationManager.NETWORK_PROVIDER,
isMock = isMock(location),
),
)
}
// Spelled out because these three are only `default` methods from API
// 30. On API 29 the interface still declares them abstract, so a SAM
// lambda would crash the moment the system called one.
@Deprecated("Removed from the framework at API 29, still dispatched below it.")
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit
override fun onProviderEnabled(provider: String) = Unit
override fun onProviderDisabled(provider: String) = Unit
}
@RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION)
override fun start(request: Request, onFix: (Fix) -> Unit) {
this.onFix = onFix
register(request)
}
/** `LocationManager` cannot re-tune a live registration, so the only way to
* change interval or distance is to drop it and ask again. */
@RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION)
override fun update(request: Request) {
manager?.removeUpdates(listener)
register(request)
}
override fun stop() {
manager?.removeUpdates(listener)
onFix = null
}
@RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION)
private fun register(request: Request) {
val lm = manager
if (lm == null) {
Log.e(TAG, "no LocationManager; no fixes will be reported")
return
}
// In STATIONARY the policy clears useGnss and only the network provider
// is registered. GNSS on a parked phone is the whole battery bill of
// this app, and it buys nothing: the position is not changing. Network
// fixes are cheap and keep the heartbeat producing points, so the phone
// still reports "here, still" rather than going silent. What gets the
// GPS back is MotionDetector, which fires SamplingPolicy.wake.
for (provider in providersFor(request)) {
if (!lm.isProviderEnabled(provider)) continue
try {
lm.requestLocationUpdates(
provider,
request.intervalMs,
request.minDistanceM,
listener,
looper,
)
} catch (e: SecurityException) {
// The permission was revoked while the service ran. Rethrowing
// would kill the service; swallowing would leave a tracker that
// looks alive and reports nothing. So: log loudly and leave the
// source stopped. Step 9 owns asking for the grant again.
Log.e(TAG, "location permission denied; source stopped", e)
stop()
return
}
}
}
/** `isMock` only exists from API 31, and `isFromMockProvider()` is
* deprecated from the same level. minSdk is 29, so both are needed. */
@Suppress("DEPRECATION")
private fun isMock(location: Location): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
location.isMock
} else {
location.isFromMockProvider
}
private companion object {
const val TAG = "OpenTracker"
/** Both when GNSS is wanted: GPS is silent indoors and network is
* silent offline. Network alone otherwise. */
fun providersFor(request: Request): List<String> = if (request.useGnss) {
listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
} else {
listOf(LocationManager.NETWORK_PROVIDER)
}
}
}
Dandroid/app/src/main/java/net/lexcom/opentracker/loc/LocationSource.kt-93
@@ -1,93 +0,0 @@
package net.lexcom.opentracker.loc
import kotlin.math.roundToLong
/**
* Where fixes come from, and how one becomes a [Fix].
*
* The interface exists so the service can be driven by a fake on the JVM. The
* device implementation is `AospLocationSource`, and it is the only file in the
* app that touches `android.location`.
*
* The mapping below takes primitives, never an `android.location.Location`.
* Unit tests run with `isReturnDefaultValues = true`, where every framework
* getter answers 0, false or null, so a test built on a real Location would
* assert nothing. Keeping the arithmetic on primitives is what makes it
* testable.
*/
/** Widest coordinate the wire accepts. Mirrors `LAT_MAX_E7` / `LON_MAX_E7` in
* `crates/otproto/src/point.rs`, which rejects a point outside them. */
const val LAT_MAX_E7 = 900_000_000
const val LON_MAX_E7 = 1_800_000_000
/**
* Accuracy assumed when the provider makes no accuracy claim, in metres.
*
* Deliberately above [ACCURACY_CEILING_M]. A fix that does not say how good it
* is has not earned trust, so the policy treats it as coarse and flags it
* `LOW_ACCURACY`. It is not lost: the staleness bypass still accepts it once
* nothing better has arrived for [STALENESS_TIMEOUT_MS]. Roughly the spread of
* a cell-tower estimate, which is what such a fix usually is.
*/
const val ACCURACY_UNKNOWN_M = 500f
interface LocationSource {
/** Begin delivering fixes. [onFix] is called on the source's callback thread. */
fun start(request: Request, onFix: (Fix) -> Unit)
/** Apply new sampling parameters, keeping the same callback. */
fun update(request: Request)
fun stop()
}
/**
* One provider reading turned into a [Fix].
*
* Every optional argument is null when the device did not measure it, which is
* what [net.lexcom.opentracker.wire.Point] encodes as the field's sentinel.
*/
fun fixFrom(
tsMs: Long,
latE7: Int,
lonE7: Int,
accuracyM: Float?,
speedMps: Float?,
altM: Float?,
bearingDeg: Float?,
fromNetwork: Boolean,
isMock: Boolean,
): Fix = Fix(
tsMs = tsMs,
latE7 = latE7,
lonE7 = lonE7,
accM = accuracyM ?: ACCURACY_UNKNOWN_M,
speedMps = speedMps,
altM = altM,
bearingDeg = bearingDeg,
fromNetwork = fromNetwork,
isMock = isMock,
)
/**
* Degrees to the wire's 1e-7 fixed point, clamped to [maxE7]. Null when the
* value is not a number.
*
* Callers pass [LAT_MAX_E7] or [LON_MAX_E7]. The bound is an argument because
* the two axes differ by a factor of two, and a latitude clamped at the
* longitude bound would let a broken provider push a point past the pole.
*
* Clamping, not wrapping: a coordinate out of range means the provider is
* wrong, and a wrap would turn that into a plausible position somewhere else.
*
* NaN gets no coordinate at all. A mock provider can inject one, and any
* substitute value is a position the phone was never at. Zero would be the
* worst of them, because it reads as a real place in the Atlantic. The caller
* drops the reading instead.
*/
fun toE7(deg: Double, maxE7: Int): Int? {
if (deg.isNaN()) return null
val scaled = (deg * 1e7).roundToLong()
return scaled.coerceIn(-maxE7.toLong(), maxE7.toLong()).toInt()
}
Dandroid/app/src/main/java/net/lexcom/opentracker/loc/MotionDetector.kt-73
@@ -1,73 +0,0 @@
package net.lexcom.opentracker.loc
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorManager
import android.hardware.TriggerEvent
import android.hardware.TriggerEventListener
/**
* The hardware significant-motion trigger, wrapped.
*
* This is what makes dropping GNSS in STATIONARY safe. With no GPS registered
* nothing can notice a departure, so something else has to, and this sensor is
* the cheapest thing on the phone that can: it runs in the sensor hub, not on
* the CPU, and it fires when the device has plausibly changed location. Then
* [SamplingPolicy.wake] promotes the mode and GNSS comes back.
*
* The sensor is one-shot. The framework cancels the request as it delivers the
* event, so a handler that wants another trigger must call [arm] again. That is
* the single easiest thing to get wrong here, and getting it wrong means the
* phone wakes once and then never again.
*
* The callback arrives on a sensor thread, not the caller's. Everything this
* app keeps state in is single-threaded, so the handler has to post its work
* onto the thread that owns that state.
*
* No permission is required for this sensor.
*
* ponytail: one sensor and nothing else. The ceiling is what the vendor's
* implementation decides is significant. A phone that slides across a car seat
* can fire it, and a slow drift on a boat or a train may not fire it at all, in
* which case the departure is noticed by the next network fix instead of at
* once. Upgrade path if that shows up in real traces: add TYPE_STEP_DETECTOR as
* a second trigger, or sample the accelerometer on the heartbeat.
*/
class MotionDetector(context: Context) {
private val manager = context.getSystemService(SensorManager::class.java)
private val sensor = manager?.getDefaultSensor(Sensor.TYPE_SIGNIFICANT_MOTION)
private var listener: TriggerEventListener? = null
/**
* Request one trigger. Returns false when this device has no such sensor,
* which the caller must handle: there is then nothing that can ever wake
* the phone.
*
* Arming twice is harmless. The old request is cancelled first, so only one
* is ever outstanding.
*/
fun arm(onMotion: () -> Unit): Boolean {
val sm = manager ?: return false
val s = sensor ?: return false
disarm()
// TriggerEventListener is an abstract class, not an interface, so it
// cannot be a SAM lambda.
val l = object : TriggerEventListener() {
override fun onTrigger(event: TriggerEvent?) {
// The request is already cancelled by now. Forget it here too,
// so a later disarm does not try to cancel a dead one.
listener = null
onMotion()
}
}
listener = l
return sm.requestTriggerSensor(l, s)
}
fun disarm() {
val l = listener ?: return
listener = null
manager?.cancelTriggerSensor(l, sensor)
}
}
Dandroid/app/src/main/java/net/lexcom/opentracker/loc/SamplingPolicy.kt-308
@@ -1,308 +0,0 @@
package net.lexcom.opentracker.loc
import net.lexcom.opentracker.wire.Point
import net.lexcom.opentracker.wire.PointFlags
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.roundToInt
import kotlin.math.roundToLong
/**
* How often the app asks for a fix, and which fixes are worth sending.
*
* Battery is the product risk of a location tracker. A phone that samples GNSS
* every second draws more than everything else the user runs, and the app gets
* uninstalled long before anyone complains about the map. So the policy spends
* power in proportion to how fast the position is actually changing.
*
* The mode ladder, fastest first:
*
* ```text
* VEHICLE driving; position changes every second, the trail must look
* like a road, so short interval and small min distance
* WALK moving on foot; a fix every half minute still draws a usable line
* DWELL was moving, is not moving now; a grace state, not a conclusion
* STATIONARY parked, asleep, at a desk; the state the phone is in most of the
* day, and therefore the one that decides the battery bill
* ```
*
* Transitions are deliberately asymmetric. Going faster happens on the first
* fix that shows it, because a missed departure loses the start of a trip and
* that hole cannot be filled in later. Going slower waits for
* [DWELL_TIMEOUT_MS], because a red light, a shop queue or a platform wait is
* not the end of a journey. DWELL is where that wait is served: stopping drops
* into DWELL at once, and only continued stillness turns it into STATIONARY.
*
* Pure Kotlin on purpose: no Android types, no clock, no I/O. The caller passes
* `nowMs` and maps `android.location.Location` onto [Fix]. That is what lets
* this run on the JVM with no emulator.
*/
/** Ceiling for a fix worth keeping, in metres. Coarser than this draws a trail
* that wanders through neighbours' gardens. */
const val ACCURACY_CEILING_M = 75f
/** After this long with nothing kept, a coarse fix is accepted anyway and
* flagged. Reporting a vague position beats reporting none because the sky is
* cloudy or the user is indoors. */
const val STALENESS_TIMEOUT_MS = 4L * 60 * 1000
/** After this long with nothing kept, the displacement gate is bypassed. A
* parked phone must still report, so the web UI can tell "here, still" from
* "gone". */
const val HEARTBEAT_MS = 15L * 60 * 1000
/** Roughly 25 km/h. Above this the user is in a vehicle, not jogging. */
const val VEHICLE_SPEED_MPS = 7f
/** Below this, GNSS speed noise on a still phone is indistinguishable from
* slow walking, so anything under it counts as not moving. */
const val WALK_SPEED_MPS = 0.7f
/** How long a slower observation must hold before the mode actually drops.
* Longer than a traffic light, shorter than a coffee. */
const val DWELL_TIMEOUT_MS = 3L * 60 * 1000
private const val VEHICLE_INTERVAL_MS = 5_000L
private const val WALK_INTERVAL_MS = 30_000L
private const val DWELL_INTERVAL_MS = 60_000L
private const val STATIONARY_INTERVAL_MS = 5L * 60 * 1000
private const val VEHICLE_MIN_DISTANCE_M = 25f
private const val WALK_MIN_DISTANCE_M = 15f
private const val DWELL_MIN_DISTANCE_M = 30f
private const val STATIONARY_MIN_DISTANCE_M = 100f
private const val EARTH_RADIUS_M = 6_371_000.0
/** Widest gap between two fixes that still yields a usable derived speed.
* Over a longer gap the straight line between them says nothing about how
* fast the phone was moving.
*
* It has to stay above [STATIONARY_INTERVAL_MS]. A device that reports no
* speed at all only ever sees gaps of one sampling interval, so a shorter
* limit would leave it unable to derive anything in the slowest mode, and it
* would stay STATIONARY for the rest of the day no matter where it went.
*
* A straight line understates a winding path, so a derived speed is a lower
* bound. It can delay a mode change by one fix. It cannot invent one. */
private const val MAX_DERIVE_GAP_MS = 10L * 60 * 1000
/** Declaration order is the ladder: a larger ordinal is a faster mode. */
enum class Motion { STATIONARY, DWELL, WALK, VEHICLE }
/**
* One candidate fix, stripped of Android.
*
* Optional fields are null when the device could not measure them, which is
* exactly what [Point] encodes as the wire sentinel.
*/
data class Fix(
val tsMs: Long,
val latE7: Int,
val lonE7: Int,
val accM: Float,
val speedMps: Float?,
val altM: Float?,
val bearingDeg: Float?,
val fromNetwork: Boolean,
val isMock: Boolean,
)
/**
* What the caller should ask the OS location API for.
*
* [useGnss] is false in STATIONARY only. GNSS is the expensive part of this app
* and a parked phone gains nothing from it, so the source drops the GPS provider
* and a hardware motion trigger calls [SamplingPolicy.wake] when the phone is
* worth watching again. The network provider stays registered in every mode: it
* is cheap, and it is what keeps the heartbeat producing points while GNSS is
* off.
*/
data class Request(val intervalMs: Long, val minDistanceM: Float, val useGnss: Boolean)
/**
* The answer to one [SamplingPolicy.offer].
*
* [keep] is null when the fix is discarded. [motion] and [requestChanged] are
* reported either way, because a discarded fix can still move the mode.
*/
data class Decision(val keep: Point?, val motion: Motion, val requestChanged: Boolean)
class SamplingPolicy {
/** Nothing is known at start, so assume the cheap mode. The first moving
* fix corrects it immediately. */
var motion: Motion = Motion.STATIONARY
private set
val request: Request get() = requestFor(motion)
private var lastKeptMs: Long? = null
private var lastKeptLatE7: Int = 0
private var lastKeptLonE7: Int = 0
private var lastSeen: Fix? = null
/** When the current mode first looked too fast for what the fixes show. */
private var slowerSince: Long? = null
fun offer(fix: Fix, nowMs: Long): Decision {
val before = request
val stale = elapsedSinceKeep(nowMs) >= STALENESS_TIMEOUT_MS
if (fix.accM > ACCURACY_CEILING_M && !stale) {
// Too vague to trust, and something better arrived recently enough.
return Decision(null, motion, false)
}
val lowAccuracy = fix.accM > ACCURACY_CEILING_M
// Only fixes that got this far feed the state machine: a fix too vague
// for the trail is also too vague to judge movement by.
updateMotion(speedOf(fix), nowMs)
lastSeen = fix
val after = request
val changed = after != before
val moved = lastKeptMs == null ||
distanceM(lastKeptLatE7, lastKeptLonE7, fix.latE7, fix.lonE7) >= after.minDistanceM
if (!moved && elapsedSinceKeep(nowMs) < HEARTBEAT_MS) {
return Decision(null, motion, changed)
}
lastKeptMs = nowMs
lastKeptLatE7 = fix.latE7
lastKeptLonE7 = fix.lonE7
return Decision(toPoint(fix, lowAccuracy), motion, changed)
}
/**
* The hardware motion trigger says the phone moved. Returns true when the
* request changed and the caller must re-register the location source.
*
* In STATIONARY there is no GNSS running, so no fix can report the
* departure and this is the only thing that can. It promotes to DWELL and
* not to WALK: the trigger says something moved, not what, and DWELL is the
* grace state this class already uses for exactly that uncertainty. A
* pocket, a passing lorry or a slammed door fires the same sensor.
*
* A false trigger therefore costs one [DWELL_TIMEOUT_MS] of GNSS and
* nothing else. The normal slow-down path sees the still fixes and drops
* back to STATIONARY on its own, so no separate timeout is needed here.
*
* In any faster mode this is a no-op. GNSS is already registered there and
* the fixes are better evidence than the sensor.
*/
fun wake(nowMs: Long): Boolean {
if (motion != Motion.STATIONARY) return false
val before = request
motion = Motion.DWELL
// The dwell timer has to start now. A stale slowerSince from an earlier
// slow-down would expire immediately and undo the promotion on the next
// fix.
slowerSince = nowMs
return request != before
}
/** Long.MAX_VALUE before the first keep, so a cold start reports at once. */
private fun elapsedSinceKeep(nowMs: Long): Long =
lastKeptMs?.let { nowMs - it } ?: Long.MAX_VALUE
/**
* Ground speed in m/s, or null when it cannot be told.
*
* Network fixes often carry no speed at all, and without a fallback such a
* device would never leave STATIONARY. Two positions and their timestamps
* are enough of an answer.
*/
private fun speedOf(fix: Fix): Float? {
fix.speedMps?.let { return it }
val prev = lastSeen ?: return null
val dtMs = fix.tsMs - prev.tsMs
if (dtMs <= 0 || dtMs > MAX_DERIVE_GAP_MS) return null
val d = distanceM(prev.latE7, prev.lonE7, fix.latE7, fix.lonE7)
return (d / (dtMs / 1000.0)).toFloat()
}
private fun updateMotion(speedMps: Float?, nowMs: Long) {
val observed = when {
speedMps == null -> return // no evidence either way: leave the mode alone
speedMps >= VEHICLE_SPEED_MPS -> Motion.VEHICLE
speedMps >= WALK_SPEED_MPS -> Motion.WALK
else -> Motion.STATIONARY
}
if (observed.ordinal >= motion.ordinal) {
// Faster, or unchanged. Faster is never delayed: the start of a
// trip is the part a coarse trail misses worst.
motion = observed
slowerSince = null
return
}
if (observed == Motion.STATIONARY && motion.ordinal > Motion.DWELL.ordinal) {
// Stopping enters the grace state at once. It is not yet a claim
// that the journey ended.
motion = Motion.DWELL
slowerSince = nowMs
return
}
val since = slowerSince ?: nowMs.also { slowerSince = it }
if (nowMs - since >= DWELL_TIMEOUT_MS) {
motion = observed
slowerSince = null
}
}
private fun toPoint(fix: Fix, lowAccuracy: Boolean): Point {
var flags = PointFlags.NONE
if (lowAccuracy) flags = flags or PointFlags.LOW_ACCURACY
if (fix.fromNetwork) flags = flags or PointFlags.NETWORK_FIX
if (fix.isMock) flags = flags or PointFlags.MOCK
// CHARGING and batPct are the service's business, not the policy's:
// this class never touches a BatteryManager. Step 9 fills them in.
return Point(
ts = (fix.tsMs / 1000).coerceIn(0L, 0xFFFF_FFFFL),
latE7 = fix.latE7,
lonE7 = fix.lonE7,
accDm = (fix.accM * 10f).roundToInt().coerceAtLeast(0),
altM = fix.altM?.roundToInt(),
spdCms = fix.speedMps?.let { (it * 100f).roundToInt().coerceAtLeast(0) },
brgCdeg = fix.bearingDeg?.let { normalizedCdeg(it) },
flags = flags,
)
}
}
private fun requestFor(motion: Motion): Request {
val gnss = motion != Motion.STATIONARY
return when (motion) {
Motion.VEHICLE -> Request(VEHICLE_INTERVAL_MS, VEHICLE_MIN_DISTANCE_M, gnss)
Motion.WALK -> Request(WALK_INTERVAL_MS, WALK_MIN_DISTANCE_M, gnss)
Motion.DWELL -> Request(DWELL_INTERVAL_MS, DWELL_MIN_DISTANCE_M, gnss)
Motion.STATIONARY -> Request(STATIONARY_INTERVAL_MS, STATIONARY_MIN_DISTANCE_M, gnss)
}
}
/** Bearings arrive as 0..360 but a negative or a 360 must not become 36000,
* which the wire reads as out of range. */
private fun normalizedCdeg(deg: Float): Int {
val wrapped = ((deg % 360f) + 360f) % 360f
return (wrapped * 100f).roundToLong().toInt().coerceIn(0, 35_999)
}
/**
* Equirectangular approximation, in metres.
*
* Exact enough well past a kilometre, which is far beyond any distance this
* policy compares, and it costs one cosine instead of the four transcendentals
* haversine wants on every fix. Ceiling: it is wrong at the poles and across
* the antimeridian, where it reports a huge distance and the gate simply lets
* the fix through.
*/
private fun distanceM(aLatE7: Int, aLonE7: Int, bLatE7: Int, bLonE7: Int): Double {
val lat1 = Math.toRadians(aLatE7 / 1e7)
val lat2 = Math.toRadians(bLatE7 / 1e7)
val dLat = lat2 - lat1
val dLon = Math.toRadians((bLonE7.toLong() - aLonE7.toLong()) / 1e7)
val x = dLon * cos((lat1 + lat2) / 2)
return hypot(x, dLat) * EARTH_RADIUS_M
}
Dandroid/app/src/main/java/net/lexcom/opentracker/net/NetWatcher.kt-41
@@ -1,41 +0,0 @@
package net.lexcom.opentracker.net
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
/**
* Says when connectivity comes back, so the uplink retries at once.
*
* Without this, a queue that filled during a tunnel waits out [Uplink]'s
* backoff before it tries again, which can be a minute of stale positions after
* the signal is already fine.
*
* Framework binding only. No logic, so no test.
*/
class NetWatcher(context: Context) {
private val manager = context.getSystemService(ConnectivityManager::class.java)
private var callback: ConnectivityManager.NetworkCallback? = null
/** [onAvailable] runs on a framework thread, not the caller's. */
fun start(onAvailable: () -> Unit) {
stop()
val cb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = onAvailable()
}
manager?.registerDefaultNetworkCallback(cb)
callback = cb
}
/**
* Must be called from the service's `onDestroy`.
*
* A callback left registered outlives the service, keeps a reference to it,
* and goes on waking the process on every network change.
*/
fun stop() {
callback?.let { manager?.unregisterNetworkCallback(it) }
callback = null
}
}
Dandroid/app/src/main/java/net/lexcom/opentracker/net/UdpTransport.kt-64
@@ -1,64 +0,0 @@
package net.lexcom.opentracker.net
import net.lexcom.opentracker.wire.MAX_DATAGRAM
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.net.SocketTimeoutException
/**
* The only file in the uplink that touches a socket.
*
* [Transport] exists for exactly one reason: it lets [Uplink] run against a fake
* on the JVM, with no device and no network. Nothing else belongs in it. There
* is no TLS fallback here; that is step 15 and it implements this same interface.
*/
interface Transport {
/** Throws [java.io.IOException] when the network is down. The caller decides. */
fun send(datagram: ByteArray)
/** The next datagram, or null if [timeoutMs] passed. A timeout is normal. */
fun receive(timeoutMs: Int): ByteArray?
fun close()
}
/** One connectionless UDP socket pointed at the server. Not thread-safe. */
class UdpTransport(private val host: String, private val port: Int) : Transport {
private val socket = DatagramSocket()
/**
* Resolved per send, never in the constructor.
*
* A phone loses and regains DNS constantly. Resolving once at construction
* would make one failed lookup permanent for the life of the service, and
* the platform resolver already caches, so the repeated call is cheap.
*
* ponytail: a cache miss blocks this thread for as long as the resolver
* takes, which on a dying cell can be seconds. Acceptable because the caller
* is already a background thread that blocks on `receive` anyway. Cache the
* InetAddress and invalidate it on send failure if that ever shows up.
*/
override fun send(datagram: ByteArray) {
socket.send(DatagramPacket(datagram, datagram.size, InetAddress.getByName(host), port))
}
override fun receive(timeoutMs: Int): ByteArray? {
socket.soTimeout = timeoutMs
// Exactly MAX_DATAGRAM bytes. A longer datagram is truncated by the OS,
// which then fails AEAD and is dropped. That is the correct outcome: no
// datagram this protocol defines is longer, so anything that is, is not
// ours.
val buf = ByteArray(MAX_DATAGRAM)
val packet = DatagramPacket(buf, buf.size)
return try {
socket.receive(packet)
buf.copyOf(packet.length)
} catch (_: SocketTimeoutException) {
null
}
}
override fun close() = socket.close()
}
Dandroid/app/src/main/java/net/lexcom/opentracker/net/Uplink.kt-306
@@ -1,306 +0,0 @@
package net.lexcom.opentracker.net
import net.lexcom.opentracker.crypto.Sealer
import net.lexcom.opentracker.queue.PointQueue
import net.lexcom.opentracker.store.Credentials
import net.lexcom.opentracker.wire.AckFlags
import net.lexcom.opentracker.wire.HelloFlags
import net.lexcom.opentracker.wire.Header
import net.lexcom.opentracker.wire.MAX_DATAGRAM
import net.lexcom.opentracker.wire.MAX_POINTS
import net.lexcom.opentracker.wire.Message
import net.lexcom.opentracker.wire.MsgType
import net.lexcom.opentracker.wire.NackReason
import net.lexcom.opentracker.wire.POINT_LEN
import net.lexcom.opentracker.wire.RevokeReason
import net.lexcom.opentracker.wire.WireFormatException
import net.lexcom.opentracker.wire.datagramLen
import java.io.IOException
import java.security.GeneralSecurityException
/**
* Drains [PointQueue] into sealed `LOC` datagrams and acts on the replies.
*
* Pure logic over an injected [Transport], [PointQueue] and clock. No Android
* types, no socket, no `Thread.sleep`, no coroutine. That is what lets
* `UplinkTest` drive a whole conversation on the JVM against a fake server.
*
* This class decides *what* to send and *whether* it may send now. It never
* decides *when*: the service's loop calls [sendRound] and this returns
* [Round.BackOff] if it is too early. Nothing here blocks except the one reply
* wait, which is bounded by [REPLY_TIMEOUT_MS].
*
* Nothing thrown from a received datagram escapes. The socket is an open port
* and garbage arrives on it; a `WireFormatException` from `MsgType.fromCode` or
* from `decodePayload` that reached the service would kill tracking outright.
*
* Not thread-safe, and it must run on the thread that owns the queue. The
* peek/ack pairing depends on it: [sendRound] calls `peek` once, and the
* matching `ack` is the next queue call it makes, so no other `peek` can slip
* between them and void the ack.
*/
class Uplink(
private val transport: Transport,
private val queue: PointQueue,
private val credentials: Credentials,
private val nowMs: () -> Long,
) {
private val kUp = Sealer.deriveUp(credentials.tokenKey)
private val kDown = Sealer.deriveDown(credentials.tokenKey)
/** Earliest time a round may touch the socket again. See [backOff]. */
private var notBeforeMs = 0L
/** Current silence penalty, doubled per failure and reset by any reply. */
private var silenceBackoffMs = MIN_BACKOFF_MS
/**
* The largest batch that fits both ceilings.
*
* [MAX_POINTS] binds today; the datagram budget would allow 48. Computed
* rather than written down so a change to either constant stays correct.
*/
private val batchSize: Int = run {
var n = MAX_POINTS
while (n > 1 && datagramLen(1 + n * POINT_LEN) > MAX_DATAGRAM) n--
n
}
/**
* Send one batch of queued points and handle the reply.
*
* Points are removed from the queue only when the server confirms them.
* Every other outcome leaves them queued for the next round.
*/
fun sendRound(): Round {
val early = tooEarly()
if (early != null) return early
val points = queue.peek(batchSize)
if (points.isEmpty()) return Round.Idle
val sent = send(Message.Loc(points)) ?: return backOff("send failed")
return awaitReply(sent, ackCount = points.size)
}
/**
* Announce this device: after a login and after the network changes.
*
* The server records the app and OS version, and answers with an `ACK` whose
* `CONFIG_PENDING` flag says whether [Credentials.configVersion] is stale.
* That flag is the whole reason to carry the version here.
*/
fun hello(appVersionCode: Int, osApiLevel: Int, firstLaunch: Boolean = false): Round {
val early = tooEarly()
if (early != null) return early
val msg = Message.Hello(
appVersionCode = appVersionCode,
osApiLevel = osApiLevel,
flags = if (firstLaunch) HelloFlags.FIRST_LAUNCH else HelloFlags.NONE,
configVersion = credentials.configVersion,
)
val sent = send(msg) ?: return backOff("send failed")
return awaitReply(sent, ackCount = 0)
}
/** Tells the next round it may go immediately. Call this when the network returns. */
fun clearBackOff() {
notBeforeMs = 0L
silenceBackoffMs = MIN_BACKOFF_MS
}
// -- sending -------------------------------------------------------------
/** Seals [msg] under `K_up` and sends it. Returns the nonce used, or null. */
private fun send(msg: Message): ByteArray? {
val nonce = Sealer.newNonce()
val datagram = Sealer.sealMessage(kUp, credentials.tokenId, nonce, msg)
return try {
transport.send(datagram)
nonce
} catch (_: IOException) {
// No route, no DNS, airplane mode. Indistinguishable from silence
// and handled the same way: keep the points, wait, try again.
null
}
}
// -- receiving -----------------------------------------------------------
/**
* Read replies until one settles this round, or until the socket goes quiet.
*
* More than one datagram can be waiting: a late reply to an earlier round,
* or junk aimed at the port. Each is judged on its own and the undecidable
* ones are dropped, so a single stale `ACK` cannot mask the real one.
* [MAX_REPLIES_PER_ROUND] bounds the work a flood can cause.
*/
private fun awaitReply(sentNonce: ByteArray, ackCount: Int): Round {
repeat(MAX_REPLIES_PER_ROUND) {
val datagram = try {
transport.receive(REPLY_TIMEOUT_MS)
} catch (_: IOException) {
null
} ?: return backOff("no reply")
val round = interpret(datagram, sentNonce, ackCount)
if (round != null) {
// Any answer at all proves the path works, so the silence
// penalty starts over from the floor.
silenceBackoffMs = MIN_BACKOFF_MS
return round
}
}
return backOff("no usable reply")
}
/** One received datagram. Null means "not for this round; keep reading". */
private fun interpret(datagram: ByteArray, sentNonce: ByteArray, ackCount: Int): Round? = try {
val header = Header.peek(datagram)
when {
// An uplink type arriving here is our own traffic replayed back, or
// someone else's. It can never be a legitimate reply.
header.type.isUplink -> null
// Both rules from the KDoc on Message.Revoked, and both are needed.
// A notice captured before the last login still opens under the old
// K_rev, so the token id is what makes it harmless afterwards.
header.type == MsgType.REVOKED ->
if (header.tokenId != credentials.tokenId) {
null
} else {
val (_, msg) = Sealer.openMessage(credentials.kRev, datagram)
(msg as? Message.Revoked)?.let { Round.TokenDead(it.reason) }
}
header.tokenId != credentials.tokenId -> null
else -> downlink(datagram, sentNonce, ackCount)
}
} catch (_: WireFormatException) {
// Structurally impossible bytes: a bad message code, a payload of the
// wrong length. Dropped, never answered, never rethrown.
null
} catch (_: GeneralSecurityException) {
// Failed the tag. Forged, corrupted, or sealed under a key we retired.
null
}
private fun downlink(datagram: ByteArray, sentNonce: ByteArray, ackCount: Int): Round? {
val (_, msg) = Sealer.openMessage(kDown, datagram)
return when (msg) {
is Message.Ack -> {
// The nonce match is the whole point. An ACK confirms one LOC
// datagram, named by that datagram's nonce, and it is the only
// evidence the points reached storage. Acking the queue on an
// ACK for some earlier round would delete points this round sent
// but the server never stored.
if (msg.nonces.none { it.contentEquals(sentNonce) }) {
null
} else {
if (ackCount > 0) queue.ack(ackCount)
Round.Acked(ackCount, msg.flags and AckFlags.CONFIG_PENDING != 0)
}
}
is Message.Nack -> if (!msg.nonce.contentEquals(sentNonce)) null else nack(msg, ackCount)
// CONFIG and PONG are valid downlink messages this step does not use.
// Dropping them costs nothing; steps 11 and 12 add the handling.
else -> null
}
}
private fun nack(msg: Message.Nack, ackCount: Int): Round = when (msg.reason) {
// The server has no live row for this token, or it is revoked. Retrying
// can only ever produce the same answer, so stop and say so.
NackReason.UNKNOWN_TOKEN -> Round.TokenDead(null)
// The server refused the payload and will refuse it again. Retrying
// wedges the queue head forever, so these points are dropped. The usual
// cause is a fix whose timestamp falls outside the server's window,
// which no amount of resending will fix.
NackReason.MALFORMED -> {
if (ackCount > 0) queue.ack(ackCount)
Round.Dropped(ackCount)
}
// Backpressure. Honouring retryAfterS is not optional: a fleet that
// ignores it is how a saturated server stays saturated. Zero is not
// taken literally, or the client would answer throttling with a flood.
NackReason.RATE_LIMITED, NackReason.STORAGE_FULL -> {
val waitMs = maxOf(msg.retryAfterS * 1000L, MIN_BACKOFF_MS)
notBeforeMs = nowMs() + waitMs
Round.Throttled(msg.reason, notBeforeMs)
}
}
// -- pacing --------------------------------------------------------------
private fun tooEarly(): Round.BackOff? =
if (nowMs() < notBeforeMs) Round.BackOff(notBeforeMs) else null
/**
* Nothing came back. Keep the points and wait longer than last time.
*
* 5 s, doubling to a 60 s ceiling. The floor stops a caller that polls
* hard from turning one outage into a send per tick; the ceiling keeps a
* long tunnel from pushing the next attempt an hour out. `NetWatcher`
* cancels the wait outright when connectivity returns, which is why these
* numbers can stay this coarse.
*/
private fun backOff(reason: String): Round.NoReply {
notBeforeMs = nowMs() + silenceBackoffMs
silenceBackoffMs = (silenceBackoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
return Round.NoReply(reason, notBeforeMs)
}
companion object {
/** One RTT plus slack. A phone on a bad cell needs the slack. */
const val REPLY_TIMEOUT_MS = 2_000
const val MIN_BACKOFF_MS = 5_000L
const val MAX_BACKOFF_MS = 60_000L
/** A flood cannot make one round read forever. */
const val MAX_REPLIES_PER_ROUND = 8
}
}
/**
* What one round did. Returned rather than signalled, so the caller decides.
*
* [Round.TokenDead] in particular is a value and not an exception or a callback:
* step 9 owns clearing `Prefs` and telling the user to log in again, and this
* class has no business reaching into either.
*/
sealed interface Round {
/** The queue was empty. Nothing was sent. */
data object Idle : Round
/** The server stored [count] points and they are gone from the queue. */
data class Acked(val count: Int, val configPending: Boolean) : Round
/** [count] points the server will never accept were discarded. */
data class Dropped(val count: Int) : Round
/** Sent, nothing usable came back. The points are still queued. */
data class NoReply(val reason: String, val retryAtMs: Long) : Round
/** The server asked for a pause. Nothing will be sent before [retryAtMs]. */
data class Throttled(val reason: NackReason, val retryAtMs: Long) : Round
/** Called too early. Nothing touched the socket. */
data class BackOff(val retryAtMs: Long) : Round
/**
* This token is finished. Log in again.
*
* [reason] is null when a `NACK` said so rather than a `REVOKED` notice,
* because that message carries no reason code.
*/
data class TokenDead(val reason: RevokeReason?) : Round
}
Dandroid/app/src/main/java/net/lexcom/opentracker/queue/PointQueue.kt-169
@@ -1,169 +0,0 @@
package net.lexcom.opentracker.queue
import net.lexcom.opentracker.wire.POINT_LEN
import net.lexcom.opentracker.wire.Point
import java.io.Closeable
import java.io.File
import java.io.RandomAccessFile
import java.util.zip.CRC32
/**
* The crash-durable outbox: a bounded FIFO of sampled points, backed by one file.
*
* The sampling loop appends; the uplink peeks a batch, sends it as a `LOC`, and
* acks it only once the server confirms. The OS can kill this process at any
* instant, including between two bytes of a write, so the file has to be
* recoverable on its own. It carries no journal, no header and no cursor file:
* every slot is self-describing, and the queue state is rebuilt by reading them.
*
* The file is a fixed ring of `capacity` slots of 32 bytes each:
*
* ```text
* off size field meaning
* 0 4 seq u32 BE sequence number, from 1; 0 = slot never written
* 4 24 point the 24-byte record, exactly Point.toBytes()
* 28 4 crc u32 BE CRC32 over bytes 0..28 of this slot
* ```
*
* The CRC is not there to catch bit rot. It is there so a write interrupted
* halfway reads back as an invalid slot rather than as a point built from half
* of one record and half of the previous one. A slot counts as live only if
* `seq != 0` and the CRC matches, so a torn write can only ever lose the point
* being written, never corrupt an older one.
*
* The whole file is preallocated with zeroes on first open. All later writes go
* to blocks that already exist, so a disk that fills up after the queue starts
* cannot make an append fail.
*
* Slot index is `seq % capacity`, so a full ring overwrites its oldest point.
* That is the right thing to drop: an hour-old position nobody has seen is worth
* far less than the current one, and the alternative — refusing new points —
* would blind the user exactly while the network is down.
*
* Not thread-safe. The service's single sampling loop owns the instance and
* calls it from that one thread; peek/ack are only ever paired on that thread.
*/
class PointQueue(file: File, private val capacity: Int) : Closeable {
init {
require(capacity > 0) { "capacity must be positive, got $capacity" }
}
private val raf = RandomAccessFile(file, "rwd")
/** Live sequence numbers, oldest first. Holes appear where a slot was lost. */
private val live = ArrayDeque<Long>()
/** Sequence number the next append will use. */
private var nextSeq = 1L
/** Oldest sequence number handed out by the last [peek]. See [ack]. */
private var peeked: Long? = null
val size: Int get() = live.size
init {
// A length mismatch means the capacity changed between runs. Resizing
// would move every slot's index, so the old contents are discarded.
if (raf.length() != capacity.toLong() * SLOT_LEN) {
raf.setLength(0)
raf.setLength(capacity.toLong() * SLOT_LEN)
}
recover()
}
fun append(point: Point) {
val seq = nextSeq++
val slot = ByteArray(SLOT_LEN)
writeU32(slot, 0, seq)
point.toBytes().copyInto(slot, SEQ_LEN)
writeU32(slot, CRC_OFF, crcOf(slot))
raf.seek(slotOffset(seq))
raf.write(slot)
// The write just landed on the slot holding seq - capacity, so that
// point is gone. It is always the oldest one still live.
if (live.firstOrNull() == seq - capacity) live.removeFirst()
live.addLast(seq)
}
/** The [max] oldest points, oldest first. Nothing is removed. */
fun peek(max: Int): List<Point> {
peeked = live.firstOrNull()
return live.asSequence().take(max.coerceAtLeast(0)).map(::readPoint).toList()
}
/**
* Drop the [count] oldest points, once the server has stored them.
*
* An ack arrives one network round trip after the matching [peek], and the
* sampling loop keeps appending in the meantime. If it appended enough to
* lap the ring, the points being acked have already been overwritten and
* the oldest ones now are points that were never sent. Dropping those would
* lose positions that are still deliverable, so the ack is ignored instead.
* The acked points are gone either way; that loss is the ring overflowing,
* which is already the documented behaviour.
*/
fun ack(count: Int) {
if (live.firstOrNull() != peeked) return
repeat(count.coerceAtMost(live.size)) {
// Zeroing the seq field is a single 4-byte store and is what makes
// the slot free again for recovery. The point bytes stay behind but
// are unreachable, since seq == 0 fails the validity test.
raf.seek(slotOffset(live.removeFirst()))
raf.writeInt(0)
}
}
override fun close() = raf.close()
/**
* Rebuild the live range by reading every slot.
*
* Sequence numbers only ever grow, so the newest valid slot names the head.
* Anything more than `capacity` behind it belongs to a lap of the ring that
* has since been overwritten, and any slot surviving from that lap is a
* stale leftover, not a queued point.
*/
private fun recover() {
val slot = ByteArray(SLOT_LEN)
val found = ArrayList<Long>(capacity)
for (i in 0 until capacity) {
raf.seek(i.toLong() * SLOT_LEN)
raf.readFully(slot)
val seq = readU32(slot, 0)
if (seq != 0L && readU32(slot, CRC_OFF) == crcOf(slot)) found.add(seq)
}
val head = found.maxOrNull() ?: return
found.filterTo(live) { it > head - capacity }
live.sort()
nextSeq = head + 1
}
private fun readPoint(seq: Long): Point {
val record = ByteArray(POINT_LEN)
raf.seek(slotOffset(seq) + SEQ_LEN)
raf.readFully(record)
return Point.fromBytes(record)
}
private fun slotOffset(seq: Long) = (seq % capacity) * SLOT_LEN
private companion object {
const val SEQ_LEN = 4
const val CRC_OFF = SEQ_LEN + POINT_LEN
const val SLOT_LEN = CRC_OFF + 4
fun crcOf(slot: ByteArray): Long =
CRC32().apply { update(slot, 0, CRC_OFF) }.value
fun writeU32(b: ByteArray, off: Int, v: Long) {
for (i in 0 until 4) b[off + i] = (v ushr (24 - 8 * i)).toByte()
}
fun readU32(b: ByteArray, off: Int): Long {
var v = 0L
for (i in 0 until 4) v = (v shl 8) or (b[off + i].toLong() and 0xFF)
return v
}
}
}
Dandroid/app/src/main/java/net/lexcom/opentracker/store/Credentials.kt-50
@@ -1,50 +0,0 @@
package net.lexcom.opentracker.store
/**
* Everything the device needs to speak OTP/1, issued once by a device login.
*
* The server returns this exactly once and never again. Losing it means logging
* in again, so [Prefs] is what stands between the user and a re-login.
*
* [kRev] is separate from [tokenKey] on purpose. `K_up` and `K_down` derive from
* the token key and die with the token's row on the server. `K_rev` derives from
* a server master and the [tokenId], so the server can still send a message this
* device can verify after that row is gone. See `Message.Revoked`.
*/
data class Credentials(
val tokenId: Long,
/** 32 bytes. `K_up` and `K_down` derive from it. */
val tokenKey: ByteArray,
/** 32 bytes. Opens a `REVOKED` notice and nothing else. */
val kRev: ByteArray,
val udpHost: String,
val udpPort: Int,
val tlsUrl: String?,
val configVersion: Int,
) {
// ByteArray identity would make the generated equality useless, and these
// are compared in tests.
override fun equals(other: Any?): Boolean =
other is Credentials &&
tokenId == other.tokenId &&
tokenKey.contentEquals(other.tokenKey) &&
kRev.contentEquals(other.kRev) &&
udpHost == other.udpHost &&
udpPort == other.udpPort &&
tlsUrl == other.tlsUrl &&
configVersion == other.configVersion
override fun hashCode(): Int {
var h = tokenId.hashCode()
h = h * 31 + tokenKey.contentHashCode()
h = h * 31 + kRev.contentHashCode()
h = h * 31 + udpHost.hashCode()
h = h * 31 + udpPort
h = h * 31 + (tlsUrl?.hashCode() ?: 0)
return h * 31 + configVersion
}
/** Keys must never reach a log line or a crash report. */
override fun toString(): String =
"Credentials(tokenId=$tokenId, udp=$udpHost:$udpPort, configVersion=$configVersion)"
}
Dandroid/app/src/main/java/net/lexcom/opentracker/store/LoginClient.kt-199
@@ -1,199 +0,0 @@
package net.lexcom.opentracker.store
import net.lexcom.opentracker.crypto.Sealer
import org.json.JSONObject
import java.io.IOException
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL
import java.util.Base64
/**
* The only HTTP request this app ever makes: `POST /api/login` with
* `purpose: "device"`, which mints an OTP/1 token and returns it exactly once.
*
* Everything after this runs over UDP, so there is no OkHttp and no Retrofit
* here. One request does not justify a client library, and
* [java.net.HttpURLConnection] resolves to `HttpsURLConnection` for an `https`
* URL, so TLS and certificate validation come from the platform.
*
* The I/O and the parsing are deliberately separate. [parseLoginResponse] is a
* pure function over the response body, so the part that can silently corrupt a
* key is testable on the JVM without a socket.
*/
/** Required on every state-changing `/api` request. See `require_csrf` in `api.rs`. */
private const val CSRF_HEADER = "X-OT-CSRF"
private const val USER_AGENT = "opentracker-android/0.1.0"
/** A tracker that hangs on a dead server stops tracking, so both are bounded. */
private const val CONNECT_TIMEOUT_MS = 15_000
private const val READ_TIMEOUT_MS = 20_000
/**
* The outcome of a login, modelled so the UI can say which one happened.
*
* "Wrong password" and "no network" need different words on screen, and a
* nullable return would collapse them into one.
*/
sealed interface LoginResult {
data class Ok(val credentials: Credentials) : LoginResult
/** HTTP 401. The username or the password is wrong. */
data object BadCredentials : LoginResult
/** HTTP 429. [retryAfterSeconds] is null when the server's hint was unreadable. */
data class RateLimited(val retryAfterSeconds: Long?) : LoginResult
/** Anything else: no network, TLS failure, 5xx, a malformed reply. */
data class Failed(val message: String) : LoginResult
}
/**
* Logs in and returns device credentials.
*
* Blocking. Call it off the main thread.
*
* [baseUrl] is the server root, for example `https://track.example.net`.
*/
fun login(
baseUrl: String,
username: String,
password: String,
deviceName: String,
): LoginResult {
val body = JSONObject()
.put("username", username)
// JSONObject escapes; a password with a quote in it must not break the body.
.put("password", password)
.put("purpose", "device")
.put("device_name", deviceName)
.put("platform", "android")
.toString()
val conn = try {
URL(baseUrl.trimEnd('/') + "/api/login").openConnection() as HttpURLConnection
} catch (e: Exception) {
return LoginResult.Failed("bad server address: ${e.message ?: e.javaClass.simpleName}")
}
return try {
conn.requestMethod = "POST"
conn.connectTimeout = CONNECT_TIMEOUT_MS
conn.readTimeout = READ_TIMEOUT_MS
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Accept", "application/json")
conn.setRequestProperty("User-Agent", USER_AGENT)
// Any value works. The defence is that a browser cannot set a custom
// header cross-origin without a preflight this server never grants.
conn.setRequestProperty(CSRF_HEADER, "1")
conn.outputStream.use { it.write(body.toByteArray()) }
val code = conn.responseCode
val text = (if (code in 200..299) conn.inputStream else conn.errorStream).readTextOrEmpty()
when {
code == 401 -> LoginResult.BadCredentials
code == 429 -> LoginResult.RateLimited(retryAfterSeconds(text))
code in 200..299 -> try {
LoginResult.Ok(parseLoginResponse(text))
} catch (e: Exception) {
LoginResult.Failed("server sent an unusable reply: ${e.message}")
}
// Keep the server's own message. Swallowing it turns every failure
// into "something went wrong", which nobody can act on.
else -> LoginResult.Failed("HTTP $code: ${errorMessage(text) ?: "no detail"}")
}
} catch (e: IOException) {
LoginResult.Failed("${e.javaClass.simpleName}: ${e.message ?: "network error"}")
} finally {
conn.disconnect()
}
}
private fun InputStream?.readTextOrEmpty(): String =
this?.use { it.bufferedReader().readText() } ?: ""
/** `ApiError` serialises as `{"error": "..."}`. */
private fun errorMessage(body: String): String? =
runCatching { JSONObject(body).getString("error") }.getOrNull()
/**
* `ApiError::TooManyRequests` renders as "too many attempts; try again in 42s".
*
* There is no `Retry-After` header, so the number comes out of that sentence.
*/
private val RETRY_HINT = Regex("""in\s+(\d+)s""")
private fun retryAfterSeconds(body: String): Long? =
errorMessage(body)?.let { RETRY_HINT.find(it)?.groupValues?.get(1)?.toLongOrNull() }
/**
* `"token_id": 12345678901234567890` — a `u64` written as a JSON number.
*
* Token ids are full 64-bit random values, so most of them are above 2^53 and
* many are above 2^63. Android's `org.json` parses a number that does not fit a
* `Long` as a `Double`, and `getLong` then clamps it, which silently returns the
* wrong token id. So the digits are read from the raw body and converted as
* unsigned, giving the exact bit pattern the wire header carries.
*
* `TokenInfo.token_id` in the same server is a *string*, for the browser's sake.
* `DeviceCredentials.token_id` is not, and it is the only `token_id` in this
* response, so a single match is unambiguous.
*/
private val TOKEN_ID = Regex(""""token_id"\s*:\s*(\d+)""")
internal fun parseTokenId(body: String): Long {
val digits = requireNotNull(TOKEN_ID.find(body)?.groupValues?.get(1)) {
"response carries no numeric token_id"
}
// Throws NumberFormatException (an IllegalArgumentException) above 2^64-1.
return java.lang.Long.parseUnsignedLong(digits)
}
/**
* Maps a `LoginResponse` body to [Credentials].
*
* Throws [IllegalArgumentException] or [org.json.JSONException] if the reply is
* not a usable device login. A short key is a corrupt login, not something to
* carry forward and fail on mysteriously at the first datagram.
*/
fun parseLoginResponse(body: String): Credentials {
val device = requireNotNull(JSONObject(body).optJSONObject("device")) {
"reply has no device credentials; was purpose=device sent?"
}
val port = device.getInt("udp_port")
require(port in 1..65535) { "udp_port $port is out of range" }
// Prefs.load rejects an empty host too. Catching it here turns a bad server
// config into a login error instead of a mystery re-login later.
val host = device.getString("udp_host")
require(host.isNotEmpty()) { "udp_host is empty" }
return Credentials(
tokenId = parseTokenId(body),
tokenKey = decodeKey(device.getString("token_key"), "token_key"),
kRev = decodeKey(device.getString("revoke_key"), "revoke_key"),
udpHost = host,
udpPort = port,
// Omitted entirely when the server has no TLS relay configured.
tlsUrl = if (device.isNull("tls_url")) null else device.getString("tls_url"),
configVersion = device.getJSONObject("config").getInt("config_version"),
)
}
/**
* `java.util.Base64` rather than `android.util.Base64`: it exists from API 26,
* below this app's minSdk 29, and unlike the Android class it also works in a
* JVM unit test, where `android.jar` stubs return defaults.
*/
internal fun decodeKey(b64: String, field: String): ByteArray {
val raw = try {
Base64.getDecoder().decode(b64)
} catch (e: IllegalArgumentException) {
throw IllegalArgumentException("$field is not valid base64", e)
}
require(raw.size == Sealer.KEY_LEN) {
"$field decoded to ${raw.size} bytes, expected ${Sealer.KEY_LEN}"
}
return raw
}
Dandroid/app/src/main/java/net/lexcom/opentracker/store/Prefs.kt-118
@@ -1,118 +0,0 @@
package net.lexcom.opentracker.store
import android.content.Context
import androidx.core.content.edit
import java.util.Base64
/**
* The one persistent record this app keeps: the device's [Credentials].
*
* The server issues them exactly once, so losing this file costs the user a
* re-login. `TrackerService` can be restarted by the system with a null Intent,
* which is why the credentials have to come from here and not from an extra.
*
* Plain [android.content.SharedPreferences] in `MODE_PRIVATE`. The file lives in
* the app's private data directory, which only this uid can read. Backup and
* device-to-device transfer are shut off by `android:allowBackup="false"` in the
* manifest, for the reason stated there: restoring the token key onto a second
* device would silently clone a credential. That is also why `DataExtractionRules`
* is disabled in lint rather than answered with an XML file.
*
* Deliberately not `EncryptedSharedPreferences`: it is a new dependency
* (`androidx.security`), it is deprecated, and it protects against an attacker
* who has already read the private data directory, at which point the game is
* over anyway.
*/
class Prefs(context: Context) {
private val sp = context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
/**
* Returns null unless every field reads back intact.
*
* A half-written or hand-edited record must not produce a [Credentials] with
* a 31-byte key. That would fail far away from here, as datagrams the server
* silently drops, instead of as a login prompt.
*/
fun load(): Credentials? {
if (!sp.contains(KEY_TOKEN_ID)) return null
val tokenKey = decodeStoredKey(sp.getString(KEY_TOKEN_KEY, null)) ?: return null
val kRev = decodeStoredKey(sp.getString(KEY_REVOKE_KEY, null)) ?: return null
val host = sp.getString(KEY_UDP_HOST, null) ?: return null
val port = sp.getInt(KEY_UDP_PORT, 0)
if (host.isEmpty() || port !in 1..65535) return null
return Credentials(
// A Long round-trips exactly here, unlike through JSON.
tokenId = sp.getLong(KEY_TOKEN_ID, 0),
tokenKey = tokenKey,
kRev = kRev,
udpHost = host,
udpPort = port,
tlsUrl = sp.getString(KEY_TLS_URL, null),
configVersion = sp.getInt(KEY_CONFIG_VERSION, 0),
)
}
/** `commit()`, not `apply()`: this runs once per login and must survive a kill. */
fun save(c: Credentials) {
sp.edit(commit = true) {
putLong(KEY_TOKEN_ID, c.tokenId)
putString(KEY_TOKEN_KEY, encodeStoredKey(c.tokenKey))
putString(KEY_REVOKE_KEY, encodeStoredKey(c.kRev))
putString(KEY_UDP_HOST, c.udpHost)
putInt(KEY_UDP_PORT, c.udpPort)
putString(KEY_TLS_URL, c.tlsUrl)
putInt(KEY_CONFIG_VERSION, c.configVersion)
}
}
/**
* Whether the user wants to be tracked, as opposed to whether the service
* happens to be running right now.
*
* Without this flag a receiver cannot tell "the user tapped Stop" from "the
* system killed us", and those two need opposite answers. BootReceiver and
* WatchdogReceiver read it to decide whether restarting is wanted at all.
*
* Default false, so a fresh install never starts tracking on its own.
*/
fun isTrackingEnabled(): Boolean = sp.getBoolean(KEY_TRACKING_ENABLED, false)
/** `commit()` for the same reason as [save]: the next reader may be a
* receiver in a process the system is about to kill. */
fun setTrackingEnabled(on: Boolean) {
sp.edit(commit = true) { putBoolean(KEY_TRACKING_ENABLED, on) }
}
/**
* Removes the keys, not just a flag.
*
* A `REVOKED` notice calls this. Leaving the token key on disk after it would
* keep a dead credential around for anyone who later gets the file.
*
* This wipes [KEY_TRACKING_ENABLED] too, because it clears the whole file.
* That is wanted: without credentials there is nothing to restart.
*/
fun clear() {
sp.edit(commit = true) { clear() }
}
private companion object {
const val FILE = "credentials"
const val KEY_TOKEN_ID = "token_id"
const val KEY_TOKEN_KEY = "token_key"
const val KEY_REVOKE_KEY = "revoke_key"
const val KEY_UDP_HOST = "udp_host"
const val KEY_UDP_PORT = "udp_port"
const val KEY_TLS_URL = "tls_url"
const val KEY_CONFIG_VERSION = "config_version"
const val KEY_TRACKING_ENABLED = "tracking_enabled"
}
}
internal fun encodeStoredKey(key: ByteArray): String =
Base64.getEncoder().encodeToString(key)
/** Null for missing, unparseable, or wrong-length input. See [Prefs.load]. */
internal fun decodeStoredKey(b64: String?): ByteArray? =
b64?.let { runCatching { decodeKey(it, "stored key") }.getOrNull() }
Dandroid/app/src/main/java/net/lexcom/opentracker/ui/HomeScreen.kt-179
@@ -1,179 +0,0 @@
package net.lexcom.opentracker.ui
import android.Manifest
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import net.lexcom.opentracker.TrackerState
import net.lexcom.opentracker.wire.Point
/**
* The main screen: permission banner, live map, status, start/stop.
*
* Stateless by design. Everything it draws arrives as [TrackerState.Snapshot]
* and everything it triggers leaves through a lambda, so the screen has no
* opinion on whether the service is bound, started or dead. That keeps the one
* piece worth testing — the formatting below — reachable from a JVM test.
*
* Strings are Kotlin constants because this app has no res/values/strings.xml
* and is not localized. See the lint block in build.gradle.kts.
*/
private const val TITLE_MISSING_PERMS = "Not everything is allowed yet"
private const val LABEL_GRANT = "Grant"
private const val LABEL_START = "Start sharing"
private const val LABEL_STOP = "Stop sharing"
private const val LABEL_SIGN_OUT = "Sign out"
/** How often the "last fix" age is recomputed. */
private const val AGE_TICK_MS = 10_000L
@Composable
fun HomeScreen(
state: TrackerState.Snapshot,
missingPermissions: List<String>,
serverHost: String,
onGrantPermissions: () -> Unit,
onStart: () -> Unit,
onStop: () -> Unit,
onSignOut: () -> Unit,
) {
Column(Modifier.fillMaxSize()) {
if (missingPermissions.isNotEmpty()) {
PermissionBanner(missingPermissions, onGrantPermissions)
}
// The map takes whatever height the rest leaves. clipToBounds because
// osmdroid draws its tiles past its own bounds, over the banner above
// and the status card below.
MapView(state.last, Modifier.fillMaxWidth().weight(1f).clipToBounds())
StatusCard(state, serverHost)
Button(
onClick = if (state.running) onStop else onStart,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
Text(if (state.running) LABEL_STOP else LABEL_START)
}
TextButton(
onClick = onSignOut,
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(LABEL_SIGN_OUT)
}
}
}
@Composable
private fun PermissionBanner(missing: List<String>, onGrant: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.weight(1f)) {
Text(TITLE_MISSING_PERMS, style = MaterialTheme.typography.titleSmall)
Text(
"Still needed: ${describeMissing(missing)}.",
style = MaterialTheme.typography.bodyMedium,
)
}
TextButton(onClick = onGrant) { Text(LABEL_GRANT) }
}
}
}
@Composable
private fun StatusCard(state: TrackerState.Snapshot, serverHost: String) {
// Recomposition is driven by the service reporting, which stops when nothing
// moves. Without this tick the age would freeze at whatever it read last and
// claim a two hour old fix is seconds old.
val nowMs by produceState(System.currentTimeMillis()) {
while (true) {
delay(AGE_TICK_MS)
value = System.currentTimeMillis()
}
}
Card(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Column(Modifier.padding(16.dp)) {
Text(state.status, style = MaterialTheme.typography.titleMedium)
Text(
"Motion ${state.motion.name.lowercase()} · ${state.queued} queued",
style = MaterialTheme.typography.bodyMedium,
)
Text(serverHost, style = MaterialTheme.typography.bodySmall)
state.last?.let {
Text(describeFix(it, nowMs), style = MaterialTheme.typography.bodySmall)
}
}
}
}
/**
* The age of a fix, as a person would say it.
*
* [tsSeconds] is [Point.ts], a Unix timestamp in *seconds* (an unsigned u32 on
* the wire, widened to a Long here). [nowMs] is milliseconds, because that is
* what the platform clock gives.
*
* A future timestamp is clamped to zero rather than rendered. The device clock
* and the fix clock are not the same clock, so a few seconds of skew is normal,
* and "-3s ago" would read as a bug in the app rather than in the clock.
*/
internal fun formatAge(tsSeconds: Long, nowMs: Long): String {
val seconds = (nowMs / 1000 - tsSeconds).coerceAtLeast(0)
return when {
seconds < 60 -> "${seconds}s ago"
seconds < 3600 -> "${seconds / 60}m ago"
else -> "${seconds / 3600}h ago"
}
}
/** [accDm] is decimetres, the wire unit. Rounded to whole metres for display. */
internal fun formatAccuracy(accDm: Int?): String? =
accDm?.let { "±${(it + 5) / 10} m" }
internal fun describeFix(point: Point, nowMs: Long): String =
listOfNotNull("Last fix ${formatAge(point.ts, nowMs)}", formatAccuracy(point.accDm))
.joinToString(" · ")
/**
* Plain words for the permissions still missing.
*
* The raw `android.permission.*` strings mean nothing to a user, and the two
* location permissions are requested as a pair, so naming both would read as a
* duplicate.
*/
internal fun describeMissing(missing: List<String>): String =
missing.map(::permissionLabel).distinct().joinToString(", ")
private fun permissionLabel(permission: String): String = when (permission) {
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
-> "your location"
Manifest.permission.ACCESS_BACKGROUND_LOCATION -> "location while the app is closed"
Manifest.permission.POST_NOTIFICATIONS -> "notifications"
else -> permission.substringAfterLast('.').lowercase().replace('_', ' ')
}
Dandroid/app/src/main/java/net/lexcom/opentracker/ui/LoginScreen.kt-197
@@ -1,197 +0,0 @@
package net.lexcom.opentracker.ui
import android.os.Build
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.lexcom.opentracker.store.LoginResult
import net.lexcom.opentracker.store.Prefs
import net.lexcom.opentracker.store.login
/**
* The first screen, shown while [Prefs.load] returns null.
*
* It exists once per install in the normal case: a successful login writes
* credentials the server never issues again, and everything after it is UDP.
* So this screen has no "remember me", no account list and no session refresh.
*
* All four fields are plain Compose state. Nothing here survives the process,
* on purpose: the only field worth keeping is the server URL, and keeping it
* would mean a new [Prefs] field that exists solely for a retry that takes one
* more second to type.
*/
@Composable
fun LoginScreen(onLoggedIn: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var serverUrl by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
// The model name is what the user would type anyway, and it is what the
// device list on the server shows. They can still overwrite it.
var deviceName by remember { mutableStateOf(Build.MODEL ?: "") }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
// Whitespace-only input would otherwise be sent and come back as a URL
// parse failure from the server round trip. Reject it here instead.
val canSubmit = !busy &&
serverUrl.isNotBlank() &&
username.isNotBlank() &&
password.isNotBlank() &&
deviceName.isNotBlank()
Column(
Modifier
.padding(24.dp)
.verticalScroll(rememberScrollState()),
) {
Text("opentracker", style = MaterialTheme.typography.headlineSmall)
Text(
"Sign in to mint this device's token.",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp),
)
Field(
value = serverUrl,
onValueChange = { serverUrl = it; error = null },
label = "Server URL",
placeholder = "https://track.example.net",
// Uri, not Text: it gives the "/" and "." keys without autocorrect
// mangling a hostname.
keyboardType = KeyboardType.Uri,
enabled = !busy,
)
Field(
value = username,
onValueChange = { username = it; error = null },
label = "Username",
enabled = !busy,
)
Field(
value = password,
onValueChange = { password = it; error = null },
label = "Password",
keyboardType = KeyboardType.Password,
enabled = !busy,
visualTransformation = PasswordVisualTransformation(),
)
Field(
value = deviceName,
onValueChange = { deviceName = it; error = null },
label = "Device name",
enabled = !busy,
imeAction = ImeAction.Done,
)
Spacer(Modifier.height(16.dp))
Button(
onClick = {
busy = true
error = null
scope.launch {
// login() opens a socket and blocks until the reply or the
// read timeout, which is up to 20 s. On the main thread that
// is an ANR.
val result = withContext(Dispatchers.IO) {
login(serverUrl.trim(), username, password, deviceName.trim())
}
busy = false
when (result) {
is LoginResult.Ok -> {
Prefs(context).save(result.credentials)
onLoggedIn()
}
LoginResult.BadCredentials ->
error = "Wrong username or password."
is LoginResult.RateLimited -> error = result.retryAfterSeconds
?.let { "Too many attempts. Try again in $it seconds." }
?: "Too many attempts. Try again later."
// Already carries the useful detail: the HTTP status,
// the server's own error text or the exception class.
is LoginResult.Failed -> error = result.message
}
}
},
enabled = canSubmit,
modifier = Modifier.fillMaxWidth(),
) {
Text("Sign in")
}
if (busy) {
// Also the only feedback during a 20-second read timeout on a dead
// server, where a disabled button alone looks like a frozen app.
CircularProgressIndicator(Modifier.padding(top = 16.dp))
}
error?.let {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 16.dp),
)
}
}
}
/** Four fields with the same shape. Written once rather than four times. */
@Composable
private fun Field(
value: String,
onValueChange: (String) -> Unit,
label: String,
enabled: Boolean,
placeholder: String? = null,
keyboardType: KeyboardType = KeyboardType.Text,
imeAction: ImeAction = ImeAction.Next,
visualTransformation: VisualTransformation = VisualTransformation.None,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
placeholder = placeholder?.let { { Text(it) } },
singleLine = true,
enabled = enabled,
visualTransformation = visualTransformation,
keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = imeAction),
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
)
}
Dandroid/app/src/main/java/net/lexcom/opentracker/ui/MapView.kt-127
@@ -1,127 +0,0 @@
package net.lexcom.opentracker.ui
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import net.lexcom.opentracker.wire.Point
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.overlay.CopyrightOverlay
import org.osmdroid.views.overlay.Marker
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import org.osmdroid.views.MapView as OsmMapView
/**
* The live map: an osmdroid [OsmMapView] hosted in Compose.
*
* osmdroid is a plain Android View, so there is no Compose-native alternative
* that does not pull in Play Services or a vector tile renderer. [AndroidView]
* is the whole integration.
*
* The one rule that is easy to get wrong: osmdroid must be configured *before*
* its first view is constructed. The view reads the global [Configuration] in
* its constructor to build the tile downloader, so configuring afterwards
* silently leaves the old values in place and tiles never load.
*/
/** Zoom used once a real position is known. Roughly a few streets across. */
private const val FOLLOW_ZOOM = 16.0
/** Zoom used while there is no position at all. A continent, not the ocean. */
private const val DEFAULT_ZOOM = 3.0
private const val TILE_CACHE_DIR = "osmdroid-tiles"
@Composable
fun MapView(point: Point?, modifier: Modifier = Modifier) {
val context = LocalContext.current
val map = remember { createMapView(context) }
val marker = remember { Marker(map) }
// Not Compose state on purpose. The update block below reads it, and a
// snapshot state read there would make the block re-run when it flips.
val centred = remember { AtomicBoolean(false) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
// osmdroid's onResume/onPause start and stop the tile downloader and the
// (optional) compass sensor. Skipping them keeps threads running while
// the app is in the background, which on a tracker is exactly the wrong
// place to burn battery.
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> map.onResume()
Lifecycle.Event.ON_PAUSE -> map.onPause()
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
AndroidView(
factory = { map },
modifier = modifier,
update = {
if (point == null) {
map.overlays.remove(marker)
} else {
// latE7/lonE7 are degrees scaled by 1e7, the wire representation.
val here = GeoPoint(point.latE7 / 1e7, point.lonE7 / 1e7)
marker.position = here
if (!map.overlays.contains(marker)) map.overlays.add(marker)
// First fix jumps and zooms in; after that the map follows.
if (centred.compareAndSet(false, true)) map.controller.setZoom(FOLLOW_ZOOM)
map.controller.setCenter(here)
}
map.invalidate()
},
// osmdroid holds a tile downloader thread pool, a tile cache and overlay
// references. onDetach() is the only thing that frees them; without it
// every visit to this screen leaks a pool.
onRelease = { it.onDetach() },
)
}
private fun createMapView(context: Context): OsmMapView {
val config = Configuration.getInstance()
// OSM's tile servers block the library's default "osmdroid" user agent
// outright, so an unset UA means every tile request returns 403.
config.userAgentValue = context.packageName
// Deliberately not Configuration.load(context, PreferenceManager...): that
// form needs the androidx.preference dependency and defaults the cache to
// external storage, which this app holds no permission for. Setting the two
// paths by hand keeps everything in the app's own cache directory, which
// the system may also reclaim under storage pressure.
config.osmdroidBasePath = context.cacheDir
config.osmdroidTileCache = File(context.cacheDir, TILE_CACHE_DIR)
return OsmMapView(context).apply {
// ponytail: step 14 repoints this at the server's own /tiles proxy, so
// the map does not leak the user's viewport to a third party. Hitting
// OSM's public servers directly is acceptable for development only.
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(true)
// Pinch covers zooming, so the overlaid +/- buttons only cost screen.
// This is what setBuiltInZoomControls(false) does; that call is
// deprecated and only forwards here.
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
controller.setZoom(DEFAULT_ZOOM)
// Not decoration. The OSM tile usage policy requires visible
// attribution, and it stays required in step 14: proxying the tiles
// through our own server changes who fetches them, not who made them.
// The overlay reads the credit line from the tile source, so it also
// stays correct if that source changes.
overlays.add(CopyrightOverlay(context))
}
}
Dandroid/app/src/main/java/net/lexcom/opentracker/wire/Frame.kt-571
@@ -1,571 +0,0 @@
package net.lexcom.opentracker.wire
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* The OTP/1 codec — the Kotlin half of the wire contract with `crates/otproto`.
*
* Deliberately pure: no Android types, no I/O, no clock, no RNG. Nonces are
* passed in. That is what lets `VectorsTest` check it against the Rust golden
* vectors on the JVM without an emulator.
*
* Layout is documented once, in `crates/otproto/src/{frame,msg,point}.rs`. This
* file must stay byte-identical to it, and `vectors.json` is what proves it does.
*
* There is no clock anywhere in this protocol except [Point.ts]. Nothing carries
* the server's time, nothing measures clock skew, nothing corrects a timestamp.
*/
const val VERSION = 1
const val HEADER_LEN = 21
const val TAG_LEN = 16
const val NONCE_LEN = 12
const val POINT_LEN = 24
const val MIN_DATAGRAM = HEADER_LEN + TAG_LEN
const val MAX_DATAGRAM = 1200
const val MAX_POINTS = 40
private const val ACC_UNKNOWN = 0xFFFF
private const val ALT_UNKNOWN = -0x8000
private const val SPD_UNKNOWN = 0xFFFF
private const val BRG_UNKNOWN = 0xFFFF
private const val BAT_UNKNOWN = 0xFF
private const val ACC_MAX = ACC_UNKNOWN - 1
private const val SPD_MAX = SPD_UNKNOWN - 1
private const val BRG_MAX = 35_999
private const val ALT_MIN = ALT_UNKNOWN + 1
/** Thrown for structurally impossible bytes. Never for merely odd values. */
class WireFormatException(message: String) : Exception(message)
enum class MsgType(val code: Int) {
LOC(0x1),
ACK(0x2),
NACK(0x3),
HELLO(0x4),
CONFIG(0x5),
CONFIG_GET(0x6),
PING(0x7),
PONG(0x8),
/** Sealed under `K_rev`, not `K_down`. See [Message.Revoked]. */
REVOKED(0x9),
;
/** Uplink messages are sealed under `K_up`, downlink under `K_down`. */
val isUplink: Boolean get() = this == LOC || this == HELLO || this == CONFIG_GET || this == PING
companion object {
fun fromCode(code: Int): MsgType =
entries.firstOrNull { it.code == code }
?: throw WireFormatException("unknown message type 0x${code.toString(16)}")
}
}
object PointFlags {
const val NONE = 0
const val CHARGING = 1 shl 0
const val NETWORK_FIX = 1 shl 1
const val LOW_ACCURACY = 1 shl 2
const val MOCK = 1 shl 3
}
object AckFlags {
const val NONE = 0
const val CONFIG_PENDING = 1 shl 0
const val THROTTLE = 1 shl 1
}
object HelloFlags {
const val NONE = 0
const val FIRST_LAUNCH = 1 shl 0
}
object ConfigFlags {
const val NONE = 0
const val TRACKING_ENABLED = 1 shl 0
const val REQUEST_HELLO = 1 shl 1
}
enum class Profile(val code: Int) {
BATTERY_SAVER(0),
BALANCED(1),
HIGH_ACCURACY(2),
;
companion object {
fun fromCode(code: Int): Profile =
entries.firstOrNull { it.code == code }
?: throw WireFormatException("unknown profile $code")
}
}
enum class RevokeReason(val code: Int) {
/** Explicitly revoked: "log out all other devices", or a password change. */
REVOKED(1),
/** Deleted by the server's staleness sweep after a long silence. */
EXPIRED(2),
/** The server has no record of this token: a restored backup, or a rotated
* server key. */
UNKNOWN(3),
;
companion object {
fun fromCode(code: Int): RevokeReason =
entries.firstOrNull { it.code == code }
?: throw WireFormatException("unknown revoke reason $code")
}
}
enum class NackReason(val code: Int) {
/** Token unknown, revoked or expired: clear local state, show login. */
UNKNOWN_TOKEN(1),
MALFORMED(2),
RATE_LIMITED(3),
STORAGE_FULL(4),
;
companion object {
fun fromCode(code: Int): NackReason =
entries.firstOrNull { it.code == code }
?: throw WireFormatException("unknown NACK reason $code")
}
}
/**
* One location report, 24 bytes on the wire.
*
* `null` means the device could not measure that field and is carried as the
* field's sentinel. [ts] is unsigned 32-bit, hence [Long].
*/
data class Point(
val ts: Long,
val latE7: Int,
val lonE7: Int,
val accDm: Int? = null,
val altM: Int? = null,
val spdCms: Int? = null,
val brgCdeg: Int? = null,
val batPct: Int? = null,
val flags: Int = PointFlags.NONE,
) {
fun writeTo(buf: ByteBuffer) {
buf.putInt(ts.toInt())
buf.putInt(latE7)
buf.putInt(lonE7)
buf.putShort((accDm?.coerceAtMost(ACC_MAX) ?: ACC_UNKNOWN).toShort())
buf.putShort((altM?.coerceAtLeast(ALT_MIN) ?: ALT_UNKNOWN).toShort())
buf.putShort((spdCms?.coerceAtMost(SPD_MAX) ?: SPD_UNKNOWN).toShort())
buf.putShort((brgCdeg?.coerceAtMost(BRG_MAX) ?: BRG_UNKNOWN).toShort())
buf.put((batPct?.coerceAtMost(100) ?: BAT_UNKNOWN).toByte())
buf.put(flags.toByte())
buf.putShort(0) // reserved
}
fun toBytes(): ByteArray = ByteArray(POINT_LEN).also { writeTo(bufferOf(it)) }
companion object {
fun readFrom(buf: ByteBuffer): Point {
val ts = buf.int.toLong() and 0xFFFFFFFFL
val lat = buf.int
val lon = buf.int
val acc = buf.short.toInt() and 0xFFFF
val alt = buf.short.toInt() // signed
val spd = buf.short.toInt() and 0xFFFF
val brg = buf.short.toInt() and 0xFFFF
val bat = buf.get().toInt() and 0xFF
val flags = buf.get().toInt() and 0xFF
buf.short // reserved: ignored, not rejected, so a later version can use it
return Point(
ts = ts,
latE7 = lat,
lonE7 = lon,
accDm = if (acc == ACC_UNKNOWN) null else acc,
altM = if (alt == ALT_UNKNOWN) null else alt,
spdCms = if (spd == SPD_UNKNOWN) null else spd,
brgCdeg = if (brg == BRG_UNKNOWN) null else brg,
batPct = if (bat == BAT_UNKNOWN) null else bat,
flags = flags,
)
}
fun fromBytes(b: ByteArray): Point {
require(b.size == POINT_LEN) { "point record must be $POINT_LEN bytes, got ${b.size}" }
return readFrom(bufferOf(b))
}
}
}
sealed interface Message {
val type: MsgType
val payloadLen: Int
fun writePayload(buf: ByteBuffer)
fun encodePayload(): ByteArray = ByteArray(payloadLen).also { writePayload(bufferOf(it)) }
/** One or more fully independent points. */
data class Loc(val points: List<Point>) : Message {
init {
require(points.size in 1..MAX_POINTS) {
"LOC must carry 1..$MAX_POINTS points, got ${points.size}"
}
}
override val type get() = MsgType.LOC
override val payloadLen get() = 1 + points.size * POINT_LEN
override fun writePayload(buf: ByteBuffer) {
buf.put(points.size.toByte())
points.forEach { it.writeTo(buf) }
}
}
data class Ack(
val nonces: List<ByteArray>,
val flags: Int,
) : Message {
init {
require(nonces.size in 1..MAX_POINTS) { "ACK must carry 1..$MAX_POINTS nonces" }
require(nonces.all { it.size == NONCE_LEN }) { "every nonce must be $NONCE_LEN bytes" }
}
override val type get() = MsgType.ACK
override val payloadLen get() = 1 + nonces.size * NONCE_LEN + 1
override fun writePayload(buf: ByteBuffer) {
buf.put(nonces.size.toByte())
nonces.forEach { buf.put(it) }
buf.put(flags.toByte())
}
// ByteArray identity would make data-class equality useless here.
override fun equals(other: Any?): Boolean =
other is Ack &&
flags == other.flags &&
nonces.size == other.nonces.size &&
nonces.indices.all { nonces[it].contentEquals(other.nonces[it]) }
override fun hashCode(): Int {
var h = flags
nonces.forEach { h = h * 31 + it.contentHashCode() }
return h
}
}
data class Nack(
val nonce: ByteArray,
val reason: NackReason,
val retryAfterS: Int,
) : Message {
init {
require(nonce.size == NONCE_LEN) { "nonce must be $NONCE_LEN bytes" }
}
override val type get() = MsgType.NACK
override val payloadLen get() = NONCE_LEN + 2
override fun writePayload(buf: ByteBuffer) {
buf.put(nonce)
buf.put(reason.code.toByte())
buf.put(retryAfterS.toByte())
}
override fun equals(other: Any?): Boolean =
other is Nack &&
nonce.contentEquals(other.nonce) &&
reason == other.reason &&
retryAfterS == other.retryAfterS
override fun hashCode(): Int =
(nonce.contentHashCode() * 31 + reason.hashCode()) * 31 + retryAfterS
}
data class Hello(
val appVersionCode: Int,
val osApiLevel: Int,
val flags: Int,
val configVersion: Int,
) : Message {
override val type get() = MsgType.HELLO
override val payloadLen get() = 6
override fun writePayload(buf: ByteBuffer) {
buf.putShort(appVersionCode.toShort())
buf.put(osApiLevel.toByte())
buf.put(flags.toByte())
buf.putShort(configVersion.toShort())
}
}
data class Config(
val configVersion: Int,
val profile: Profile,
val flags: Int,
val heartbeatS: Int,
val intervalScalePct: Int,
val minDistanceM: Int,
val maxPointsPerLoc: Int,
) : Message {
override val type get() = MsgType.CONFIG
override val payloadLen get() = 12
override fun writePayload(buf: ByteBuffer) {
buf.putShort(configVersion.toShort())
buf.put(profile.code.toByte())
buf.put(flags.toByte())
buf.putShort(heartbeatS.toShort())
buf.putShort(intervalScalePct.toShort())
buf.putShort(minDistanceM.toShort())
buf.put(maxPointsPerLoc.toByte())
buf.put(0) // reserved
}
}
data class ConfigGet(val haveVersion: Int) : Message {
override val type get() = MsgType.CONFIG_GET
override val payloadLen get() = 2
override fun writePayload(buf: ByteBuffer) {
buf.putShort(haveVersion.toShort())
}
}
/**
* [echo] is opaque to the server and comes back verbatim in the [Pong], so
* this side can match a reply and measure a round trip. Put a monotonic
* reading in it — deliberately not a wall-clock time, which the server has no
* business interpreting.
*/
data class Ping(val echo: Long, val seq: Int) : Message {
override val type get() = MsgType.PING
override val payloadLen get() = 6
override fun writePayload(buf: ByteBuffer) {
buf.putInt(echo.toInt())
buf.putShort(seq.toShort())
}
}
data class Pong(val echo: Long, val seq: Int) : Message {
override val type get() = MsgType.PONG
override val payloadLen get() = 6
override fun writePayload(buf: ByteBuffer) {
buf.putInt(echo.toInt())
buf.putShort(seq.toShort())
}
}
/**
* "This token is dead; log in again."
*
* The one message opened with `K_rev` rather than `K_down`. `K_down` derives
* from the token key, so it dies with the token's row on the server — and the
* moment the server most needs to speak is exactly when that row is gone.
* `K_rev` is issued at login and derives from a server master plus this
* device's `tokenId`, so no third party and no other device can produce one.
*
* Acting on it requires no clock and no counting. Two rules and nothing else:
* the datagram must open under this device's `K_rev`, and its header
* `tokenId` must match the token currently held. A captured notice for an old
* token therefore does nothing after the next login.
*/
data class Revoked(val reason: RevokeReason) : Message {
override val type get() = MsgType.REVOKED
override val payloadLen get() = 1
override fun writePayload(buf: ByteBuffer) {
buf.put(reason.code.toByte())
}
}
companion object {
fun decodePayload(type: MsgType, payload: ByteArray): Message {
fun exact(expected: Int) {
if (payload.size != expected) {
throw WireFormatException(
"$type: expected $expected payload bytes, got ${payload.size}",
)
}
}
val buf = bufferOf(payload)
return when (type) {
MsgType.LOC -> {
if (payload.isEmpty()) throw WireFormatException("LOC: empty payload")
val count = buf.get().toInt() and 0xFF
if (count !in 1..MAX_POINTS) {
throw WireFormatException("LOC point count $count out of range 1..$MAX_POINTS")
}
exact(1 + count * POINT_LEN)
Loc(List(count) { Point.readFrom(buf) })
}
MsgType.ACK -> {
if (payload.isEmpty()) throw WireFormatException("ACK: empty payload")
val count = buf.get().toInt() and 0xFF
if (count !in 1..MAX_POINTS) {
throw WireFormatException("ACK nonce count $count out of range 1..$MAX_POINTS")
}
exact(1 + count * NONCE_LEN + 1)
val nonces = List(count) { ByteArray(NONCE_LEN).also(buf::get) }
Ack(nonces = nonces, flags = buf.get().toInt() and 0xFF)
}
MsgType.NACK -> {
exact(NONCE_LEN + 2)
Nack(
nonce = ByteArray(NONCE_LEN).also(buf::get),
reason = NackReason.fromCode(buf.get().toInt() and 0xFF),
retryAfterS = buf.get().toInt() and 0xFF,
)
}
MsgType.HELLO -> {
exact(6)
Hello(
appVersionCode = buf.short.toInt() and 0xFFFF,
osApiLevel = buf.get().toInt() and 0xFF,
flags = buf.get().toInt() and 0xFF,
configVersion = buf.short.toInt() and 0xFFFF,
)
}
MsgType.CONFIG -> {
exact(12)
val configVersion = buf.short.toInt() and 0xFFFF
val profile = Profile.fromCode(buf.get().toInt() and 0xFF)
val flags = buf.get().toInt() and 0xFF
val heartbeatS = buf.short.toInt() and 0xFFFF
val intervalScalePct = buf.short.toInt() and 0xFFFF
val minDistanceM = buf.short.toInt() and 0xFFFF
val maxPointsPerLoc = buf.get().toInt() and 0xFF
buf.get() // reserved
Config(
configVersion = configVersion,
profile = profile,
flags = flags,
heartbeatS = heartbeatS,
intervalScalePct = intervalScalePct,
minDistanceM = minDistanceM,
maxPointsPerLoc = maxPointsPerLoc,
)
}
MsgType.CONFIG_GET -> {
exact(2)
ConfigGet(haveVersion = buf.short.toInt() and 0xFFFF)
}
MsgType.PING -> {
exact(6)
Ping(
echo = buf.int.toLong() and 0xFFFFFFFFL,
seq = buf.short.toInt() and 0xFFFF,
)
}
MsgType.PONG -> {
exact(6)
Pong(
echo = buf.int.toLong() and 0xFFFFFFFFL,
seq = buf.short.toInt() and 0xFFFF,
)
}
MsgType.REVOKED -> {
exact(1)
Revoked(RevokeReason.fromCode(buf.get().toInt() and 0xFF))
}
}
}
}
}
/**
* The 21 cleartext header bytes, which are also the AEAD's additional data.
*
* Cleartext because the server must read [tokenId] to pick a key before it can
* decrypt; authenticated as AAD so a ciphertext cannot be retargeted to another
* token or another message type.
*/
data class Header(
val type: MsgType,
val tokenId: Long,
val nonce: ByteArray,
) {
init {
require(nonce.size == NONCE_LEN) { "nonce must be $NONCE_LEN bytes" }
}
fun toBytes(): ByteArray {
val b = ByteArray(HEADER_LEN)
val buf = bufferOf(b)
buf.put(((VERSION shl 4) or type.code).toByte())
buf.putLong(tokenId)
buf.put(nonce)
return b
}
override fun equals(other: Any?): Boolean =
other is Header && type == other.type && tokenId == other.tokenId &&
nonce.contentEquals(other.nonce)
override fun hashCode(): Int =
(type.hashCode() * 31 + tokenId.hashCode()) * 31 + nonce.contentHashCode()
companion object {
/**
* Parse a datagram's header without decrypting it. Length, version and
* type filtering all happen here, before any crypto is spent.
*/
fun peek(datagram: ByteArray): Header {
if (datagram.size < MIN_DATAGRAM) {
throw WireFormatException(
"datagram too short: ${datagram.size} bytes, minimum is $MIN_DATAGRAM",
)
}
if (datagram.size > MAX_DATAGRAM) {
throw WireFormatException(
"datagram too long: ${datagram.size} bytes, maximum is $MAX_DATAGRAM",
)
}
val verType = datagram[0].toInt() and 0xFF
val version = verType shr 4
if (version != VERSION) {
throw WireFormatException("unsupported protocol version $version")
}
val buf = bufferOf(datagram)
buf.get()
return Header(
type = MsgType.fromCode(verType and 0x0F),
tokenId = buf.long,
nonce = ByteArray(NONCE_LEN).also(buf::get),
)
}
}
}
/** Total datagram size for a payload of [payloadLen] bytes. */
fun datagramLen(payloadLen: Int): Int = HEADER_LEN + payloadLen + TAG_LEN
/**
* Ceiling on any datagram the server sends in reply to one it received.
*
* This is the anti-amplification control. It replaces an earlier rule that
* `response <= request` for every message type, which was achievable only by
* padding requests with reserved bytes, and which guarded against a threat
* authentication already removes: a reply is only ever sent to a datagram that
* passed AEAD verification, so a reflection attacker must already hold a live
* token key, and the leverage on offer is a ratio near 1.
*/
const val MAX_REPLY = 64
fun fitsReplyBudget(responseLen: Int): Boolean = responseLen <= MAX_REPLY
private fun bufferOf(b: ByteArray): ByteBuffer = ByteBuffer.wrap(b).order(ByteOrder.BIG_ENDIAN)
Dandroid/app/src/main/res/values/themes.xml-22
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The one res/ file that could not be omitted.
The plan assumed `@android:style/Theme.DeviceDefault.DayNight.NoActionBar`
exists so the manifest could point at a framework theme and res/ could stay
empty. It does not: the framework has `Theme.DeviceDefault.DayNight` (API 29+)
and it has `Theme.DeviceDefault.NoActionBar`, but there is no combination of the
two, and `Theme.DeviceDefault` on its own is the dark variant rather than a
day/night one.
So this file is the minimum needed for the ~150 ms pre-first-frame window to
follow the system light/dark setting *and* not flash an action bar. Six lines,
and no values-night/ counterpart — DayNight already handles that. Everything
else about the app's appearance is Compose.
-->
<resources>
<style name="Theme.OpenTracker" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources>
Dandroid/app/src/test/java/net/lexcom/opentracker/BootReceiverTest.kt-63
@@ -1,63 +0,0 @@
package net.lexcom.opentracker
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* The one piece of [BootReceiver] that is a decision rather than framework
* wiring: whether restarting after a reboot will actually produce positions.
*
* The receiver itself is not tested. Under `isReturnDefaultValues` every
* framework getter answers with a default, so a test of `onReceive` would only
* assert that the stubs still return their defaults.
*/
class BootReceiverTest {
@Test
fun `restarts when the user wants it and it can work`() {
assertTrue(
shouldRestartTracking(
enabled = true,
hasCredentials = true,
hasBackgroundLocation = true,
),
)
}
@Test
fun `does not restart what the user turned off`() {
assertFalse(
shouldRestartTracking(
enabled = false,
hasCredentials = true,
hasBackgroundLocation = true,
),
)
}
@Test
fun `does not restart without credentials`() {
// Nothing to authenticate with, so every datagram would be dropped.
assertFalse(
shouldRestartTracking(
enabled = true,
hasCredentials = false,
hasBackgroundLocation = true,
),
)
}
@Test
fun `does not restart without background location`() {
// The one that costs battery for nothing: the service would run and
// never receive a fix, because the app is not visible.
assertFalse(
shouldRestartTracking(
enabled = true,
hasCredentials = true,
hasBackgroundLocation = false,
),
)
}
}
Dandroid/app/src/test/java/net/lexcom/opentracker/FrameTest.kt-293
@@ -1,293 +0,0 @@
package net.lexcom.opentracker
import net.lexcom.opentracker.crypto.Sealer
import net.lexcom.opentracker.wire.AckFlags
import net.lexcom.opentracker.wire.HEADER_LEN
import net.lexcom.opentracker.wire.Header
import net.lexcom.opentracker.wire.MAX_DATAGRAM
import net.lexcom.opentracker.wire.MAX_POINTS
import net.lexcom.opentracker.wire.MAX_REPLY
import net.lexcom.opentracker.wire.MIN_DATAGRAM
import net.lexcom.opentracker.wire.Message
import net.lexcom.opentracker.wire.MsgType
import net.lexcom.opentracker.wire.NackReason
import net.lexcom.opentracker.wire.POINT_LEN
import net.lexcom.opentracker.wire.Point
import net.lexcom.opentracker.wire.PointFlags
import net.lexcom.opentracker.wire.Profile
import net.lexcom.opentracker.wire.RevokeReason
import net.lexcom.opentracker.wire.WireFormatException
import net.lexcom.opentracker.wire.datagramLen
import net.lexcom.opentracker.wire.fitsReplyBudget
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFails
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Codec behaviour that the golden vectors do not pin down: quantization bounds,
* sentinel handling, degenerate lengths, and tamper detection.
*
* `VectorsTest` proves this codec agrees with the Rust one. This proves it
* behaves sensibly on input the vectors do not contain.
*/
class FrameTest {
private val tokenKey = ByteArray(32) { it.toByte() }
private val kUp get() = Sealer.deriveUp(tokenKey)
private val kDown get() = Sealer.deriveDown(tokenKey)
private val kRev get() = Sealer.deriveRevocation(ByteArray(32) { 0xC3.toByte() }, tokenId)
/** REVOKED travels downlink but is sealed under K_rev, so the key cannot be
* chosen from the direction alone. */
private fun keyFor(type: MsgType) = when {
type == MsgType.REVOKED -> kRev
type.isUplink -> kUp
else -> kDown
}
private val tokenId = 0x1122334455667788L
private val nonce = ByteArray(12) { 0xA0.toByte() }
private fun allMessages() = listOf(
Message.Loc(listOf(Point(1_785_000_042L, 525_200_080, 134_050_000))),
Message.Ack(listOf(nonce), AckFlags.CONFIG_PENDING),
Message.Nack(nonce, NackReason.UNKNOWN_TOKEN, 0),
Message.Hello(2, 34, 0, 1),
Message.Config(1, Profile.BALANCED, 1, 900, 100, 20, MAX_POINTS),
Message.ConfigGet(1),
Message.Ping(0xDEADBEEFL, 2),
Message.Pong(0xDEADBEEFL, 3),
Message.Revoked(RevokeReason.EXPIRED),
)
@Test
fun `every message round trips through seal and open`() {
for (msg in allMessages()) {
val key = keyFor(msg.type)
val datagram = Sealer.sealMessage(key, tokenId, nonce, msg)
assertEquals(datagramLen(msg.payloadLen), datagram.size, "${msg.type}: size")
assertTrue(datagram.size <= MAX_DATAGRAM, "${msg.type}: over budget")
val (header, back) = Sealer.openMessage(key, datagram)
assertEquals(tokenId, header.tokenId)
assertEquals(msg.type, header.type)
assertEquals(msg, back, "${msg.type}: round trip")
}
}
@Test
fun `unknown optional fields survive as null`() {
val p = Point(ts = 1L, latE7 = 2, lonE7 = 3)
val back = Point.fromBytes(p.toBytes())
assertNull(back.accDm)
assertNull(back.altM)
assertNull(back.spdCms)
assertNull(back.brgCdeg)
assertNull(back.batPct)
assertEquals(p, back)
}
@Test
fun `an all-ones record reads as unknown except altitude`() {
// 0xFFFF as a signed altitude is -1 m, a perfectly good value, so it is
// not the sentinel — 0x8000 is. Easy to get backwards.
val p = Point.fromBytes(ByteArray(POINT_LEN) { 0xFF.toByte() })
assertNull(p.accDm)
assertNull(p.spdCms)
assertNull(p.brgCdeg)
assertNull(p.batPct)
assertEquals(-1, p.altM)
}
@Test
fun `out of range values clamp rather than becoming unknown`() {
val p = Point(
ts = 0L, latE7 = 0, lonE7 = 0,
accDm = 0xFFFF, altM = -0x8000, spdCms = 0xFFFF,
brgCdeg = 40_000, batPct = 200,
)
val back = Point.fromBytes(p.toBytes())
assertEquals(0xFFFE, back.accDm)
assertEquals(-0x7FFF, back.altM)
assertEquals(0xFFFE, back.spdCms)
assertEquals(35_999, back.brgCdeg)
assertEquals(100, back.batPct)
}
@Test
fun `timestamps past 2038 survive as unsigned`() {
// ts is unsigned 32-bit, so it is good to 2106. A signed Int would wrap
// to a negative in 2038, which is exactly the bug this guards.
val p = Point(ts = 4_000_000_000L, latE7 = 1, lonE7 = 2)
assertEquals(4_000_000_000L, Point.fromBytes(p.toBytes()).ts)
}
@Test
fun `reserved bytes are written zero and ignored on read`() {
val bytes = Point(1L, 2, 3).toBytes()
assertEquals(0, bytes[22])
assertEquals(0, bytes[23])
// A future version populating them must not change what we decode.
val future = bytes.copyOf().also { it[22] = 0x5A; it[23] = 0x3C }
assertEquals(Point.fromBytes(bytes), Point.fromBytes(future))
}
@Test
fun `quantization error stays within the documented precision`() {
val lat = 52.520008
val lon = 13.405
val p = Point(0L, Math.round(lat * 1e7).toInt(), Math.round(lon * 1e7).toInt())
val back = Point.fromBytes(p.toBytes())
assertTrue(Math.abs(back.latE7 / 1e7 - lat) < 1e-7, "latitude lost precision")
assertTrue(Math.abs(back.lonE7 / 1e7 - lon) < 1e-7, "longitude lost precision")
}
@Test
fun `documented wire sizes hold`() {
val one = Point(0L, 0, 0)
assertEquals(62, datagramLen(Message.Loc(listOf(one)).payloadLen))
assertEquals(518, datagramLen(Message.Loc(List(20) { one }).payloadLen))
assertEquals(998, datagramLen(Message.Loc(List(MAX_POINTS) { one }).payloadLen))
}
@Test
fun `a max size LOC still fits the datagram budget`() {
val msg = Message.Loc(List(MAX_POINTS) { Point(it.toLong(), it, -it) })
val datagram = Sealer.sealMessage(kUp, tokenId, nonce, msg)
assertTrue(datagram.size <= MAX_DATAGRAM)
assertEquals(msg, Sealer.openMessage(kUp, datagram).second)
}
@Test
fun `a LOC count that disagrees with the length is rejected`() {
val payload = ByteArray(1 + POINT_LEN).also { it[0] = 2 } // claims two, carries one
assertFails { Message.decodePayload(MsgType.LOC, payload) }
assertFails { Message.decodePayload(MsgType.LOC, byteArrayOf(0)) }
}
@Test
fun `the wrong direction key cannot open a datagram`() {
val datagram = Sealer.sealMessage(kUp, tokenId, nonce, Message.Ping(1L, 2))
assertFails { Sealer.openMessage(kDown, datagram) }
}
@Test
fun `every byte of the datagram is authenticated`() {
val datagram = Sealer.sealMessage(kUp, tokenId, nonce, Message.Ping(9L, 1))
for (i in datagram.indices) {
val bad = datagram.copyOf().also { it[i] = (it[i].toInt() xor 1).toByte() }
assertFails("byte $i was not authenticated") { Sealer.openMessage(kUp, bad) }
}
}
@Test
fun `a datagram cannot be retargeted to another token`() {
val datagram = Sealer.sealMessage(kUp, tokenId, nonce, Message.Ping(1L, 1))
val retargeted = datagram.copyOf()
// Overwrite the token_id field: it is cleartext, but it is also the AAD.
for (i in 1..8) retargeted[i] = 0
assertFails { Sealer.openMessage(kUp, retargeted) }
}
@Test
fun `truncated and oversized datagrams are rejected before any crypto`() {
val datagram = Sealer.sealMessage(kUp, tokenId, nonce, Message.Ping(1L, 1))
for (cut in 0 until MIN_DATAGRAM) {
assertFails("truncation to $cut accepted") { Header.peek(datagram.copyOf(cut)) }
}
assertFails { Header.peek(ByteArray(MAX_DATAGRAM + 1) { 0x11 }) }
}
@Test
fun `bad versions and unknown types are rejected`() {
val bad = ByteArray(MIN_DATAGRAM)
bad[0] = 0x21 // version 2
assertFails { Header.peek(bad) }
bad[0] = 0x1F // version 1, type 0xF
assertFails { Header.peek(bad) }
}
@Test
fun `the header is exactly the AAD`() {
val header = Header(MsgType.PING, tokenId, nonce)
val datagram = Sealer.seal(kUp, header, Message.Ping(1L, 1).encodePayload())
assertContentEquals(header.toBytes(), datagram.copyOf(HEADER_LEN))
}
@Test
fun `every reply fits the reply budget and stays near a 1x ratio`() {
val one = Point(0L, 0, 0)
val oneLoc = Message.Loc(listOf(one))
val fullLoc = Message.Loc(List(MAX_POINTS) { one })
val ackOne = Message.Ack(listOf(nonce), AckFlags.NONE)
val hello = Message.Hello(0, 0, 0, 0)
val configGet = Message.ConfigGet(0)
val config = Message.Config(0, Profile.BALANCED, 0, 0, 0, 0, 0)
val nack = Message.Nack(nonce, NackReason.MALFORMED, 0)
val revoked = Message.Revoked(RevokeReason.UNKNOWN)
for (reply in listOf(ackOne, nack, config, Message.Pong(0L, 0), revoked)) {
val len = datagramLen(reply.payloadLen)
assertTrue(fitsReplyBudget(len), "${reply.type} is $len B, over the $MAX_REPLY B budget")
}
val exchanges = listOf(
oneLoc to ackOne,
fullLoc to ackOne,
hello to ackOne,
configGet to config,
Message.Ping(0L, 0) to Message.Pong(0L, 0),
oneLoc to nack,
)
for ((req, resp) in exchanges) {
val ratio = datagramLen(resp.payloadLen).toDouble() / datagramLen(req.payloadLen)
assertTrue(ratio <= 1.5, "${req.type} -> ${resp.type} amplifies ${ratio}x")
}
}
/** The sizes both implementations must agree on. */
@Test
fun `documented message sizes hold`() {
assertEquals(51, datagramLen(Message.Ack(listOf(nonce), AckFlags.NONE).payloadLen))
assertEquals(51, datagramLen(Message.Nack(nonce, NackReason.MALFORMED, 0).payloadLen))
assertEquals(49, datagramLen(Message.Config(0, Profile.BALANCED, 0, 0, 0, 0, 0).payloadLen))
assertEquals(39, datagramLen(Message.ConfigGet(0).payloadLen))
assertEquals(43, datagramLen(Message.Ping(0L, 0).payloadLen))
assertEquals(43, datagramLen(Message.Pong(0L, 0).payloadLen))
assertEquals(43, datagramLen(Message.Hello(0, 0, 0, 0).payloadLen))
// The smallest message in the protocol, and the only reply the server
// sends without having verified the request. One byte of payload is what
// lets it be refused to anything shorter, so it can never amplify.
assertEquals(38, datagramLen(Message.Revoked(RevokeReason.UNKNOWN).payloadLen))
assertTrue(
datagramLen(Message.Revoked(RevokeReason.UNKNOWN).payloadLen) > MIN_DATAGRAM,
"a REVOKED must be larger than an empty datagram, or the length rule is vacuous",
)
}
@Test
fun `flag bits have the documented values`() {
assertEquals(1, PointFlags.CHARGING)
assertEquals(2, PointFlags.NETWORK_FIX)
assertEquals(4, PointFlags.LOW_ACCURACY)
assertEquals(8, PointFlags.MOCK)
}
@Test
fun `unknown enum codes throw a wire format error`() {
assertFails<WireFormatException> { MsgType.fromCode(0) }
assertFails<WireFormatException> { MsgType.fromCode(10) }
assertFails<WireFormatException> { Profile.fromCode(3) }
assertFails<WireFormatException> { NackReason.fromCode(0) }
assertFails<WireFormatException> { RevokeReason.fromCode(0) }
assertFails<WireFormatException> { RevokeReason.fromCode(4) }
}
}
private inline fun <reified T : Throwable> assertFails(noinline block: () -> Unit) {
val e = assertFails(block)
assertTrue(e is T, "expected ${T::class.simpleName} but got ${e::class.simpleName}: $e")
}
Dandroid/app/src/test/java/net/lexcom/opentracker/LocationSourceTest.kt-151
@@ -1,151 +0,0 @@
package net.lexcom.opentracker
import net.lexcom.opentracker.loc.ACCURACY_CEILING_M
import net.lexcom.opentracker.loc.ACCURACY_UNKNOWN_M
import net.lexcom.opentracker.loc.LAT_MAX_E7
import net.lexcom.opentracker.loc.LON_MAX_E7
import net.lexcom.opentracker.loc.SamplingPolicy
import net.lexcom.opentracker.loc.fixFrom
import net.lexcom.opentracker.loc.toE7
import net.lexcom.opentracker.wire.Point
import net.lexcom.opentracker.wire.PointFlags
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The half of the location source that has no Android in it.
*
* Unit tests run with `isReturnDefaultValues = true`, so an
* `android.location.Location` answers 0 and null to everything and proves
* nothing. These tests defend the part that survives that: the field mapping,
* the fixed-point conversion, and the agreement between an unknown accuracy and
* what the policy then does with it.
*/
class LocationSourceTest {
private val tsMs = 1_785_000_042_000L
@Test
fun `a complete reading maps every field through unchanged`() {
val fix = fixFrom(
tsMs = tsMs,
latE7 = 525_200_080,
lonE7 = 134_050_000,
accuracyM = 7.5f,
speedMps = 12.25f,
altM = 41f,
bearingDeg = 183.5f,
fromNetwork = false,
isMock = false,
)
assertEquals(tsMs, fix.tsMs)
assertEquals(525_200_080, fix.latE7)
assertEquals(134_050_000, fix.lonE7)
assertEquals(7.5f, fix.accM)
assertEquals(12.25f, fix.speedMps)
assertEquals(41f, fix.altM)
assertEquals(183.5f, fix.bearingDeg)
}
@Test
fun `an unmeasured accuracy is coarse enough for the policy to flag it`() {
// A provider that makes no accuracy claim has not earned trust. The
// constant only works if it lands above the policy's ceiling, and this
// is the test that ties the two halves together. A cold start is stale,
// so the fix is still kept rather than dropped.
assertTrue(ACCURACY_UNKNOWN_M > ACCURACY_CEILING_M)
val fix = fixFrom(
tsMs = tsMs,
latE7 = 525_200_080,
lonE7 = 134_050_000,
accuracyM = null,
speedMps = null,
altM = null,
bearingDeg = null,
fromNetwork = true,
isMock = false,
)
assertEquals(ACCURACY_UNKNOWN_M, fix.accM)
val kept = SamplingPolicy().offer(fix, tsMs).keep
assertNotNull(kept)
assertTrue(kept.flags and PointFlags.LOW_ACCURACY != 0)
}
@Test
fun `toE7 round-trips a normal coordinate`() {
assertEquals(525_200_080, toE7(52.5200080, LAT_MAX_E7))
assertEquals(-134_050_000, toE7(-13.4050000, LON_MAX_E7))
}
@Test
fun `toE7 clamps past the pole and past the antimeridian`() {
// 95 degrees of latitude is 950_000_000, which still fits an Int but is
// off the planet, and the server rejects the whole point for it.
assertEquals(LAT_MAX_E7, toE7(95.0, LAT_MAX_E7))
assertEquals(-LAT_MAX_E7, toE7(-95.0, LAT_MAX_E7))
// 200 degrees of longitude is 2_000_000_000, and doubling it overflows
// Int, so the clamp has to happen in Long.
assertEquals(LON_MAX_E7, toE7(200.0, LON_MAX_E7))
assertEquals(-LON_MAX_E7, toE7(-400.0, LON_MAX_E7))
}
@Test
fun `toE7 refuses NaN rather than reporting a place in the Atlantic`() {
// A mock provider can inject NaN. Any substitute is a position the
// phone was never at, and zero is the worst one because it looks real.
assertNull(toE7(Double.NaN, LAT_MAX_E7))
assertNull(toE7(Double.NaN, LON_MAX_E7))
}
@Test
fun `unmeasured speed altitude and bearing stay null so the wire sends sentinels`() {
val fix = fixFrom(
tsMs = tsMs,
latE7 = 525_200_080,
lonE7 = 134_050_000,
accuracyM = 20f,
speedMps = null,
altM = null,
bearingDeg = null,
fromNetwork = false,
isMock = false,
)
assertNull(fix.speedMps)
assertNull(fix.altM)
assertNull(fix.bearingDeg)
val kept = SamplingPolicy().offer(fix, tsMs).keep
assertNotNull(kept)
val decoded = Point.fromBytes(kept.toBytes())
assertNull(decoded.spdCms)
assertNull(decoded.altM)
assertNull(decoded.brgCdeg)
}
@Test
fun `a network fix and a mock fix reach the wire flags`() {
val fix = fixFrom(
tsMs = tsMs,
latE7 = 525_200_080,
lonE7 = 134_050_000,
accuracyM = 30f,
speedMps = null,
altM = null,
bearingDeg = null,
fromNetwork = true,
isMock = true,
)
assertTrue(fix.fromNetwork)
assertTrue(fix.isMock)
val kept = SamplingPolicy().offer(fix, tsMs).keep
assertNotNull(kept)
assertTrue(kept.flags and PointFlags.NETWORK_FIX != 0)
assertTrue(kept.flags and PointFlags.MOCK != 0)
}
}
Dandroid/app/src/test/java/net/lexcom/opentracker/LoginClientTest.kt-139
@@ -1,139 +0,0 @@
package net.lexcom.opentracker
import net.lexcom.opentracker.store.parseLoginResponse
import net.lexcom.opentracker.store.parseTokenId
import java.util.Base64
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Guards the login reply parser, which is the only place a wrong token id or a
* short key can enter this app.
*
* Both failures are invisible at the point they happen: the device would just
* send datagrams the server drops. So they are caught here, at parse time.
*
* No socket is involved. `parseLoginResponse` is pure, which is the whole reason
* it is separate from the HTTP call.
*/
class LoginClientTest {
private val tokenKey = ByteArray(32) { it.toByte() }
private val revokeKey = ByteArray(32) { (0xFF - it).toByte() }
private val tokenKeyB64 = Base64.getEncoder().encodeToString(tokenKey)
private val revokeKeyB64 = Base64.getEncoder().encodeToString(revokeKey)
private fun body(
tokenId: String = "81985529216486895",
tokenKey: String = tokenKeyB64,
revokeKey: String = revokeKeyB64,
tlsUrl: String? = "\"https://track.example.net\"",
udpPort: String = "5353",
) = """
{
"user": {"id": 1, "username": "ada", "display_name": "Ada",
"is_admin": false, "server_time": 1785000042},
"device": {
"token_id": $tokenId,
"token_key": "$tokenKey",
"revoke_key": "$revokeKey",
"udp_host": "track.example.net",
"udp_port": $udpPort,
${if (tlsUrl != null) "\"tls_url\": $tlsUrl," else ""}
"config": {"config_version": 3, "profile": "balanced"}
}
}
""".trimIndent()
@Test
fun `a device login maps to credentials`() {
val c = parseLoginResponse(body())
assertEquals(81_985_529_216_486_895L, c.tokenId)
assertContentEquals(tokenKey, c.tokenKey)
assertContentEquals(revokeKey, c.kRev)
assertEquals("track.example.net", c.udpHost)
assertEquals(5353, c.udpPort)
assertEquals("https://track.example.net", c.tlsUrl)
assertEquals(3, c.configVersion)
}
@Test
fun `a token id above 2 to the 63 keeps its exact bit pattern`() {
// 0xFEDCBA9876543210 as an unsigned decimal. It is above Long.MAX_VALUE,
// so it only survives as a negative Long with the same 64 bits. Android's
// org.json turns such a literal into a Double and getLong then clamps it,
// which is why the digits are read from the raw body instead.
val c = parseLoginResponse(body(tokenId = "18364758544493064720"))
assertEquals(-0x0123456789abcdf0L, c.tokenId)
assertEquals("fedcba9876543210", java.lang.Long.toHexString(c.tokenId))
}
@Test
fun `the largest token id round trips`() {
assertEquals(-1L, parseTokenId("""{"token_id": 18446744073709551615}"""))
}
@Test
fun `a token id above 2 to the 53 is not rounded`() {
// The first integer a Double cannot represent. A parser that goes through
// a Double answers 9007199254740992 here.
assertEquals(9_007_199_254_740_993L, parseTokenId("""{"token_id": 9007199254740993}"""))
}
@Test
fun `a token id wider than 64 bits is rejected`() {
assertFailsWith<NumberFormatException> {
parseTokenId("""{"token_id": 18446744073709551616}""")
}
}
@Test
fun `a short token key is a corrupt login`() {
val short = Base64.getEncoder().encodeToString(ByteArray(31))
val e = assertFailsWith<IllegalArgumentException> {
parseLoginResponse(body(tokenKey = short))
}
assertTrue(e.message!!.contains("31 bytes"))
}
@Test
fun `a short revoke key is a corrupt login`() {
val short = Base64.getEncoder().encodeToString(ByteArray(16))
assertFailsWith<IllegalArgumentException> {
parseLoginResponse(body(revokeKey = short))
}
}
@Test
fun `a key that is not base64 is rejected`() {
assertFailsWith<IllegalArgumentException> {
parseLoginResponse(body(tokenKey = "not base64!!"))
}
}
@Test
fun `an absent tls_url reads as null`() {
assertNull(parseLoginResponse(body(tlsUrl = null)).tlsUrl)
}
@Test
fun `an explicit null tls_url reads as null`() {
assertNull(parseLoginResponse(body(tlsUrl = "null")).tlsUrl)
}
@Test
fun `a browser login carries no device credentials`() {
val browser = """{"user": {"id": 1, "username": "ada", "display_name": "Ada",
"is_admin": false, "server_time": 1785000042}}"""
assertFailsWith<IllegalArgumentException> { parseLoginResponse(browser) }
}
@Test
fun `a nonsense udp port is rejected`() {
assertFailsWith<IllegalArgumentException> { parseLoginResponse(body(udpPort = "0")) }
}
}
Dandroid/app/src/test/java/net/lexcom/opentracker/PointQueueTest.kt-150
@@ -1,150 +0,0 @@
package net.lexcom.opentracker
import net.lexcom.opentracker.queue.PointQueue
import net.lexcom.opentracker.wire.Point
import java.io.File
import java.io.RandomAccessFile
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* The queue's contract under the two things that actually happen to a phone:
* being killed mid-write, and running out of ring while the network is down.
*/
class PointQueueTest {
private val file = File.createTempFile("pointqueue", ".bin").also { it.delete() }
@AfterTest
fun cleanUp() {
file.delete()
}
/** Distinct enough that a mixed-up order or a stale slot is visible. */
private fun point(i: Int) = Point(ts = 1_785_000_000L + i, latE7 = 525_200_000 + i, lonE7 = i)
private fun open(capacity: Int) = PointQueue(file, capacity)
private fun slotSize() = 32L
@Test
fun `a fresh file is empty`() {
open(8).use { q ->
assertEquals(0, q.size)
assertEquals(emptyList(), q.peek(10))
}
assertEquals(8 * slotSize(), file.length())
}
@Test
fun `points come back in the order they were appended`() {
open(8).use { q ->
repeat(5) { q.append(point(it)) }
assertEquals(5, q.size)
assertEquals(List(5) { point(it) }, q.peek(10))
}
}
@Test
fun `peek returns at most max and never more than it holds`() {
open(8).use { q ->
repeat(5) { q.append(point(it)) }
assertEquals(List(2) { point(it) }, q.peek(2))
assertEquals(5, q.peek(99).size)
assertEquals(emptyList(), q.peek(0))
// Peeking does not consume.
assertEquals(5, q.size)
}
}
@Test
fun `ack removes exactly the acked prefix`() {
open(8).use { q ->
repeat(5) { q.append(point(it)) }
q.peek(2)
q.ack(2)
assertEquals(3, q.size)
assertEquals(listOf(point(2), point(3), point(4)), q.peek(10))
q.ack(99) // over-acking drains rather than throwing
assertEquals(0, q.size)
}
}
@Test
fun `an ack is ignored when the ring lapped past the peeked batch`() {
open(4).use { q ->
repeat(3) { q.append(point(it)) }
assertEquals(listOf(point(0), point(1)), q.peek(2))
// The send is in flight while sampling keeps going, and the ring
// wraps past everything that was peeked.
repeat(4) { q.append(point(10 + it)) }
q.ack(2)
// Points 10 and 11 were never sent, so they must still be here.
assertEquals(listOf(point(10), point(11), point(12), point(13)), q.peek(10))
}
}
@Test
fun `reopening recovers the unacked points in order`() {
open(8).use { q ->
repeat(5) { q.append(point(it)) }
q.peek(2)
q.ack(2)
}
open(8).use { q ->
assertEquals(3, q.size)
assertEquals(listOf(point(2), point(3), point(4)), q.peek(10))
// The recovered sequence continues, it does not restart over a live slot.
q.append(point(9))
assertEquals(listOf(point(2), point(3), point(4), point(9)), q.peek(10))
}
}
@Test
fun `a torn write is not read back as a point`() {
open(8).use { q -> repeat(3) { q.append(point(it)) } }
// Point 1 lands in slot 2 (seq 2). Damage its CRC the way a kill between
// two writes would: the slot is no longer self-consistent.
RandomAccessFile(file, "rwd").use {
it.seek(2 * slotSize() + 28)
it.writeInt(0x0BADC0DE)
}
open(8).use { q ->
assertEquals(2, q.size)
assertEquals(listOf(point(0), point(2)), q.peek(10))
}
}
@Test
fun `a full ring drops the oldest points and keeps the newest`() {
val capacity = 4
open(capacity).use { q ->
repeat(capacity + 5) { q.append(point(it)) }
assertEquals(capacity, q.size)
assertEquals(listOf(point(5), point(6), point(7), point(8)), q.peek(10))
}
// The overwrite is durable, not just an in-memory bookkeeping trick.
open(capacity).use { q ->
assertEquals(listOf(point(5), point(6), point(7), point(8)), q.peek(10))
}
}
@Test
fun `the file never grows beyond its preallocated size`() {
val capacity = 4
open(capacity).use { q -> repeat(100) { q.append(point(it)) } }
assertEquals(capacity * slotSize(), file.length())
}
@Test
fun `a file written with another capacity is discarded rather than misread`() {
open(8).use { q -> repeat(5) { q.append(point(it)) } }
open(16).use { q ->
// Slot indices would all move, so keeping the old bytes would hand
// back points in the wrong order. Starting empty is the honest answer.
assertEquals(0, q.size)
}
assertEquals(16 * slotSize(), file.length())
}
}
Dandroid/app/src/test/java/net/lexcom/opentracker/PrefsTest.kt-45
@@ -1,45 +0,0 @@
package net.lexcom.opentracker
import net.lexcom.opentracker.store.decodeStoredKey
import net.lexcom.opentracker.store.encodeStoredKey
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertNull
/**
* Covers only the encode/decode pair `Prefs` stores its two keys with.
*
* `SharedPreferences` itself cannot be exercised here: `isReturnDefaultValues`
* makes every framework method a no-op, so a test around `Prefs.load()` would
* assert nothing. What matters and is testable is the rule that a damaged stored
* key reads back as null, never as a wrong-length key.
*/
class PrefsTest {
@Test
fun `a stored key round trips`() {
val key = ByteArray(32) { (it * 7).toByte() }
assertContentEquals(key, decodeStoredKey(encodeStoredKey(key)))
}
@Test
fun `a missing key reads as null`() {
assertNull(decodeStoredKey(null))
}
@Test
fun `a truncated key reads as null rather than as a short key`() {
val full = encodeStoredKey(ByteArray(32))
assertNull(decodeStoredKey(full.substring(0, 20)))
}
@Test
fun `a key of the wrong length reads as null`() {
assertNull(decodeStoredKey(encodeStoredKey(ByteArray(16))))
}
@Test
fun `a non-base64 value reads as null`() {
assertNull(decodeStoredKey("not base64!!"))
}
}
Dandroid/app/src/test/java/net/lexcom/opentracker/SamplingPolicyTest.kt-234
@@ -1,234 +0,0 @@
package net.lexcom.opentracker
import net.lexcom.opentracker.loc.DWELL_TIMEOUT_MS
import net.lexcom.opentracker.loc.Fix
import net.lexcom.opentracker.loc.HEARTBEAT_MS
import net.lexcom.opentracker.loc.Motion
import net.lexcom.opentracker.loc.STALENESS_TIMEOUT_MS
import net.lexcom.opentracker.loc.SamplingPolicy
import net.lexcom.opentracker.wire.PointFlags
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The policy is a state machine over a fake clock: every test passes its own
* `nowMs`, so nothing here waits and nothing here needs an emulator.
*/
class SamplingPolicyTest {
private val baseLatE7 = 525_200_080
private val baseLonE7 = 134_050_000
private val baseTsMs = 1_785_000_042_000L
/** ~1.11 cm per 1e-7 degree of latitude, so this is metres north. */
private fun northE7(metres: Double): Int = (metres / 0.0111).toInt()
private fun fix(
atMs: Long,
northM: Double = 0.0,
accM: Float = 8f,
speedMps: Float? = null,
altM: Float? = null,
bearingDeg: Float? = null,
fromNetwork: Boolean = false,
isMock: Boolean = false,
) = Fix(
tsMs = baseTsMs + atMs,
latE7 = baseLatE7 + northE7(northM),
lonE7 = baseLonE7,
accM = accM,
speedMps = speedMps,
altM = altM,
bearingDeg = bearingDeg,
fromNetwork = fromNetwork,
isMock = isMock,
)
@Test
fun `a device that reports no speed still leaves STATIONARY`() {
// Network-only fixes carry no speed field. The policy derives one from
// two positions, and the gap it tolerates has to cover a full
// STATIONARY sampling interval or the device would never wake up.
val policy = SamplingPolicy()
val interval = policy.request.intervalMs
policy.offer(fix(0, fromNetwork = true), 0)
// One interval later, 3 km further north: unmistakably driving.
val d = policy.offer(fix(interval, northM = 3_000.0, fromNetwork = true), interval)
assertEquals(Motion.VEHICLE, d.motion)
assertNotNull(d.keep)
}
@Test
fun `a sustained fast speed enters VEHICLE and shortens the request interval`() {
val policy = SamplingPolicy()
val idle = policy.request
var d = policy.offer(fix(0, speedMps = 12f), 0)
assertEquals(Motion.VEHICLE, d.motion)
assertTrue(d.requestChanged)
d = policy.offer(fix(5_000, northM = 60.0, speedMps = 12f), 5_000)
assertEquals(Motion.VEHICLE, d.motion)
assertTrue(policy.request.intervalMs < idle.intervalMs)
assertTrue(policy.request.minDistanceM < idle.minDistanceM)
}
@Test
fun `movement stops but the mode stays in DWELL until the dwell timeout`() {
val policy = SamplingPolicy()
policy.offer(fix(0, speedMps = 2f), 0)
assertEquals(Motion.WALK, policy.motion)
val stopped = 10_000L
assertEquals(Motion.DWELL, policy.offer(fix(stopped, speedMps = 0f), stopped).motion)
val nearly = stopped + DWELL_TIMEOUT_MS - 1
assertEquals(Motion.DWELL, policy.offer(fix(nearly, speedMps = 0f), nearly).motion)
val over = stopped + DWELL_TIMEOUT_MS
assertEquals(Motion.STATIONARY, policy.offer(fix(over, speedMps = 0f), over).motion)
}
@Test
fun `entering a faster mode takes one fix but leaving one does not`() {
val policy = SamplingPolicy()
assertEquals(Motion.VEHICLE, policy.offer(fix(0, speedMps = 30f), 0).motion)
// Walking pace while in VEHICLE is a traffic jam until it persists.
assertEquals(Motion.VEHICLE, policy.offer(fix(1_000, speedMps = 2f), 1_000).motion)
val over = 1_000 + DWELL_TIMEOUT_MS
assertEquals(Motion.WALK, policy.offer(fix(over, northM = 400.0, speedMps = 2f), over).motion)
}
@Test
fun `GNSS is asked for in every mode except STATIONARY`() {
// The whole point of step 13: a parked phone runs on the network
// provider alone, and every mode that implies real movement gets GPS.
val policy = SamplingPolicy()
assertEquals(Motion.STATIONARY, policy.motion)
assertFalse(policy.request.useGnss)
assertEquals(Motion.VEHICLE, policy.offer(fix(0, speedMps = 30f), 0).motion)
assertTrue(policy.request.useGnss)
// Slowing down takes two fixes: the first only starts the timer.
policy.offer(fix(1_000, speedMps = 2f), 1_000)
val walking = 1_000 + DWELL_TIMEOUT_MS
policy.offer(fix(walking, northM = 400.0, speedMps = 2f), walking)
assertEquals(Motion.WALK, policy.motion)
assertTrue(policy.request.useGnss)
val stopped = walking + 1_000
policy.offer(fix(stopped, speedMps = 0f), stopped)
assertEquals(Motion.DWELL, policy.motion)
assertTrue(policy.request.useGnss)
}
@Test
fun `a motion trigger promotes STATIONARY to DWELL and changes the request`() {
// With GNSS off there is no fix that could report the departure, so
// the sensor is the only thing that can. DWELL, not WALK: the trigger
// says something moved, not what.
val policy = SamplingPolicy()
assertTrue(policy.wake(1_000))
assertEquals(Motion.DWELL, policy.motion)
assertTrue(policy.request.useGnss)
}
@Test
fun `a motion trigger in a faster mode changes nothing`() {
val policy = SamplingPolicy()
policy.offer(fix(0, speedMps = 30f), 0)
assertEquals(Motion.VEHICLE, policy.motion)
val before = policy.request
assertFalse(policy.wake(1_000))
assertEquals(Motion.VEHICLE, policy.motion)
assertEquals(before, policy.request)
}
@Test
fun `a motion trigger with no real movement behind it falls back to STATIONARY`() {
// This is what makes a false trigger cheap. A pocket or a passing lorry
// buys one dwell period of GNSS and nothing more, so no separate
// timeout is needed for the wake path.
val policy = SamplingPolicy()
val woke = 1_000L
assertTrue(policy.wake(woke))
val nearly = woke + DWELL_TIMEOUT_MS - 1
assertEquals(Motion.DWELL, policy.offer(fix(nearly, speedMps = 0f), nearly).motion)
val over = woke + DWELL_TIMEOUT_MS
assertEquals(Motion.STATIONARY, policy.offer(fix(over, speedMps = 0f), over).motion)
assertFalse(policy.request.useGnss)
}
@Test
fun `a fix worse than the accuracy ceiling is discarded`() {
val policy = SamplingPolicy()
assertNotNull(policy.offer(fix(0), 0).keep)
assertNull(policy.offer(fix(1_000, northM = 400.0, accM = 300f), 1_000).keep)
}
@Test
fun `a coarse fix is kept with LOW_ACCURACY once nothing has been kept for the staleness timeout`() {
val policy = SamplingPolicy()
assertNotNull(policy.offer(fix(0), 0).keep)
val late = STALENESS_TIMEOUT_MS
val kept = policy.offer(fix(late, northM = 400.0, accM = 300f), late).keep
assertNotNull(kept)
assertEquals(PointFlags.LOW_ACCURACY, kept.flags and PointFlags.LOW_ACCURACY)
}
@Test
fun `a fix within the mode min distance of the last kept point is discarded`() {
val policy = SamplingPolicy()
assertNotNull(policy.offer(fix(0, speedMps = 0f), 0).keep)
assertNull(policy.offer(fix(60_000, northM = 4.0, speedMps = 0f), 60_000).keep)
}
@Test
fun `the heartbeat interval still reports a parked phone`() {
val policy = SamplingPolicy()
assertNotNull(policy.offer(fix(0, speedMps = 0f), 0).keep)
val late = HEARTBEAT_MS
assertNotNull(policy.offer(fix(late, northM = 4.0, speedMps = 0f), late).keep)
}
@Test
fun `a fix converts to a point with the documented units`() {
val policy = SamplingPolicy()
val kept = policy.offer(
fix(0, accM = 12.3f, speedMps = 5.5f, altM = 33.4f, bearingDeg = 91.5f),
0,
).keep
assertNotNull(kept)
assertEquals(1_785_000_042L, kept.ts)
assertEquals(baseLatE7, kept.latE7)
assertEquals(123, kept.accDm)
assertEquals(550, kept.spdCms)
assertEquals(9_150, kept.brgCdeg)
assertEquals(33, kept.altM)
// Battery belongs to the service, not the policy.
assertNull(kept.batPct)
}
@Test
fun `unmeasured fields stay null and a wrapped bearing stays in range`() {
val policy = SamplingPolicy()
val kept = policy.offer(fix(0, bearingDeg = 360f), 0).keep
assertNotNull(kept)
assertNull(kept.spdCms)
assertNull(kept.altM)
assertEquals(0, kept.brgCdeg)
}
@Test
fun `network and mock flags come from the fix`() {
val policy = SamplingPolicy()
val kept = policy.offer(fix(0, fromNetwork = true, isMock = true), 0).keep
assertNotNull(kept)
assertEquals(PointFlags.NETWORK_FIX or PointFlags.MOCK, kept.flags)
}
}
Dandroid/app/src/test/java/net/lexcom/opentracker/TrackerServiceTest.kt-49
@@ -1,49 +0,0 @@
package net.lexcom.opentracker
import android.os.BatteryManager
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The one piece of [TrackerService] that is logic rather than framework wiring:
* the battery Intent's extras turned into a percentage and a charging flag.
*
* Everything else in that file is a Handler, a Service callback or a call into a
* class that already has its own JVM test, so it is not tested here.
*/
class TrackerServiceTest {
@Test
fun `scales level against scale`() {
assertEquals(50, batteryOf(50, 100, 0).pct)
// Some devices report a scale other than 100.
assertEquals(50, batteryOf(128, 256, 0).pct)
assertEquals(100, batteryOf(100, 100, 0).pct)
assertEquals(0, batteryOf(0, 100, 0).pct)
}
@Test
fun `unknown rather than wrong when the extras are missing`() {
// A scale of 0 would divide by zero; a missing extra reads back as -1.
assertNull(batteryOf(50, 0, 0).pct)
assertNull(batteryOf(-1, -1, -1).pct)
assertNull(batteryOf(-1, 100, 0).pct)
}
@Test
fun `a broken level cannot leave the 0 to 100 range`() {
assertEquals(100, batteryOf(200, 100, 0).pct)
}
@Test
fun `charging covers full as well`() {
assertTrue(batteryOf(50, 100, BatteryManager.BATTERY_STATUS_CHARGING).charging)
// FULL is still on the cable, and the wire flag means "on power".
assertTrue(batteryOf(100, 100, BatteryManager.BATTERY_STATUS_FULL).charging)
assertFalse(batteryOf(50, 100, BatteryManager.BATTERY_STATUS_DISCHARGING).charging)
assertFalse(batteryOf(50, 100, -1).charging)
}
}
Dandroid/app/src/test/java/net/lexcom/opentracker/UplinkTest.kt-347
@@ -1,347 +0,0 @@
package net.lexcom.opentracker
import net.lexcom.opentracker.crypto.Sealer
import net.lexcom.opentracker.net.Round
import net.lexcom.opentracker.net.Transport
import net.lexcom.opentracker.net.Uplink
import net.lexcom.opentracker.queue.PointQueue
import net.lexcom.opentracker.store.Credentials
import net.lexcom.opentracker.wire.AckFlags
import net.lexcom.opentracker.wire.Header
import net.lexcom.opentracker.wire.Message
import net.lexcom.opentracker.wire.MsgType
import net.lexcom.opentracker.wire.NackReason
import net.lexcom.opentracker.wire.Point
import net.lexcom.opentracker.wire.RevokeReason
import java.io.File
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
/**
* The uplink against a fake server that speaks the real protocol.
*
* The fake opens every datagram with the real [Sealer] and answers the real
* nonce, so a passing test proves the bytes a server would actually receive.
*
* What is defended here: points leave the queue only once the server confirms
* them, a revocation is acted on under exactly the two rules `Message.Revoked`
* states, backpressure is obeyed, and nothing arriving on an open UDP port can
* throw out of the loop.
*/
class UplinkTest {
private val file = File.createTempFile("uplink", ".bin").also { it.delete() }
private val tokenId = 0x0123456789abcdefL
private val otherTokenId = 0x00000000deadbeefL
private val tokenKey = ByteArray(32) { it.toByte() }
private val kRev = ByteArray(32) { (0xA0 + it).toByte() }
private val kUp = Sealer.deriveUp(tokenKey)
private val kDown = Sealer.deriveDown(tokenKey)
private val credentials = Credentials(
tokenId = tokenId,
tokenKey = tokenKey,
kRev = kRev,
udpHost = "127.0.0.1",
udpPort = 7373,
tlsUrl = null,
configVersion = 3,
)
private val queue = PointQueue(file, 64)
private var clockMs = 1_785_000_000_000L
@AfterTest
fun cleanUp() {
queue.close()
file.delete()
}
/**
* A server on a wire with no latency.
*
* [respond] is handed the request it just received, so a reply can name the
* nonce the uplink actually chose. That is the one thing a canned reply
* queue could not do, and nonce matching is most of what these tests check.
*/
private inner class FakeServer : Transport {
val sent = ArrayDeque<ByteArray>()
val replies = ArrayDeque<ByteArray>()
var respond: (Header, Message) -> List<ByteArray> = { _, _ -> emptyList() }
override fun send(datagram: ByteArray) {
sent.addLast(datagram)
val (header, msg) = Sealer.openMessage(kUp, datagram)
replies.addAll(respond(header, msg))
}
/** Null is a timeout, which is what an empty reply queue means. */
override fun receive(timeoutMs: Int): ByteArray? = replies.removeFirstOrNull()
override fun close() = Unit
/** The last request, as the server saw it. */
fun lastRequest(): Pair<Header, Message> = Sealer.openMessage(kUp, sent.last())
}
private val server = FakeServer()
private fun uplink() = Uplink(server, queue, credentials) { clockMs }
private fun point(i: Int) =
Point(ts = 1_785_000_000L + i, latE7 = 525_200_000 + i, lonE7 = 134_050_000 + i, batPct = 70)
private fun sealDown(msg: Message) = Sealer.sealMessage(kDown, tokenId, Sealer.newNonce(), msg)
private fun ack(nonce: ByteArray, flags: Int = AckFlags.NONE) =
sealDown(Message.Ack(listOf(nonce), flags))
private fun nack(nonce: ByteArray, reason: NackReason, retryAfterS: Int = 0) =
sealDown(Message.Nack(nonce, reason, retryAfterS))
/** Answers every request with a plain ACK for its own nonce. */
private fun acksEverything() {
server.respond = { header, _ -> listOf(ack(header.nonce)) }
}
@Test
fun `a queued point goes out as a LOC the server can open and decode`() {
queue.append(point(1))
acksEverything()
uplink().sendRound()
val (header, msg) = server.lastRequest()
assertEquals(MsgType.LOC, header.type)
assertEquals(tokenId, header.tokenId)
assertEquals(Message.Loc(listOf(point(1))), msg)
}
@Test
fun `an ACK carrying the sent nonce clears the queue`() {
queue.append(point(1))
queue.append(point(2))
acksEverything()
val result = assertIs<Round.Acked>(uplink().sendRound())
assertEquals(2, result.count)
assertEquals(0, queue.size)
}
@Test
fun `an ACK carrying a different nonce clears nothing`() {
queue.append(point(1))
// An ACK retires the LOC datagram it names. One for another datagram is
// no evidence at all that these points were stored.
server.respond = { _, _ -> listOf(ack(ByteArray(12) { 0x77 })) }
assertIs<Round.NoReply>(uplink().sendRound())
assertEquals(1, queue.size)
}
@Test
fun `a stale ACK does not hide the real one behind it`() {
queue.append(point(1))
server.respond = { header, _ -> listOf(ack(ByteArray(12) { 0x77 }), ack(header.nonce)) }
assertEquals(1, assertIs<Round.Acked>(uplink().sendRound()).count)
assertEquals(0, queue.size)
}
@Test
fun `no reply at all leaves the points queued for the next round`() {
queue.append(point(1))
queue.append(point(2))
assertIs<Round.NoReply>(uplink().sendRound())
assertEquals(2, queue.size)
assertEquals(1, server.sent.size)
// The retry sends the same two points again.
val u = uplink()
acksEverything()
assertEquals(2, assertIs<Round.Acked>(u.sendRound()).count)
assertEquals(0, queue.size)
}
@Test
fun `a silent round backs off before it touches the socket again`() {
queue.append(point(1))
val u = uplink()
assertIs<Round.NoReply>(u.sendRound())
// Same millisecond, so the wait has not expired and nothing is sent.
assertIs<Round.BackOff>(u.sendRound())
assertEquals(1, server.sent.size)
// Connectivity returning cancels the wait outright.
u.clearBackOff()
u.sendRound()
assertEquals(2, server.sent.size)
}
@Test
fun `a REVOKED under K_rev naming this token reports the token as dead`() {
queue.append(point(1))
server.respond = { _, _ ->
listOf(
Sealer.sealMessage(
kRev,
tokenId,
Sealer.newNonce(),
Message.Revoked(RevokeReason.REVOKED),
),
)
}
val result = assertIs<Round.TokenDead>(uplink().sendRound())
assertEquals(RevokeReason.REVOKED, result.reason)
// A dead token is not a delivery. The points stay.
assertEquals(1, queue.size)
}
@Test
fun `a REVOKED naming a different token is ignored`() {
queue.append(point(1))
// Correctly sealed for that other token, then captured off the wire. The
// header token id is the only thing that makes it inert here.
val otherKRev = Sealer.deriveRevocation(ByteArray(32) { 9 }, otherTokenId)
server.respond = { _, _ ->
listOf(
Sealer.sealMessage(
otherKRev,
otherTokenId,
Sealer.newNonce(),
Message.Revoked(RevokeReason.EXPIRED),
),
)
}
assertIs<Round.NoReply>(uplink().sendRound())
assertEquals(1, queue.size)
}
@Test
fun `a REVOKED sealed under the wrong key is ignored`() {
queue.append(point(1))
// Right token id, right message, wrong key. K_down must not be enough to
// declare this device finished.
server.respond = { _, _ -> listOf(sealDown(Message.Revoked(RevokeReason.REVOKED))) }
assertIs<Round.NoReply>(uplink().sendRound())
assertEquals(1, queue.size)
}
@Test
fun `a NACK with UNKNOWN_TOKEN reports the token as dead`() {
queue.append(point(1))
server.respond = { header, _ -> listOf(nack(header.nonce, NackReason.UNKNOWN_TOKEN)) }
// Null reason: a NACK carries no revoke reason code.
assertNull(assertIs<Round.TokenDead>(uplink().sendRound()).reason)
assertEquals(1, queue.size)
}
@Test
fun `a NACK with retryAfterS is honoured rather than ignored`() {
queue.append(point(1))
server.respond = { header, _ -> listOf(nack(header.nonce, NackReason.RATE_LIMITED, 30)) }
val u = uplink()
val throttled = assertIs<Round.Throttled>(u.sendRound())
assertEquals(NackReason.RATE_LIMITED, throttled.reason)
assertEquals(clockMs + 30_000L, throttled.retryAtMs)
assertEquals(1, queue.size)
val sentSoFar = server.sent.size
clockMs += 29_000
assertIs<Round.BackOff>(u.sendRound())
assertEquals(sentSoFar, server.sent.size)
clockMs += 2_000
u.sendRound()
assertEquals(sentSoFar + 1, server.sent.size)
}
@Test
fun `a throttle with no retry hint still waits`() {
queue.append(point(1))
server.respond = { header, _ -> listOf(nack(header.nonce, NackReason.STORAGE_FULL, 0)) }
val u = uplink()
val throttled = assertIs<Round.Throttled>(u.sendRound())
assertEquals(clockMs + Uplink.MIN_BACKOFF_MS, throttled.retryAtMs)
assertIs<Round.BackOff>(u.sendRound())
}
@Test
fun `a NACK with MALFORMED drops points the server will never accept`() {
queue.append(point(1))
// Retrying a rejected payload forever would wedge the queue head.
server.respond = { header, _ -> listOf(nack(header.nonce, NackReason.MALFORMED)) }
assertEquals(1, assertIs<Round.Dropped>(uplink().sendRound()).count)
assertEquals(0, queue.size)
}
@Test
fun `garbage a truncated datagram and an unknown type code are all dropped`() {
queue.append(point(1))
server.respond = { header, _ ->
val good = ack(header.nonce)
listOf(
ByteArray(0),
ByteArray(4) { 0xFF.toByte() }, // shorter than any header
ByteArray(80) { 0x5A }, // right length, wrong everything
good.copyOf(good.size / 2), // truncated mid-ciphertext
// Version 1, message type 0xF. MsgType.fromCode throws on that
// code, and the throw must not reach the service.
good.copyOf().also { it[0] = 0x1F },
)
}
assertIs<Round.NoReply>(uplink().sendRound())
assertEquals(1, queue.size)
}
@Test
fun `an uplink message arriving at the device is dropped`() {
queue.append(point(1))
// Our own LOC replayed back at us. It opens under no downlink key, and
// the type check rejects it before any of that.
server.respond = { _, _ -> listOf(server.sent.last()) }
assertIs<Round.NoReply>(uplink().sendRound())
assertEquals(1, queue.size)
}
@Test
fun `HELLO carries the config version and its ACK reports a pending config`() {
server.respond = { header, _ -> listOf(ack(header.nonce, AckFlags.CONFIG_PENDING)) }
val result = assertIs<Round.Acked>(uplink().hello(appVersionCode = 7, osApiLevel = 29))
val (header, msg) = server.lastRequest()
assertEquals(MsgType.HELLO, header.type)
val hello = assertIs<Message.Hello>(msg)
assertEquals(3, hello.configVersion)
assertEquals(7, hello.appVersionCode)
assertEquals(29, hello.osApiLevel)
// A HELLO retires no points, so the queue is never touched.
assertEquals(0, result.count)
assertEquals(true, result.configPending)
}
@Test
fun `an empty queue sends nothing`() {
assertEquals(Round.Idle, uplink().sendRound())
assertEquals(0, server.sent.size)
}
}
Dandroid/app/src/test/java/net/lexcom/opentracker/VectorsTest.kt-282
@@ -1,282 +0,0 @@
package net.lexcom.opentracker
import net.lexcom.opentracker.crypto.Sealer
import net.lexcom.opentracker.wire.HEADER_LEN
import net.lexcom.opentracker.wire.Header
import net.lexcom.opentracker.wire.MAX_DATAGRAM
import net.lexcom.opentracker.wire.MAX_POINTS
import net.lexcom.opentracker.wire.Message
import net.lexcom.opentracker.wire.MsgType
import net.lexcom.opentracker.wire.NackReason
import net.lexcom.opentracker.wire.POINT_LEN
import net.lexcom.opentracker.wire.Point
import net.lexcom.opentracker.wire.Profile
import net.lexcom.opentracker.wire.RevokeReason
import net.lexcom.opentracker.wire.TAG_LEN
import net.lexcom.opentracker.wire.VERSION
import org.json.JSONArray
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFails
import kotlin.test.assertTrue
/**
* Decodes `crates/otproto/tests/vectors.json` — the same file the Rust test
* suite checks — and verifies this Kotlin codec reproduces every byte.
*
* This is the whole reason the wire format cannot drift on one side only. The
* file is read from the Rust crate's directory (wired up as a test resource in
* `build.gradle.kts`), so there is exactly one copy of it and no sync step to
* forget.
*
* Runs on the JVM: JDK 11+ SunJCE ships ChaCha20-Poly1305 via JEP 329, so no
* emulator is needed to test the crypto.
*/
class VectorsTest {
private val root: JSONObject by lazy {
val stream = checkNotNull(javaClass.classLoader?.getResourceAsStream("vectors.json")) {
"vectors.json is not on the test classpath — check the test resources srcDir"
}
JSONObject(stream.bufferedReader().readText())
}
private val tokenKey: ByteArray by lazy { Sealer.hexToBytes(root.getString("token_key_hex")) }
private val tokenId: Long by lazy { root.getLong("token_id") }
private val kUp: ByteArray by lazy { Sealer.deriveUp(tokenKey) }
private val kDown: ByteArray by lazy { Sealer.deriveDown(tokenKey) }
private val revocationMaster: ByteArray by lazy {
Sealer.hexToBytes(root.getString("revocation_master_hex"))
}
private val kRev: ByteArray by lazy { Sealer.deriveRevocation(revocationMaster, tokenId) }
/** Which key seals a case, recorded explicitly: REVOKED is downlink but is
* sealed under K_rev so it can outlive the token's row on the server. */
private fun keyFor(name: String): ByteArray = when (name) {
"up" -> kUp
"down" -> kDown
"rev" -> kRev
else -> error("unknown key $name")
}
@Test
fun `constants agree with the generator`() {
assertEquals("OTP/1", root.getString("protocol"))
assertEquals(VERSION, root.getInt("version"))
assertEquals(HEADER_LEN, root.getInt("header_len"))
assertEquals(TAG_LEN, root.getInt("tag_len"))
assertEquals(MAX_DATAGRAM, root.getInt("max_datagram"))
assertEquals(MAX_POINTS, root.getInt("max_points"))
}
@Test
fun `key derivation matches`() {
assertEquals(root.getString("k_up_hex"), Sealer.bytesToHex(kUp), "K_up drifted")
assertEquals(root.getString("k_down_hex"), Sealer.bytesToHex(kDown), "K_down drifted")
assertEquals(root.getString("k_rev_hex"), Sealer.bytesToHex(kRev), "K_rev drifted")
}
@Test
fun `point records encode and decode exactly as recorded`() {
val points = root.getJSONArray("points")
assertTrue(points.length() > 0)
for (i in 0 until points.length()) {
val case = points.getJSONObject(i)
val name = case.getString("name")
val expected = Sealer.hexToBytes(case.getString("bytes_hex"))
assertEquals(POINT_LEN, expected.size, "$name: wrong record length")
val point = pointFrom(case.getJSONObject("point"))
assertContentEquals(expected, point.toBytes(), "$name: encode drifted")
assertEquals(point, Point.fromBytes(expected), "$name: decode drifted")
}
}
@Test
fun `every datagram vector reproduces byte for byte`() {
val cases = root.getJSONArray("datagrams")
val covered = mutableSetOf<MsgType>()
for (i in 0 until cases.length()) {
val case = cases.getJSONObject(i)
val name = case.getString("name")
val msg = messageFrom(case.getJSONObject("message"))
covered += msg.type
assertEquals(msg.type.code, case.getInt("msg_type"), "$name: msg_type disagrees")
val expectedDir = if (msg.type.isUplink) "up" else "down"
assertEquals(expectedDir, case.getString("direction"), "$name: direction disagrees")
val key = keyFor(case.getString("key"))
val nonce = Sealer.hexToBytes(case.getString("nonce_hex"))
val header = Header(msg.type, tokenId, nonce)
assertEquals(case.getString("header_hex"), Sealer.bytesToHex(header.toBytes()), "$name: header")
assertEquals(case.getString("payload_hex"), Sealer.bytesToHex(msg.encodePayload()), "$name: payload")
val datagram = Sealer.seal(key, header, msg.encodePayload())
assertEquals(case.getString("datagram_hex"), Sealer.bytesToHex(datagram), "$name: datagram")
assertEquals(case.getInt("datagram_len"), datagram.size, "$name: length")
// And the recorded bytes must open back to the recorded message —
// encode agreeing with itself would prove nothing about decode.
val (openedHeader, opened) = Sealer.openMessage(key, Sealer.hexToBytes(case.getString("datagram_hex")))
assertEquals(header, openedHeader, "$name: header round trip")
assertEquals(msg, opened, "$name: message round trip")
}
assertEquals(MsgType.entries.toSet(), covered, "some message type has no golden vector")
}
@Test
fun `vectors only open under the key that sealed them`() {
val cases = root.getJSONArray("datagrams")
val keys = mapOf("up" to kUp, "down" to kDown, "rev" to kRev)
for (i in 0 until cases.length()) {
val case = cases.getJSONObject(i)
val name = case.getString("name")
val datagram = Sealer.hexToBytes(case.getString("datagram_hex"))
val sealedWith = case.getString("key")
for ((keyName, key) in keys) {
if (keyName == sealedWith) continue
assertFails("$name: opened under K_$keyName, which did not seal it") {
Sealer.openMessage(key, datagram)
}
}
}
}
/**
* K_rev is derived per token id precisely so one device cannot forge a
* revocation notice for another. If this ever passes, any phone that has
* logged in could log every other phone out.
*/
@Test
fun `a revocation notice for another token does not open here`() {
val other = Sealer.deriveRevocation(revocationMaster, tokenId xor 1L)
val cases = root.getJSONArray("datagrams")
var checked = 0
for (i in 0 until cases.length()) {
val case = cases.getJSONObject(i)
if (case.getString("key") != "rev") continue
checked++
assertFails("${case.getString("name")}: opened under another token's K_rev") {
Sealer.openMessage(other, Sealer.hexToBytes(case.getString("datagram_hex")))
}
}
assertTrue(checked > 0, "no REVOKED vectors to check")
}
@Test
fun `the crypto self test passes on this JVM`() {
val report = Sealer.selfTest()
assertTrue(report.ok, "self test failed: ${report.summary}")
}
// -- JSON -> wire structs ------------------------------------------------
// Written by hand rather than with a serialization library: the app ships no
// serialization dependency, and doing it manually is what makes a renamed
// field fail here instead of in production.
private fun JSONObject.intOrNull(key: String): Int? = if (isNull(key)) null else getInt(key)
private fun pointFrom(o: JSONObject) = Point(
ts = o.getLong("ts"),
latE7 = o.getInt("lat_e7"),
lonE7 = o.getInt("lon_e7"),
accDm = o.intOrNull("acc_dm"),
altM = o.intOrNull("alt_m"),
spdCms = o.intOrNull("spd_cms"),
brgCdeg = o.intOrNull("brg_cdeg"),
batPct = o.intOrNull("bat_pct"),
flags = o.getInt("flags"),
)
private fun bytesFrom(a: JSONArray) = ByteArray(a.length()) { a.getInt(it).toByte() }
private fun messageFrom(o: JSONObject): Message {
val value = o.get("value")
return when (val type = o.getString("type")) {
"loc" -> {
val arr = value as JSONArray
Message.Loc(List(arr.length()) { pointFrom(arr.getJSONObject(it)) })
}
"ack" -> {
val v = value as JSONObject
val nonces = v.getJSONArray("nonces")
Message.Ack(
nonces = List(nonces.length()) { bytesFrom(nonces.getJSONArray(it)) },
flags = v.getInt("flags"),
)
}
"nack" -> {
val v = value as JSONObject
Message.Nack(
nonce = bytesFrom(v.getJSONArray("nonce")),
reason = when (val r = v.getString("reason")) {
"unknown_token" -> NackReason.UNKNOWN_TOKEN
"malformed" -> NackReason.MALFORMED
"rate_limited" -> NackReason.RATE_LIMITED
"storage_full" -> NackReason.STORAGE_FULL
else -> error("unknown NACK reason $r")
},
retryAfterS = v.getInt("retry_after_s"),
)
}
"hello" -> {
val v = value as JSONObject
Message.Hello(
appVersionCode = v.getInt("app_version_code"),
osApiLevel = v.getInt("os_api_level"),
flags = v.getInt("flags"),
configVersion = v.getInt("config_version"),
)
}
"config" -> {
val v = value as JSONObject
Message.Config(
configVersion = v.getInt("config_version"),
profile = when (val p = v.getString("profile")) {
"battery_saver" -> Profile.BATTERY_SAVER
"balanced" -> Profile.BALANCED
"high_accuracy" -> Profile.HIGH_ACCURACY
else -> error("unknown profile $p")
},
flags = v.getInt("flags"),
heartbeatS = v.getInt("heartbeat_s"),
intervalScalePct = v.getInt("interval_scale_pct"),
minDistanceM = v.getInt("min_distance_m"),
maxPointsPerLoc = v.getInt("max_points_per_loc"),
)
}
"config_get" -> Message.ConfigGet((value as JSONObject).getInt("have_version"))
"ping" -> {
val v = value as JSONObject
Message.Ping(echo = v.getLong("echo"), seq = v.getInt("seq"))
}
"pong" -> {
val v = value as JSONObject
Message.Pong(echo = v.getLong("echo"), seq = v.getInt("seq"))
}
"revoked" -> Message.Revoked(
when (val r = (value as JSONObject).getString("reason")) {
"revoked" -> RevokeReason.REVOKED
"expired" -> RevokeReason.EXPIRED
"unknown" -> RevokeReason.UNKNOWN
else -> error("unknown revoke reason $r")
},
)
else -> error("unknown message type $type")
}
}
}
Dandroid/app/src/test/java/net/lexcom/opentracker/ui/HomeFormatTest.kt-48
@@ -1,48 +0,0 @@
package net.lexcom.opentracker.ui
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Only the age formatter. It is the one part of the home screen with a branch
* that can be wrong, and the only one that can be wrong *quietly*: a bad unit
* still renders a plausible looking string.
*/
class HomeFormatTest {
/** Fixed so the test never depends on the machine's clock. */
private val nowMs = 1_785_000_000_000L
private val nowS = nowMs / 1000
private fun ageAt(secondsAgo: Long) = formatAge(nowS - secondsAgo, nowMs)
@Test
fun `an age below a minute is shown in seconds`() {
assertEquals("0s ago", ageAt(0))
assertEquals("59s ago", ageAt(59))
}
@Test
fun `an age of a minute or more is shown in minutes`() {
assertEquals("1m ago", ageAt(60))
assertEquals("59m ago", ageAt(3599))
}
@Test
fun `an age of an hour or more is shown in hours`() {
assertEquals("1h ago", ageAt(3600))
assertEquals("25h ago", ageAt(25 * 3600))
}
@Test
fun `a timestamp from the future reads as zero, never as a negative age`() {
assertEquals("0s ago", ageAt(-90))
}
@Test
fun `accuracy is rounded from decimetres to metres`() {
assertEquals("±1 m", formatAccuracy(12))
assertEquals("±2 m", formatAccuracy(15))
assertEquals(null, formatAccuracy(null))
}
}
Dandroid/gradle.properties-3
@@ -1,3 +0,0 @@
org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8
org.gradle.caching=true
org.gradle.configuration-cache=true
Dandroid/gradle/wrapper/gradle-wrapper.jarBin 45633 → 0 bytes
Binary file — not shown
Dandroid/gradle/wrapper/gradle-wrapper.properties-7
@@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Dandroid/gradlew-248
@@ -1,248 +0,0 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Dandroid/settings.gradle.kts-24
@@ -1,24 +0,0 @@
// Gradle walks *up* the tree looking for a settings file, so this one being
// here — and there being none at the repository root — is what keeps the Android
// build and the Cargo workspace from entangling.
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
// Fail the build if a subproject declares its own repositories, so there is
// exactly one place dependencies can come from.
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "opentracker"
include(":app")
Acompose.yaml
@@ -0,0 +1,14 @@
services:
server:
build: .
ports:
- "127.0.0.1:8080:8080"
environment:
OT_PUBLIC_URL: https://track.example.com
OT_RETENTION_DAYS: 30
volumes:
- ./data:/data
# The image has no HTTP client, so bash talks HTTP itself.
healthcheck:
test: ["CMD", "bash", "-c", 'exec 3<>/dev/tcp/127.0.0.1/8080 && printf "GET /healthz HTTP/1.0\r\n\r\n" >&3 && head -1 <&3 | grep -q " 200 "']
interval: 30s
Acrates/api/Cargo.toml
@@ -0,0 +1,7 @@
[package]
name = "api"
version.workspace = true
edition.workspace = true
[dependencies]
serde.workspace = true
Acrates/api/src/lib.rs
@@ -0,0 +1,329 @@
//! JSON types shared by the server, the CLI and the web UI.
use serde::{Deserialize, Serialize};
/// Unix seconds.
pub type Ts = i64;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Point {
pub ts: Ts,
pub lat: f64,
pub lon: f64,
/// Metres.
#[serde(default)]
pub acc: Option<f32>,
/// Metres.
#[serde(default)]
pub alt: Option<f32>,
/// m/s.
#[serde(default)]
pub speed: Option<f32>,
/// Degrees.
#[serde(default)]
pub bearing: Option<f32>,
/// Percent.
#[serde(default)]
pub battery: Option<u8>,
}
/// Someone whose position the caller may see. Always includes the caller.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Person {
pub id: i64,
pub username: String,
/// The visible devices that have a position, newest first.
pub devices: Vec<PersonDevice>,
pub trail: Trail,
/// Positions are rounded to about this many metres. 0 means exact.
pub precision_m: u32,
}
impl Person {
/// The device with the newest position. Its position is the person's position.
pub fn last(&self) -> Option<&PersonDevice> {
self.devices.first()
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PersonDevice {
pub id: i64,
pub name: String,
pub last: Point,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Me {
pub id: i64,
pub username: String,
pub is_admin: bool,
pub has_password: bool,
/// Sign-in needs the password and a passkey. Otherwise either one is enough.
pub two_factor: bool,
/// The user's own limit. None means the server limit.
pub retention_days: Option<i64>,
/// The server limit. None means points are kept forever.
pub max_retention_days: Option<i64>,
/// The address browsers use, if the server knows it.
pub public_url: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SetupStatus {
/// True while no user exists. The first account becomes the admin.
pub needed: bool,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Credentials {
pub username: String,
pub password: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
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.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct LoginResult {
pub ok: bool,
/// 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.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Challenge {
pub state_id: String,
/// The options for `navigator.credentials`, as JSON.
pub options: String,
}
/// The second leg of a WebAuthn ceremony.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ChallengeAnswer {
pub state_id: String,
/// The browser's `PublicKeyCredential.toJSON()`, as JSON.
pub credential: String,
/// A label for a new passkey. Ignored when signing in.
#[serde(default)]
pub name: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Passkey {
pub id: i64,
pub name: String,
pub created_at: Ts,
pub last_used_at: Option<Ts>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ChangePassword {
/// Required when the account has a password.
pub old: Option<String>,
pub new: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SetTwoFactor {
pub enabled: bool,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SetRetention {
/// None means the server limit.
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,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct DeviceToken {
pub token: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Device {
pub id: i64,
pub name: String,
/// The one device that every browser with the web UI uploads as. It has no token.
pub web: bool,
pub created_at: Ts,
pub last_seen_at: Option<Ts>,
}
/// Reply to a point upload.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Uploaded {
/// Points that were new. Duplicates of stored points are ignored.
pub stored: usize,
pub people: Vec<Person>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct NewShare {
pub viewer: String,
/// None means no expiry.
pub expires_at: Option<Ts>,
#[serde(flatten)]
pub settings: ShareSettings,
}
/// How much of the history a viewer sees besides the current position.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Default)]
#[serde(rename_all = "snake_case")]
pub enum Trail {
None,
/// Points from this time on.
Since(Ts),
#[default]
All,
}
/// What a share or guest link shows. Sharing again with the same viewer replaces these.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
pub struct ShareSettings {
/// The owner's device ids. None means all devices, including ones added later.
#[serde(default)]
pub devices: Option<Vec<i64>>,
#[serde(default)]
pub trail: Trail,
/// Rounds positions to about this many metres. 0 means exact.
#[serde(default)]
pub precision_m: u32,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct NewLink {
/// A label for the owner, for example whom the link is for.
pub name: String,
pub expires_at: Option<Ts>,
#[serde(flatten)]
pub settings: ShareSettings,
/// Guests must enter it before they see anything.
#[serde(default)]
pub password: Option<String>,
}
/// A guest link: anyone with the token can see what it shows.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Link {
pub id: i64,
pub name: String,
pub token: String,
pub expires_at: Option<Ts>,
pub created_at: Ts,
pub has_password: bool,
#[serde(flatten)]
pub settings: ShareSettings,
}
/// Identifies a guest request.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct GuestAuth {
pub token: String,
/// From GuestKey. Needed when the link has a password.
#[serde(default)]
pub key: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct GuestUnlock {
pub token: String,
pub password: String,
}
/// Proves the link password. It stops working when the link is deleted.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct GuestKey {
pub key: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct GuestView {
pub expires_at: Option<Ts>,
pub person: Person,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct GuestTrack {
#[serde(flatten)]
pub auth: GuestAuth,
pub device: i64,
pub from: Ts,
pub to: Ts,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Share {
pub id: i64,
/// The other party: the viewer for outgoing shares, the owner for incoming ones.
pub username: String,
pub expires_at: Option<Ts>,
pub created_at: Ts,
#[serde(flatten)]
pub settings: ShareSettings,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Shares {
pub outgoing: Vec<Share>,
pub incoming: Vec<Share>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct User {
pub id: i64,
pub username: String,
pub is_admin: bool,
pub created_at: Ts,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct NewUser {
pub username: String,
pub password: String,
pub is_admin: bool,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SetRole {
pub is_admin: bool,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ResetPassword {
pub password: String,
}
/// Upper bound for one upload, so one request cannot hold the database for long.
pub const MAX_BATCH: usize = 1000;
/// Coarsest share precision.
pub const MAX_PRECISION_M: u32 = 100_000;
/// Longest time range one track request may cover.
pub const MAX_TRACK_SECS: Ts = 31 * 86400;
Acrates/cli/Cargo.toml
@@ -0,0 +1,16 @@
[package]
name = "cli"
version.workspace = true
edition.workspace = true
[[bin]]
name = "ot"
path = "src/main.rs"
[dependencies]
api.workspace = true
getrandom = { version = "0.4.3", default-features = false }
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "http2", "http3"] }
serde.workspace = true
serde_json.workspace = true
tokio = { version = "1.53.1", default-features = false, features = ["rt", "macros", "time"] }
Acrates/cli/src/main.rs
@@ -0,0 +1,299 @@
//! `ot`: a test client for the opentracker API.
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use api::{DeviceToken, MAX_BATCH, Person, Point, RegisterDevice, 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
--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.
config file: $OT_CONFIG, default ~/.config/ot/config.json";
#[derive(Serialize, Deserialize)]
struct Config {
url: String,
token: String,
}
fn config_path() -> PathBuf {
if let Ok(p) = std::env::var("OT_CONFIG") {
return p.into();
}
let base = std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(std::env::var("HOME").expect("HOME")).join(".config"));
base.join("ot/config.json")
}
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.",
path.display()
))
});
serde_json::from_str(&text).expect("valid config file")
}
fn save_config(c: &Config) {
use std::os::unix::fs::OpenOptionsExt;
let path = config_path();
std::fs::create_dir_all(path.parent().unwrap()).expect("create config dir");
let file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)
.expect("write config");
serde_json::to_writer_pretty(file, c).expect("write config");
println!("saved {}", path.display());
}
fn fail(msg: &str) -> ! {
eprintln!("{msg}");
std::process::exit(1);
}
fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
/// Removes `--name value` from `args` and returns the value.
fn take_opt(args: &mut Vec<String>, name: &str) -> Option<String> {
let i = args.iter().position(|a| a == name)?;
if i + 1 >= args.len() {
fail(&format!("{name} needs a value"));
}
args.remove(i);
Some(args.remove(i))
}
fn take_flag(args: &mut Vec<String>, name: &str) -> bool {
let found = args.iter().any(|a| a == name);
args.retain(|a| a != name);
found
}
fn num<T: std::str::FromStr>(s: &str) -> T {
s.parse()
.unwrap_or_else(|_| fail(&format!("not a number: {s}")))
}
struct Http {
client: reqwest::Client,
http3: bool,
}
impl Http {
fn post(&self, url: String) -> reqwest::RequestBuilder {
let req = self.client.post(url);
// reqwest picks HTTP/3 per request, not per client.
if self.http3 {
req.version(reqwest::Version::HTTP_3)
} else {
req
}
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let mut args: Vec<String> = std::env::args().skip(1).collect();
let http3 = take_flag(&mut args, "--http3");
let insecure = take_flag(&mut args, "--insecure");
let mut builder = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.danger_accept_invalid_certs(insecure);
if http3 {
builder = builder.http3_prior_knowledge();
}
let client = Http {
client: builder.build().expect("HTTP client"),
http3,
};
let interval = take_opt(&mut args, "--interval").map_or(2.0, |s| num::<f64>(&s));
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(),
}),
["send", lat, lon] => {
let p = Point {
ts: now(),
..point(num(lat), num(lon))
};
let up = upload(&client, &load_config(), &[p])
.await
.unwrap_or_else(|e| fail(&e));
println!("stored {}", up.stored);
}
["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(), &[])
.await
.unwrap_or_else(|e| fail(&e));
print_people(&up.people);
}
_ => 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);
}
let status = res.status();
let body = res.text().await.unwrap_or_default();
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))
.bearer_auth(&cfg.token)
.json(points)
.send()
.await
.map_err(|e| format!("{e:?}"))?;
let res = check(res).await?;
eprintln!("[{:?}] uploaded {} point(s)", res.version(), points.len());
res.json().await.map_err(|e| e.to_string())
}
fn point(lat: f64, lon: f64) -> Point {
Point {
ts: 0,
lat,
lon,
acc: None,
alt: None,
speed: None,
bearing: None,
battery: None,
}
}
fn print_people(people: &[Person]) {
for p in people {
match p.last() {
Some(d) => {
let l = &d.last;
println!(
"{:<16} {:<12} {:>10.6} {:>11.6} {:>5}s ago acc {:>4} bat {:>3}",
p.username,
d.name,
l.lat,
l.lon,
now() - l.ts,
l.acc.map_or("-".into(), |a| format!("{a:.0}m")),
l.battery.map_or("-".into(), |b| format!("{b}%")),
)
}
None => println!("{:<16} no position yet", p.username),
}
}
}
/// Uniform in [0, 1).
fn rand01() -> f64 {
getrandom::u32().expect("OS random number generator") as f64 / (u32::MAX as f64 + 1.0)
}
async fn simulate(client: &Http, mut lat: f64, mut lon: f64, interval: f64, batch: usize) {
let cfg = load_config();
let speed = 8.0; // m/s, fast enough to see movement on the map
let mut heading = rand01() * 360.0;
let mut pending: Vec<Point> = Vec::new();
let start = now();
let mut tick = tokio::time::interval(Duration::from_secs_f64(interval));
loop {
tick.tick().await;
heading = (heading + (rand01() - 0.5) * 40.0).rem_euclid(360.0);
let dist = speed * interval;
lat += dist * heading.to_radians().cos() / 111_320.0;
lon += dist * heading.to_radians().sin() / (111_320.0 * lat.to_radians().cos());
pending.push(Point {
ts: now(),
acc: Some(5.0 + rand01() as f32 * 10.0),
speed: Some(speed as f32),
bearing: Some(heading as f32),
battery: Some((100 - (now() - start) / 60 % 100) as u8),
..point(lat, lon)
});
// Several points can share one second when the interval is short. The server keeps the first.
pending.dedup_by_key(|p| p.ts);
if pending.len() < batch {
continue;
}
match upload(client, &cfg, &pending).await {
Ok(_) => pending.clear(),
Err(e) => {
eprintln!("upload failed, keeping {} point(s): {e}", pending.len());
if pending.len() > MAX_BATCH {
pending.drain(..pending.len() - MAX_BATCH);
}
}
}
}
}
Dcrates/otproto/Cargo.toml-34
@@ -1,34 +0,0 @@
[package]
name = "otproto"
description = "OTP/1 wire protocol for opentracker: codec + AEAD. No I/O, no async."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc"] }
hkdf = "0.12"
sha2 = { version = "0.10", default-features = false }
thiserror.workspace = true
serde = { workspace = true, optional = true }
[dev-dependencies]
proptest = "1"
serde_json.workspace = true
hex.workspace = true
serde = { workspace = true }
[features]
default = []
# Serde impls for the wire structs. Used by the vector generator and by
# otserver when it hands protocol structs to the JSON API.
serde = ["dep:serde"]
# `cargo run -p otproto --features serde --example gen_vectors`
# Written as an example rather than a bin so it can use serde_json/hex from
# dev-dependencies without dragging them into the library's dependency tree.
[[example]]
name = "gen_vectors"
required-features = ["serde"]
Dcrates/otproto/examples/gen_vectors.rs-381
@@ -1,381 +0,0 @@
//! Regenerates `tests/vectors.json` — the golden vectors that are the Rust ↔
//! Kotlin contract for OTP/1.
//!
//! ```sh
//! cargo run -p otproto --features serde --example gen_vectors
//! ```
//!
//! The output is committed. `tests/vectors.rs` verifies every entry against a
//! freshly built datagram, so a protocol change that is not reflected here fails
//! the Rust test suite; the Android build decodes the same file in
//! `./gradlew test`, so a change reflected here but not implemented in Kotlin
//! fails there. Between them, the wire format cannot drift on one side only.
//!
//! Everything is deterministic: fixed token key, fixed token id, nonces derived
//! from the case index. Nothing here calls an RNG or a clock.
use std::collections::BTreeMap;
use std::path::PathBuf;
use otproto::msg::Direction;
use otproto::point::Flags;
use otproto::{
Ack, AckFlags, Config, ConfigFlags, ConfigGet, Header, Hello, HelloFlags, MAX_POINTS, Message,
MsgType, Nack, NackReason, Nonce, Ping, Point, Pong, Profile, RevokeReason, Revoked, kdf,
};
use serde::Serialize;
/// Bytes 0x00..0x1F. Chosen to be obviously synthetic.
const TOKEN_KEY: [u8; 32] = {
let mut k = [0u8; 32];
let mut i = 0;
while i < 32 {
k[i] = i as u8;
i += 1;
}
k
};
const TOKEN_ID: u64 = 0x0123_4567_89AB_CDEF;
/// Stands in for the server's revocation master. Bytes 0xE0..0xFF, so it is
/// visibly distinct from [`TOKEN_KEY`] in a hex dump.
const REVOCATION_MASTER: [u8; 32] = {
let mut k = [0u8; 32];
let mut i = 0;
while i < 32 {
k[i] = 0xE0 + i as u8;
i += 1;
}
k
};
/// A fixed reference instant, 2026-08-19T09:20:42Z, used everywhere a timestamp
/// is needed so the vectors never depend on when they were generated.
const T0: u32 = 1_785_000_042;
#[derive(Serialize)]
struct Vectors {
protocol: &'static str,
version: u8,
note: &'static str,
header_len: usize,
tag_len: usize,
max_datagram: usize,
max_points: usize,
token_id: u64,
token_key_hex: String,
k_up_hex: String,
k_down_hex: String,
revocation_master_hex: String,
/// `K_rev` for [`TOKEN_ID`]. Derived from the master and the id, not from
/// the token key, so it outlives the token's row.
k_rev_hex: String,
/// Record-level vectors: the 24-byte point encoding on its own.
points: Vec<PointVector>,
/// Full datagram vectors, one per interesting message.
datagrams: Vec<DatagramVector>,
}
#[derive(Serialize)]
struct PointVector {
name: &'static str,
point: Point,
bytes_hex: String,
}
#[derive(Serialize)]
struct DatagramVector {
name: &'static str,
direction: &'static str,
/// Which key seals this datagram: `up`, `down`, or `rev`. Not implied by
/// `direction`: `REVOKED` travels downlink but is sealed under `K_rev`.
key: &'static str,
msg_type: u8,
nonce_hex: String,
/// The 21 cleartext header bytes, which are also the AAD.
header_hex: String,
payload_hex: String,
datagram_hex: String,
datagram_len: usize,
message: Message,
}
/// Distinct, obviously-synthetic nonce per case.
fn nonce_for(idx: usize) -> Nonce {
let mut n = [0u8; 12];
for (j, b) in n.iter_mut().enumerate() {
*b = (idx as u8) << 4 | j as u8;
}
n
}
fn point_vectors() -> Vec<PointVector> {
let cases: Vec<(&'static str, Point)> = vec![
("all_unknown", Point::new(T0, 525_200_080, 134_050_000)),
(
"fully_populated",
Point {
ts: T0,
lat_e7: 525_200_080,
lon_e7: 134_050_000,
acc_dm: Some(80),
alt_m: Some(34),
spd_cms: Some(450),
brg_cdeg: Some(21_400),
bat_pct: Some(76),
flags: Flags::NETWORK_FIX,
},
),
(
"southern_western_hemisphere",
Point {
acc_dm: Some(1_200),
alt_m: Some(-31),
spd_cms: Some(0),
brg_cdeg: Some(0),
bat_pct: Some(0),
flags: Flags::CHARGING | Flags::LOW_ACCURACY,
..Point::new(T0, -338_688_000, -1_754_500_000)
},
),
(
"extremes",
Point {
ts: u32::MAX,
lat_e7: 900_000_000,
lon_e7: -1_800_000_000,
acc_dm: Some(65_534),
alt_m: Some(-32_767),
spd_cms: Some(65_534),
brg_cdeg: Some(35_999),
bat_pct: Some(100),
flags: Flags(Flags::KNOWN),
},
),
("epoch_zero", Point::new(0, 0, 0)),
];
cases
.into_iter()
.map(|(name, point)| {
let point = point.canonical();
PointVector {
name,
bytes_hex: hex::encode(point.to_bytes()),
point,
}
})
.collect()
}
fn messages() -> Vec<(&'static str, Message)> {
let pv = point_vectors();
let populated = pv[1].point;
let mut cases = vec![
("loc_single", Message::Loc(vec![populated])),
(
"loc_three_independent",
Message::Loc(vec![
Point::new(T0 - 120, 525_200_080, 134_050_000),
populated,
Point {
acc_dm: Some(2_500),
flags: Flags::NETWORK_FIX | Flags::LOW_ACCURACY,
..Point::new(T0 + 60, 525_201_000, 134_051_000)
},
]),
),
(
"loc_max_points",
Message::Loc(
(0..MAX_POINTS)
.map(|i| Point {
acc_dm: Some(50 + i as u16),
bat_pct: Some(100 - i as u8),
..Point::new(
T0 + i as u32 * 30,
525_200_080 + i as i32 * 100,
134_050_000,
)
})
.collect(),
),
),
("ack_single", Message::Ack(Ack::single(nonce_for(0)))),
(
"ack_config_pending",
Message::Ack(Ack {
nonces: vec![nonce_for(1), nonce_for(2)],
flags: AckFlags::CONFIG_PENDING,
}),
),
(
"ack_max_throttle",
Message::Ack(Ack {
nonces: (0..MAX_POINTS).map(nonce_for).collect(),
flags: AckFlags::CONFIG_PENDING,
}),
),
(
"hello",
Message::Hello(Hello {
app_version_code: 17,
os_api_level: 34,
flags: HelloFlags::FIRST_LAUNCH,
config_version: 1,
}),
),
("config_balanced", Message::Config(Config::default())),
(
"config_battery_saver_paused",
Message::Config(Config {
config_version: 9,
profile: Profile::BatterySaver,
flags: ConfigFlags::REQUEST_HELLO,
heartbeat_s: 1_800,
interval_scale_pct: 250,
min_distance_m: 100,
max_points_per_loc: 20,
}),
),
(
"config_high_accuracy",
Message::Config(Config {
config_version: 2,
profile: Profile::HighAccuracy,
flags: ConfigFlags::TRACKING_ENABLED,
heartbeat_s: 600,
interval_scale_pct: 50,
min_distance_m: 10,
max_points_per_loc: MAX_POINTS as u8,
}),
),
(
"config_get",
Message::ConfigGet(ConfigGet { have_version: 1 }),
),
(
"ping",
Message::Ping(Ping {
echo: 0xDEAD_BEEF,
seq: 7,
}),
),
(
"pong",
Message::Pong(Pong {
echo: 0xDEAD_BEEF,
seq: 7,
}),
),
];
// One REVOKED per reason. Each drives the same client behaviour — clear
// state, show the login screen — but they are what the user is told, so a
// silently renumbered reason would be a real regression.
for (name, reason) in [
("revoked_explicit", RevokeReason::Revoked),
("revoked_expired", RevokeReason::Expired),
("revoked_unknown", RevokeReason::Unknown),
] {
cases.push((name, Message::Revoked(Revoked { reason })));
}
// One NACK per reason: each is a distinct client behaviour, so each is worth
// pinning byte-for-byte.
for (name, reason, retry) in [
("nack_unknown_token", NackReason::UnknownToken, 0),
("nack_malformed", NackReason::Malformed, 0),
("nack_rate_limited", NackReason::RateLimited, 30),
("nack_storage_full", NackReason::StorageFull, 255),
] {
cases.push((
name,
Message::Nack(Nack {
nonce: nonce_for(3),
reason,
retry_after_s: retry,
}),
));
}
cases
}
fn main() {
let (k_up, k_down) = kdf::derive_both(&TOKEN_KEY);
let k_rev = kdf::revocation_key(&REVOCATION_MASTER, TOKEN_ID);
let datagrams = messages()
.into_iter()
.enumerate()
.map(|(idx, (name, message))| {
let ty = message.msg_type();
let dir = ty.direction();
let (key, key_name) = match ty {
MsgType::Revoked => (&k_rev, "rev"),
_ => match dir {
Direction::Up => (&k_up, "up"),
Direction::Down => (&k_down, "down"),
},
};
let nonce = nonce_for(idx);
let header = Header::new(ty, TOKEN_ID, nonce);
let payload = message.encode_payload();
let datagram = otproto::seal(key, header, &payload);
DatagramVector {
name,
direction: match dir {
Direction::Up => "up",
Direction::Down => "down",
},
key: key_name,
msg_type: ty as u8,
nonce_hex: hex::encode(nonce),
header_hex: hex::encode(header.to_bytes()),
payload_hex: hex::encode(&payload),
datagram_len: datagram.len(),
datagram_hex: hex::encode(&datagram),
message,
}
})
.collect::<Vec<_>>();
// Distinct names are what let the Kotlin side address a single case.
let mut seen = BTreeMap::new();
for d in &datagrams {
assert!(
seen.insert(d.name, ()).is_none(),
"duplicate vector name {}",
d.name
);
}
let vectors = Vectors {
protocol: "OTP/1",
version: otproto::VERSION,
note: "Generated by `cargo run -p otproto --features serde --example gen_vectors`. \
Do not edit by hand.",
header_len: otproto::HEADER_LEN,
tag_len: otproto::TAG_LEN,
max_datagram: otproto::MAX_DATAGRAM,
max_points: MAX_POINTS,
token_id: TOKEN_ID,
token_key_hex: hex::encode(TOKEN_KEY),
k_up_hex: hex::encode(k_up),
k_down_hex: hex::encode(k_down),
revocation_master_hex: hex::encode(REVOCATION_MASTER),
k_rev_hex: hex::encode(k_rev),
points: point_vectors(),
datagrams,
};
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/vectors.json");
let mut json = serde_json::to_string_pretty(&vectors).expect("vectors serialize");
json.push('\n');
std::fs::write(&path, json).expect("write vectors.json");
println!(
"wrote {} ({} cases)",
path.display(),
vectors.datagrams.len()
);
}
Dcrates/otproto/fuzz/Cargo.lock-338
@@ -1,338 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "cc"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"cipher",
"poly1305",
"zeroize",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"zeroize",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "jobserver"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom",
"libc",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libfuzzer-sys"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
dependencies = [
"arbitrary",
"cc",
]
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "otproto"
version = "0.1.0"
dependencies = [
"chacha20poly1305",
"hkdf",
"sha2",
"thiserror",
]
[[package]]
name = "otproto-fuzz"
version = "0.0.0"
dependencies = [
"libfuzzer-sys",
"otproto",
]
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
Dcrates/otproto/fuzz/Cargo.toml-33
@@ -1,33 +0,0 @@
[package]
name = "otproto-fuzz"
version = "0.0.0"
publish = false
edition = "2024"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
otproto = { path = ".." }
[[bin]]
name = "decode"
path = "fuzz_targets/decode.rs"
test = false
doc = false
bench = false
[[bin]]
name = "decode_payload"
path = "fuzz_targets/decode_payload.rs"
test = false
doc = false
bench = false
[profile.release]
debug = 1
# cargo-fuzz invokes cargo directly on this manifest, which then finds the
# workspace root above it. An empty table detaches it.
[workspace]
Dcrates/otproto/fuzz/fuzz_targets/decode.rs-26
@@ -1,26 +0,0 @@
//! Fuzzes the decoder against arbitrary datagrams — the exact input an open UDP
//! port receives from the internet.
//!
//! ```sh
//! cargo +nightly fuzz run decode
//! ```
//!
//! Note the deliberate limitation: without the key, almost nothing here gets
//! past the AEAD, so this target mostly exercises `Header::peek` and the
//! ChaCha20-Poly1305 layer. The payload decoders — where the interesting
//! structural parsing lives — are reached by the `decode_payload` target
//! instead. Fuzzing only this one would give a comforting but nearly meaningless
//! coverage number.
#![no_main]
use libfuzzer_sys::fuzz_target;
/// Fixed key: a fuzzer cannot forge a tag either way, so varying it would only
/// waste the corpus.
const KEY: [u8; 32] = [0x11; 32];
fuzz_target!(|data: &[u8]| {
let _ = otproto::Header::peek(data);
let _ = otproto::open_message(&KEY, data);
});
Dcrates/otproto/fuzz/fuzz_targets/decode_payload.rs-38
@@ -1,38 +0,0 @@
//! Fuzzes the payload decoders directly, behind the AEAD.
//!
//! ```sh
//! cargo +nightly fuzz run decode_payload
//! ```
//!
//! This is where the structural parsing lives — point counts that disagree with
//! the payload length, unknown enum discriminants, arithmetic on attacker-chosen
//! lengths. Reaching it through a sealed datagram would require forging a
//! Poly1305 tag, so it gets its own target with the crypto stripped away. In
//! production only a client holding a valid token can reach this code, which
//! bounds the blast radius but does not make it safe to panic in.
#![no_main]
use libfuzzer_sys::fuzz_target;
use otproto::{Message, MsgType};
fuzz_target!(|data: &[u8]| {
// First byte picks the message type; the rest is the payload.
let Some((&ty, payload)) = data.split_first() else {
return;
};
let Ok(ty) = MsgType::try_from(ty & 0x0F) else {
return;
};
if let Ok(msg) = Message::decode_payload(ty, payload) {
assert_eq!(msg.msg_type(), ty);
// Anything that decodes must re-encode to the same *length*, and
// `payload_len` must agree without allocating. Byte equality is not
// asserted because reserved bytes are ignored on decode and written as
// zero on encode — deliberately, so a later version can populate them.
let re = msg.encode_payload();
assert_eq!(re.len(), payload.len());
assert_eq!(msg.payload_len(), payload.len());
}
});
Dcrates/otproto/src/error.rs-63
@@ -1,63 +0,0 @@
/// A datagram could not be turned into a [`crate::Message`].
///
/// Everything here is a *structural* failure: the bytes cannot be parsed at
/// all. Values that parse but are nonsensical (a latitude of 300°, a timestamp
/// in 1970) are not errors at this layer — see [`ValidationError`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum DecodeError {
#[error("datagram too short: {0} bytes, minimum is {min}", min = crate::MIN_DATAGRAM)]
TooShort(usize),
#[error("datagram too long: {0} bytes, maximum is {max}", max = crate::MAX_DATAGRAM)]
TooLong(usize),
#[error("unsupported protocol version {0}, this build speaks {ours}", ours = crate::VERSION)]
BadVersion(u8),
#[error("unknown message type 0x{0:x}")]
BadMsgType(u8),
/// The AEAD tag did not verify. Never answer this — see
/// [`crate::may_respond`] and the "no oracle" rule.
#[error("authentication failed")]
AuthFailed,
#[error("{what}: expected {expected} payload bytes, got {actual}")]
BadPayloadLen {
what: &'static str,
expected: usize,
actual: usize,
},
#[error("LOC point count {0} out of range 1..={max}", max = crate::MAX_POINTS)]
BadPointCount(usize),
#[error("ACK nonce count {0} out of range 1..={max}", max = crate::MAX_POINTS)]
BadNonceCount(usize),
#[error("unknown {field} discriminant {value}")]
BadEnum { field: &'static str, value: u8 },
}
/// A message parsed cleanly but carries values the server should not store.
///
/// Kept separate from [`DecodeError`] on purpose: the codec stays a total
/// function over well-formed byte strings, so the round-trip property holds
/// without carve-outs, and policy lives where policy belongs.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ValidationError {
#[error("latitude {0} out of range ±90e7")]
Latitude(i32),
#[error("longitude {0} out of range ±180e7")]
Longitude(i32),
#[error("bearing {0} centidegrees out of range 0..=35999")]
Bearing(u16),
#[error("battery {0}% out of range 0..=100")]
Battery(u8),
#[error("timestamp {ts} is {off_by}s outside the accepted window around {now}")]
Timestamp { ts: u32, now: u32, off_by: i64 },
}
Dcrates/otproto/src/frame.rs-510
@@ -1,510 +0,0 @@
//! Datagram framing: a 21-byte cleartext header authenticated as AAD, followed
//! by a ChaCha20-Poly1305 sealed payload.
//!
//! ```text
//! off size field
//! 0 1 ver_type — high nibble version (1), low nibble message type
//! 1 8 token_id u64 BE — random, server-assigned at login
//! 9 12 nonce — random; also this message's id
//! ```
//!
//! The header is cleartext because the server must read `token_id` to select a
//! key before it can decrypt anything; it is authenticated as AAD so a
//! ciphertext cannot be retargeted to another token or another message type.
//!
//! `datagram = header || ChaCha20Poly1305(K_dir, nonce, payload, aad = header)`
use chacha20poly1305::aead::{AeadInPlace, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Tag};
use crate::error::DecodeError;
use crate::kdf::Key;
use crate::msg::{Message, MsgType, Nonce};
pub const VERSION: u8 = 1;
pub const HEADER_LEN: usize = 21;
pub const TAG_LEN: usize = 16;
/// Smallest possible datagram: header, empty payload, tag. No message type
/// actually has an empty payload, but this is the floor a length check can use
/// before it knows the type.
pub const MIN_DATAGRAM: usize = HEADER_LEN + TAG_LEN;
/// Path-MTU-safe ceiling for both IPv4 and IPv6. The 40-point `LOC` cap keeps
/// the largest real datagram at 998 bytes.
pub const MAX_DATAGRAM: usize = 1200;
/// Total datagram size for a payload of `payload_len` bytes.
#[must_use]
pub const fn datagram_len(payload_len: usize) -> usize {
HEADER_LEN + payload_len + TAG_LEN
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Header {
pub msg_type: MsgType,
/// The u64 assigned at login. A *credential*, not an identity: it
/// authorises writing into its owner's position stream and never appears in
/// a stored point's key.
pub token_id: u64,
pub nonce: Nonce,
}
impl Header {
#[must_use]
pub const fn new(msg_type: MsgType, token_id: u64, nonce: Nonce) -> Self {
Self {
msg_type,
token_id,
nonce,
}
}
#[must_use]
pub fn to_bytes(self) -> [u8; HEADER_LEN] {
let mut b = [0u8; HEADER_LEN];
b[0] = (VERSION << 4) | (self.msg_type as u8);
b[1..9].copy_from_slice(&self.token_id.to_be_bytes());
b[9..21].copy_from_slice(&self.nonce);
b
}
/// Read the header of a datagram without decrypting it.
///
/// This is the server's first look at a packet: it yields the `token_id`
/// needed to pick a key, and it is where length, version and type filtering
/// happen — all before any crypto work is spent on the packet.
pub fn peek(datagram: &[u8]) -> Result<Self, DecodeError> {
if datagram.len() < MIN_DATAGRAM {
return Err(DecodeError::TooShort(datagram.len()));
}
if datagram.len() > MAX_DATAGRAM {
return Err(DecodeError::TooLong(datagram.len()));
}
let version = datagram[0] >> 4;
if version != VERSION {
return Err(DecodeError::BadVersion(version));
}
Ok(Self {
msg_type: MsgType::try_from(datagram[0] & 0x0F)?,
token_id: u64::from_be_bytes(datagram[1..9].try_into().expect("length checked")),
nonce: datagram[9..HEADER_LEN].try_into().expect("length checked"),
})
}
}
/// Seal a message into a datagram.
///
/// The nonce is passed in rather than generated here: this crate does no I/O and
/// owns no RNG, which is what makes it deterministically testable and lets the
/// golden vectors be reproducible. Callers must supply 12 fresh random bytes per
/// message — at 12 bytes the collision probability after 16 million messages is
/// about 2⁻⁴⁹, so a counter buys nothing and costs persistence.
#[must_use]
pub fn seal(key: &Key, header: Header, payload: &[u8]) -> Vec<u8> {
debug_assert!(
datagram_len(payload.len()) <= MAX_DATAGRAM,
"payload of {} bytes exceeds the datagram budget",
payload.len()
);
let aad = header.to_bytes();
let mut out = Vec::with_capacity(datagram_len(payload.len()));
out.extend_from_slice(&aad);
out.extend_from_slice(payload);
let cipher = ChaCha20Poly1305::new(key.into());
let tag = cipher
.encrypt_in_place_detached((&header.nonce).into(), &aad, &mut out[HEADER_LEN..])
.expect("in-place detached encryption of a bounded buffer cannot fail");
out.extend_from_slice(&tag);
out
}
/// Seal an already-typed message, deriving the header from it.
#[must_use]
pub fn seal_message(key: &Key, token_id: u64, nonce: Nonce, msg: &Message) -> Vec<u8> {
seal(
key,
Header::new(msg.msg_type(), token_id, nonce),
&msg.encode_payload(),
)
}
/// Open a datagram, returning its header and decrypted payload.
///
/// A [`DecodeError::AuthFailed`] must never be answered — not with a `NACK`, not
/// with anything. The server cannot know who sent it, and replying would make
/// the open port both a forgery oracle and a reflector.
pub fn open(key: &Key, datagram: &[u8]) -> Result<(Header, Vec<u8>), DecodeError> {
let header = Header::peek(datagram)?;
let (aad, rest) = datagram.split_at(HEADER_LEN);
let (ciphertext, tag) = rest.split_at(rest.len() - TAG_LEN);
let mut payload = ciphertext.to_vec();
ChaCha20Poly1305::new(key.into())
.decrypt_in_place_detached(
(&header.nonce).into(),
aad,
&mut payload,
Tag::from_slice(tag),
)
.map_err(|_| DecodeError::AuthFailed)?;
Ok((header, payload))
}
/// Open a datagram and parse its payload.
pub fn open_message(key: &Key, datagram: &[u8]) -> Result<(Header, Message), DecodeError> {
let (header, payload) = open(key, datagram)?;
let msg = Message::decode_payload(header.msg_type, &payload)?;
Ok((header, msg))
}
/// Ceiling on any datagram the server sends in reply to one it received.
///
/// This is the anti-amplification control, and it replaces an earlier rule that
/// `len(response) <= len(request)` for every message type. That rule was
/// achievable only by padding requests with reserved bytes — paying real bytes on
/// every `HELLO` and `PING` to make replies "fit" — and it protected against a
/// threat that authentication already eliminates:
///
/// **Nearly every reply is sent only to a datagram that passed AEAD
/// verification.** A bad tag gets silence. So a reflection attacker must already
/// hold a live token key, and the leverage they gain is the ratio below — against
/// DNS at ~50× and NTP `monlist` at ~550×, which is the scale at which reflection
/// is worth doing at all.
///
/// [`Revoked`] is the one exception, and it is deliberately the smallest message
/// in the protocol. The server cannot verify a datagram naming a token it has no
/// record of, yet that is exactly the device it must tell to log in again. Two
/// rules keep it from being useful: it is sent only when the request was at least
/// as long (see [`may_answer_unverified`], so the ratio never exceeds 1.0), and
/// the server rate-limits it per *destination* address, which for a spoofed
/// packet is the victim. Whether it is sent at all is a config switch.
///
/// | request | bytes | reply | bytes | ratio |
/// |---|---|---|---|---|
/// | `LOC`, 1 point | 62 | `ACK` | 51 | 0.82 |
/// | `LOC`, 40 points | 998 | `ACK` | 51 | 0.05 |
/// | `HELLO` | 43 | `ACK` | 51 | 1.19 |
/// | `PING` | 43 | `PONG` | 43 | 1.00 |
/// | `CONFIG_GET` | 39 | `CONFIG` | 49 | 1.26 |
/// | any, ≥ 38 B | 38 | `REVOKED` | 38 | ≤ 1.00 |
///
/// An absolute ceiling is also a stronger statement than a relative one: however
/// the protocol grows, the open port cannot be made to emit more than this many
/// bytes for one received datagram. A multi-nonce `ACK` is the one reply that
/// scales, and it scales with the number of datagrams *already received* from that
/// token, so it cannot amplify either — but nothing in this build emits one, and
/// [`fits_reply_budget`] holds for every reply it does emit.
pub const MAX_REPLY: usize = 64;
/// Whether a reply respects [`MAX_REPLY`].
#[must_use]
pub const fn fits_reply_budget(response_len: usize) -> bool {
response_len <= MAX_REPLY
}
/// Size of a sealed [`Revoked`] notice: the smallest datagram this protocol can
/// produce, since its payload is one byte.
pub const REVOKED_DATAGRAM_LEN: usize = HEADER_LEN + 1 + TAG_LEN;
/// Whether a request is long enough to earn an unverifiable [`Revoked`] reply.
///
/// The server cannot authenticate a datagram naming a token it does not know, so
/// answering one means answering an address the sender merely claimed. That is a
/// reflector. It is a tolerable one only while it can never be an *amplifier*, so
/// the reply must not exceed the request — which for a fixed 38-byte reply is
/// just this length test.
///
/// [`MIN_DATAGRAM`] is 37, one byte short, so a minimum-size datagram earns
/// nothing. Every real message is far larger: the smallest a device ever sends is
/// a 39-byte `CONFIG_GET`.
#[must_use]
pub const fn may_answer_unverified(request_len: usize) -> bool {
request_len >= REVOKED_DATAGRAM_LEN
}
#[cfg(test)]
mod tests {
use super::*;
use crate::kdf;
use crate::msg::*;
use crate::point::Point;
const TOKEN_KEY: Key = [0x5A; 32];
const TOKEN_ID: u64 = 0x1122_3344_5566_7788;
const NONCE: Nonce = [0xA0; NONCE_LEN];
fn keys() -> (Key, Key) {
kdf::derive_both(&TOKEN_KEY)
}
#[test]
fn header_round_trips() {
let h = Header::new(MsgType::Loc, TOKEN_ID, NONCE);
assert_eq!(
Header::peek(&[h.to_bytes().as_slice(), &[0; TAG_LEN]].concat()),
Ok(h)
);
}
#[test]
fn seal_open_round_trips_every_type() {
let (up, down) = keys();
let messages = [
Message::Loc(vec![Point::new(1_785_000_042, 525_200_080, 134_050_000)]),
Message::Ack(Ack::single(NONCE)),
Message::Nack(Nack {
nonce: NONCE,
reason: NackReason::UnknownToken,
retry_after_s: 0,
}),
Message::Hello(Hello {
app_version_code: 2,
os_api_level: 34,
flags: HelloFlags::NONE,
config_version: 1,
}),
Message::Config(Config::default()),
Message::ConfigGet(ConfigGet { have_version: 1 }),
Message::Ping(Ping { echo: 1, seq: 2 }),
Message::Pong(Pong { echo: 1, seq: 3 }),
];
for msg in messages {
let key = if msg.msg_type().is_uplink() {
&up
} else {
&down
};
let dg = seal_message(key, TOKEN_ID, NONCE, &msg);
assert_eq!(dg.len(), datagram_len(msg.payload_len()));
assert!(dg.len() <= MAX_DATAGRAM);
let (h, back) = open_message(key, &dg).expect("opens");
assert_eq!(h.token_id, TOKEN_ID);
assert_eq!(h.msg_type, msg.msg_type());
assert_eq!(back, msg);
}
}
#[test]
fn the_wrong_direction_key_cannot_open_a_datagram() {
let (up, down) = keys();
let dg = seal_message(&up, TOKEN_ID, NONCE, &Message::Ping(Ping::default()));
assert_eq!(open(&down, &dg), Err(DecodeError::AuthFailed));
}
#[test]
fn every_header_byte_is_authenticated() {
let (up, _) = keys();
let dg = seal_message(
&up,
TOKEN_ID,
NONCE,
&Message::Ping(Ping { echo: 9, seq: 1 }),
);
for i in 0..HEADER_LEN {
let mut bad = dg.clone();
bad[i] ^= 0x01;
// A flipped version or type nibble fails the header check; anything
// else fails the tag. Either way it never yields a message.
assert!(
open(&up, &bad).is_err(),
"byte {i} of the header was not authenticated"
);
}
}
#[test]
fn a_flipped_ciphertext_or_tag_byte_fails() {
let (up, _) = keys();
let dg = seal_message(&up, TOKEN_ID, NONCE, &Message::Ping(Ping::default()));
for i in HEADER_LEN..dg.len() {
let mut bad = dg.clone();
bad[i] ^= 0x80;
assert_eq!(open(&up, &bad), Err(DecodeError::AuthFailed), "byte {i}");
}
}
#[test]
fn truncation_is_caught_before_any_crypto() {
let (up, _) = keys();
let dg = seal_message(&up, TOKEN_ID, NONCE, &Message::Ping(Ping::default()));
for cut in 0..MIN_DATAGRAM {
assert_eq!(Header::peek(&dg[..cut]), Err(DecodeError::TooShort(cut)));
}
// Long enough to look like a header, short enough to be corrupt.
for cut in MIN_DATAGRAM..dg.len() {
assert!(
open(&up, &dg[..cut]).is_err(),
"truncation to {cut} accepted"
);
}
}
#[test]
fn oversized_and_misversioned_datagrams_are_rejected() {
assert_eq!(
Header::peek(&vec![0x11; MAX_DATAGRAM + 1]),
Err(DecodeError::TooLong(MAX_DATAGRAM + 1))
);
let mut dg = vec![0u8; MIN_DATAGRAM];
dg[0] = 0x21; // version 2
assert_eq!(Header::peek(&dg), Err(DecodeError::BadVersion(2)));
dg[0] = 0x1F; // version 1, type 0xF
assert_eq!(Header::peek(&dg), Err(DecodeError::BadMsgType(0xF)));
}
/// The control that keeps the open UDP port useless as a reflector.
#[test]
fn every_reply_fits_the_budget() {
// Every message the server can send downstream.
let replies = [
Message::Ack(Ack::single(NONCE)),
Message::Nack(Nack {
nonce: NONCE,
reason: NackReason::Malformed,
retry_after_s: 0,
}),
Message::Config(Config::default()),
Message::Pong(Pong::default()),
Message::Revoked(Revoked {
reason: RevokeReason::Revoked,
}),
];
for reply in &replies {
let len = datagram_len(reply.payload_len());
assert!(
fits_reply_budget(len),
"{:?} is {len} B, over the {MAX_REPLY} B reply budget",
reply.msg_type(),
);
}
}
/// The unverifiable reply can never be an amplifier.
///
/// This is the whole justification for REVOKED being one byte of payload.
/// A datagram short enough to be profitable to reflect is short enough to be
/// refused an answer.
#[test]
fn an_unverified_notice_never_amplifies() {
let revoked = Message::Revoked(Revoked {
reason: RevokeReason::Unknown,
});
assert_eq!(datagram_len(revoked.payload_len()), REVOKED_DATAGRAM_LEN);
assert!(
!may_answer_unverified(MIN_DATAGRAM),
"the smallest possible datagram must not earn a reply"
);
for request_len in MIN_DATAGRAM..=MAX_DATAGRAM {
if may_answer_unverified(request_len) {
assert!(
REVOKED_DATAGRAM_LEN <= request_len,
"a {request_len} B request drew a {REVOKED_DATAGRAM_LEN} B reply"
);
}
}
}
/// The ratios the reply budget actually permits, pinned so a future field
/// cannot quietly turn the port into a useful reflector. Nothing here is
/// remotely in DNS (~50x) or NTP monlist (~550x) territory, and every one of
/// them still requires the sender to hold a valid token key.
#[test]
fn amplification_ratios_stay_near_one() {
let hello = Message::Hello(Hello {
app_version_code: 0,
os_api_level: 0,
flags: HelloFlags::NONE,
config_version: 0,
});
let nack = Message::Nack(Nack {
nonce: NONCE,
reason: NackReason::Malformed,
retry_after_s: 0,
});
let exchanges = [
(
Message::Loc(vec![Point::new(0, 0, 0)]),
Message::Ack(Ack::single(NONCE)),
),
(
Message::Loc(vec![Point::new(0, 0, 0); MAX_POINTS]),
Message::Ack(Ack::single(NONCE)),
),
(hello, Message::Ack(Ack::single(NONCE))),
(
Message::Ping(Ping::default()),
Message::Pong(Pong::default()),
),
(
Message::ConfigGet(ConfigGet::default()),
Message::Config(Config::default()),
),
(Message::Loc(vec![Point::new(0, 0, 0)]), nack),
];
for (req, resp) in &exchanges {
let req_len = datagram_len(req.payload_len()) as f64;
let resp_len = datagram_len(resp.payload_len()) as f64;
let ratio = resp_len / req_len;
assert!(
ratio <= 1.5,
"{:?} -> {:?} amplifies {ratio:.2}x, more leverage than this design allows",
req.msg_type(),
resp.msg_type(),
);
}
}
/// The sizes the ratio table in the module documentation is computed from.
#[test]
fn documented_message_sizes_hold() {
let sizes = [
(Message::Ack(Ack::single(NONCE)), 51),
(
Message::Nack(Nack {
nonce: NONCE,
reason: NackReason::Malformed,
retry_after_s: 0,
}),
51,
),
(Message::Config(Config::default()), 49),
(
Message::Revoked(Revoked {
reason: RevokeReason::Revoked,
}),
REVOKED_DATAGRAM_LEN,
),
(Message::ConfigGet(ConfigGet::default()), 39),
(Message::Ping(Ping::default()), 43),
(Message::Pong(Pong::default()), 43),
(
Message::Hello(Hello {
app_version_code: 0,
os_api_level: 0,
flags: HelloFlags::NONE,
config_version: 0,
}),
43,
),
];
for (msg, want) in &sizes {
assert_eq!(
datagram_len(msg.payload_len()),
*want,
"{:?} changed size, which moves the amplification ratios",
msg.msg_type()
);
}
}
#[test]
fn documented_wire_sizes_hold() {
let one = datagram_len(Message::Loc(vec![Point::new(0, 0, 0)]).payload_len());
let twenty = datagram_len(Message::Loc(vec![Point::new(0, 0, 0); 20]).payload_len());
let forty = datagram_len(Message::Loc(vec![Point::new(0, 0, 0); MAX_POINTS]).payload_len());
assert_eq!((one, twenty, forty), (62, 518, 998));
}
}
Dcrates/otproto/src/kdf.rs-123
@@ -1,123 +0,0 @@
//! Key derivation from the token secret issued at login.
//!
//! ```text
//! K_up = HKDF-Expand(token_key, "otp/1/up", 32) device -> server
//! K_down = HKDF-Expand(token_key, "otp/1/down", 32) server -> device
//! K_rev = HKDF-Expand(master, "otp/1/revoke" || token_id, 32) server -> device
//! ```
//!
//! Expand only, no extract: `token_key` is already 32 uniformly random bytes
//! from the server's CSPRNG, so there is no entropy to condition. Two
//! directions means two keys, so a captured uplink datagram can never be
//! replayed back as a downlink one — which is also why the nonce space of the
//! two directions may overlap freely.
use hkdf::Hkdf;
use sha2::Sha256;
use crate::msg::Direction;
pub const KEY_LEN: usize = 32;
/// A 32-byte symmetric key: either the token secret or one of its two
/// derivatives.
pub type Key = [u8; KEY_LEN];
/// Derive the directional key for `dir`.
#[must_use]
pub fn derive(token_key: &Key, dir: Direction) -> Key {
let hk = Hkdf::<Sha256>::from_prk(token_key).expect("32-byte PRK is valid for HKDF-SHA256");
let mut out = [0u8; KEY_LEN];
hk.expand(dir.info(), &mut out)
.expect("32 bytes is well under HKDF-SHA256's output limit");
out
}
/// The key that seals a [`crate::Revoked`] notice for `token_id`.
///
/// Derived from a server master key and the id, *not* from the token key. That
/// is the point: `K_up` and `K_down` both die with the token's row, and the
/// moment the server most needs to speak is exactly when that row is gone. This
/// key the server can recompute for any id, including one it has never issued.
///
/// Per-id rather than one shared server key, so a device that learns its own
/// `K_rev` still cannot forge a notice for anyone else.
///
/// The master is a deployment secret, so rotating it invalidates every `K_rev`
/// already handed out. Devices that logged in beforehand then fall back to
/// silence, which is the pre-existing behaviour, not a new failure.
#[must_use]
pub fn revocation_key(master: &Key, token_id: u64) -> Key {
let hk = Hkdf::<Sha256>::from_prk(master).expect("32-byte PRK is valid for HKDF-SHA256");
let mut info = [0u8; 12 + 8];
info[..12].copy_from_slice(b"otp/1/revoke");
info[12..].copy_from_slice(&token_id.to_be_bytes());
let mut out = [0u8; KEY_LEN];
hk.expand(&info, &mut out)
.expect("32 bytes is well under HKDF-SHA256's output limit");
out
}
/// Both directional keys at once, in the order the server caches them.
#[must_use]
pub fn derive_both(token_key: &Key) -> (Key, Key) {
(
derive(token_key, Direction::Up),
derive(token_key, Direction::Down),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn directions_are_independent() {
let (up, down) = derive_both(&[0x42; KEY_LEN]);
assert_ne!(up, down);
assert_ne!(up, [0x42; KEY_LEN]);
}
#[test]
fn derivation_is_deterministic() {
assert_eq!(
derive(&[1; KEY_LEN], Direction::Up),
derive(&[1; KEY_LEN], Direction::Up)
);
}
#[test]
fn revocation_keys_differ_per_token_id() {
let master = [0x11; KEY_LEN];
let a = revocation_key(&master, 1);
let b = revocation_key(&master, 2);
assert_ne!(a, b, "one device could forge a notice for another");
assert_eq!(a, revocation_key(&master, 1), "must be recomputable");
}
/// The whole point of the separate master: `K_rev` must not be derivable
/// from anything that dies with the token row.
#[test]
fn a_revocation_key_is_independent_of_the_token_key() {
let key = [0x42; KEY_LEN];
let (up, down) = derive_both(&key);
let rev = revocation_key(&key, 7);
assert_ne!(rev, up);
assert_ne!(rev, down);
assert_ne!(rev, key);
}
#[test]
fn a_one_bit_token_change_changes_the_whole_key() {
let a = derive(&[0; KEY_LEN], Direction::Up);
let mut tk = [0u8; KEY_LEN];
tk[31] = 1;
let b = derive(&tk, Direction::Up);
assert_ne!(a, b);
let differing = a.iter().zip(&b).filter(|(x, y)| x != y).count();
assert!(
differing > KEY_LEN / 2,
"expected avalanche, only {differing} bytes differ"
);
}
}
Dcrates/otproto/src/lib.rs-70
@@ -1,70 +0,0 @@
//! OTP/1 — the opentracker wire protocol.
//!
//! One encrypted UDP datagram per report, no handshake, no connection state. A
//! cold TCP+TLS connection costs roughly ten round trips before the first byte
//! of payload, and a phone that sleeps between reports has a cold connection
//! nearly every time; a single datagram that survives the phone changing IP
//! mid-journey is the entire point of this protocol.
//!
//! This crate is the codec and nothing else: **no I/O, no async, no RNG, no
//! clock.** Nonces and timestamps are passed in by the caller. That is what
//! makes it fuzzable, property-testable, and able to emit reproducible golden
//! vectors — which are the contract the Kotlin implementation is checked
//! against, and the thing that stops a protocol change from silently bricking
//! installed apps.
//!
//! ## Shape of a datagram
//!
//! ```text
//! header[21] || ChaCha20Poly1305(K_dir, nonce, payload, aad = header)
//! ```
//!
//! ## Why there is no replay protection
//!
//! Timestamps are absolute and client-supplied, and the server stores points
//! under `UNIQUE(user_id, ts)` with `ON CONFLICT DO UPDATE`. A replayed
//! datagram therefore carries a timestamp that already exists and collapses
//! into the row already there — replay is idempotent *by construction*. An
//! attacker without the key cannot forge new positions and cannot create
//! duplicate rows, so a counter, a persisted ledger and a replay window would
//! all be state to maintain for no gain.
//!
//! ## Example
//!
//! ```
//! use otproto::{Header, Message, MsgType, Point, kdf, msg::Direction};
//!
//! let token_key = [0x11; 32];
//! let k_up = kdf::derive(&token_key, Direction::Up);
//! let nonce = [0x22; 12]; // in production: 12 fresh random bytes
//!
//! let msg = Message::Loc(vec![Point::new(1_785_000_042, 525_200_080, 134_050_000)]);
//! let datagram = otproto::seal_message(&k_up, 0x1122_3344_5566_7788, nonce, &msg);
//! assert_eq!(datagram.len(), 62);
//!
//! // The server peeks the header to choose a key, then opens.
//! let peeked = Header::peek(&datagram).unwrap();
//! assert_eq!(peeked.msg_type, MsgType::Loc);
//! let (_, back) = otproto::open_message(&k_up, &datagram).unwrap();
//! assert_eq!(back, msg);
//! ```
pub mod error;
pub mod frame;
pub mod kdf;
pub mod msg;
pub mod point;
pub use error::{DecodeError, ValidationError};
pub use frame::{
HEADER_LEN, Header, MAX_DATAGRAM, MAX_REPLY, MIN_DATAGRAM, REVOKED_DATAGRAM_LEN, TAG_LEN,
VERSION, datagram_len, fits_reply_budget, may_answer_unverified, open, open_message, seal,
seal_message,
};
pub use kdf::{KEY_LEN, Key, revocation_key};
pub use msg::{
Ack, AckFlags, Config, ConfigFlags, ConfigGet, Direction, Hello, HelloFlags, MAX_POINTS,
Message, MsgType, NONCE_LEN, Nack, NackReason, Nonce, Ping, Pong, Profile, RevokeReason,
Revoked,
};
pub use point::{Flags as PointFlags, POINT_LEN, Point};
Dcrates/otproto/src/msg.rs-822
@@ -1,822 +0,0 @@
//! Message types and their payload layouts.
//!
//! Every payload is fixed-width except `LOC` (which is a count plus that many
//! point records) and `ACK` (a count plus that many nonces).
//!
//! **There is no clock anywhere in this protocol except `Point::ts`.** No message
//! carries the server's time, nothing measures or reports clock skew, and nothing
//! corrects a timestamp. The client's `ts` is stored and displayed exactly as
//! sent. That is the whole of the timestamp design.
//!
//! Reserved bytes, where they exist, are written as zero and ignored on decode,
//! so a later version can populate them without this build rejecting the packet.
use crate::error::DecodeError;
use crate::point::{POINT_LEN, Point};
/// Number of nonce bytes, which is also the length of a message id.
pub const NONCE_LEN: usize = 12;
/// A message id: the random AEAD nonce, reused as the identifier that `ACK` and
/// `NACK` echo. It is unique and already on the wire, so a separate id field
/// would be pure overhead.
pub type Nonce = [u8; NONCE_LEN];
/// Most points in one `LOC`, and most nonces in one `ACK`.
///
/// 40 points is `21 + 1 + 40*24 + 16` = 998 bytes, comfortably inside the
/// 1200-byte datagram budget.
pub const MAX_POINTS: usize = 40;
const HELLO_LEN: usize = 6;
const CONFIG_LEN: usize = 12;
const CONFIG_GET_LEN: usize = 2;
const PING_LEN: usize = 6;
const PONG_LEN: usize = 6;
const NACK_LEN: usize = NONCE_LEN + 2;
const REVOKED_LEN: usize = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[repr(u8)]
pub enum MsgType {
Loc = 0x1,
Ack = 0x2,
Nack = 0x3,
Hello = 0x4,
Config = 0x5,
ConfigGet = 0x6,
Ping = 0x7,
Pong = 0x8,
/// Sealed under `K_rev`, not `K_down`. See [`Revoked`].
Revoked = 0x9,
}
impl MsgType {
pub const ALL: [Self; 9] = [
Self::Loc,
Self::Ack,
Self::Nack,
Self::Hello,
Self::Config,
Self::ConfigGet,
Self::Ping,
Self::Pong,
Self::Revoked,
];
/// True for messages a device sends to the server, which are sealed under
/// `K_up`. The two directions have separate keys, so a captured uplink
/// datagram can never be replayed back as a downlink one.
#[must_use]
pub const fn is_uplink(self) -> bool {
matches!(self, Self::Loc | Self::Hello | Self::ConfigGet | Self::Ping)
}
#[must_use]
pub const fn direction(self) -> Direction {
if self.is_uplink() {
Direction::Up
} else {
Direction::Down
}
}
}
impl TryFrom<u8> for MsgType {
type Error = DecodeError;
fn try_from(v: u8) -> Result<Self, Self::Error> {
Ok(match v {
0x1 => Self::Loc,
0x2 => Self::Ack,
0x3 => Self::Nack,
0x4 => Self::Hello,
0x5 => Self::Config,
0x6 => Self::ConfigGet,
0x7 => Self::Ping,
0x8 => Self::Pong,
0x9 => Self::Revoked,
other => return Err(DecodeError::BadMsgType(other)),
})
}
}
/// Which of the two derived keys seals a message.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Direction {
/// Device → server, `K_up`.
Up,
/// Server → device, `K_down`.
Down,
}
impl Direction {
/// HKDF info string. Distinct strings are what make the two keys
/// independent.
#[must_use]
pub const fn info(self) -> &'static [u8] {
match self {
Self::Up => b"otp/1/up",
Self::Down => b"otp/1/down",
}
}
}
// ---------------------------------------------------------------------------
// ACK
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct AckFlags(pub u8);
impl AckFlags {
pub const NONE: Self = Self(0);
/// The server has a newer config; the device should send `CONFIG_GET`.
/// Config delivery is pull-based so it never depends on a live NAT binding.
pub const CONFIG_PENDING: Self = Self(1 << 0);
/// The write path is saturated; back off before the next flush.
pub const THROTTLE: Self = Self(1 << 1);
#[must_use]
pub const fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
}
/// Retires one or more messages. One `ACK` can cover a whole flush burst.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Ack {
pub nonces: Vec<Nonce>,
pub flags: AckFlags,
}
impl Ack {
#[must_use]
pub fn single(nonce: Nonce) -> Self {
Self {
nonces: vec![nonce],
flags: AckFlags::NONE,
}
}
const fn payload_len_for(count: usize) -> usize {
1 + count * NONCE_LEN + 1
}
}
// ---------------------------------------------------------------------------
// NACK
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[repr(u8)]
pub enum NackReason {
/// Token unknown, revoked, or expired. The device clears local state and
/// shows the login screen.
UnknownToken = 1,
/// Decrypted cleanly but the payload made no sense.
Malformed = 2,
RateLimited = 3,
/// Per-account storage quota exhausted.
StorageFull = 4,
}
impl TryFrom<u8> for NackReason {
type Error = DecodeError;
fn try_from(v: u8) -> Result<Self, Self::Error> {
Ok(match v {
1 => Self::UnknownToken,
2 => Self::Malformed,
3 => Self::RateLimited,
4 => Self::StorageFull,
value => {
return Err(DecodeError::BadEnum {
field: "NackReason",
value,
});
}
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Nack {
pub nonce: Nonce,
pub reason: NackReason,
/// Seconds to wait before retrying. 0 means "no advice".
pub retry_after_s: u8,
}
// ---------------------------------------------------------------------------
// REVOKED
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[repr(u8)]
pub enum RevokeReason {
/// Explicitly revoked: "log out all other devices", or a password change.
Revoked = 1,
/// Deleted by the staleness sweep after a long silence.
Expired = 2,
/// The server has no record of this token at all. A restored backup or a
/// rotated server key looks like this.
Unknown = 3,
}
impl TryFrom<u8> for RevokeReason {
type Error = DecodeError;
fn try_from(v: u8) -> Result<Self, Self::Error> {
Ok(match v {
1 => Self::Revoked,
2 => Self::Expired,
3 => Self::Unknown,
value => {
return Err(DecodeError::BadEnum {
field: "RevokeReason",
value,
});
}
})
}
}
/// "This token is dead; log in again."
///
/// The one message sealed under `K_rev` rather than `K_down`, which is the whole
/// reason it exists as a separate type. `K_down` derives from the token key, so
/// it dies with the token's row — and the moment the server most needs to speak
/// is exactly when that row is gone. `K_rev` is derived from a server master key
/// and the `token_id` (see [`crate::kdf::revocation_key`]), so the server can
/// recompute it for any id, including one it has never seen.
///
/// Two properties follow from deriving per `token_id` rather than sharing one
/// server key: a third party cannot forge this message, and neither can another
/// legitimate device — it only ever learns its own `K_rev`.
///
/// Replay needs no counter. `token_id` lives in the header, and the header is the
/// AEAD's associated data, so a captured `REVOKED` names the token it was issued
/// against. After the user logs in again the device holds a different id and the
/// rule "act only on my current `token_id`" discards it.
///
/// One byte of payload, deliberately: at 38 bytes on the wire this is the
/// smallest reply in the protocol, and the server refuses to send it in answer to
/// anything shorter, so it can never amplify.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Revoked {
pub reason: RevokeReason,
}
// ---------------------------------------------------------------------------
// HELLO
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct HelloFlags(pub u8);
impl HelloFlags {
pub const NONE: Self = Self(0);
/// First run after an install or reinstall.
pub const FIRST_LAUNCH: Self = Self(1 << 0);
}
/// Sent once per service start, so the server has app and OS versions to show
/// alongside a token. Carries no time: the server has nothing to compare it to
/// that it would be allowed to act on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Hello {
pub app_version_code: u16,
pub os_api_level: u8,
pub flags: HelloFlags,
/// The config version the device currently holds.
pub config_version: u16,
}
// ---------------------------------------------------------------------------
// CONFIG / CONFIG_GET
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[repr(u8)]
pub enum Profile {
BatterySaver = 0,
#[default]
Balanced = 1,
HighAccuracy = 2,
}
impl TryFrom<u8> for Profile {
type Error = DecodeError;
fn try_from(v: u8) -> Result<Self, Self::Error> {
Ok(match v {
0 => Self::BatterySaver,
1 => Self::Balanced,
2 => Self::HighAccuracy,
value => {
return Err(DecodeError::BadEnum {
field: "Profile",
value,
});
}
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct ConfigFlags(pub u8);
impl ConfigFlags {
pub const NONE: Self = Self(0);
/// Master switch. Clearing it stops sharing without revoking the token.
pub const TRACKING_ENABLED: Self = Self(1 << 0);
/// Server wants a fresh `HELLO` (e.g. it has no version info on record).
pub const REQUEST_HELLO: Self = Self(1 << 1);
#[must_use]
pub const fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
}
/// The server's view of how this device should behave.
///
/// `profile` names a parameter set the *client* owns; the numeric fields are
/// server-side overrides on top of it. That keeps the message small and means a
/// profile can be retuned by shipping an app update, without a protocol change.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Config {
pub config_version: u16,
pub profile: Profile,
pub flags: ConfigFlags,
/// Stationary heartbeat period. Clamped by the client to what inexact
/// alarms can actually deliver in doze (~9 min floor).
pub heartbeat_s: u16,
/// Scales the profile's intervals, in percent. 100 = profile default.
pub interval_scale_pct: u16,
pub min_distance_m: u16,
pub max_points_per_loc: u8,
}
impl Default for Config {
fn default() -> Self {
Self {
config_version: 1,
profile: Profile::Balanced,
flags: ConfigFlags::TRACKING_ENABLED,
heartbeat_s: 900,
interval_scale_pct: 100,
min_distance_m: 20,
max_points_per_loc: MAX_POINTS as u8,
}
}
}
/// Asks for the current [`Config`]. Padded to `CONFIG`'s size so the reply is
/// never larger than the request.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ConfigGet {
/// The version the device already has, so the server can skip a no-op push.
pub have_version: u16,
}
// ---------------------------------------------------------------------------
// PING / PONG
// ---------------------------------------------------------------------------
/// Probes whether UDP works on the current network.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Ping {
/// Opaque to the server, echoed verbatim in the `PONG`. The client puts
/// whatever lets it match up a reply and measure a round trip — a reading of
/// its own monotonic clock, typically. Deliberately *not* a wall-clock time
/// the server is invited to interpret.
pub echo: u32,
pub seq: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Pong {
/// Copied from the `PING`, so the client needs no per-probe state.
pub echo: u32,
pub seq: u16,
}
// ---------------------------------------------------------------------------
// Message
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
// Adjacently tagged rather than internally tagged: `Loc` carries a sequence,
// which an internally-tagged representation cannot express.
#[cfg_attr(
feature = "serde",
serde(tag = "type", content = "value", rename_all = "snake_case")
)]
pub enum Message {
/// One or more independent points.
Loc(Vec<Point>),
Ack(Ack),
Nack(Nack),
Hello(Hello),
Config(Config),
ConfigGet(ConfigGet),
Ping(Ping),
Pong(Pong),
Revoked(Revoked),
}
impl Message {
#[must_use]
pub const fn msg_type(&self) -> MsgType {
match self {
Self::Loc(_) => MsgType::Loc,
Self::Ack(_) => MsgType::Ack,
Self::Nack(_) => MsgType::Nack,
Self::Hello(_) => MsgType::Hello,
Self::Config(_) => MsgType::Config,
Self::ConfigGet(_) => MsgType::ConfigGet,
Self::Ping(_) => MsgType::Ping,
Self::Pong(_) => MsgType::Pong,
Self::Revoked(_) => MsgType::Revoked,
}
}
/// Exact payload length, without allocating.
#[must_use]
pub fn payload_len(&self) -> usize {
match self {
Self::Loc(points) => 1 + points.len() * POINT_LEN,
Self::Ack(ack) => Ack::payload_len_for(ack.nonces.len()),
Self::Nack(_) => NACK_LEN,
Self::Hello(_) => HELLO_LEN,
Self::Config(_) => CONFIG_LEN,
Self::ConfigGet(_) => CONFIG_GET_LEN,
Self::Ping(_) => PING_LEN,
Self::Pong(_) => PONG_LEN,
Self::Revoked(_) => REVOKED_LEN,
}
}
/// Append this message's payload (the part that gets encrypted).
///
/// # Panics
/// If a `Loc` or `Ack` holds more than [`MAX_POINTS`] elements, or a `Loc`
/// holds none. Those are caller bugs, not wire conditions: nothing outside
/// this process can trigger them.
pub fn encode_payload_into(&self, out: &mut Vec<u8>) {
out.reserve(self.payload_len());
match self {
Self::Loc(points) => {
assert!(
(1..=MAX_POINTS).contains(&points.len()),
"LOC must carry 1..={MAX_POINTS} points, got {}",
points.len()
);
out.push(points.len() as u8);
for p in points {
out.extend_from_slice(&p.to_bytes());
}
}
Self::Ack(ack) => {
assert!(
(1..=MAX_POINTS).contains(&ack.nonces.len()),
"ACK must carry 1..={MAX_POINTS} nonces, got {}",
ack.nonces.len()
);
out.push(ack.nonces.len() as u8);
for n in &ack.nonces {
out.extend_from_slice(n);
}
out.push(ack.flags.0);
}
Self::Nack(nack) => {
out.extend_from_slice(&nack.nonce);
out.push(nack.reason as u8);
out.push(nack.retry_after_s);
}
Self::Hello(h) => {
out.extend_from_slice(&h.app_version_code.to_be_bytes());
out.push(h.os_api_level);
out.push(h.flags.0);
out.extend_from_slice(&h.config_version.to_be_bytes());
}
Self::Config(c) => {
out.extend_from_slice(&c.config_version.to_be_bytes());
out.push(c.profile as u8);
out.push(c.flags.0);
out.extend_from_slice(&c.heartbeat_s.to_be_bytes());
out.extend_from_slice(&c.interval_scale_pct.to_be_bytes());
out.extend_from_slice(&c.min_distance_m.to_be_bytes());
out.push(c.max_points_per_loc);
out.push(0); // reserved
}
Self::ConfigGet(g) => {
out.extend_from_slice(&g.have_version.to_be_bytes());
}
Self::Ping(p) => {
out.extend_from_slice(&p.echo.to_be_bytes());
out.extend_from_slice(&p.seq.to_be_bytes());
}
Self::Pong(p) => {
out.extend_from_slice(&p.echo.to_be_bytes());
out.extend_from_slice(&p.seq.to_be_bytes());
}
Self::Revoked(r) => out.push(r.reason as u8),
}
debug_assert_eq!(
out.len(),
self.payload_len(),
"payload_len disagrees with the encoder"
);
}
#[must_use]
pub fn encode_payload(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(self.payload_len());
self.encode_payload_into(&mut out);
out
}
/// Parse a decrypted payload.
pub fn decode_payload(ty: MsgType, p: &[u8]) -> Result<Self, DecodeError> {
let exact = |what: &'static str, expected: usize| -> Result<(), DecodeError> {
if p.len() == expected {
Ok(())
} else {
Err(DecodeError::BadPayloadLen {
what,
expected,
actual: p.len(),
})
}
};
Ok(match ty {
MsgType::Loc => {
let count = *p.first().ok_or(DecodeError::BadPayloadLen {
what: "LOC",
expected: 1 + POINT_LEN,
actual: 0,
})? as usize;
if !(1..=MAX_POINTS).contains(&count) {
return Err(DecodeError::BadPointCount(count));
}
exact("LOC", 1 + count * POINT_LEN)?;
let points = p[1..]
.chunks_exact(POINT_LEN)
.map(|c| {
Point::from_bytes(c.try_into().expect("chunks_exact yields POINT_LEN"))
})
.collect();
Self::Loc(points)
}
MsgType::Ack => {
let count = *p.first().ok_or(DecodeError::BadPayloadLen {
what: "ACK",
expected: Ack::payload_len_for(1),
actual: 0,
})? as usize;
if !(1..=MAX_POINTS).contains(&count) {
return Err(DecodeError::BadNonceCount(count));
}
exact("ACK", Ack::payload_len_for(count))?;
let nonces = p[1..1 + count * NONCE_LEN]
.chunks_exact(NONCE_LEN)
.map(|c| -> Nonce { c.try_into().expect("chunks_exact yields NONCE_LEN") })
.collect();
let tail = 1 + count * NONCE_LEN;
Self::Ack(Ack {
nonces,
flags: AckFlags(p[tail]),
})
}
MsgType::Nack => {
exact("NACK", NACK_LEN)?;
Self::Nack(Nack {
nonce: p[..NONCE_LEN].try_into().expect("length checked"),
reason: NackReason::try_from(p[NONCE_LEN])?,
retry_after_s: p[NONCE_LEN + 1],
})
}
MsgType::Hello => {
exact("HELLO", HELLO_LEN)?;
Self::Hello(Hello {
app_version_code: be16(p),
os_api_level: p[2],
flags: HelloFlags(p[3]),
config_version: be16(&p[4..]),
})
}
MsgType::Config => {
exact("CONFIG", CONFIG_LEN)?;
Self::Config(Config {
config_version: be16(p),
profile: Profile::try_from(p[2])?,
flags: ConfigFlags(p[3]),
heartbeat_s: be16(&p[4..]),
interval_scale_pct: be16(&p[6..]),
min_distance_m: be16(&p[8..]),
max_points_per_loc: p[10],
})
}
MsgType::ConfigGet => {
exact("CONFIG_GET", CONFIG_GET_LEN)?;
Self::ConfigGet(ConfigGet {
have_version: be16(p),
})
}
MsgType::Ping => {
exact("PING", PING_LEN)?;
Self::Ping(Ping {
echo: be32(p),
seq: be16(&p[4..]),
})
}
MsgType::Pong => {
exact("PONG", PONG_LEN)?;
Self::Pong(Pong {
echo: be32(p),
seq: be16(&p[4..]),
})
}
MsgType::Revoked => {
exact("REVOKED", REVOKED_LEN)?;
Self::Revoked(Revoked {
reason: RevokeReason::try_from(p[0])?,
})
}
})
}
}
fn be16(b: &[u8]) -> u16 {
u16::from_be_bytes([b[0], b[1]])
}
fn be32(b: &[u8]) -> u32 {
u32::from_be_bytes([b[0], b[1], b[2], b[3]])
}
#[cfg(test)]
mod tests {
use super::*;
fn round_trip(m: &Message) {
let bytes = m.encode_payload();
assert_eq!(bytes.len(), m.payload_len());
let back = Message::decode_payload(m.msg_type(), &bytes).expect("decodes");
assert_eq!(&back, m);
}
#[test]
fn every_type_round_trips() {
round_trip(&Message::Loc(vec![Point::new(
1_785_000_042,
525_200_080,
134_050_000,
)]));
round_trip(&Message::Loc(
(0..MAX_POINTS as u32)
.map(|i| Point::new(i, i as i32, -(i as i32)))
.collect(),
));
round_trip(&Message::Ack(Ack {
nonces: vec![[7; NONCE_LEN], [9; NONCE_LEN]],
flags: AckFlags::CONFIG_PENDING,
}));
round_trip(&Message::Nack(Nack {
nonce: [3; NONCE_LEN],
reason: NackReason::RateLimited,
retry_after_s: 30,
}));
round_trip(&Message::Hello(Hello {
app_version_code: 17,
os_api_level: 34,
flags: HelloFlags::FIRST_LAUNCH,
config_version: 2,
}));
round_trip(&Message::Config(Config::default()));
round_trip(&Message::ConfigGet(ConfigGet { have_version: 2 }));
round_trip(&Message::Ping(Ping {
echo: 0xDEAD_BEEF,
seq: 5,
}));
round_trip(&Message::Pong(Pong {
echo: 0xDEAD_BEEF,
seq: 5,
}));
}
#[test]
fn loc_rejects_degenerate_counts() {
assert_eq!(
Message::decode_payload(MsgType::Loc, &[0]),
Err(DecodeError::BadPointCount(0))
);
let mut too_many = vec![(MAX_POINTS + 1) as u8];
too_many.extend(std::iter::repeat_n(0u8, (MAX_POINTS + 1) * POINT_LEN));
assert_eq!(
Message::decode_payload(MsgType::Loc, &too_many),
Err(DecodeError::BadPointCount(MAX_POINTS + 1))
);
}
#[test]
fn loc_rejects_a_count_that_disagrees_with_the_length() {
// Claims two points, carries one. The classic truncation bug.
let mut p = vec![2u8];
p.extend_from_slice(&Point::new(1, 2, 3).to_bytes());
assert!(matches!(
Message::decode_payload(MsgType::Loc, &p),
Err(DecodeError::BadPayloadLen { .. })
));
}
#[test]
fn fixed_payload_sizes_are_what_the_spec_says() {
assert_eq!(
Message::Nack(Nack {
nonce: [0; NONCE_LEN],
reason: NackReason::Malformed,
retry_after_s: 0
})
.payload_len(),
14
);
assert_eq!(
Message::Hello(Hello {
app_version_code: 0,
os_api_level: 0,
flags: HelloFlags::NONE,
config_version: 0
})
.payload_len(),
6
);
assert_eq!(Message::Config(Config::default()).payload_len(), 12);
assert_eq!(Message::ConfigGet(ConfigGet::default()).payload_len(), 2);
assert_eq!(Message::Ping(Ping::default()).payload_len(), 6);
assert_eq!(Message::Pong(Pong::default()).payload_len(), 6);
assert_eq!(Message::Loc(vec![Point::new(0, 0, 0)]).payload_len(), 25);
}
/// CONFIG holds the only reserved byte left in the protocol. A later version
/// may populate it, and this build must ignore it rather than reject the
/// packet.
#[test]
fn reserved_bytes_do_not_break_decoding() {
let config = Config::default();
let mut p = Message::Config(config).encode_payload();
p[11] = 0xAB;
assert_eq!(
Message::decode_payload(MsgType::Config, &p),
Ok(Message::Config(config))
);
}
#[test]
fn unknown_discriminants_are_rejected() {
assert!(MsgType::try_from(0).is_err());
assert!(MsgType::try_from(10).is_err());
assert!(Profile::try_from(3).is_err());
assert!(NackReason::try_from(0).is_err());
assert!(NackReason::try_from(5).is_err());
assert!(RevokeReason::try_from(0).is_err());
assert!(RevokeReason::try_from(4).is_err());
}
#[test]
fn direction_split_matches_the_key_schedule() {
for ty in MsgType::ALL {
assert_eq!(
ty.direction() == Direction::Up,
matches!(
ty,
MsgType::Loc | MsgType::Hello | MsgType::ConfigGet | MsgType::Ping
)
);
}
assert_ne!(Direction::Up.info(), Direction::Down.info());
}
}
Dcrates/otproto/src/point.rs-323
@@ -1,323 +0,0 @@
//! The 24-byte point record — the only part of OTP/1 that appears in bulk.
//!
//! ```text
//! off size field range / unit
//! 0 4 ts u32 unix seconds (good to 2106)
//! 4 4 lat i32 degrees × 1e7 → 1.1 cm
//! 8 4 lon i32 degrees × 1e7
//! 12 2 acc_dm u16 decimetres, 0–6553 m; 0xFFFF unknown
//! 14 2 alt_m i16 metres, ±32 km; 0x8000 unknown
//! 16 2 spd_cms u16 cm/s, 0–655 m/s; 0xFFFF unknown
//! 18 2 brg_cdeg u16 centidegrees, 0–35999; 0xFFFF unknown
//! 20 1 bat_pct u8 0–100; 0xFF unknown
//! 21 1 flags u8
//! 22 2 reserved zero
//! ```
//!
//! Fixed width, no varints, no delta coding, no flag-driven optional fields:
//! the codec is a straight struct read, and a 40-point datagram still fits in
//! 998 bytes.
use crate::error::ValidationError;
/// Wire size of one point record.
pub const POINT_LEN: usize = 24;
const ACC_UNKNOWN: u16 = 0xFFFF;
const ALT_UNKNOWN: i16 = i16::MIN; // 0x8000
const SPD_UNKNOWN: u16 = 0xFFFF;
const BRG_UNKNOWN: u16 = 0xFFFF;
const BAT_UNKNOWN: u8 = 0xFF;
/// Largest representable value for each sentinel-terminated field.
const ACC_MAX: u16 = ACC_UNKNOWN - 1;
const SPD_MAX: u16 = SPD_UNKNOWN - 1;
const BRG_MAX: u16 = 35_999;
const ALT_MIN: i16 = i16::MIN + 1;
pub const LAT_MAX_E7: i32 = 900_000_000;
pub const LON_MAX_E7: i32 = 1_800_000_000;
/// Per-point flag bits.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct Flags(pub u8);
impl Flags {
pub const NONE: Self = Self(0);
/// Device is plugged in.
pub const CHARGING: Self = Self(1 << 0);
/// Fix came from the network provider rather than GNSS.
pub const NETWORK_FIX: Self = Self(1 << 1);
/// Accepted despite exceeding the accuracy gate — nothing better arrived.
pub const LOW_ACCURACY: Self = Self(1 << 2);
/// `Location.isFromMockProvider()`.
pub const MOCK: Self = Self(1 << 3);
/// Bits with an assigned meaning in version 1.
pub const KNOWN: u8 = 0b0000_1111;
#[must_use]
pub const fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
#[must_use]
pub const fn union(self, other: Self) -> Self {
Self(self.0 | other.0)
}
}
impl core::ops::BitOr for Flags {
type Output = Self;
fn bitor(self, rhs: Self) -> Self {
self.union(rhs)
}
}
/// One location report.
///
/// `None` in an optional field means "the device could not measure this" and is
/// carried on the wire as that field's sentinel. Points inside a multi-point
/// `LOC` are fully independent — any order, any spacing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Point {
/// Unix seconds, from the device's wall clock. Trusted as-is; the server
/// never rewrites it, it only reports the observed skew back to the user.
pub ts: u32,
pub lat_e7: i32,
pub lon_e7: i32,
/// Horizontal accuracy in decimetres.
pub acc_dm: Option<u16>,
pub alt_m: Option<i16>,
/// Ground speed in cm/s.
pub spd_cms: Option<u16>,
/// Bearing in centidegrees, 0..=35999.
pub brg_cdeg: Option<u16>,
pub bat_pct: Option<u8>,
pub flags: Flags,
}
impl Point {
/// A point with only a time and a position — every optional field unknown.
#[must_use]
pub const fn new(ts: u32, lat_e7: i32, lon_e7: i32) -> Self {
Self {
ts,
lat_e7,
lon_e7,
acc_dm: None,
alt_m: None,
spd_cms: None,
brg_cdeg: None,
bat_pct: None,
flags: Flags::NONE,
}
}
/// Decode a point record. Infallible: every 24-byte string is a point.
///
/// The two reserved bytes are dropped rather than rejected, so a future
/// version can put something there without this build treating the packet
/// as garbage.
#[must_use]
pub fn from_bytes(b: &[u8; POINT_LEN]) -> Self {
let acc = u16::from_be_bytes([b[12], b[13]]);
let alt = i16::from_be_bytes([b[14], b[15]]);
let spd = u16::from_be_bytes([b[16], b[17]]);
let brg = u16::from_be_bytes([b[18], b[19]]);
Self {
ts: u32::from_be_bytes([b[0], b[1], b[2], b[3]]),
lat_e7: i32::from_be_bytes([b[4], b[5], b[6], b[7]]),
lon_e7: i32::from_be_bytes([b[8], b[9], b[10], b[11]]),
acc_dm: (acc != ACC_UNKNOWN).then_some(acc),
alt_m: (alt != ALT_UNKNOWN).then_some(alt),
spd_cms: (spd != SPD_UNKNOWN).then_some(spd),
brg_cdeg: (brg != BRG_UNKNOWN).then_some(brg),
bat_pct: (b[20] != BAT_UNKNOWN).then_some(b[20]),
flags: Flags(b[21]),
}
}
/// Encode a point record.
///
/// Values that would collide with a sentinel are clamped to the largest
/// representable value, so "20 km up" degrades to "32.767 km up" rather
/// than silently becoming "unknown".
#[must_use]
pub fn to_bytes(self) -> [u8; POINT_LEN] {
let mut b = [0u8; POINT_LEN];
b[0..4].copy_from_slice(&self.ts.to_be_bytes());
b[4..8].copy_from_slice(&self.lat_e7.to_be_bytes());
b[8..12].copy_from_slice(&self.lon_e7.to_be_bytes());
b[12..14].copy_from_slice(
&self
.acc_dm
.map_or(ACC_UNKNOWN, |v| v.min(ACC_MAX))
.to_be_bytes(),
);
b[14..16].copy_from_slice(
&self
.alt_m
.map_or(ALT_UNKNOWN, |v| v.max(ALT_MIN))
.to_be_bytes(),
);
b[16..18].copy_from_slice(
&self
.spd_cms
.map_or(SPD_UNKNOWN, |v| v.min(SPD_MAX))
.to_be_bytes(),
);
b[18..20].copy_from_slice(
&self
.brg_cdeg
.map_or(BRG_UNKNOWN, |v| v.min(BRG_MAX))
.to_be_bytes(),
);
b[20] = self.bat_pct.map_or(BAT_UNKNOWN, |v| v.min(100));
b[21] = self.flags.0;
// b[22..24] stay zero.
b
}
/// True when `to_bytes` will not have to clamp anything, i.e. the struct
/// survives a round trip unchanged.
#[must_use]
pub fn is_canonical(self) -> bool {
self.acc_dm.is_none_or(|v| v <= ACC_MAX)
&& self.alt_m.is_none_or(|v| v >= ALT_MIN)
&& self.spd_cms.is_none_or(|v| v <= SPD_MAX)
&& self.brg_cdeg.is_none_or(|v| v <= BRG_MAX)
&& self.bat_pct.is_none_or(|v| v <= 100)
}
/// Clamp every field into its representable range. `to_bytes` does this
/// implicitly; call this when you want the struct itself to agree.
#[must_use]
pub fn canonical(self) -> Self {
Self::from_bytes(&self.to_bytes())
}
/// Reject values the server should not store.
///
/// `now` is the server's clock; timestamps are accepted within ±`window_s`
/// of it. That bound exists to stop a badly-set phone clock from writing
/// points into the year 2100 where retention will never reach them — it is
/// not a security control, since the client clock is trusted by design.
pub fn validate(self, now: u32, window_s: u32) -> Result<(), ValidationError> {
if !(-LAT_MAX_E7..=LAT_MAX_E7).contains(&self.lat_e7) {
return Err(ValidationError::Latitude(self.lat_e7));
}
if !(-LON_MAX_E7..=LON_MAX_E7).contains(&self.lon_e7) {
return Err(ValidationError::Longitude(self.lon_e7));
}
if let Some(brg) = self.brg_cdeg
&& brg > BRG_MAX
{
return Err(ValidationError::Bearing(brg));
}
if let Some(bat) = self.bat_pct
&& bat > 100
{
return Err(ValidationError::Battery(bat));
}
let off_by = i64::from(self.ts) - i64::from(now);
if off_by.unsigned_abs() > u64::from(window_s) {
return Err(ValidationError::Timestamp {
ts: self.ts,
now,
off_by,
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sentinels_round_trip_as_none() {
let p = Point::from_bytes(&[0xFF; POINT_LEN]);
assert_eq!(p.acc_dm, None);
assert_eq!(p.spd_cms, None);
assert_eq!(p.brg_cdeg, None);
assert_eq!(p.bat_pct, None);
// 0xFFFF as i16 is -1, a perfectly good altitude, not the sentinel.
assert_eq!(p.alt_m, Some(-1));
assert_eq!(
Point::from_bytes(&{
let mut b = [0u8; POINT_LEN];
b[14..16].copy_from_slice(&ALT_UNKNOWN.to_be_bytes());
b
})
.alt_m,
None
);
}
#[test]
fn out_of_range_values_clamp_rather_than_vanish() {
let p = Point {
acc_dm: Some(u16::MAX),
alt_m: Some(i16::MIN),
spd_cms: Some(u16::MAX),
brg_cdeg: Some(40_000),
bat_pct: Some(200),
..Point::new(0, 0, 0)
};
assert!(!p.is_canonical());
let back = p.canonical();
assert_eq!(back.acc_dm, Some(ACC_MAX));
assert_eq!(back.alt_m, Some(ALT_MIN));
assert_eq!(back.spd_cms, Some(SPD_MAX));
assert_eq!(back.brg_cdeg, Some(BRG_MAX));
assert_eq!(back.bat_pct, Some(100));
assert!(back.is_canonical());
}
#[test]
fn reserved_bytes_are_written_zero() {
let b = Point::new(1, 2, 3).to_bytes();
assert_eq!(&b[22..24], &[0, 0]);
}
#[test]
fn quantization_stays_inside_stated_precision() {
// 1e7 fixed point resolves to ~1.1 cm at the equator; assert the
// encoder does not lose more than one unit.
let lat = 52.520_008_f64;
let e7 = (lat * 1e7).round() as i32;
let p = Point::new(0, e7, 0).canonical();
assert!((f64::from(p.lat_e7) / 1e7 - lat).abs() < 1e-7);
}
#[test]
fn validate_rejects_impossible_coordinates() {
let now = 1_785_000_000;
assert!(Point::new(now, 910_000_000, 0).validate(now, 60).is_err());
assert!(
Point::new(now, 0, -1_810_000_000)
.validate(now, 60)
.is_err()
);
assert!(
Point::new(now, 525_200_000, 134_050_000)
.validate(now, 60)
.is_ok()
);
}
#[test]
fn validate_rejects_timestamps_outside_the_window() {
let now = 1_785_000_000;
assert!(Point::new(now - 61, 0, 0).validate(now, 60).is_err());
assert!(Point::new(now + 61, 0, 0).validate(now, 60).is_err());
assert!(Point::new(now - 60, 0, 0).validate(now, 60).is_ok());
// A zero timestamp must not underflow into "close enough".
assert!(Point::new(0, 0, 0).validate(now, 60).is_err());
}
}
Dcrates/otproto/tests/props.proptest-regressions-7
@@ -1,7 +0,0 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc f484ce92166914b8909c369ce9a6099cca28b797c6c8366a79013020464193c6 # shrinks to mut b = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 101, 0, 0, 0]
Dcrates/otproto/tests/props.rs-189
@@ -1,189 +0,0 @@
//! Property tests for the OTP/1 codec.
//!
//! Two shapes of property, and both matter for different reasons:
//!
//! * **Round-trip** — `decode(encode(x)) == x` and `encode(decode(b)) == b`.
//! These pin the codec's meaning.
//! * **Totality** — arbitrary bytes either fail cleanly or produce a message;
//! they never panic. The decoder faces the open internet, so a panic is a
//! remote denial of service. `cargo fuzz run decode` covers the same ground
//! with better coverage guidance; this keeps it honest on every `cargo test`.
use otproto::msg::Direction;
use otproto::point::{Flags, POINT_LEN};
use otproto::{Ack, AckFlags, Header, MAX_POINTS, Message, Nonce, Point, kdf};
use proptest::prelude::*;
/// Any point that survives encoding unchanged, i.e. no field needs clamping.
fn canonical_point() -> impl Strategy<Value = Point> {
(
any::<u32>(),
-otproto::point::LAT_MAX_E7..=otproto::point::LAT_MAX_E7,
-otproto::point::LON_MAX_E7..=otproto::point::LON_MAX_E7,
proptest::option::of(0u16..=65_534),
proptest::option::of(-32_767i16..=32_767),
proptest::option::of(0u16..=65_534),
proptest::option::of(0u16..=35_999),
proptest::option::of(0u8..=100),
any::<u8>(),
)
.prop_map(
|(ts, lat_e7, lon_e7, acc_dm, alt_m, spd_cms, brg_cdeg, bat_pct, flags)| Point {
ts,
lat_e7,
lon_e7,
acc_dm,
alt_m,
spd_cms,
brg_cdeg,
bat_pct,
flags: Flags(flags),
},
)
}
/// Any point at all, including values the encoder will clamp.
fn wild_point() -> impl Strategy<Value = Point> {
proptest::array::uniform24(any::<u8>()).prop_map(|b| Point::from_bytes(&b))
}
fn nonce() -> impl Strategy<Value = Nonce> {
proptest::array::uniform12(any::<u8>())
}
proptest! {
#[test]
fn canonical_points_round_trip(p in canonical_point()) {
prop_assert!(p.is_canonical());
prop_assert_eq!(Point::from_bytes(&p.to_bytes()), p);
}
/// The other direction. Not every 24-byte string is a canonical record:
/// battery 101..=254 and bearing 36000..=65534 parse fine but re-encode
/// clamped, since only their sentinel is reserved, not the whole tail of
/// their range. Those two fields are normalised here, and the clamping
/// itself is covered by `canonicalisation_is_idempotent`.
#[test]
fn canonical_point_bytes_round_trip(mut b in proptest::array::uniform24(any::<u8>())) {
let brg = u16::from_be_bytes([b[18], b[19]]);
if brg > 35_999 && brg != 0xFFFF {
b[18..20].copy_from_slice(&35_999u16.to_be_bytes());
}
if b[20] > 100 && b[20] != 0xFF {
b[20] = 100;
}
b[22] = 0;
b[23] = 0;
prop_assert_eq!(Point::from_bytes(&b).to_bytes(), b);
}
/// Clamping is idempotent: canonicalising twice changes nothing more.
#[test]
fn canonicalisation_is_idempotent(p in wild_point()) {
let once = p.canonical();
prop_assert!(once.is_canonical());
prop_assert_eq!(once.canonical(), once);
}
#[test]
fn loc_messages_round_trip(points in prop::collection::vec(canonical_point(), 1..=MAX_POINTS)) {
let msg = Message::Loc(points);
let payload = msg.encode_payload();
prop_assert_eq!(payload.len(), msg.payload_len());
prop_assert_eq!(Message::decode_payload(otproto::MsgType::Loc, &payload).unwrap(), msg);
}
#[test]
fn ack_messages_round_trip(
nonces in prop::collection::vec(nonce(), 1..=MAX_POINTS),
flags in any::<u8>(),
) {
let msg = Message::Ack(Ack { nonces, flags: AckFlags(flags) });
let payload = msg.encode_payload();
prop_assert_eq!(Message::decode_payload(otproto::MsgType::Ack, &payload).unwrap(), msg);
}
#[test]
fn seal_open_round_trips(
points in prop::collection::vec(canonical_point(), 1..=MAX_POINTS),
token_key in proptest::array::uniform32(any::<u8>()),
token_id in any::<u64>(),
n in nonce(),
) {
let k_up = kdf::derive(&token_key, Direction::Up);
let msg = Message::Loc(points);
let dg = otproto::seal_message(&k_up, token_id, n, &msg);
prop_assert!(dg.len() <= otproto::MAX_DATAGRAM);
let (h, back) = otproto::open_message(&k_up, &dg).unwrap();
prop_assert_eq!(h.token_id, token_id);
prop_assert_eq!(h.nonce, n);
prop_assert_eq!(back, msg);
}
/// Any single bit flipped anywhere must be caught. The header is covered
/// because it is the AAD, not because it is separately checksummed.
#[test]
fn any_single_bit_flip_is_detected(
token_key in proptest::array::uniform32(any::<u8>()),
token_id in any::<u64>(),
n in nonce(),
point in canonical_point(),
bit in 0usize..(otproto::HEADER_LEN + 1 + POINT_LEN + otproto::TAG_LEN) * 8,
) {
let k_up = kdf::derive(&token_key, Direction::Up);
let mut dg = otproto::seal_message(&k_up, token_id, n, &Message::Loc(vec![point]));
dg[bit / 8] ^= 1 << (bit % 8);
prop_assert!(otproto::open_message(&k_up, &dg).is_err());
}
/// Totality: arbitrary bytes never panic the header parser.
#[test]
fn peek_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..1300)) {
let _ = Header::peek(&bytes);
}
/// Totality: arbitrary bytes never panic the AEAD layer either.
#[test]
fn open_never_panics(
bytes in prop::collection::vec(any::<u8>(), 0..1300),
token_key in proptest::array::uniform32(any::<u8>()),
) {
let k = kdf::derive(&token_key, Direction::Up);
let _ = otproto::open_message(&k, &bytes);
}
/// Totality on the payload decoder specifically, reached without having to
/// forge a valid tag first — the interesting half of the decoder is behind
/// the AEAD, so fuzzing the datagram alone would almost never get here.
#[test]
fn decode_payload_never_panics(
ty in 1u8..=8,
payload in prop::collection::vec(any::<u8>(), 0..1200),
) {
let ty = otproto::MsgType::try_from(ty).unwrap();
if let Ok(msg) = Message::decode_payload(ty, &payload) {
// Anything that decodes must re-encode to the same length, and for
// types without reserved padding, to the same bytes.
prop_assert_eq!(msg.payload_len(), payload.len());
prop_assert_eq!(msg.msg_type(), ty);
}
}
/// A datagram sealed for one token must not open under another token's key,
/// even with the ciphertext untouched — the header is AAD, so the token id
/// is bound into the tag.
#[test]
fn a_datagram_cannot_be_retargeted(
token_key in proptest::array::uniform32(any::<u8>()),
a in any::<u64>(),
b in any::<u64>(),
n in nonce(),
point in canonical_point(),
) {
prop_assume!(a != b);
let k_up = kdf::derive(&token_key, Direction::Up);
let mut dg = otproto::seal_message(&k_up, a, n, &Message::Loc(vec![point]));
dg[1..9].copy_from_slice(&b.to_be_bytes());
prop_assert!(otproto::open_message(&k_up, &dg).is_err());
}
}
Dcrates/otproto/tests/vectors.json-1606
@@ -1,1606 +0,0 @@
{
"protocol": "OTP/1",
"version": 1,
"note": "Generated by `cargo run -p otproto --features serde --example gen_vectors`. Do not edit by hand.",
"header_len": 21,
"tag_len": 16,
"max_datagram": 1200,
"max_points": 40,
"token_id": 81985529216486895,
"token_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"k_up_hex": "52c8535360382dd1d2b9d4b5d605f7c46f8a69fd4b5d62dfd900ca10b8ac6196",
"k_down_hex": "94171a6d07e341d8333b7c0dd809b789b378887cf4890ff1f5520c992d7637bf",
"revocation_master_hex": "e0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff",
"k_rev_hex": "a2c99bfa84c749a08c3c3b37d18ead4b4d296c608fe168d73b89d24607c60626",
"points": [
{
"name": "all_unknown",
"point": {
"ts": 1785000042,
"lat_e7": 525200080,
"lon_e7": 134050000,
"acc_dm": null,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": null,
"flags": 0
},
"bytes_hex": "6a64f06a1f4dead007fd70d0ffff8000ffffffffff000000"
},
{
"name": "fully_populated",
"point": {
"ts": 1785000042,
"lat_e7": 525200080,
"lon_e7": 134050000,
"acc_dm": 80,
"alt_m": 34,
"spd_cms": 450,
"brg_cdeg": 21400,
"bat_pct": 76,
"flags": 2
},
"bytes_hex": "6a64f06a1f4dead007fd70d00050002201c253984c020000"
},
{
"name": "southern_western_hemisphere",
"point": {
"ts": 1785000042,
"lat_e7": -338688000,
"lon_e7": -1754500000,
"acc_dm": 1200,
"alt_m": -31,
"spd_cms": 0,
"brg_cdeg": 0,
"bat_pct": 0,
"flags": 5
},
"bytes_hex": "6a64f06aebd00800976c746004b0ffe10000000000050000"
},
{
"name": "extremes",
"point": {
"ts": 4294967295,
"lat_e7": 900000000,
"lon_e7": -1800000000,
"acc_dm": 65534,
"alt_m": -32767,
"spd_cms": 65534,
"brg_cdeg": 35999,
"bat_pct": 100,
"flags": 15
},
"bytes_hex": "ffffffff35a4e90094b62e00fffe8001fffe8c9f640f0000"
},
{
"name": "epoch_zero",
"point": {
"ts": 0,
"lat_e7": 0,
"lon_e7": 0,
"acc_dm": null,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": null,
"flags": 0
},
"bytes_hex": "000000000000000000000000ffff8000ffffffffff000000"
}
],
"datagrams": [
{
"name": "loc_single",
"direction": "up",
"key": "up",
"msg_type": 1,
"nonce_hex": "000102030405060708090a0b",
"header_hex": "110123456789abcdef000102030405060708090a0b",
"payload_hex": "016a64f06a1f4dead007fd70d00050002201c253984c020000",
"datagram_hex": "110123456789abcdef000102030405060708090a0bee7fe4db683bd4169d85b517d4e14fceed13336f3437fe90f2c162c7b9a8073cfefc686999c6fa2a83",
"datagram_len": 62,
"message": {
"type": "loc",
"value": [
{
"ts": 1785000042,
"lat_e7": 525200080,
"lon_e7": 134050000,
"acc_dm": 80,
"alt_m": 34,
"spd_cms": 450,
"brg_cdeg": 21400,
"bat_pct": 76,
"flags": 2
}
]
}
},
{
"name": "loc_three_independent",
"direction": "up",
"key": "up",
"msg_type": 1,
"nonce_hex": "101112131415161718191a1b",
"header_hex": "110123456789abcdef101112131415161718191a1b",
"payload_hex": "036a64eff21f4dead007fd70d0ffff8000ffffffffff0000006a64f06a1f4dead007fd70d00050002201c253984c0200006a64f0a61f4dee6807fd74b809c48000ffffffffff060000",
"datagram_hex": "110123456789abcdef101112131415161718191a1bfcba28492d9b93538f030a25fd0c73922802d86dc08bf593234b35d4b339dfe4e584a79e3ad1910312bdb162639b74536eb61bd445ef23a2fa4cb632d5a6177ae14ecdf7f180111dba2ffaae94033ee3895341dd5df5c9929f",
"datagram_len": 110,
"message": {
"type": "loc",
"value": [
{
"ts": 1784999922,
"lat_e7": 525200080,
"lon_e7": 134050000,
"acc_dm": null,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": null,
"flags": 0
},
{
"ts": 1785000042,
"lat_e7": 525200080,
"lon_e7": 134050000,
"acc_dm": 80,
"alt_m": 34,
"spd_cms": 450,
"brg_cdeg": 21400,
"bat_pct": 76,
"flags": 2
},
{
"ts": 1785000102,
"lat_e7": 525201000,
"lon_e7": 134051000,
"acc_dm": 2500,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": null,
"flags": 6
}
]
}
},
{
"name": "loc_max_points",
"direction": "up",
"key": "up",
"msg_type": 1,
"nonce_hex": "202122232425262728292a2b",
"header_hex": "110123456789abcdef202122232425262728292a2b",
"payload_hex": "286a64f06a1f4dead007fd70d000328000ffffffff640000006a64f0881f4deb3407fd70d000338000ffffffff630000006a64f0a61f4deb9807fd70d000348000ffffffff620000006a64f0c41f4debfc07fd70d000358000ffffffff610000006a64f0e21f4dec6007fd70d000368000ffffffff600000006a64f1001f4decc407fd70d000378000ffffffff5f0000006a64f11e1f4ded2807fd70d000388000ffffffff5e0000006a64f13c1f4ded8c07fd70d000398000ffffffff5d0000006a64f15a1f4dedf007fd70d0003a8000ffffffff5c0000006a64f1781f4dee5407fd70d0003b8000ffffffff5b0000006a64f1961f4deeb807fd70d0003c8000ffffffff5a0000006a64f1b41f4def1c07fd70d0003d8000ffffffff590000006a64f1d21f4def8007fd70d0003e8000ffffffff580000006a64f1f01f4defe407fd70d0003f8000ffffffff570000006a64f20e1f4df04807fd70d000408000ffffffff560000006a64f22c1f4df0ac07fd70d000418000ffffffff550000006a64f24a1f4df11007fd70d000428000ffffffff540000006a64f2681f4df17407fd70d000438000ffffffff530000006a64f2861f4df1d807fd70d000448000ffffffff520000006a64f2a41f4df23c07fd70d000458000ffffffff510000006a64f2c21f4df2a007fd70d000468000ffffffff500000006a64f2e01f4df30407fd70d000478000ffffffff4f0000006a64f2fe1f4df36807fd70d000488000ffffffff4e0000006a64f31c1f4df3cc07fd70d000498000ffffffff4d0000006a64f33a1f4df43007fd70d0004a8000ffffffff4c0000006a64f3581f4df49407fd70d0004b8000ffffffff4b0000006a64f3761f4df4f807fd70d0004c8000ffffffff4a0000006a64f3941f4df55c07fd70d0004d8000ffffffff490000006a64f3b21f4df5c007fd70d0004e8000ffffffff480000006a64f3d01f4df62407fd70d0004f8000ffffffff470000006a64f3ee1f4df68807fd70d000508000ffffffff460000006a64f40c1f4df6ec07fd70d000518000ffffffff450000006a64f42a1f4df75007fd70d000528000ffffffff440000006a64f4481f4df7b407fd70d000538000ffffffff430000006a64f4661f4df81807fd70d000548000ffffffff420000006a64f4841f4df87c07fd70d000558000ffffffff410000006a64f4a21f4df8e007fd70d000568000ffffffff400000006a64f4c01f4df94407fd70d000578000ffffffff3f0000006a64f4de1f4df9a807fd70d000588000ffffffff3e0000006a64f4fc1f4dfa0c07fd70d000598000ffffffff3d000000",
"datagram_hex": "110123456789abcdef202122232425262728292a2bd24428a6fed55b93ae0980e4d45792c41f88e7a5461ab511264a62c55b228627a9c37b15946c830fda93d831337a3836a649adfd1307c8fa78c5800cad4625d0d1a655d2b4622a26b8f171f3411d2df39bea83f5f2d526020e35c0e488a99c5e9c4d052ef96289e9026ca5125583674e317e7ff2ee772f1d9972f3491fb5f9ed20c092007df1c1dcd186363a62d7524f6db4681689f0342e808c1cfa9faf872cb728f9f81c5a61e7a58e4c6223122cd8ce6ad2736c770c40b94a74d19327cb19e321f79fc0dc44da2c0815f756e3cc8a97e824d73f89998a4b6a4633e7c070dfe376d7cfce841dea72af42dbe7f82ada86f0c3021e089ec8795479958e85f5dce0da62bb80752a695f7bfc21d785687e7a13c291d0389c3908d855147a66a071a93e3ea153d44a502c4e128596018cd66bb1d0ae21c2470ebbe0f332c0e36f2575120eb2977d0573ea4bb9cbbd65d4037c18a7d48e55d17581226d8cc34d1459e949155c4c12049aaef715b4de6eb3fe7e8e4edee989553eb873f41e4552b5bf6c24e21866db788a53e18365e49d1fbac65f177c4e2a393fb285f4a06a4c6f0401c71bb85ac025abcdff0a821a3c30f84b76c60b98aea6dc7a9d36829000e5da251cfd6cfc7db36a731f899d0f4215e15a05edb20ae6f60ff7e1bd71a1e424d1855014f5c1b04fc76ad32d1a2c73beb83b4ffb5d88cb4ff8b2486d259558b63131a03a48a51b857124a092419f10fe55ea33648cb08ae0835905007d6048572e48aefa73f7687ae2a9937b754a0924fa83cb2697f14d965de7d80858f0cea02a4b173014000f1683c44309e9303528562c67b367f93f9c9c248704879d606e1dc3a316378da5ca24990e558aa88f52e63da38f7a05e2d7499fc2b412fef499097c3b46f039c98b820673a0e6d9c693a240e4cbac2e590cf5624a6bed7de98ce5dcc47e7d58373335216d050ad73e78b1748057dd568453b25772558f2a9665e2bbace08db2f41541373ee3a6f0ffc181d297cee39bf25ccb36e065fec5cbb9ba61fcbd5883c3f94a52e9eda0c761d1b6d2e2469ff25a741533e288e414448e51ff06f89ce466c1e69136cbfc6ec97a0ffb986d57a087703966a46d97f3ed9948f938b647a5b6f51bb843f6a5aad4261144bbe97bc90c1801a09aafe0746ff5d4d4eaab6ff8589cd08de39bcbfcf2d3badbde4eed2b67732b0ce850c7c8fe1047c60343fa4920a6b41ffee2d3489cd851b9ce729aa682613bcc033bcdf3d2c50e92405b44b3ff366fba7f99d60637642d5f8bd8ca62d2bcd1c4ec5b883b03af4159eefe3617e718f32aaf85cdae7bd412c8813c800bf89293567cde482102bf8b1fedd41b1f1517425d90a2b0f784ba301d",
"datagram_len": 998,
"message": {
"type": "loc",
"value": [
{
"ts": 1785000042,
"lat_e7": 525200080,
"lon_e7": 134050000,
"acc_dm": 50,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 100,
"flags": 0
},
{
"ts": 1785000072,
"lat_e7": 525200180,
"lon_e7": 134050000,
"acc_dm": 51,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 99,
"flags": 0
},
{
"ts": 1785000102,
"lat_e7": 525200280,
"lon_e7": 134050000,
"acc_dm": 52,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 98,
"flags": 0
},
{
"ts": 1785000132,
"lat_e7": 525200380,
"lon_e7": 134050000,
"acc_dm": 53,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 97,
"flags": 0
},
{
"ts": 1785000162,
"lat_e7": 525200480,
"lon_e7": 134050000,
"acc_dm": 54,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 96,
"flags": 0
},
{
"ts": 1785000192,
"lat_e7": 525200580,
"lon_e7": 134050000,
"acc_dm": 55,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 95,
"flags": 0
},
{
"ts": 1785000222,
"lat_e7": 525200680,
"lon_e7": 134050000,
"acc_dm": 56,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 94,
"flags": 0
},
{
"ts": 1785000252,
"lat_e7": 525200780,
"lon_e7": 134050000,
"acc_dm": 57,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 93,
"flags": 0
},
{
"ts": 1785000282,
"lat_e7": 525200880,
"lon_e7": 134050000,
"acc_dm": 58,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 92,
"flags": 0
},
{
"ts": 1785000312,
"lat_e7": 525200980,
"lon_e7": 134050000,
"acc_dm": 59,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 91,
"flags": 0
},
{
"ts": 1785000342,
"lat_e7": 525201080,
"lon_e7": 134050000,
"acc_dm": 60,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 90,
"flags": 0
},
{
"ts": 1785000372,
"lat_e7": 525201180,
"lon_e7": 134050000,
"acc_dm": 61,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 89,
"flags": 0
},
{
"ts": 1785000402,
"lat_e7": 525201280,
"lon_e7": 134050000,
"acc_dm": 62,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 88,
"flags": 0
},
{
"ts": 1785000432,
"lat_e7": 525201380,
"lon_e7": 134050000,
"acc_dm": 63,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 87,
"flags": 0
},
{
"ts": 1785000462,
"lat_e7": 525201480,
"lon_e7": 134050000,
"acc_dm": 64,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 86,
"flags": 0
},
{
"ts": 1785000492,
"lat_e7": 525201580,
"lon_e7": 134050000,
"acc_dm": 65,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 85,
"flags": 0
},
{
"ts": 1785000522,
"lat_e7": 525201680,
"lon_e7": 134050000,
"acc_dm": 66,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 84,
"flags": 0
},
{
"ts": 1785000552,
"lat_e7": 525201780,
"lon_e7": 134050000,
"acc_dm": 67,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 83,
"flags": 0
},
{
"ts": 1785000582,
"lat_e7": 525201880,
"lon_e7": 134050000,
"acc_dm": 68,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 82,
"flags": 0
},
{
"ts": 1785000612,
"lat_e7": 525201980,
"lon_e7": 134050000,
"acc_dm": 69,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 81,
"flags": 0
},
{
"ts": 1785000642,
"lat_e7": 525202080,
"lon_e7": 134050000,
"acc_dm": 70,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 80,
"flags": 0
},
{
"ts": 1785000672,
"lat_e7": 525202180,
"lon_e7": 134050000,
"acc_dm": 71,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 79,
"flags": 0
},
{
"ts": 1785000702,
"lat_e7": 525202280,
"lon_e7": 134050000,
"acc_dm": 72,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 78,
"flags": 0
},
{
"ts": 1785000732,
"lat_e7": 525202380,
"lon_e7": 134050000,
"acc_dm": 73,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 77,
"flags": 0
},
{
"ts": 1785000762,
"lat_e7": 525202480,
"lon_e7": 134050000,
"acc_dm": 74,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 76,
"flags": 0
},
{
"ts": 1785000792,
"lat_e7": 525202580,
"lon_e7": 134050000,
"acc_dm": 75,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 75,
"flags": 0
},
{
"ts": 1785000822,
"lat_e7": 525202680,
"lon_e7": 134050000,
"acc_dm": 76,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 74,
"flags": 0
},
{
"ts": 1785000852,
"lat_e7": 525202780,
"lon_e7": 134050000,
"acc_dm": 77,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 73,
"flags": 0
},
{
"ts": 1785000882,
"lat_e7": 525202880,
"lon_e7": 134050000,
"acc_dm": 78,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 72,
"flags": 0
},
{
"ts": 1785000912,
"lat_e7": 525202980,
"lon_e7": 134050000,
"acc_dm": 79,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 71,
"flags": 0
},
{
"ts": 1785000942,
"lat_e7": 525203080,
"lon_e7": 134050000,
"acc_dm": 80,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 70,
"flags": 0
},
{
"ts": 1785000972,
"lat_e7": 525203180,
"lon_e7": 134050000,
"acc_dm": 81,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 69,
"flags": 0
},
{
"ts": 1785001002,
"lat_e7": 525203280,
"lon_e7": 134050000,
"acc_dm": 82,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 68,
"flags": 0
},
{
"ts": 1785001032,
"lat_e7": 525203380,
"lon_e7": 134050000,
"acc_dm": 83,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 67,
"flags": 0
},
{
"ts": 1785001062,
"lat_e7": 525203480,
"lon_e7": 134050000,
"acc_dm": 84,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 66,
"flags": 0
},
{
"ts": 1785001092,
"lat_e7": 525203580,
"lon_e7": 134050000,
"acc_dm": 85,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 65,
"flags": 0
},
{
"ts": 1785001122,
"lat_e7": 525203680,
"lon_e7": 134050000,
"acc_dm": 86,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 64,
"flags": 0
},
{
"ts": 1785001152,
"lat_e7": 525203780,
"lon_e7": 134050000,
"acc_dm": 87,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 63,
"flags": 0
},
{
"ts": 1785001182,
"lat_e7": 525203880,
"lon_e7": 134050000,
"acc_dm": 88,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 62,
"flags": 0
},
{
"ts": 1785001212,
"lat_e7": 525203980,
"lon_e7": 134050000,
"acc_dm": 89,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 61,
"flags": 0
}
]
}
},
{
"name": "ack_single",
"direction": "down",
"key": "down",
"msg_type": 2,
"nonce_hex": "303132333435363738393a3b",
"header_hex": "120123456789abcdef303132333435363738393a3b",
"payload_hex": "01000102030405060708090a0b00",
"datagram_hex": "120123456789abcdef303132333435363738393a3b64c8a72ba8ab580a637fc66a7efeefd375f778c6db7ebcf42696f1bab284",
"datagram_len": 51,
"message": {
"type": "ack",
"value": {
"nonces": [
[
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11
]
],
"flags": 0
}
}
},
{
"name": "ack_config_pending",
"direction": "down",
"key": "down",
"msg_type": 2,
"nonce_hex": "404142434445464748494a4b",
"header_hex": "120123456789abcdef404142434445464748494a4b",
"payload_hex": "02101112131415161718191a1b202122232425262728292a2b01",
"datagram_hex": "120123456789abcdef404142434445464748494a4b55ed5ad3b9272addf354b0ac530973a513f3804962d432e6937beba6da315c481aa13a8efef07f201775",
"datagram_len": 63,
"message": {
"type": "ack",
"value": {
"nonces": [
[
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27
],
[
32,
33,
34,
35,
36,
37,
38,
39,
40,
41,
42,
43
]
],
"flags": 1
}
}
},
{
"name": "ack_max_throttle",
"direction": "down",
"key": "down",
"msg_type": 2,
"nonce_hex": "505152535455565758595a5b",
"header_hex": "120123456789abcdef505152535455565758595a5b",
"payload_hex": "28000102030405060708090a0b101112131415161718191a1b202122232425262728292a2b303132333435363738393a3b404142434445464748494a4b505152535455565758595a5b606162636465666768696a6b707172737475767778797a7b808182838485868788898a8b909192939495969798999a9ba0a1a2a3a4a5a6a7a8a9aaabb0b1b2b3b4b5b6b7b8b9babbc0c1c2c3c4c5c6c7c8c9cacbd0d1d2d3d4d5d6d7d8d9dadbe0e1e2e3e4e5e6e7e8e9eaebf0f1f2f3f4f5f6f7f8f9fafb000102030405060708090a0b101112131415161718191a1b202122232425262728292a2b303132333435363738393a3b404142434445464748494a4b505152535455565758595a5b606162636465666768696a6b707172737475767778797a7b808182838485868788898a8b909192939495969798999a9ba0a1a2a3a4a5a6a7a8a9aaabb0b1b2b3b4b5b6b7b8b9babbc0c1c2c3c4c5c6c7c8c9cacbd0d1d2d3d4d5d6d7d8d9dadbe0e1e2e3e4e5e6e7e8e9eaebf0f1f2f3f4f5f6f7f8f9fafb000102030405060708090a0b101112131415161718191a1b202122232425262728292a2b303132333435363738393a3b404142434445464748494a4b505152535455565758595a5b606162636465666768696a6b707172737475767778797a7b01",
"datagram_hex": "120123456789abcdef505152535455565758595a5b046202da952cecd25faf23b82c58e1275cf883d0d2949d1c457dfd620ab46281d93e21ce7fb5dda4dfb24fa9eb7ae4ece8d81ef9b7d2bd2fe32a089bf98149b627c3c2a44e82a4717af026a3d369f0df612dbf65b391ebac81f2773f2c5204ca2eee5f68ec9719b0011cad9f4ad022318f9a6a804f11befc046382e56778cca51cd26b6e233b9c384cd8bb7702bd889665c02257cbe49a75225db87122056ca186ec5fd79f7face5e088696daa15b7e3f310d820b3f4281a3202374b13873291a71563dc5538fd583d8a96f0fe64184fc344fddd843f3cd000afb0493679464b9910955159f4a86a10f8b4240378eb80780052bcf608be178f290f49d45f0a14b190cbfa9625ed0a8f5fd50dca61e43c3fb5f200146a88eb92e1adefe7e7a484a8810bd79079ea4a18d93b7376ad0e09fdeb6b179c78219a28c0c6bbb7b2997e549c8428d80d85ff8eb336dbf22e1ca800d9870fbe5525d26e327166c39b40b1a5c836514ca2bab96c0f405813c2ab007b8ec4773881144aab916b92cc0c1cb4417a51ccf08c4750798f329a1ed0aa206ce518d5cafd89c5a7dd0244bdc5da988c7f2ec603267d636534782c102bd2fc444620e19ab9288354eb9b3b3d4d9aac1378310bcb9f1f5bbb9e94aa54c5e42aa215a578fc3fc07707f18a4f6c4205fb45bce83200a9c684379944bd87ab70c9f28a",
"datagram_len": 519,
"message": {
"type": "ack",
"value": {
"nonces": [
[
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11
],
[
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27
],
[
32,
33,
34,
35,
36,
37,
38,
39,
40,
41,
42,
43
],
[
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
58,
59
],
[
64,
65,
66,
67,
68,
69,
70,
71,
72,
73,
74,
75
],
[
80,
81,
82,
83,
84,
85,
86,
87,
88,
89,
90,
91
],
[
96,
97,
98,
99,
100,
101,
102,
103,
104,
105,
106,
107
],
[
112,
113,
114,
115,
116,
117,
118,
119,
120,
121,
122,
123
],
[
128,
129,
130,
131,
132,
133,
134,
135,
136,
137,
138,
139
],
[
144,
145,
146,
147,
148,
149,
150,
151,
152,
153,
154,
155
],
[
160,
161,
162,
163,
164,
165,
166,
167,
168,
169,
170,
171
],
[
176,
177,
178,
179,
180,
181,
182,
183,
184,
185,
186,
187
],
[
192,
193,
194,
195,
196,
197,
198,
199,
200,
201,
202,
203
],
[
208,
209,
210,
211,
212,
213,
214,
215,
216,
217,
218,
219
],
[
224,
225,
226,
227,
228,
229,
230,
231,
232,
233,
234,
235
],
[
240,
241,
242,
243,
244,
245,
246,
247,
248,
249,
250,
251
],
[
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11
],
[
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27
],
[
32,
33,
34,
35,
36,
37,
38,
39,
40,
41,
42,
43
],
[
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
58,
59
],
[
64,
65,
66,
67,
68,
69,
70,
71,
72,
73,
74,
75
],
[
80,
81,
82,
83,
84,
85,
86,
87,
88,
89,
90,
91
],
[
96,
97,
98,
99,
100,
101,
102,
103,
104,
105,
106,
107
],
[
112,
113,
114,
115,
116,
117,
118,
119,
120,
121,
122,
123
],
[
128,
129,
130,
131,
132,
133,
134,
135,
136,
137,
138,
139
],
[
144,
145,
146,
147,
148,
149,
150,
151,
152,
153,
154,
155
],
[
160,
161,
162,
163,
164,
165,
166,
167,
168,
169,
170,
171
],
[
176,
177,
178,
179,
180,
181,
182,
183,
184,
185,
186,
187
],
[
192,
193,
194,
195,
196,
197,
198,
199,
200,
201,
202,
203
],
[
208,
209,
210,
211,
212,
213,
214,
215,
216,
217,
218,
219
],
[
224,
225,
226,
227,
228,
229,
230,
231,
232,
233,
234,
235
],
[
240,
241,
242,
243,
244,
245,
246,
247,
248,
249,
250,
251
],
[
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11
],
[
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27
],
[
32,
33,
34,
35,
36,
37,
38,
39,
40,
41,
42,
43
],
[
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
58,
59
],
[
64,
65,
66,
67,
68,
69,
70,
71,
72,
73,
74,
75
],
[
80,
81,
82,
83,
84,
85,
86,
87,
88,
89,
90,
91
],
[
96,
97,
98,
99,
100,
101,
102,
103,
104,
105,
106,
107
],
[
112,
113,
114,
115,
116,
117,
118,
119,
120,
121,
122,
123
]
],
"flags": 1
}
}
},
{
"name": "hello",
"direction": "up",
"key": "up",
"msg_type": 4,
"nonce_hex": "606162636465666768696a6b",
"header_hex": "140123456789abcdef606162636465666768696a6b",
"payload_hex": "001122010001",
"datagram_hex": "140123456789abcdef606162636465666768696a6be793556613e45f7ecd7893ff805bbb0f636315b3742b",
"datagram_len": 43,
"message": {
"type": "hello",
"value": {
"app_version_code": 17,
"os_api_level": 34,
"flags": 1,
"config_version": 1
}
}
},
{
"name": "config_balanced",
"direction": "down",
"key": "down",
"msg_type": 5,
"nonce_hex": "707172737475767778797a7b",
"header_hex": "150123456789abcdef707172737475767778797a7b",
"payload_hex": "000101010384006400142800",
"datagram_hex": "150123456789abcdef707172737475767778797a7b92ad85802373b275dc95893e3885ace8290635fd02fbd2e60be71769",
"datagram_len": 49,
"message": {
"type": "config",
"value": {
"config_version": 1,
"profile": "balanced",
"flags": 1,
"heartbeat_s": 900,
"interval_scale_pct": 100,
"min_distance_m": 20,
"max_points_per_loc": 40
}
}
},
{
"name": "config_battery_saver_paused",
"direction": "down",
"key": "down",
"msg_type": 5,
"nonce_hex": "808182838485868788898a8b",
"header_hex": "150123456789abcdef808182838485868788898a8b",
"payload_hex": "00090002070800fa00641400",
"datagram_hex": "150123456789abcdef808182838485868788898a8b274ce3908a6f175d4062e2bc70204fcbf3f08c8bab6c2bc004865402",
"datagram_len": 49,
"message": {
"type": "config",
"value": {
"config_version": 9,
"profile": "battery_saver",
"flags": 2,
"heartbeat_s": 1800,
"interval_scale_pct": 250,
"min_distance_m": 100,
"max_points_per_loc": 20
}
}
},
{
"name": "config_high_accuracy",
"direction": "down",
"key": "down",
"msg_type": 5,
"nonce_hex": "909192939495969798999a9b",
"header_hex": "150123456789abcdef909192939495969798999a9b",
"payload_hex": "0002020102580032000a2800",
"datagram_hex": "150123456789abcdef909192939495969798999a9bf45dadac47c25bffd827a323e33fcd8086832232daf04e9fb3f658c4",
"datagram_len": 49,
"message": {
"type": "config",
"value": {
"config_version": 2,
"profile": "high_accuracy",
"flags": 1,
"heartbeat_s": 600,
"interval_scale_pct": 50,
"min_distance_m": 10,
"max_points_per_loc": 40
}
}
},
{
"name": "config_get",
"direction": "up",
"key": "up",
"msg_type": 6,
"nonce_hex": "a0a1a2a3a4a5a6a7a8a9aaab",
"header_hex": "160123456789abcdefa0a1a2a3a4a5a6a7a8a9aaab",
"payload_hex": "0001",
"datagram_hex": "160123456789abcdefa0a1a2a3a4a5a6a7a8a9aaab1ddf4edda37226b349f0cf2f15d08f8d496b",
"datagram_len": 39,
"message": {
"type": "config_get",
"value": {
"have_version": 1
}
}
},
{
"name": "ping",
"direction": "up",
"key": "up",
"msg_type": 7,
"nonce_hex": "b0b1b2b3b4b5b6b7b8b9babb",
"header_hex": "170123456789abcdefb0b1b2b3b4b5b6b7b8b9babb",
"payload_hex": "deadbeef0007",
"datagram_hex": "170123456789abcdefb0b1b2b3b4b5b6b7b8b9babbaaa9eb40514d112664d40269b4d168dbbd77ae9957ce",
"datagram_len": 43,
"message": {
"type": "ping",
"value": {
"echo": 3735928559,
"seq": 7
}
}
},
{
"name": "pong",
"direction": "down",
"key": "down",
"msg_type": 8,
"nonce_hex": "c0c1c2c3c4c5c6c7c8c9cacb",
"header_hex": "180123456789abcdefc0c1c2c3c4c5c6c7c8c9cacb",
"payload_hex": "deadbeef0007",
"datagram_hex": "180123456789abcdefc0c1c2c3c4c5c6c7c8c9cacb7d075ab6f083e35ebf55ef3b44d3430a6806381d7a4b",
"datagram_len": 43,
"message": {
"type": "pong",
"value": {
"echo": 3735928559,
"seq": 7
}
}
},
{
"name": "revoked_explicit",
"direction": "down",
"key": "rev",
"msg_type": 9,
"nonce_hex": "d0d1d2d3d4d5d6d7d8d9dadb",
"header_hex": "190123456789abcdefd0d1d2d3d4d5d6d7d8d9dadb",
"payload_hex": "01",
"datagram_hex": "190123456789abcdefd0d1d2d3d4d5d6d7d8d9dadb6998f929776ef06b618c34357aca9de47e",
"datagram_len": 38,
"message": {
"type": "revoked",
"value": {
"reason": "revoked"
}
}
},
{
"name": "revoked_expired",
"direction": "down",
"key": "rev",
"msg_type": 9,
"nonce_hex": "e0e1e2e3e4e5e6e7e8e9eaeb",
"header_hex": "190123456789abcdefe0e1e2e3e4e5e6e7e8e9eaeb",
"payload_hex": "02",
"datagram_hex": "190123456789abcdefe0e1e2e3e4e5e6e7e8e9eaeb77f758cde484776dada217a72eb9be3e18",
"datagram_len": 38,
"message": {
"type": "revoked",
"value": {
"reason": "expired"
}
}
},
{
"name": "revoked_unknown",
"direction": "down",
"key": "rev",
"msg_type": 9,
"nonce_hex": "f0f1f2f3f4f5f6f7f8f9fafb",
"header_hex": "190123456789abcdeff0f1f2f3f4f5f6f7f8f9fafb",
"payload_hex": "03",
"datagram_hex": "190123456789abcdeff0f1f2f3f4f5f6f7f8f9fafbb48f9c80666e141ad8a1d9637cf79d02a4",
"datagram_len": 38,
"message": {
"type": "revoked",
"value": {
"reason": "unknown"
}
}
},
{
"name": "nack_unknown_token",
"direction": "down",
"key": "down",
"msg_type": 3,
"nonce_hex": "000102030405060708090a0b",
"header_hex": "130123456789abcdef000102030405060708090a0b",
"payload_hex": "303132333435363738393a3b0100",
"datagram_hex": "130123456789abcdef000102030405060708090a0b3bb067de27a9d0ff1932ef2884bdb3cf8c68509bd338566335fa9a3150ae",
"datagram_len": 51,
"message": {
"type": "nack",
"value": {
"nonce": [
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
58,
59
],
"reason": "unknown_token",
"retry_after_s": 0
}
}
},
{
"name": "nack_malformed",
"direction": "down",
"key": "down",
"msg_type": 3,
"nonce_hex": "101112131415161718191a1b",
"header_hex": "130123456789abcdef101112131415161718191a1b",
"payload_hex": "303132333435363738393a3b0200",
"datagram_hex": "130123456789abcdef101112131415161718191a1b4183e084ad9a89fb168b345b2c86072059c685823f6d60624c17d2d2d8ba",
"datagram_len": 51,
"message": {
"type": "nack",
"value": {
"nonce": [
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
58,
59
],
"reason": "malformed",
"retry_after_s": 0
}
}
},
{
"name": "nack_rate_limited",
"direction": "down",
"key": "down",
"msg_type": 3,
"nonce_hex": "202122232425262728292a2b",
"header_hex": "130123456789abcdef202122232425262728292a2b",
"payload_hex": "303132333435363738393a3b031e",
"datagram_hex": "130123456789abcdef202122232425262728292a2b11a89f6b98d57c79682ff412abc9a549099aef7092d8e7e3913f37cb2117",
"datagram_len": 51,
"message": {
"type": "nack",
"value": {
"nonce": [
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
58,
59
],
"reason": "rate_limited",
"retry_after_s": 30
}
}
},
{
"name": "nack_storage_full",
"direction": "down",
"key": "down",
"msg_type": 3,
"nonce_hex": "303132333435363738393a3b",
"header_hex": "130123456789abcdef303132333435363738393a3b",
"payload_hex": "303132333435363738393a3b04ff",
"datagram_hex": "130123456789abcdef303132333435363738393a3b55f9941a9f9a6b3b5c4ef55b71012fd33f014bc0c5fc8ad0898acb6209dd",
"datagram_len": 51,
"message": {
"type": "nack",
"value": {
"nonce": [
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
58,
59
],
"reason": "storage_full",
"retry_after_s": 255
}
}
}
]
}
Dcrates/otproto/tests/vectors.rs-386
@@ -1,386 +0,0 @@
//! Verifies the committed golden vectors against a freshly built codec.
//!
//! This test deliberately reads `vectors.json` as untyped JSON and rebuilds each
//! message field by field, rather than deserializing straight into
//! [`otproto::Message`]. Two reasons:
//!
//! 1. It runs without the `serde` feature, so `cargo test` covers it by default.
//! 2. It is the same exercise the Kotlin test performs, so this file doubles as
//! the reference for that implementation. A shared `Deserialize` impl would
//! let a renamed field pass here and fail on the phone.
use std::collections::BTreeSet;
use otproto::msg::Direction;
use otproto::point::Flags;
use otproto::{
Ack, AckFlags, Config, ConfigFlags, ConfigGet, Header, Hello, HelloFlags, Message, MsgType,
Nack, NackReason, Nonce, Ping, Point, Pong, Profile, RevokeReason, Revoked, kdf,
};
use serde_json::Value;
const VECTORS: &str = include_str!("vectors.json");
fn load() -> Value {
serde_json::from_str(VECTORS).expect("vectors.json is valid JSON")
}
fn hex(v: &Value, key: &str) -> Vec<u8> {
hex_str(
v[key]
.as_str()
.unwrap_or_else(|| panic!("{key} is not a string")),
)
}
fn hex_str(s: &str) -> Vec<u8> {
assert!(s.len().is_multiple_of(2), "odd-length hex string {s:?}");
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("hex digit"))
.collect()
}
fn u32f(v: &Value, key: &str) -> u32 {
v[key].as_u64().unwrap_or_else(|| panic!("{key} missing")) as u32
}
fn u16f(v: &Value, key: &str) -> u16 {
v[key].as_u64().unwrap_or_else(|| panic!("{key} missing")) as u16
}
fn u8f(v: &Value, key: &str) -> u8 {
v[key].as_u64().unwrap_or_else(|| panic!("{key} missing")) as u8
}
fn opt<T, F: Fn(u64) -> T>(v: &Value, key: &str, f: F) -> Option<T> {
match &v[key] {
Value::Null => None,
other => Some(f(other.as_u64().unwrap_or_else(|| {
// Negative values (altitude) arrive as i64.
other.as_i64().expect("numeric") as u64
}))),
}
}
fn point_from_json(v: &Value) -> Point {
Point {
ts: u32f(v, "ts"),
lat_e7: v["lat_e7"].as_i64().expect("lat_e7") as i32,
lon_e7: v["lon_e7"].as_i64().expect("lon_e7") as i32,
acc_dm: opt(v, "acc_dm", |n| n as u16),
alt_m: match &v["alt_m"] {
Value::Null => None,
other => Some(other.as_i64().expect("alt_m") as i16),
},
spd_cms: opt(v, "spd_cms", |n| n as u16),
brg_cdeg: opt(v, "brg_cdeg", |n| n as u16),
bat_pct: opt(v, "bat_pct", |n| n as u8),
flags: Flags(u8f(v, "flags")),
}
}
fn nonce_from_hex(s: &str) -> Nonce {
hex_str(s).try_into().expect("12-byte nonce")
}
fn nonces_from_json(v: &Value) -> Vec<Nonce> {
v.as_array()
.expect("nonces array")
.iter()
.map(|n| {
let bytes: Vec<u8> = n
.as_array()
.expect("nonce is an array of bytes")
.iter()
.map(|b| b.as_u64().expect("byte") as u8)
.collect();
bytes.try_into().expect("12-byte nonce")
})
.collect()
}
fn message_from_json(v: &Value) -> Message {
let ty = v["type"].as_str().expect("message type");
let val = &v["value"];
match ty {
"loc" => Message::Loc(
val.as_array()
.expect("points")
.iter()
.map(point_from_json)
.collect(),
),
"ack" => Message::Ack(Ack {
nonces: nonces_from_json(&val["nonces"]),
flags: AckFlags(u8f(val, "flags")),
}),
"nack" => Message::Nack(Nack {
nonce: {
let bytes: Vec<u8> = val["nonce"]
.as_array()
.expect("nonce bytes")
.iter()
.map(|b| b.as_u64().expect("byte") as u8)
.collect();
bytes.try_into().expect("12-byte nonce")
},
reason: match val["reason"].as_str().expect("reason") {
"unknown_token" => NackReason::UnknownToken,
"malformed" => NackReason::Malformed,
"rate_limited" => NackReason::RateLimited,
"storage_full" => NackReason::StorageFull,
other => panic!("unknown NACK reason {other:?}"),
},
retry_after_s: u8f(val, "retry_after_s"),
}),
"hello" => Message::Hello(Hello {
app_version_code: u16f(val, "app_version_code"),
os_api_level: u8f(val, "os_api_level"),
flags: HelloFlags(u8f(val, "flags")),
config_version: u16f(val, "config_version"),
}),
"config" => Message::Config(Config {
config_version: u16f(val, "config_version"),
profile: match val["profile"].as_str().expect("profile") {
"battery_saver" => Profile::BatterySaver,
"balanced" => Profile::Balanced,
"high_accuracy" => Profile::HighAccuracy,
other => panic!("unknown profile {other:?}"),
},
flags: ConfigFlags(u8f(val, "flags")),
heartbeat_s: u16f(val, "heartbeat_s"),
interval_scale_pct: u16f(val, "interval_scale_pct"),
min_distance_m: u16f(val, "min_distance_m"),
max_points_per_loc: u8f(val, "max_points_per_loc"),
}),
"config_get" => Message::ConfigGet(ConfigGet {
have_version: u16f(val, "have_version"),
}),
"ping" => Message::Ping(Ping {
echo: u32f(val, "echo"),
seq: u16f(val, "seq"),
}),
"pong" => Message::Pong(Pong {
echo: u32f(val, "echo"),
seq: u16f(val, "seq"),
}),
"revoked" => Message::Revoked(Revoked {
reason: match val["reason"].as_str().expect("reason") {
"revoked" => RevokeReason::Revoked,
"expired" => RevokeReason::Expired,
"unknown" => RevokeReason::Unknown,
other => panic!("unknown revoke reason {other:?}"),
},
}),
other => panic!("unknown message type {other:?}"),
}
}
#[test]
fn file_level_constants_match_this_build() {
let v = load();
assert_eq!(v["protocol"], "OTP/1");
assert_eq!(v["version"].as_u64(), Some(u64::from(otproto::VERSION)));
assert_eq!(v["header_len"].as_u64(), Some(otproto::HEADER_LEN as u64));
assert_eq!(v["tag_len"].as_u64(), Some(otproto::TAG_LEN as u64));
assert_eq!(
v["max_datagram"].as_u64(),
Some(otproto::MAX_DATAGRAM as u64)
);
assert_eq!(v["max_points"].as_u64(), Some(otproto::MAX_POINTS as u64));
}
#[test]
fn key_derivation_matches_the_vectors() {
let v = load();
let token_key: [u8; 32] = hex(&v, "token_key_hex")
.try_into()
.expect("32-byte token key");
let (up, down) = kdf::derive_both(&token_key);
assert_eq!(hex(&v, "k_up_hex"), up, "K_up drifted");
assert_eq!(hex(&v, "k_down_hex"), down, "K_down drifted");
assert_ne!(up, down);
let master: [u8; 32] = hex(&v, "revocation_master_hex")
.try_into()
.expect("32-byte master");
let token_id = v["token_id"].as_u64().expect("token_id");
let rev = otproto::revocation_key(&master, token_id);
assert_eq!(hex(&v, "k_rev_hex"), rev, "K_rev drifted");
// Independent of the token key, which is the property that lets a REVOKED
// notice outlive the token's row.
assert_ne!(rev, up);
assert_ne!(rev, down);
assert_ne!(rev, token_key);
}
#[test]
fn point_records_encode_exactly_as_recorded() {
let v = load();
let points = v["points"].as_array().expect("points array");
assert!(!points.is_empty());
for case in points {
let name = case["name"].as_str().expect("name");
let expected = hex(case, "bytes_hex");
assert_eq!(
expected.len(),
otproto::POINT_LEN,
"{name}: wrong record length"
);
let point = point_from_json(&case["point"]);
assert_eq!(
point.to_bytes().as_slice(),
expected.as_slice(),
"{name}: encode drifted"
);
let decoded = Point::from_bytes(expected.as_slice().try_into().expect("length checked"));
assert_eq!(decoded, point, "{name}: decode drifted");
}
}
#[test]
fn every_datagram_vector_reproduces_byte_for_byte() {
let v = load();
let token_key: [u8; 32] = hex(&v, "token_key_hex")
.try_into()
.expect("32-byte token key");
let token_id = v["token_id"].as_u64().expect("token_id");
let (k_up, k_down) = kdf::derive_both(&token_key);
let master: [u8; 32] = hex(&v, "revocation_master_hex")
.try_into()
.expect("32-byte master");
let k_rev = otproto::revocation_key(&master, token_id);
let cases = v["datagrams"].as_array().expect("datagrams array");
assert!(cases.len() >= 9, "vectors must cover every message type");
let mut covered = BTreeSet::new();
for case in cases {
let name = case["name"].as_str().expect("name");
let message = message_from_json(&case["message"]);
let ty = message.msg_type();
covered.insert(ty as u8);
// The recorded type, direction and key must agree with what this build
// derives from the message itself.
assert_eq!(
u8f(case, "msg_type"),
ty as u8,
"{name}: msg_type disagrees"
);
let dir = ty.direction();
assert_eq!(
case["direction"].as_str(),
Some(match dir {
Direction::Up => "up",
Direction::Down => "down",
}),
"{name}: direction disagrees"
);
// Which key seals this datagram is recorded explicitly, because it is
// not implied by the direction: REVOKED travels downlink but is sealed
// under K_rev so it survives the token's row being deleted.
let (key, key_name) = match ty {
MsgType::Revoked => (&k_rev, "rev"),
_ => match dir {
Direction::Up => (&k_up, "up"),
Direction::Down => (&k_down, "down"),
},
};
assert_eq!(
case["key"].as_str(),
Some(key_name),
"{name}: sealing key disagrees"
);
let nonce = nonce_from_hex(case["nonce_hex"].as_str().expect("nonce_hex"));
let header = Header::new(ty, token_id, nonce);
assert_eq!(
header.to_bytes().as_slice(),
hex(case, "header_hex"),
"{name}: header drifted"
);
let payload = message.encode_payload();
assert_eq!(payload, hex(case, "payload_hex"), "{name}: payload drifted");
let datagram = otproto::seal(key, header, &payload);
assert_eq!(
datagram,
hex(case, "datagram_hex"),
"{name}: datagram drifted"
);
assert_eq!(
datagram.len(),
case["datagram_len"].as_u64().expect("datagram_len") as usize,
"{name}: recorded length is wrong"
);
assert!(
datagram.len() <= otproto::MAX_DATAGRAM,
"{name}: exceeds the datagram budget"
);
// And the other direction: the recorded bytes must open to the recorded
// message. Encoding agreeing with itself would not prove that.
let (h, back) =
otproto::open_message(key, &datagram).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(h, header, "{name}: header did not survive a round trip");
assert_eq!(
back, message,
"{name}: message did not survive a round trip"
);
}
let all: BTreeSet<u8> = MsgType::ALL.iter().map(|t| *t as u8).collect();
assert_eq!(covered, all, "some message type has no golden vector");
}
#[test]
fn vectors_only_open_under_the_key_that_sealed_them() {
let v = load();
let token_key: [u8; 32] = hex(&v, "token_key_hex")
.try_into()
.expect("32-byte token key");
let token_id = v["token_id"].as_u64().expect("token_id");
let (k_up, k_down) = kdf::derive_both(&token_key);
let master: [u8; 32] = hex(&v, "revocation_master_hex")
.try_into()
.expect("32-byte master");
let k_rev = otproto::revocation_key(&master, token_id);
for case in v["datagrams"].as_array().expect("datagrams") {
let name = case["name"].as_str().expect("name");
let datagram = hex(case, "datagram_hex");
let sealed_with = case["key"].as_str().expect("key");
// Every key except the right one must fail. Uplink versus downlink is
// the classic reflection guard; K_rev matters for a different reason —
// if a REVOKED opened under K_down, a device could be told it was
// revoked by anyone holding the token key.
for (name_of, key) in [("up", &k_up), ("down", &k_down), ("rev", &k_rev)] {
if name_of == sealed_with {
continue;
}
assert!(
otproto::open(key, &datagram).is_err(),
"{name}: opened under K_{name_of}, which did not seal it"
);
}
}
// A revocation notice for another token must not open here either: K_rev is
// per-id precisely so one device cannot forge one for another.
let other = otproto::revocation_key(&master, token_id ^ 1);
for case in v["datagrams"].as_array().expect("datagrams") {
if case["key"] != "rev" {
continue;
}
let datagram = hex(case, "datagram_hex");
assert!(
otproto::open(&other, &datagram).is_err(),
"a REVOKED opened under another token's K_rev"
);
}
}
Dcrates/otserver/Cargo.toml-38
@@ -1,38 +0,0 @@
[package]
name = "otserver"
version.workspace = true
edition.workspace = true
[dependencies]
anyhow = "1"
argon2 = "0.5"
axum = "0.8"
base64 = "0.22"
chacha20poly1305 = "0.10"
dashmap = "6"
governor = "0.8"
hex.workspace = true
hkdf = "0.12"
mime_guess = "2"
otproto = { version = "0.1.0", path = "../otproto", features = ["serde"] }
rand = "0.9"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "http2", "json"] }
rust-embed = "8"
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2 = "0.10"
socket2 = "0.6"
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "migrate", "macros"] }
thiserror.workspace = true
time = { version = "0.3", features = ["formatting"] }
tokio = { version = "1", features = ["full"] }
toml = "1.1.3"
tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace", "compression-gzip", "set-header"] }
tower-sessions = "0.14"
tower-sessions-sqlx-store = { version = "0.15.0", features = ["sqlite"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
tempfile = "3.27.0"
Dcrates/otserver/migrations/0001_init.sql-162
@@ -1,162 +0,0 @@
-- opentracker initial schema.
--
-- Every table is STRICT: SQLite's default type affinity would happily store the
-- string 'north' in an INTEGER latitude column, and this database is written to
-- by a hot path that must never be the place a type error is discovered.
--
-- The central decision here: **the position stream belongs to the account.** A
-- token is a credential that authorises writing into its owner's stream. It is
-- never an identity — it does not appear in a point's key, in a share, or on the
-- map. One person is one dot, regardless of which pocket the phone is in.
--
-- Coordinates are integers (degrees × 1e7) end to end — protocol, database, JSON,
-- UI — so there is no float-formatting drift anywhere in the system.
CREATE TABLE users (
id INTEGER PRIMARY KEY,
-- NOCASE so 'Marc' and 'marc' are the same account, which is what users
-- assume, and which closes a whole class of impersonation confusion.
username TEXT NOT NULL COLLATE NOCASE UNIQUE,
-- argon2id PHC string; carries its own parameters so the policy can change
-- without invalidating existing hashes.
pw_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0, 1)),
-- Set rather than deleting the row: points and shares reference it.
disabled_at INTEGER,
created_at INTEGER NOT NULL,
-- Sessions and tokens issued before this are treated as revoked, which is
-- how "changing my password logs everything else out" is enforced without
-- having to enumerate them.
pw_changed_at INTEGER NOT NULL
) STRICT;
-- One row per login. Purely a credential plus per-phone telemetry.
CREATE TABLE tokens (
-- The u64 that travels in every datagram header. Random, not sequential:
-- guessing one must not be easier than guessing a key.
token_id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- The 32-byte token secret, wrapped with the server key from OT_SECRET_KEY
-- (AAD = token_id), so a stolen .db alone yields no working keys.
key_wrapped BLOB NOT NULL,
name TEXT NOT NULL,
platform TEXT NOT NULL DEFAULT '',
app_version INTEGER,
os_api_level INTEGER,
config_version INTEGER NOT NULL DEFAULT 1,
config_json TEXT NOT NULL DEFAULT '{}',
last_seen_at INTEGER,
last_src_ip TEXT,
last_src_port INTEGER,
last_transport TEXT,
created_at INTEGER NOT NULL,
created_ip TEXT,
revoked_at INTEGER
) STRICT;
CREATE INDEX tokens_user ON tokens(user_id);
-- Drives the 30-day staleness sweep.
CREATE INDEX tokens_last_seen ON tokens(last_seen_at);
CREATE TABLE points (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Unix seconds from the client's wall clock, stored as sent.
ts INTEGER NOT NULL,
lat INTEGER NOT NULL,
lon INTEGER NOT NULL,
acc_dm INTEGER,
alt_m INTEGER,
spd_cms INTEGER,
brg_cdeg INTEGER,
bat_pct INTEGER,
flags INTEGER NOT NULL DEFAULT 0,
recv_at INTEGER NOT NULL,
-- Provenance only: nullable, never in a key, never read by the UI. It exists
-- so "which phone sent this?" is answerable while debugging. Deliberately
-- NOT a foreign key with CASCADE — a token being deleted by the staleness
-- sweep must not take history with it.
src_token_id INTEGER,
-- The dedup that makes retries and replays both harmless. A replayed
-- datagram carries a ts that already exists and collapses into the row
-- already there, which is why this schema needs no replay window.
PRIMARY KEY (user_id, ts)
) STRICT, WITHOUT ROWID;
-- For the retention sweep, which scans by age across all users.
CREATE INDEX points_ts ON points(ts);
-- Current position per *account*, in its own table so the retention GC can never
-- delete the live marker.
CREATE TABLE user_latest (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
ts INTEGER NOT NULL,
lat INTEGER NOT NULL,
lon INTEGER NOT NULL,
acc_dm INTEGER,
alt_m INTEGER,
spd_cms INTEGER,
brg_cdeg INTEGER,
bat_pct INTEGER,
flags INTEGER NOT NULL DEFAULT 0,
recv_at INTEGER NOT NULL,
src_token_id INTEGER
) STRICT;
-- 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,
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,
-- NULL means no expiry. Enforced at query time, never by a background job,
-- so a stalled job can never leak a position.
expires_at INTEGER,
revoked_at INTEGER,
created_at INTEGER NOT 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);
-- 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
-- that makes max_cache_bytes enforceable.
CREATE TABLE tiles (
z INTEGER NOT NULL,
x INTEGER NOT NULL,
y INTEGER NOT NULL,
etag TEXT,
last_modified TEXT,
fetched_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
bytes INTEGER NOT NULL,
last_access INTEGER NOT NULL,
PRIMARY KEY (z, x, y)
) STRICT, WITHOUT ROWID;
-- Drives least-recently-used eviction.
CREATE INDEX tiles_last_access ON tiles(last_access);
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY,
at INTEGER NOT NULL,
-- Nullable: failed logins have no authenticated user yet.
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
src_ip TEXT
) STRICT;
CREATE INDEX audit_log_at ON audit_log(at);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
) STRICT, WITHOUT ROWID;
Dcrates/otserver/src/api.rs-1491
@@ -1,1491 +0,0 @@
//! The HTTP API.
//!
//! There is **no WebSocket and no fan-out hub**. The web UI polls
//! `GET /api/state` every 5 s while its tab is visible, and that endpoint returns
//! an `ETag` so an unchanged poll is a 304 with no body. For a handful of users
//! that costs less than a broadcast hub, per-connection filter tasks, and
//! lag/resync handling would — and it cannot desynchronise, because there is no
//! second copy of the state to drift.
//!
//! Visibility is resolved in exactly one place, [`visible_user_ids`], used by
//! every read path. Share expiry is enforced there, at query time, never by a
//! background job: a stalled job must not be able to leak a position.
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use axum::extract::{ConnectInfo, Path, Query, Request, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use tower_sessions::Session;
use tracing::warn;
use crate::auth::{self, AuthError, LoginThrottle};
use crate::config::Config;
use crate::db::{Db, now};
use crate::ingest::Ingest;
use crate::keys::KeyVault;
use crate::writer::{WriteHandle, WriteOp};
/// Session key holding the authenticated user id.
const SESSION_USER: &str = "uid";
/// Required on every state-changing request.
///
/// The value is never read. A browser cannot set a custom header on a
/// cross-origin request without a CORS preflight, and this server grants no CORS
/// at all, so its mere presence proves the request came from our own page.
/// Together with `SameSite=Lax` on the session cookie that is the entire CSRF
/// defence: no token to mint, store, rotate, or leak into a log.
const CSRF_HEADER: &str = "x-ot-csrf";
/// Session key holding the moment the session was created, compared against
/// `users.pw_changed_at` so a password change invalidates older sessions without
/// having to enumerate them.
const SESSION_ISSUED: &str = "iat";
pub struct AppState {
pub db: Db,
pub cfg: Config,
pub vault: KeyVault,
pub ingest: Arc<Ingest>,
pub writer: WriteHandle,
/// Shared with the periodic GC task, which sweeps its expired windows.
pub throttle: Arc<LoginThrottle>,
}
pub type Shared = Arc<AppState>;
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
#[derive(Debug)]
pub enum ApiError {
Unauthorized,
Forbidden,
NotFound,
BadRequest(String),
TooManyRequests(u64),
/// Anything unexpected. The detail is logged, never returned: an internal
/// error message is a free source of schema and path information.
Internal(anyhow::Error),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "not signed in".to_string()),
Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden".to_string()),
Self::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
Self::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
Self::TooManyRequests(retry) => (
StatusCode::TOO_MANY_REQUESTS,
format!("too many attempts; try again in {retry}s"),
),
Self::Internal(e) => {
warn!(error = ?e, "internal error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
};
(status, Json(ErrorBody { error: message })).into_response()
}
}
impl From<anyhow::Error> for ApiError {
fn from(e: anyhow::Error) -> Self {
Self::Internal(e)
}
}
impl From<sqlx::Error> for ApiError {
fn from(e: sqlx::Error) -> Self {
Self::Internal(e.into())
}
}
#[derive(Serialize)]
struct ErrorBody {
error: String,
}
type ApiResult<T> = Result<T, ApiError>;
// ---------------------------------------------------------------------------
// Session helpers
// ---------------------------------------------------------------------------
/// The authenticated user, or [`ApiError::Unauthorized`].
///
/// Also checks the session against `pw_changed_at`, which is how a password
/// change logs out every other browser without keeping a revocation list.
pub(crate) async fn current_user(state: &Shared, session: &Session) -> ApiResult<i64> {
let uid: i64 = session
.get(SESSION_USER)
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("session store: {e}")))?
.ok_or(ApiError::Unauthorized)?;
let issued: i64 = session
.get(SESSION_ISSUED)
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("session store: {e}")))?
.unwrap_or(0);
let row: Option<(i64, Option<i64>)> =
sqlx::query_as("SELECT pw_changed_at, disabled_at FROM users WHERE id = ?")
.bind(uid)
.fetch_optional(&state.db.read)
.await?;
let Some((pw_changed_at, disabled_at)) = row else {
let _ = session.flush().await;
return Err(ApiError::Unauthorized);
};
if disabled_at.is_some() || issued < pw_changed_at {
let _ = session.flush().await;
return Err(ApiError::Unauthorized);
}
Ok(uid)
}
async fn require_admin(state: &Shared, session: &Session) -> ApiResult<i64> {
let uid = current_user(state, session).await?;
let is_admin: i64 = sqlx::query_scalar("SELECT is_admin FROM users WHERE id = ?")
.bind(uid)
.fetch_one(&state.db.read)
.await?;
if is_admin == 0 {
return Err(ApiError::Forbidden);
}
Ok(uid)
}
/// The set of `user_id`s `viewer` may see: themselves, plus anyone reachable
/// through a live share.
///
/// One helper, used by every read path. Per-account positions make this
/// noticeably simpler than a per-device model would: there is no
/// `device_id IS NULL` "all my devices" special case in the join.
async fn visible_user_ids(pool: &SqlitePool, viewer: i64) -> ApiResult<Vec<i64>> {
let now = now();
let ids: Vec<i64> = sqlx::query_scalar(
"SELECT ? AS user_id \
UNION \
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 = ?",
)
.bind(viewer)
.bind(now)
.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
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
/// `"browser"` (default) or `"device"`. A device login additionally mints an
/// OTP/1 token.
#[serde(default)]
pub purpose: Purpose,
#[serde(default)]
pub device_name: Option<String>,
#[serde(default)]
pub platform: Option<String>,
}
#[derive(Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Purpose {
#[default]
Browser,
Device,
}
#[derive(Serialize)]
pub struct LoginResponse {
pub user: Me,
/// Present only for a device login.
#[serde(skip_serializing_if = "Option::is_none")]
pub device: Option<DeviceCredentials>,
}
/// Everything a phone needs, returned exactly once.
#[derive(Serialize)]
pub struct DeviceCredentials {
pub token_id: u64,
/// base64 of 32 bytes. The only time the server emits this in the clear.
pub token_key: String,
/// base64 of 32 bytes: the key that seals a `REVOKED` notice for this token.
///
/// Separate from `token_key` because it must outlive it. `K_up` and `K_down`
/// derive from the token key and die with the token's row; this one is
/// derived from a server master and the `token_id`, so the server can still
/// speak to a device whose row is gone.
///
/// It has to be issued at login and cannot be retrofitted: a device that
/// never received one can never verify a notice, and the thing that would
/// prompt it to log in again is exactly that notice.
pub revoke_key: String,
pub udp_host: String,
pub udp_port: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub tls_url: Option<String>,
pub config: DeviceConfig,
}
#[derive(Serialize)]
pub struct DeviceConfig {
pub config_version: u16,
pub profile: &'static str,
}
#[derive(Serialize)]
pub struct Me {
pub id: i64,
pub username: String,
pub display_name: String,
pub is_admin: bool,
pub server_time: i64,
}
#[derive(Serialize)]
pub struct PersonState {
pub user_id: i64,
pub display_name: String,
/// True for the viewer's own entry.
pub is_self: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub position: Option<Position>,
}
#[derive(Serialize)]
pub struct Position {
pub ts: i64,
/// Degrees × 1e7. Integers end to end, so there is no float-formatting drift
/// between the wire, the database, this JSON, and the map.
pub lat_e7: i64,
pub lon_e7: i64,
pub acc_dm: Option<i64>,
pub alt_m: Option<i64>,
pub spd_cms: Option<i64>,
pub brg_cdeg: Option<i64>,
pub bat_pct: Option<i64>,
pub flags: i64,
pub recv_at: i64,
}
#[derive(Serialize)]
pub struct StateResponse {
pub server_time: i64,
pub people: Vec<PersonState>,
}
#[derive(Serialize)]
pub struct TokenInfo {
/// A decimal string, not a number. `token_id` is a full 64-bit random value
/// and JavaScript's `number` is exact only to 2^53, so a JSON number would
/// silently round — and a token id that does not round-trip cannot be
/// revoked. The phone's `DeviceCredentials` keeps the numeric form because
/// its parser has real 64-bit integers.
pub token_id: String,
pub name: String,
pub platform: String,
pub app_version: Option<i64>,
pub os_api_level: Option<i64>,
pub last_seen_at: Option<i64>,
pub last_src_ip: Option<String>,
pub last_transport: Option<String>,
pub created_at: i64,
}
#[derive(Deserialize)]
pub struct TrackQuery {
pub from: Option<i64>,
pub to: Option<i64>,
#[serde(default = "default_max")]
pub max: usize,
}
fn default_max() -> usize {
2000
}
#[derive(Serialize)]
pub struct TrackResponse {
pub user_id: i64,
pub from: i64,
pub to: i64,
/// Google-style encoded polyline at 1e5 precision.
pub polyline: String,
pub point_count: usize,
}
#[derive(Deserialize)]
pub struct PasswordChange {
pub current_password: String,
pub new_password: String,
}
#[derive(Deserialize)]
pub struct CreateUser {
pub username: String,
pub password: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
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
// ---------------------------------------------------------------------------
/// Rejects a state-changing request that did not come from our own page.
async fn require_csrf(req: Request, next: Next) -> Response {
if req.method().is_safe() || req.headers().contains_key(CSRF_HEADER) {
return next.run(req).await;
}
(
StatusCode::FORBIDDEN,
Json(ErrorBody {
error: format!("missing {CSRF_HEADER} header"),
}),
)
.into_response()
}
pub fn router(state: Shared) -> Router {
Router::new()
.route("/api/login", post(login))
.route("/api/logout", post(logout))
.route("/api/me", get(me))
.route("/api/me/password", post(change_password))
.route("/api/state", get(state_handler))
.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
// matches, so the static fallback below is untouched.
.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)
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
async fn healthz(State(state): State<Shared>) -> ApiResult<Json<serde_json::Value>> {
// A real query, not a constant: "healthy" has to mean the database answers.
let _: i64 = sqlx::query_scalar("SELECT 1")
.fetch_one(&state.db.read)
.await?;
Ok(Json(serde_json::json!({
"ok": true,
"version": env!("CARGO_PKG_VERSION"),
"server_time": now(),
"tokens_loaded": state.ingest.active_token_count(),
})))
}
/// Aggregate counters, in Prometheus text format.
///
/// Bound to loopback callers only: these numbers say how much abuse the UDP port
/// is absorbing and how close the writer is to saturation, which is exactly the
/// reconnaissance an attacker would want. Scrape it through the reverse proxy or
/// over an SSH tunnel.
async fn metrics(
State(state): State<Shared>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
) -> ApiResult<String> {
if !peer.ip().is_loopback() {
return Err(ApiError::Forbidden);
}
use std::sync::atomic::Ordering::Relaxed;
let c = &state.ingest.counters;
let mut out = String::new();
for (name, value) in [
("otp_datagrams_received", c.received.load(Relaxed)),
("otp_datagrams_malformed", c.malformed.load(Relaxed)),
("otp_unknown_token", c.unknown_token.load(Relaxed)),
("otp_auth_failed", c.auth_failed.load(Relaxed)),
("otp_rate_limited", c.rate_limited.load(Relaxed)),
("otp_throttled", c.throttled.load(Relaxed)),
("otp_points_accepted", c.points_accepted.load(Relaxed)),
("otp_points_rejected", c.points_rejected.load(Relaxed)),
("otp_acks_sent", c.acks_sent.load(Relaxed)),
("otp_nacks_sent", c.nacks_sent.load(Relaxed)),
("otp_revoked_notices", c.revoked_notices_sent.load(Relaxed)),
// The reflection budget actually spent. If this is nonzero and climbing,
// someone is probing the port with forged token ids.
(
"otp_unverified_notices",
c.unverified_notices_sent.load(Relaxed),
),
("otp_notices_suppressed", c.notices_suppressed.load(Relaxed)),
("otp_silent_drops", c.silent_drops.load(Relaxed)),
] {
out.push_str(&format!("# TYPE {name} counter\n{name} {value}\n"));
}
for (name, value) in [
(
"otp_tokens_loaded",
state.ingest.active_token_count() as u64,
),
(
"otp_limiter_tracked_ips",
state.ingest.limits().tracked_ips() as u64,
),
("otp_writer_capacity_free", state.writer.capacity() as u64),
] {
out.push_str(&format!("# TYPE {name} gauge\n{name} {value}\n"));
}
Ok(out)
}
async fn login(
State(state): State<Shared>,
session: Session,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Json(req): Json<LoginRequest>,
) -> ApiResult<Json<LoginResponse>> {
let ip = peer.ip();
let account = match auth::authenticate(
&state.db.read,
&state.writer,
&state.cfg,
&state.throttle,
ip,
&req.username,
req.password,
)
.await
{
Ok(a) => a,
Err(AuthError::LockedOut { retry_after_s }) => {
return Err(ApiError::TooManyRequests(retry_after_s));
}
Err(AuthError::Invalid) => {
audit(&state, None, "login_failed", &req.username, ip).await;
return Err(ApiError::Unauthorized);
}
};
// Rotate the session id on login, so a fixation attempt cannot survive it.
session
.cycle_id()
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
let issued = now();
session
.insert(SESSION_USER, account.id)
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
session
.insert(SESSION_ISSUED, issued)
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
let device = if req.purpose == Purpose::Device {
let name = req.device_name.unwrap_or_else(|| "phone".to_string());
let platform = req.platform.unwrap_or_else(|| "android".to_string());
let minted = auth::mint_token(
&state.db.write,
&state.vault,
&state.ingest,
account.id,
&name,
&platform,
ip,
)
.await?;
audit(&state, Some(account.id), "token_minted", &name, ip).await;
use base64::Engine as _;
Some(DeviceCredentials {
token_id: minted.token_id,
token_key: base64::engine::general_purpose::STANDARD.encode(minted.token_key),
revoke_key: base64::engine::general_purpose::STANDARD
.encode(state.vault.revocation_key(minted.token_id)),
udp_host: state.cfg.public_udp_host.clone(),
udp_port: state.cfg.public_udp_port,
tls_url: state.cfg.public_tls_url.clone(),
config: DeviceConfig {
config_version: minted.config_version,
profile: "balanced",
},
})
} else {
audit(&state, Some(account.id), "login", "browser", ip).await;
None
};
Ok(Json(LoginResponse {
user: Me {
id: account.id,
username: account.username,
display_name: account.display_name,
is_admin: account.is_admin,
server_time: issued,
},
device,
}))
}
async fn logout(session: Session) -> ApiResult<StatusCode> {
session
.flush()
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
Ok(StatusCode::NO_CONTENT)
}
async fn me(State(state): State<Shared>, session: Session) -> ApiResult<Json<Me>> {
let uid = current_user(&state, &session).await?;
let (username, display_name, is_admin): (String, String, i64) =
sqlx::query_as("SELECT username, display_name, is_admin FROM users WHERE id = ?")
.bind(uid)
.fetch_one(&state.db.read)
.await?;
Ok(Json(Me {
id: uid,
username,
display_name,
is_admin: is_admin != 0,
server_time: now(),
}))
}
async fn change_password(
State(state): State<Shared>,
session: Session,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Json(req): Json<PasswordChange>,
) -> ApiResult<StatusCode> {
let uid = current_user(&state, &session).await?;
if req.new_password.chars().count() < 10 {
return Err(ApiError::BadRequest(
"the new password must be at least 10 characters".into(),
));
}
let stored: String = sqlx::query_scalar("SELECT pw_hash FROM users WHERE id = ?")
.bind(uid)
.fetch_one(&state.db.read)
.await?;
if !auth::verify(&state.cfg, stored, req.current_password)
.await?
.ok
{
return Err(ApiError::Unauthorized);
}
let hash = auth::hash_password(&state.cfg, req.new_password).await?;
let at = now();
sqlx::query("UPDATE users SET pw_hash = ?, pw_changed_at = ? WHERE id = ?")
.bind(hash)
.bind(at)
.bind(uid)
.execute(&state.db.write)
.await?;
// A password change logs out every phone and every other browser. Browsers
// are handled by the pw_changed_at comparison in current_user; phones need
// their tokens actually revoked, since they carry a key rather than a cookie.
let revoked = auth::revoke_other_tokens(&state.db.write, &state.ingest, uid, None).await?;
session
.cycle_id()
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
session
.insert(SESSION_ISSUED, at)
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
audit(
&state,
Some(uid),
"password_changed",
&format!("{revoked} tokens revoked"),
peer.ip(),
)
.await;
Ok(StatusCode::NO_CONTENT)
}
/// Everything the live view needs, in one call.
async fn state_handler(
State(state): State<Shared>,
session: Session,
headers: HeaderMap,
) -> ApiResult<Response> {
let uid = current_user(&state, &session).await?;
let visible = visible_user_ids(&state.db.read, uid).await?;
// One query for everyone visible. A LEFT JOIN so a person with no position
// yet still appears in the list — otherwise they would silently vanish from
// the UI until their first fix, which reads as a bug.
//
// The id set is passed as a JSON array through `json_each` rather than by
// building an `IN (?, ?, ?)` string: the SQL stays a literal, so there is no
// interpolation to audit and the statement cache gets one entry instead of
// one per group size.
let ids_json = serde_json::to_string(&visible).map_err(|e| ApiError::Internal(e.into()))?;
let rows = sqlx::query_as::<_, LatestRow>(
"SELECT u.id, u.display_name, l.ts, l.lat, l.lon, l.acc_dm, l.alt_m, l.spd_cms, \
l.brg_cdeg, l.bat_pct, l.flags, l.recv_at \
FROM users u \
JOIN json_each(?) v ON v.value = u.id \
LEFT JOIN user_latest l ON l.user_id = u.id \
ORDER BY u.display_name",
)
.bind(&ids_json)
.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| {
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();
let body = StateResponse {
server_time: now(),
people,
};
// ETag over the people list only — deliberately *not* including
// `server_time`, which changes every second and would make every poll a 200.
let etag = etag_of(&body.people);
if headers
.get(header::IF_NONE_MATCH)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v == etag)
{
return Ok((StatusCode::NOT_MODIFIED, [(header::ETAG, etag)]).into_response());
}
Ok((
[
(header::ETAG, etag),
(header::CACHE_CONTROL, "no-store".to_string()),
],
Json(body),
)
.into_response())
}
#[derive(sqlx::FromRow)]
struct LatestRow {
id: i64,
display_name: String,
ts: Option<i64>,
lat: Option<i64>,
lon: Option<i64>,
acc_dm: Option<i64>,
alt_m: Option<i64>,
spd_cms: Option<i64>,
brg_cdeg: Option<i64>,
bat_pct: Option<i64>,
flags: Option<i64>,
recv_at: Option<i64>,
}
/// A weak ETag over the payload's meaningful content.
fn etag_of(people: &[PersonState]) -> String {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
for p in people {
p.user_id.hash(&mut h);
p.display_name.hash(&mut h);
if let Some(pos) = &p.position {
pos.ts.hash(&mut h);
pos.lat_e7.hash(&mut h);
pos.lon_e7.hash(&mut h);
pos.acc_dm.hash(&mut h);
pos.bat_pct.hash(&mut h);
pos.flags.hash(&mut h);
} else {
0u8.hash(&mut h);
}
}
format!("W/\"{:x}\"", h.finish())
}
async fn list_tokens(
State(state): State<Shared>,
session: Session,
) -> ApiResult<Json<Vec<TokenInfo>>> {
let uid = current_user(&state, &session).await?;
let rows: Vec<TokenRow> = sqlx::query_as(
"SELECT token_id, name, platform, app_version, os_api_level, last_seen_at, last_src_ip, \
last_transport, created_at \
FROM tokens WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC",
)
.bind(uid)
.fetch_all(&state.db.read)
.await?;
Ok(Json(
rows.into_iter()
.map(|r| TokenInfo {
token_id: (r.token_id as u64).to_string(),
name: r.name,
platform: r.platform,
app_version: r.app_version,
os_api_level: r.os_api_level,
last_seen_at: r.last_seen_at,
last_src_ip: r.last_src_ip,
last_transport: r.last_transport,
created_at: r.created_at,
})
.collect(),
))
}
#[derive(sqlx::FromRow)]
struct TokenRow {
token_id: i64,
name: String,
platform: String,
app_version: Option<i64>,
os_api_level: Option<i64>,
last_seen_at: Option<i64>,
last_src_ip: Option<String>,
last_transport: Option<String>,
created_at: i64,
}
async fn revoke_one_token(
State(state): State<Shared>,
session: Session,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Path(token_id): Path<String>,
) -> ApiResult<StatusCode> {
let uid = current_user(&state, &session).await?;
// Parsed as the u64 it is on the wire, then bit-cast: SQLite has no unsigned
// integer type, so that cast is how every token id is stored.
let token_id = token_id
.parse::<u64>()
.map_err(|_| ApiError::BadRequest("token id must be a u64".into()))?
as i64;
// Scope the ownership check into the query: fetching then comparing invites
// the check being forgotten on some future path.
let owner: Option<i64> = sqlx::query_scalar("SELECT user_id FROM tokens WHERE token_id = ?")
.bind(token_id)
.fetch_optional(&state.db.read)
.await?;
match owner {
None => return Err(ApiError::NotFound),
Some(o) if o != uid => return Err(ApiError::Forbidden),
Some(_) => {}
}
if !auth::revoke_token(&state.db.write, &state.ingest, token_id).await? {
return Err(ApiError::NotFound);
}
audit(
&state,
Some(uid),
"token_revoked",
&token_id.to_string(),
peer.ip(),
)
.await;
Ok(StatusCode::NO_CONTENT)
}
async fn revoke_others(
State(state): State<Shared>,
session: Session,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
) -> ApiResult<Json<serde_json::Value>> {
let uid = current_user(&state, &session).await?;
// From a browser there is no "current token" to keep, so this revokes every
// phone. The browser's own session is unaffected.
let revoked = auth::revoke_other_tokens(&state.db.write, &state.ingest, uid, None).await?;
audit(
&state,
Some(uid),
"tokens_revoked_all",
&revoked.to_string(),
peer.ip(),
)
.await;
Ok(Json(serde_json::json!({ "revoked": revoked })))
}
async fn track(
State(state): State<Shared>,
session: Session,
Path(user_id): Path<i64>,
Query(q): Query<TrackQuery>,
) -> ApiResult<Json<TrackResponse>> {
let viewer = current_user(&state, &session).await?;
let visible = visible_user_ids(&state.db.read, viewer).await?;
if !visible.contains(&user_id) {
// 403 rather than 404: the caller already knows this user exists if they
// saw them in /api/state, and pretending otherwise buys nothing.
return Err(ApiError::Forbidden);
}
// Trails are only visible when the share says so. Your own trail is always
// 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, 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 = ?",
)
.bind(user_id)
.bind(now())
.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);
let from = q.from.unwrap_or(to - 24 * 3_600);
if from >= to {
return Err(ApiError::BadRequest("from must be before to".into()));
}
let max = q.max.clamp(2, 10_000);
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)
.bind(from)
.bind(to)
.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);
let polyline = crate::polyline::encode(&simplified);
Ok(Json(TrackResponse {
user_id,
from,
to,
point_count: simplified.len(),
polyline,
}))
}
async fn create_user(
State(state): State<Shared>,
session: Session,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Json(req): Json<CreateUser>,
) -> ApiResult<Json<serde_json::Value>> {
let admin = require_admin(&state, &session).await?;
let username = req.username.trim().to_string();
if username.is_empty() || username.chars().count() > 64 {
return Err(ApiError::BadRequest(
"username must be 1..=64 characters".into(),
));
}
if req.password.chars().count() < 10 {
return Err(ApiError::BadRequest(
"password must be at least 10 characters".into(),
));
}
let hash = auth::hash_password(&state.cfg, req.password).await?;
let at = now();
let result = sqlx::query(
"INSERT INTO users (username, pw_hash, display_name, is_admin, created_at, pw_changed_at) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&username)
.bind(hash)
.bind(req.display_name.unwrap_or_else(|| username.clone()))
.bind(i64::from(req.is_admin))
.bind(at)
.bind(at)
.execute(&state.db.write)
.await;
let id = match result {
Ok(r) => r.last_insert_rowid(),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
return Err(ApiError::BadRequest("that username is taken".into()));
}
Err(e) => return Err(e.into()),
};
audit(&state, Some(admin), "user_created", &username, peer.ip()).await;
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
.send(WriteOp::Audit {
user_id,
at: now(),
action: action.to_string(),
detail: detail.to_string(),
src_ip: Some(ip.to_string()),
})
.await;
}
#[cfg(test)]
mod tests {
use super::*;
/// The web UI's `web/src/api.ts` types are hand-written. This is what stops
/// them drifting: renaming a field here fails `cargo test` instead of
/// producing `undefined` in a browser at runtime.
///
/// Keys only, not values — the types carry no invariants worth asserting,
/// and a value check would just restate the constructor above it.
fn keys(value: &serde_json::Value) -> Vec<&str> {
let mut k: Vec<&str> = value
.as_object()
.expect("expected a JSON object")
.keys()
.map(String::as_str)
.collect();
k.sort_unstable();
k
}
#[test]
fn the_json_shape_is_the_one_the_web_ui_expects() {
let me = Me {
id: 1,
username: "a".into(),
display_name: "A".into(),
is_admin: false,
server_time: 0,
};
assert_eq!(
keys(&serde_json::to_value(&me).expect("serialize")),
["display_name", "id", "is_admin", "server_time", "username"]
);
let position = Position {
ts: 0,
lat_e7: 0,
lon_e7: 0,
acc_dm: None,
alt_m: None,
spd_cms: None,
brg_cdeg: None,
bat_pct: None,
flags: 0,
recv_at: 0,
};
assert_eq!(
keys(&serde_json::to_value(&position).expect("serialize")),
[
"acc_dm", "alt_m", "bat_pct", "brg_cdeg", "flags", "lat_e7", "lon_e7", "recv_at",
"spd_cms", "ts"
]
);
let person = PersonState {
user_id: 1,
display_name: "A".into(),
is_self: true,
position: Some(position),
};
assert_eq!(
keys(&serde_json::to_value(&person).expect("serialize")),
["display_name", "is_self", "position", "user_id"]
);
// `position` is skipped when absent, which is why the TypeScript field is
// optional rather than nullable.
let no_fix = PersonState {
position: None,
..person
};
assert_eq!(
keys(&serde_json::to_value(&no_fix).expect("serialize")),
["display_name", "is_self", "user_id"]
);
let state = StateResponse {
server_time: 0,
people: vec![],
};
assert_eq!(
keys(&serde_json::to_value(&state).expect("serialize")),
["people", "server_time"]
);
let token = TokenInfo {
token_id: "1".into(),
name: "p".into(),
platform: "android".into(),
app_version: None,
os_api_level: None,
last_seen_at: None,
last_src_ip: None,
last_transport: None,
created_at: 0,
};
assert_eq!(
keys(&serde_json::to_value(&token).expect("serialize")),
[
"app_version",
"created_at",
"last_seen_at",
"last_src_ip",
"last_transport",
"name",
"os_api_level",
"platform",
"token_id"
]
);
let track = TrackResponse {
user_id: 1,
from: 0,
to: 1,
polyline: String::new(),
point_count: 0,
};
assert_eq!(
keys(&serde_json::to_value(&track).expect("serialize")),
["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.
#[test]
fn token_ids_are_too_large_for_a_javascript_number() {
let big = (0..64)
.map(|_| crate::keys::random_token_id().expect("rng"))
.filter(|id| *id >= (1u64 << 53))
.count();
assert!(
big > 32,
"expected most token ids above 2^53, got {big} of 64"
);
}
/// The exact value a browser must be able to send back and have match.
#[test]
fn a_large_token_id_round_trips_through_its_string_form() {
let id = u64::MAX - 3;
let text = id.to_string();
assert_eq!(text.parse::<u64>().expect("parse") as i64, id as i64);
}
}
Dcrates/otserver/src/auth.rs-834
@@ -1,834 +0,0 @@
//! Passwords, sessions, and token minting.
//!
//! Logging in *is* pairing. There is no QR code, no enrollment token, no
//! out-of-band step: a phone posts credentials and gets back a `token_id` and a
//! 32-byte key, which is exactly the mental model of a browser session. A
//! reinstall is just another login, and because positions belong to the account,
//! the new token writes into the same continuous history.
use std::net::IpAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
// `SaltString::generate` wants the rand_core that password-hash was built
// against, which is not the same major version as the `rand` used elsewhere in
// this crate. Importing it through argon2 keeps the two from being confused.
use argon2::password_hash::rand_core::OsRng as PhOsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::{Algorithm, Argon2, Params, Version};
use dashmap::DashMap;
use sqlx::SqlitePool;
use crate::config::Config;
use crate::db::now;
use crate::ingest::{Ingest, TokenSlot};
use crate::keys::{KeyVault, random_token_id, random_token_key};
use otproto::RevokeReason;
/// Failed logins allowed per (IP, username) before the pair is locked out.
const MAX_ATTEMPTS: u32 = 5;
const ATTEMPT_WINDOW: Duration = Duration::from_secs(15 * 60);
/// An argon2 hash of a throwaway password, used to spend the same CPU on an
/// unknown username as on a known one.
///
/// Without this, "user not found" returns in microseconds while a real user's
/// verify takes ~50 ms, and the difference enumerates the whole user list.
const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$\
Fj5r5rD/hqCvQeYqNSlF9y5FZbTCUYPl5jrCLj/eKz0";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthError {
/// Wrong username, wrong password, or a disabled account — deliberately not
/// distinguished, so a caller cannot learn which usernames exist.
Invalid,
/// Too many failures for this (IP, username) pair.
LockedOut { retry_after_s: u64 },
}
impl std::fmt::Display for AuthError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Invalid => write!(f, "invalid username or password"),
Self::LockedOut { retry_after_s } => {
write!(f, "too many attempts; try again in {retry_after_s}s")
}
}
}
}
impl std::error::Error for AuthError {}
#[derive(Debug, Default)]
struct Attempts {
count: u32,
window_started: Option<Instant>,
}
/// Per-(IP, username) failed-login throttle.
#[derive(Default)]
pub struct LoginThrottle {
attempts: DashMap<(IpAddr, String), Attempts>,
}
impl LoginThrottle {
/// Keyed on the pair, not on either alone: keying on IP would let one shared
/// office address lock out a whole team, and keying on username alone would
/// let anyone lock a known user out on purpose.
pub fn check(&self, ip: IpAddr, username: &str) -> Result<(), AuthError> {
let key = (ip, username.to_lowercase());
let now = Instant::now();
if let Some(a) = self.attempts.get(&key)
&& let Some(started) = a.window_started
&& now.duration_since(started) <= ATTEMPT_WINDOW
&& a.count >= MAX_ATTEMPTS
{
return Err(AuthError::LockedOut {
retry_after_s: (ATTEMPT_WINDOW - now.duration_since(started)).as_secs(),
});
}
Ok(())
}
pub fn note_failure(&self, ip: IpAddr, username: &str) {
let key = (ip, username.to_lowercase());
let now = Instant::now();
let mut entry = self.attempts.entry(key).or_default();
match entry.window_started {
Some(started) if now.duration_since(started) <= ATTEMPT_WINDOW => entry.count += 1,
_ => {
entry.window_started = Some(now);
entry.count = 1;
}
}
}
pub fn note_success(&self, ip: IpAddr, username: &str) {
self.attempts.remove(&(ip, username.to_lowercase()));
}
pub fn gc(&self) {
let now = Instant::now();
self.attempts.retain(|_, a| {
a.window_started
.is_some_and(|t| now.duration_since(t) <= ATTEMPT_WINDOW)
});
}
}
/// Argon2id parameters from config.
///
/// 19 MiB / 2 passes / 1 lane is OWASP's second recommended profile and fits a
/// small VM. The parameters are stored inside each PHC hash string, so raising
/// them later does not invalidate anything — [`verify`] re-hashes on the next
/// successful login instead.
fn hasher(cfg: &Config) -> Result<Argon2<'static>> {
let params = Params::new(
cfg.argon2_memory_kib,
cfg.argon2_iterations,
cfg.argon2_parallelism,
None,
)
.map_err(|e| anyhow::anyhow!("invalid argon2 parameters: {e}"))?;
Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
}
/// Hash a password. Runs on a blocking thread: argon2 is deliberately expensive,
/// and ~50 ms of CPU on an async worker would stall every other request on it.
pub async fn hash_password(cfg: &Config, password: String) -> Result<String> {
let argon = hasher(cfg)?;
tokio::task::spawn_blocking(move || {
let salt = SaltString::generate(&mut PhOsRng);
argon
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| anyhow::anyhow!("hashing failed: {e}"))
})
.await
.context("hashing task panicked")?
}
/// Outcome of a verify, including whether the stored hash should be upgraded.
pub struct Verified {
pub ok: bool,
/// A fresh hash under current policy, when the stored one used older params.
pub rehashed: Option<String>,
}
pub async fn verify(cfg: &Config, stored: String, password: String) -> Result<Verified> {
let argon = hasher(cfg)?;
let want = Params::new(
cfg.argon2_memory_kib,
cfg.argon2_iterations,
cfg.argon2_parallelism,
None,
)
.map_err(|e| anyhow::anyhow!("invalid argon2 parameters: {e}"))?;
tokio::task::spawn_blocking(move || {
let parsed = match PasswordHash::new(&stored) {
Ok(p) => p,
Err(_) => {
// A corrupt hash must still cost the same time as a real one, or
// the failure mode becomes an oracle.
let dummy = PasswordHash::new(DUMMY_HASH).expect("the dummy hash is a literal");
let _ = argon.verify_password(password.as_bytes(), &dummy);
return Ok(Verified {
ok: false,
rehashed: None,
});
}
};
if argon.verify_password(password.as_bytes(), &parsed).is_err() {
return Ok(Verified {
ok: false,
rehashed: None,
});
}
// Transparent upgrade when policy has moved on since this hash was made.
//
// Only the three cost parameters are compared. A parsed `Params` also
// carries an output length and optional keyid/data fields that the freshly
// built one does not, so comparing whole structs would rehash on every
// single login — an easy 50 ms tax to add by accident.
let current: Params = (&parsed).try_into().unwrap_or_else(|_| want.clone());
let costs_differ = current.m_cost() != want.m_cost()
|| current.t_cost() != want.t_cost()
|| current.p_cost() != want.p_cost();
let rehashed = if costs_differ {
let salt = SaltString::generate(&mut PhOsRng);
argon
.hash_password(password.as_bytes(), &salt)
.ok()
.map(|h| h.to_string())
} else {
None
};
Ok(Verified { ok: true, rehashed })
})
.await
.context("verify task panicked")?
}
/// Spend the same CPU as a real verify would, then fail.
///
/// Called when the username does not exist, so that the response time carries no
/// information about which accounts are real.
pub async fn dummy_verify(cfg: &Config, password: String) {
let _ = verify(cfg, DUMMY_HASH.to_string(), password).await;
}
#[derive(Debug)]
pub struct Account {
pub id: i64,
pub username: String,
pub display_name: String,
pub is_admin: bool,
}
/// Look up and authenticate a user.
pub async fn authenticate(
pool: &SqlitePool,
writer: &crate::writer::WriteHandle,
cfg: &Config,
throttle: &LoginThrottle,
ip: IpAddr,
username: &str,
password: String,
) -> Result<Account, AuthError> {
throttle.check(ip, username)?;
let row: Option<(i64, String, String, String, i64, Option<i64>)> = sqlx::query_as(
"SELECT id, username, display_name, pw_hash, is_admin, disabled_at \
FROM users WHERE username = ?",
)
.bind(username)
.fetch_optional(pool)
.await
.map_err(|_| AuthError::Invalid)?;
let Some((id, username_stored, display_name, pw_hash, is_admin, disabled_at)) = row else {
dummy_verify(cfg, password).await;
throttle.note_failure(ip, username);
return Err(AuthError::Invalid);
};
// A disabled account still pays for a full verify, so disabling somebody is
// not observable from outside.
let verified = verify(cfg, pw_hash, password)
.await
.map_err(|_| AuthError::Invalid)?;
if !verified.ok || disabled_at.is_some() {
throttle.note_failure(ip, username);
return Err(AuthError::Invalid);
}
if let Some(new_hash) = verified.rehashed {
let _ = writer
.send(crate::writer::WriteOp::Audit {
user_id: Some(id),
at: now(),
action: "password_rehashed".into(),
detail: String::new(),
src_ip: Some(ip.to_string()),
})
.await;
// Best effort: a failed upgrade must not fail the login.
let _ = sqlx::query("UPDATE users SET pw_hash = ? WHERE id = ?")
.bind(new_hash)
.bind(id)
.execute(pool)
.await;
}
throttle.note_success(ip, username);
Ok(Account {
id,
username: username_stored,
display_name,
is_admin: is_admin != 0,
})
}
/// A freshly minted device credential.
pub struct MintedToken {
pub token_id: u64,
pub token_key: [u8; 32],
pub config_version: u16,
}
/// Mint a token for a login.
///
/// The plaintext key is returned exactly once — here — and stored only wrapped.
/// The caller must hand it to the device and then drop it.
pub async fn mint_token(
pool: &SqlitePool,
vault: &KeyVault,
ingest: &Arc<Ingest>,
user_id: i64,
name: &str,
platform: &str,
ip: IpAddr,
) -> Result<MintedToken> {
let token_id = random_token_id()?;
let token_key = random_token_key()?;
let wrapped = vault.wrap(token_id, &token_key)?;
let config_version: u16 = 1;
sqlx::query(
"INSERT INTO tokens (token_id, user_id, key_wrapped, name, platform, config_version, \
created_at, created_ip) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(token_id as i64)
.bind(user_id)
.bind(&wrapped)
.bind(name)
.bind(platform)
.bind(i64::from(config_version))
.bind(now())
.bind(ip.to_string())
.execute(pool)
.await
.context("inserting token")?;
// Insert into the ingest cache before returning, so the device's very first
// datagram is served — there is no window where a just-issued token looks
// unknown.
ingest.insert_token(TokenSlot::new(
token_id,
user_id,
&token_key,
config_version,
));
Ok(MintedToken {
token_id,
token_key,
config_version,
})
}
/// Load every usable token into the ingest cache. Called once at startup.
///
/// A token whose key cannot be unwrapped is skipped with a warning rather than
/// aborting startup: that is what a botched `OT_SECRET_KEY` rotation looks like,
/// and refusing to boot would turn a recoverable mistake into an outage.
pub async fn load_tokens(
pool: &SqlitePool,
vault: &KeyVault,
ingest: &Arc<Ingest>,
) -> Result<usize> {
// Revoked tokens are loaded too, flagged. Their keys authorise nothing; they
// exist so a phone that has not noticed yet gets a sealed answer instead of
// silence. A disabled account is treated as a revocation, since the effect on
// the device is the same.
/// `(token_id, user_id, key_wrapped, config_version, revoked_at, disabled_at)`.
type TokenRow = (i64, i64, Vec<u8>, i64, Option<i64>, Option<i64>);
let rows: Vec<TokenRow> = sqlx::query_as(
"SELECT t.token_id, t.user_id, t.key_wrapped, t.config_version, t.revoked_at, \
u.disabled_at \
FROM tokens t JOIN users u ON u.id = t.user_id",
)
.fetch_all(pool)
.await
.context("loading tokens")?;
let mut loaded = 0;
let mut revoked = 0;
for (token_id, user_id, wrapped, config_version, revoked_at, disabled_at) in rows {
match vault.unwrap(token_id as u64, &wrapped) {
Ok(key) => {
let slot = TokenSlot::new(token_id as u64, user_id, &key, config_version as u16);
if revoked_at.is_some() || disabled_at.is_some() {
ingest.insert_token(slot.revoked(RevokeReason::Revoked));
revoked += 1;
} else {
ingest.insert_token(slot);
loaded += 1;
}
}
Err(e) => tracing::warn!(token_id, error = %e, "skipping token with an unusable key"),
}
}
tracing::debug!(revoked, "revoked tokens kept so their devices can be told");
Ok(loaded)
}
/// Revoke one token: database row, then ingest cache.
///
/// The row and its wrapped key are kept, and the cache slot is flagged rather
/// than dropped. That is deliberate: the key is the only thing that lets the
/// server tell this device it has been logged out, in a message no third party
/// could forge. Dropping it would leave silence as the only safe answer.
pub async fn revoke_token(pool: &SqlitePool, ingest: &Arc<Ingest>, token_id: i64) -> Result<bool> {
let affected =
sqlx::query("UPDATE tokens SET revoked_at = ? WHERE token_id = ? AND revoked_at IS NULL")
.bind(now())
.bind(token_id)
.execute(pool)
.await
.context("revoking token")?
.rows_affected();
ingest.mark_revoked(token_id as u64, RevokeReason::Revoked);
Ok(affected > 0)
}
/// Revoke every token for an account except optionally one.
///
/// This is what "log out all other devices" and a password change both do.
pub async fn revoke_other_tokens(
pool: &SqlitePool,
ingest: &Arc<Ingest>,
user_id: i64,
keep: Option<i64>,
) -> Result<usize> {
let ids: Vec<i64> = sqlx::query_scalar(
"SELECT token_id FROM tokens WHERE user_id = ? AND revoked_at IS NULL AND token_id IS NOT ?",
)
.bind(user_id)
.bind(keep)
.fetch_all(pool)
.await
.context("listing tokens to revoke")?;
for id in &ids {
revoke_token(pool, ingest, *id).await?;
}
Ok(ids.len())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Db;
const IP: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 7));
/// Cheap argon2 parameters: these tests are about logic, not about how long a
/// hash takes, and the real parameters make the suite unbearably slow.
fn cfg() -> Config {
Config {
argon2_memory_kib: 8,
argon2_iterations: 1,
argon2_parallelism: 1,
admin_email: "ops@example.net".into(),
..Config::default()
}
}
async fn fixture() -> (
Db,
Arc<Ingest>,
crate::writer::WriteHandle,
tempfile::TempDir,
) {
let dir = tempfile::tempdir().expect("temp dir");
let db = Db::open(&dir.path().join("t.db")).await.expect("open");
let (writer, _task) = crate::writer::spawn(db.write.clone());
let ingest = Arc::new(Ingest::new(writer.clone(), 30 * 86_400, None));
(db, ingest, writer, dir)
}
async fn make_user(db: &Db, cfg: &Config, username: &str, password: &str) -> i64 {
let hash = hash_password(cfg, password.to_string())
.await
.expect("hash");
let n = now();
sqlx::query(
"INSERT INTO users (username, pw_hash, display_name, created_at, pw_changed_at) \
VALUES (?, ?, ?, ?, ?)",
)
.bind(username)
.bind(hash)
.bind(username)
.bind(n)
.bind(n)
.execute(&db.write)
.await
.expect("user");
sqlx::query_scalar("SELECT id FROM users WHERE username = ?")
.bind(username)
.fetch_one(&db.read)
.await
.expect("id")
}
#[tokio::test]
async fn a_password_verifies_and_a_wrong_one_does_not() {
let cfg = cfg();
let hash = hash_password(&cfg, "correct horse".into())
.await
.expect("hash");
assert!(
verify(&cfg, hash.clone(), "correct horse".into())
.await
.expect("v")
.ok
);
assert!(
!verify(&cfg, hash, "wrong horse".into())
.await
.expect("v")
.ok
);
}
#[tokio::test]
async fn hashes_are_salted() {
let cfg = cfg();
let a = hash_password(&cfg, "same".into()).await.expect("hash");
let b = hash_password(&cfg, "same".into()).await.expect("hash");
assert_ne!(a, b, "two hashes of one password must differ");
}
#[tokio::test]
async fn a_corrupt_stored_hash_fails_closed() {
let cfg = cfg();
let v = verify(&cfg, "not a phc string".into(), "anything".into())
.await
.expect("v");
assert!(!v.ok);
}
#[tokio::test]
async fn a_hash_with_stale_parameters_is_upgraded_on_login() {
// Hash under weak parameters, then verify under stronger ones.
let weak = cfg();
let hash = hash_password(&weak, "pw".into()).await.expect("hash");
let strong = Config {
argon2_iterations: 3,
..weak
};
let v = verify(&strong, hash, "pw".into()).await.expect("v");
assert!(v.ok);
let rehashed = v
.rehashed
.expect("stale parameters should produce a new hash");
assert!(
rehashed.contains("t=3"),
"the new hash should use current parameters: {rehashed}"
);
}
#[tokio::test]
async fn a_hash_with_current_parameters_is_not_rehashed() {
let cfg = cfg();
let hash = hash_password(&cfg, "pw".into()).await.expect("hash");
let v = verify(&cfg, hash, "pw".into()).await.expect("v");
assert!(v.ok);
assert!(
v.rehashed.is_none(),
"no upgrade needed, so no needless write"
);
}
#[tokio::test]
async fn usernames_are_case_insensitive() {
let (db, _ingest, writer, _dir) = fixture().await;
let cfg = cfg();
make_user(&db, &cfg, "Marc", "pw").await;
let throttle = LoginThrottle::default();
let account = authenticate(&db.read, &writer, &cfg, &throttle, IP, "MARC", "pw".into())
.await
.expect("should authenticate regardless of case");
assert_eq!(
account.username, "Marc",
"the stored spelling is what is returned"
);
}
#[tokio::test]
async fn a_disabled_account_cannot_log_in() {
let (db, _ingest, writer, _dir) = fixture().await;
let cfg = cfg();
let id = make_user(&db, &cfg, "gone", "pw").await;
sqlx::query("UPDATE users SET disabled_at = ? WHERE id = ?")
.bind(now())
.bind(id)
.execute(&db.write)
.await
.expect("disable");
let throttle = LoginThrottle::default();
let err = authenticate(&db.read, &writer, &cfg, &throttle, IP, "gone", "pw".into())
.await
.expect_err("must be refused");
assert_eq!(
err,
AuthError::Invalid,
"a disabled account must be indistinguishable from a wrong password"
);
}
#[tokio::test]
async fn repeated_failures_lock_the_pair_out() {
let (db, _ingest, writer, _dir) = fixture().await;
let cfg = cfg();
make_user(&db, &cfg, "victim", "pw").await;
let throttle = LoginThrottle::default();
for _ in 0..MAX_ATTEMPTS {
assert_eq!(
authenticate(
&db.read,
&writer,
&cfg,
&throttle,
IP,
"victim",
"bad".into()
)
.await
.expect_err("wrong password"),
AuthError::Invalid
);
}
let err = authenticate(
&db.read,
&writer,
&cfg,
&throttle,
IP,
"victim",
"pw".into(),
)
.await
.expect_err("should be locked out even with the right password");
assert!(matches!(err, AuthError::LockedOut { .. }));
}
#[tokio::test]
async fn a_lockout_does_not_spread_to_other_addresses() {
let (db, _ingest, writer, _dir) = fixture().await;
let cfg = cfg();
make_user(&db, &cfg, "victim", "pw").await;
let throttle = LoginThrottle::default();
for _ in 0..MAX_ATTEMPTS {
let _ = authenticate(
&db.read,
&writer,
&cfg,
&throttle,
IP,
"victim",
"bad".into(),
)
.await;
}
let elsewhere = IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, 1));
authenticate(
&db.read,
&writer,
&cfg,
&throttle,
elsewhere,
"victim",
"pw".into(),
)
.await
.expect("another address must not be locked out — otherwise this is a DoS on the user");
}
#[tokio::test]
async fn a_successful_login_clears_the_failure_count() {
let (db, _ingest, writer, _dir) = fixture().await;
let cfg = cfg();
make_user(&db, &cfg, "u", "pw").await;
let throttle = LoginThrottle::default();
for _ in 0..MAX_ATTEMPTS - 1 {
let _ = authenticate(&db.read, &writer, &cfg, &throttle, IP, "u", "bad".into()).await;
}
authenticate(&db.read, &writer, &cfg, &throttle, IP, "u", "pw".into())
.await
.expect("still allowed");
for _ in 0..MAX_ATTEMPTS - 1 {
let _ = authenticate(&db.read, &writer, &cfg, &throttle, IP, "u", "bad".into()).await;
}
authenticate(&db.read, &writer, &cfg, &throttle, IP, "u", "pw".into())
.await
.expect("the counter should have been reset by the successful login");
}
#[tokio::test]
async fn minting_a_token_makes_it_immediately_usable() {
let (db, ingest, _writer, _dir) = fixture().await;
let cfg = cfg();
let user_id = make_user(&db, &cfg, "u", "pw").await;
let vault = KeyVault::for_test([9; 32]);
let minted = mint_token(&db.write, &vault, &ingest, user_id, "Pixel", "android", IP)
.await
.expect("mint");
// A device that logs in and immediately sends a LOC must be served: there
// must be no window where a just-issued token looks unknown.
assert!(
ingest
.key_for(minted.token_id, otproto::msg::Direction::Up)
.is_some(),
"the token must be in the ingest cache before mint_token returns"
);
// And the stored key must round-trip through the vault.
let wrapped: Vec<u8> =
sqlx::query_scalar("SELECT key_wrapped FROM tokens WHERE token_id = ?")
.bind(minted.token_id as i64)
.fetch_one(&db.read)
.await
.expect("wrapped");
assert_eq!(
vault.unwrap(minted.token_id, &wrapped).expect("unwrap"),
minted.token_key
);
assert_ne!(
wrapped.as_slice(),
minted.token_key.as_slice(),
"the plaintext key must never be what is stored"
);
}
#[tokio::test]
async fn tokens_reload_into_the_cache_at_startup() {
let (db, ingest, _writer, _dir) = fixture().await;
let cfg = cfg();
let user_id = make_user(&db, &cfg, "u", "pw").await;
let vault = KeyVault::for_test([9; 32]);
let a = mint_token(&db.write, &vault, &ingest, user_id, "A", "android", IP)
.await
.expect("a");
let b = mint_token(&db.write, &vault, &ingest, user_id, "B", "android", IP)
.await
.expect("b");
revoke_token(&db.write, &ingest, b.token_id as i64)
.await
.expect("revoke");
// Simulate a restart: a fresh cache, repopulated from the database.
let (writer2, _t) = crate::writer::spawn(db.write.clone());
let fresh = Arc::new(Ingest::new(writer2, 30 * 86_400, None));
let loaded = load_tokens(&db.read, &vault, &fresh).await.expect("load");
assert_eq!(loaded, 1, "a revoked token must not come back as active");
assert_eq!(fresh.active_token_count(), 1);
assert!(
fresh
.key_for(a.token_id, otproto::msg::Direction::Up)
.is_some()
);
// The revoked token keeps its key across a restart, flagged. Without it
// the phone that still holds that token could only be met with silence,
// and silence is indistinguishable from a network fault.
assert!(
fresh
.key_for(b.token_id, otproto::msg::Direction::Up)
.is_some(),
"a revoked token must keep its key so its device can be told"
);
}
#[tokio::test]
async fn revoke_others_keeps_the_current_token_only() {
let (db, ingest, _writer, _dir) = fixture().await;
let cfg = cfg();
let user_id = make_user(&db, &cfg, "u", "pw").await;
let vault = KeyVault::for_test([9; 32]);
let keep = mint_token(&db.write, &vault, &ingest, user_id, "keep", "android", IP)
.await
.expect("k");
for name in ["a", "b", "c"] {
mint_token(&db.write, &vault, &ingest, user_id, name, "android", IP)
.await
.expect("m");
}
assert_eq!(ingest.active_token_count(), 4);
let revoked = revoke_other_tokens(&db.write, &ingest, user_id, Some(keep.token_id as i64))
.await
.expect("revoke others");
assert_eq!(revoked, 3);
assert_eq!(ingest.active_token_count(), 1);
assert!(
ingest
.key_for(keep.token_id, otproto::msg::Direction::Up)
.is_some()
);
}
#[tokio::test]
async fn a_token_belonging_to_a_disabled_user_is_not_loaded() {
let (db, ingest, _writer, _dir) = fixture().await;
let cfg = cfg();
let user_id = make_user(&db, &cfg, "u", "pw").await;
let vault = KeyVault::for_test([9; 32]);
mint_token(&db.write, &vault, &ingest, user_id, "phone", "android", IP)
.await
.expect("m");
sqlx::query("UPDATE users SET disabled_at = ? WHERE id = ?")
.bind(now())
.bind(user_id)
.execute(&db.write)
.await
.expect("disable");
let (writer2, _t) = crate::writer::spawn(db.write.clone());
let fresh = Arc::new(Ingest::new(writer2, 30 * 86_400, None));
assert_eq!(
load_tokens(&db.read, &vault, &fresh).await.expect("load"),
0,
"disabling an account must stop its phones, not just its browser logins"
);
}
#[test]
fn the_dummy_hash_is_parseable() {
// If this literal ever stops parsing, the unknown-username path silently
// becomes fast and the timing oracle reopens.
PasswordHash::new(DUMMY_HASH).expect("the dummy hash must be a valid PHC string");
}
}
Dcrates/otserver/src/config.rs-263
@@ -1,263 +0,0 @@
//! Configuration: a TOML file with `OT_*` environment overrides.
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::Deserialize;
/// The placeholder that must be replaced before the server will start.
const CONTACT_PLACEHOLDER: &str = "you@example.com";
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Config {
/// HTTP listener. Bound to localhost by default because TLS termination is
/// the reverse proxy's job.
pub http_addr: SocketAddr,
/// OTP/1 UDP listener. **This one is not proxied**: HTTP reverse proxies do
/// not forward UDP, so this port needs its own firewall/NAT rule. Operators
/// get this wrong on day one, which is why the TLS fallback has to be good.
pub udp_addr: SocketAddr,
/// TLS-over-TCP fallback listener for UDP-hostile networks.
pub tls_addr: Option<SocketAddr>,
/// Number of `SO_REUSEPORT` receive tasks. Defaults to `min(cpus, 4)`.
pub udp_workers: Option<usize>,
/// What the login response tells phones to connect to.
pub public_udp_host: String,
pub public_udp_port: u16,
pub public_tls_url: Option<String>,
/// Public base URL, used in the tile proxy's User-Agent and in cookies.
pub base_url: String,
/// Contact address embedded in the tile proxy's User-Agent. The OSM tile
/// policy explicitly prohibits library defaults and unidentified proxies, so
/// the server refuses to start while this is the placeholder.
pub admin_email: String,
pub db_path: PathBuf,
pub cache_dir: PathBuf,
/// Tile cache ceiling. Eviction runs down to 90% of this.
pub max_cache_bytes: u64,
pub tile_upstream_url: String,
/// Hard drop for points older than this.
pub retention_days: u32,
/// Delete tokens with no activity for this long. A phone genuinely idle for
/// a month must log in again — the same contract as an expiring browser
/// session.
pub token_stale_days: u32,
/// Accept timestamps within ±this many days of the server clock.
///
/// The *only* server-side handling of a client timestamp. It is not a
/// correction, not skew detection, and not a security control — the client
/// clock is trusted and stored verbatim. It is a storage bound: a phone whose
/// clock says 2106 would otherwise write rows the retention sweep can never
/// reclaim, and the database would grow without limit. Set it high, or raise
/// it, but do not set it to zero.
pub ts_window_days: u32,
/// Answer a datagram naming an unknown token with a sealed `REVOKED` notice
/// instead of silence.
///
/// This is the one place the server replies to something it could not
/// verify, which makes the UDP port a reflector: source addresses are
/// forgeable, so the reply goes wherever the sender claimed to be. Three
/// things bound it — the notice is 38 bytes and is refused to any shorter
/// request, so it can never amplify; it is rate limited to one per
/// destination address per minute under a global ceiling; and the sender is
/// struck and eventually banned for naming unknown tokens either way.
///
/// It cannot be forged: the notice is sealed with a key derived from the
/// server master and that `token_id`, so no third party and no other device
/// can produce one.
///
/// Turning it off costs nothing in the common case. A revoked token keeps its
/// row and its key, so the ordinary "you are logged out" answer is a fully
/// authenticated `NACK` that never takes this path. This switch matters only
/// when the row is genuinely gone: a restored backup that predates the login,
/// or a rotated `OT_SECRET_KEY`. With it off, those devices get silence until
/// someone opens the app.
pub revocation_notices: bool,
/// Argon2id parameters. 19 MiB / 2 passes / 1 lane is the OWASP-recommended
/// second-choice profile and fits comfortably in a small VM.
pub argon2_memory_kib: u32,
pub argon2_iterations: u32,
pub argon2_parallelism: u32,
}
impl Default for Config {
fn default() -> Self {
Self {
http_addr: "127.0.0.1:7372".parse().expect("literal"),
udp_addr: "0.0.0.0:7373".parse().expect("literal"),
tls_addr: None,
udp_workers: None,
public_udp_host: "localhost".into(),
public_udp_port: 7373,
public_tls_url: None,
base_url: "http://localhost:7372".into(),
admin_email: CONTACT_PLACEHOLDER.into(),
db_path: PathBuf::from("opentracker.db"),
cache_dir: PathBuf::from("cache"),
max_cache_bytes: 1024 * 1024 * 1024,
tile_upstream_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png".into(),
retention_days: 7,
token_stale_days: 30,
ts_window_days: 30,
revocation_notices: true,
argon2_memory_kib: 19 * 1024,
argon2_iterations: 2,
argon2_parallelism: 1,
}
}
}
impl Config {
pub fn load(path: Option<&Path>) -> Result<Self> {
let mut cfg = match path {
Some(p) => {
let text = std::fs::read_to_string(p)
.with_context(|| format!("reading config {}", p.display()))?;
toml::from_str(&text).with_context(|| format!("parsing config {}", p.display()))?
}
None => Self::default(),
};
cfg.apply_env()?;
Ok(cfg)
}
/// `OT_*` overrides, so a systemd unit or container can configure the server
/// without a file.
fn apply_env(&mut self) -> Result<()> {
fn env(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|v| !v.is_empty())
}
fn parse<T: std::str::FromStr>(key: &str, slot: &mut T) -> Result<()>
where
T::Err: std::fmt::Display,
{
if let Some(v) = env(key) {
*slot = v.parse().map_err(|e| anyhow::anyhow!("{key}: {e}"))?;
}
Ok(())
}
parse("OT_HTTP_ADDR", &mut self.http_addr)?;
parse("OT_UDP_ADDR", &mut self.udp_addr)?;
parse("OT_PUBLIC_UDP_HOST", &mut self.public_udp_host)?;
parse("OT_PUBLIC_UDP_PORT", &mut self.public_udp_port)?;
parse("OT_BASE_URL", &mut self.base_url)?;
parse("OT_ADMIN_EMAIL", &mut self.admin_email)?;
parse("OT_DB_PATH", &mut self.db_path)?;
parse("OT_CACHE_DIR", &mut self.cache_dir)?;
parse("OT_MAX_CACHE_BYTES", &mut self.max_cache_bytes)?;
parse("OT_TILE_UPSTREAM_URL", &mut self.tile_upstream_url)?;
parse("OT_RETENTION_DAYS", &mut self.retention_days)?;
parse("OT_TOKEN_STALE_DAYS", &mut self.token_stale_days)?;
parse("OT_REVOCATION_NOTICES", &mut self.revocation_notices)?;
if let Some(v) = env("OT_TLS_ADDR") {
self.tls_addr = Some(v.parse().context("OT_TLS_ADDR")?);
}
if let Some(v) = env("OT_PUBLIC_TLS_URL") {
self.public_tls_url = Some(v);
}
Ok(())
}
/// Checks that would otherwise become confusing runtime failures.
pub fn validate(&self) -> Result<()> {
if self.admin_email == CONTACT_PLACEHOLDER || self.admin_email.is_empty() {
bail!(
"admin_email is still {CONTACT_PLACEHOLDER}. The OSM tile usage policy requires a \
contactable User-Agent, and an unidentified tile proxy gets blocked without \
notice. Set admin_email (or OT_ADMIN_EMAIL) to a real address."
);
}
if !self.tile_upstream_url.contains("{z}")
|| !self.tile_upstream_url.contains("{x}")
|| !self.tile_upstream_url.contains("{y}")
{
bail!("tile_upstream_url must contain {{z}}, {{x}} and {{y}} placeholders");
}
if self.retention_days == 0 {
bail!("retention_days must be at least 1");
}
Ok(())
}
pub fn udp_worker_count(&self) -> usize {
self.udp_workers
.unwrap_or_else(|| std::thread::available_parallelism().map_or(1, |n| n.get().min(4)))
}
/// `User-Agent` for upstream tile requests. The policy prohibits library
/// defaults, so this is deliberately specific and contactable.
///
/// 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: {})",
env!("CARGO_PKG_VERSION"),
self.base_url,
self.admin_email,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_placeholder_contact_blocks_startup() {
let cfg = Config::default();
assert!(
cfg.validate().is_err(),
"placeholder admin_email must be rejected"
);
}
#[test]
fn a_real_contact_passes() {
let cfg = Config {
admin_email: "ops@example.net".into(),
..Config::default()
};
cfg.validate().expect("should validate");
}
#[test]
fn a_tile_url_without_placeholders_is_rejected() {
let cfg = Config {
admin_email: "ops@example.net".into(),
tile_upstream_url: "https://tiles.example.com/map.png".into(),
..Config::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn the_user_agent_identifies_the_deployment() {
let cfg = Config {
admin_email: "ops@example.net".into(),
base_url: "https://track.example.net".into(),
..Config::default()
};
let ua = cfg.tile_user_agent();
assert!(ua.starts_with("opentracker/"));
assert!(ua.contains("track.example.net"));
assert!(ua.contains("ops@example.net"));
}
}
Dcrates/otserver/src/db.rs-278
@@ -1,278 +0,0 @@
//! SQLite setup: pragmas, migrations, and the two-pool split.
//!
//! There are two pools, and the split is the whole performance story:
//!
//! * a **read pool** (4–8 connections) for HTTP handlers, and
//! * a **writer pool of exactly one connection**, owned by the writer task.
//!
//! SQLite allows one writer at a time. Rather than discovering that as
//! `SQLITE_BUSY` under load, the design makes it structural: all writes funnel
//! through one task that batches them. 100 devices reporting once a minute becomes
//! ~4 transactions per second instead of 100 fsyncs, and `SQLITE_BUSY` cannot
//! happen because there is never a second writer to contend with.
use std::path::Path;
use std::str::FromStr;
use std::time::Duration;
use anyhow::{Context, Result};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
use sqlx::{Executor, SqlitePool};
pub struct Db {
/// For HTTP handlers. Concurrent readers are free under WAL.
pub read: SqlitePool,
/// Single connection, held by the writer task. Nothing else may write.
pub write: SqlitePool,
}
/// Pragmas that are not optional.
///
/// * `journal_mode=WAL` — readers never block the writer, and vice versa.
/// * `synchronous=NORMAL` — with WAL this risks losing the last few
/// *transactions* on an OS crash, not corruption. For location history that is
/// the right trade against an fsync per commit.
/// * `busy_timeout` — belt and braces; the single-writer design should make it
/// unreachable.
/// * `foreign_keys=ON` — off by default in SQLite, which surprises everyone once.
/// * `auto_vacuum=INCREMENTAL` — lets the retention sweep return space in bounded
/// chunks instead of a stop-the-world VACUUM.
fn write_options(path: &Path) -> Result<SqliteConnectOptions> {
Ok(base_options(path)?
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal)
.pragma("auto_vacuum", "incremental")
// Keep the WAL from growing without bound between checkpoints.
.pragma("journal_size_limit", "67108864")) // 64 MiB
}
/// Options for the read pool.
///
/// Read-only is enforced with `PRAGMA query_only` rather than by opening the file
/// `SQLITE_OPEN_READONLY`. The distinction matters: several of the pragmas above
/// are themselves writes, and a genuinely read-only handle also cannot create the
/// `-shm` file a WAL database needs, so it fails in ways that depend on whether a
/// writer happens to be attached. `query_only` rejects writes at the statement
/// level, which is the property actually wanted here.
fn read_options(path: &Path) -> Result<SqliteConnectOptions> {
Ok(base_options(path)?
.create_if_missing(false)
.pragma("query_only", "ON"))
}
fn base_options(path: &Path) -> Result<SqliteConnectOptions> {
Ok(
SqliteConnectOptions::from_str(&format!("sqlite://{}", path.display()))
.with_context(|| format!("bad database path {}", path.display()))?
.foreign_keys(true)
.busy_timeout(Duration::from_secs(5))
.pragma("mmap_size", "268435456"), // 256 MiB
)
}
impl Db {
pub async fn open(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
// Migrations run on the writer pool: they are writes, and running them
// here means the read pool never sees a half-migrated schema.
let write = SqlitePoolOptions::new()
.max_connections(1)
.min_connections(1)
.connect_with(write_options(path)?)
.await
.with_context(|| format!("opening {} for writing", path.display()))?;
sqlx::migrate!("./migrations")
.run(&write)
.await
.context("running migrations")?;
let read = SqlitePoolOptions::new()
.max_connections(8)
.min_connections(2)
.connect_with(read_options(path)?)
.await
.with_context(|| format!("opening {} for reading", path.display()))?;
Ok(Self { read, write })
}
/// Flush the WAL back into the main database file. Called on shutdown so the
/// on-disk file is self-contained.
pub async fn checkpoint(&self) -> Result<()> {
self.write
.execute("PRAGMA wal_checkpoint(TRUNCATE);")
.await
.context("WAL checkpoint")?;
Ok(())
}
pub async fn close(&self) {
self.read.close().await;
self.write.close().await;
}
}
/// Seconds since the Unix epoch.
///
/// Every timestamp in this system is an `i64` of Unix seconds. No `TEXT`
/// datetimes, no local time, nowhere.
pub fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64)
}
#[cfg(test)]
mod tests {
use super::*;
/// A throwaway database in a temp directory, migrated and ready.
pub async fn test_db() -> (Db, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir");
let db = Db::open(&dir.path().join("test.db")).await.expect("open");
(db, dir)
}
#[tokio::test]
async fn migrations_apply_and_pragmas_take_effect() {
let (db, _dir) = test_db().await;
let journal: String = sqlx::query_scalar("PRAGMA journal_mode")
.fetch_one(&db.write)
.await
.expect("journal_mode");
assert_eq!(journal.to_lowercase(), "wal");
let fk: i64 = sqlx::query_scalar("PRAGMA foreign_keys")
.fetch_one(&db.write)
.await
.expect("foreign_keys");
assert_eq!(fk, 1, "foreign keys are off by default and must be enabled");
}
#[tokio::test]
async fn every_table_is_strict() {
let (db, _dir) = test_db().await;
let sql: Vec<String> = sqlx::query_scalar(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' \
AND name NOT LIKE '_sqlx%'",
)
.fetch_all(&db.read)
.await
.expect("schema");
assert!(!sql.is_empty());
for stmt in sql {
assert!(
stmt.to_uppercase().contains("STRICT"),
"table is not STRICT, so type affinity could store a string in an integer \
column:\n{stmt}"
);
}
}
#[tokio::test]
async fn the_read_pool_cannot_write() {
let (db, _dir) = test_db().await;
let err = sqlx::query("INSERT INTO settings (key, value) VALUES ('x', 'y')")
.execute(&db.read)
.await;
assert!(err.is_err(), "the read pool must be read-only");
}
#[tokio::test]
async fn the_points_primary_key_makes_replay_idempotent() {
let (db, _dir) = test_db().await;
sqlx::query(
"INSERT INTO users (id, username, pw_hash, display_name, created_at, pw_changed_at) \
VALUES (1, 'a', 'x', 'A', 0, 0)",
)
.execute(&db.write)
.await
.expect("user");
let insert = "INSERT INTO points (user_id, ts, lat, lon, acc_dm, recv_at) \
VALUES (1, 100, 5, 6, ?, 0) \
ON CONFLICT (user_id, ts) DO UPDATE SET \
acc_dm = excluded.acc_dm WHERE excluded.acc_dm < points.acc_dm";
sqlx::query(insert)
.bind(80)
.execute(&db.write)
.await
.expect("first");
// The same point again — a retry or a replay.
sqlx::query(insert)
.bind(80)
.execute(&db.write)
.await
.expect("replay");
// A second phone, same second, worse accuracy: must not win.
sqlx::query(insert)
.bind(200)
.execute(&db.write)
.await
.expect("worse");
// A second phone, same second, better accuracy: must win.
sqlx::query(insert)
.bind(30)
.execute(&db.write)
.await
.expect("better");
let (count, acc): (i64, i64) =
sqlx::query_as("SELECT COUNT(*), MIN(acc_dm) FROM points WHERE user_id = 1")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(count, 1, "a replayed datagram must not create a second row");
assert_eq!(
acc, 30,
"the better-accuracy point must win a same-second collision"
);
}
#[tokio::test]
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(
"INSERT INTO users (id, username, pw_hash, display_name, created_at, pw_changed_at) \
VALUES (?, ?, 'x', 'X', 0, 0)",
)
.bind(id)
.bind(username)
.execute(&db.write)
.await
.expect("user");
}
// No viewer at all.
assert!(
sqlx::query("INSERT INTO shares (owner_user_id, created_at) VALUES (1, 0)")
.execute(&db.write)
.await
.is_err()
);
// Sharing with yourself.
assert!(
sqlx::query(
"INSERT INTO shares (owner_user_id, viewer_user_id, created_at) VALUES (1, 1, 0)"
)
.execute(&db.write)
.await
.is_err()
);
// A real share.
sqlx::query(
"INSERT INTO shares (owner_user_id, viewer_user_id, created_at) VALUES (1, 2, 0)",
)
.execute(&db.write)
.await
.expect("valid share");
}
}
Dcrates/otserver/src/ingest.rs-1004
@@ -1,1004 +0,0 @@
//! Transport-agnostic datagram handling.
//!
//! [`Ingest::handle`] is **synchronous** and takes a couple of microseconds. It
//! never touches the database: every active token lives in an in-memory map,
//! loaded at startup and updated on mint/revoke. That is what lets the UDP
//! receive loop stay a tight `recv_from` → `handle` → `send_to` cycle with no
//! `.await` in the middle, and it is why an unknown `token_id` is genuinely
//! unknown rather than merely uncached.
//!
//! The cost is memory proportional to the number of active tokens. At ~90 bytes
//! per slot, a million tokens would be 90 MB; for a self-hosted instance with a
//! handful of users it is a few kilobytes. If that ever stops being true, this is
//! the module to revisit.
//!
//! The same function serves the UDP loop and the TLS fallback listener, so the
//! two transports cannot drift apart in their handling of anything.
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use dashmap::DashMap;
#[cfg(test)]
use otproto::msg::Direction;
use otproto::{
Ack, AckFlags, DecodeError, Header, Key, MAX_POINTS, Message, Nack, NackReason, Point,
RevokeReason, Revoked, kdf,
};
use rand::TryRngCore;
use rand::rngs::OsRng;
use tracing::{debug, trace};
use crate::limits::Limits;
use crate::writer::{Accepted, WriteHandle, WriteOp};
/// Which transport a datagram arrived on. Recorded per token so the UI can show
/// whether a phone is on UDP or has fallen back to TLS.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport {
Udp,
/// Constructed by the TLS-over-TCP fallback listener, which lands in a later
/// step; the recording path is already transport-agnostic so that listener
/// only has to call `Ingest::handle` with this variant.
#[allow(dead_code)]
Tls,
}
impl Transport {
const fn as_str(self) -> &'static str {
match self {
Self::Udp => "udp",
Self::Tls => "tls",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Peer {
pub addr: SocketAddr,
pub transport: Transport,
}
impl Peer {
pub fn ip(&self) -> IpAddr {
self.addr.ip()
}
}
/// Everything needed to serve one token, with no database round trip.
#[derive(Debug)]
pub struct TokenSlot {
pub token_id: u64,
/// Resolved once, at load time, so the write path never has to ask which
/// account a token belongs to.
pub user_id: i64,
pub k_up: Key,
pub k_down: Key,
pub config_version: u16,
/// Revoked tokens stay in the map instead of being removed.
///
/// The key is what lets the server answer at all, and a revoked device is
/// precisely the one it needs to answer. Dropping the slot would leave the
/// only possible reply an unauthenticated one — a reflector — where keeping
/// it makes the reply a sealed `NACK` nobody else could have produced.
///
/// The token authorises nothing from the moment this is set; the key survives
/// only so the server can say so.
pub revoked: Option<RevokeReason>,
}
impl TokenSlot {
pub fn new(token_id: u64, user_id: i64, token_key: &Key, config_version: u16) -> Self {
let (k_up, k_down) = kdf::derive_both(token_key);
Self {
token_id,
user_id,
k_up,
k_down,
config_version,
revoked: None,
}
}
#[must_use]
pub fn revoked(mut self, reason: RevokeReason) -> Self {
self.revoked = Some(reason);
self
}
}
/// Aggregate counters. Per-packet logging would itself be an amplifier, so these
/// are the only per-packet observability, exposed on a localhost-only `/metrics`.
#[derive(Debug, Default)]
pub struct Counters {
pub received: AtomicU64,
pub malformed: AtomicU64,
pub unknown_token: AtomicU64,
pub auth_failed: AtomicU64,
pub rate_limited: AtomicU64,
pub throttled: AtomicU64,
pub points_accepted: AtomicU64,
pub points_rejected: AtomicU64,
pub acks_sent: AtomicU64,
pub nacks_sent: AtomicU64,
pub silent_drops: AtomicU64,
/// Sealed notices to a token the server still holds a key for.
pub revoked_notices_sent: AtomicU64,
/// Notices sent without being able to verify the request. The reflection
/// budget, in other words — worth watching.
pub unverified_notices_sent: AtomicU64,
pub notices_suppressed: AtomicU64,
}
impl Counters {
fn bump(counter: &AtomicU64) {
counter.fetch_add(1, Ordering::Relaxed);
}
fn add(counter: &AtomicU64, n: u64) {
counter.fetch_add(n, Ordering::Relaxed);
}
}
pub struct Ingest {
tokens: DashMap<u64, Arc<TokenSlot>>,
limits: Limits,
writer: WriteHandle,
pub counters: Counters,
/// Accept timestamps within ±this many seconds of the server clock.
ts_window_s: u32,
/// Master for per-token revocation keys, or `None` when unverified notices
/// are switched off in config.
///
/// `Some` is the only thing that lets this server reply to a datagram it
/// cannot verify, so the config switch is represented as the presence of the
/// key rather than as a separate boolean. There is then no way to enable the
/// behaviour by accident.
revocation_master: Option<Key>,
}
impl Ingest {
pub fn new(writer: WriteHandle, ts_window_s: u32, revocation_master: Option<Key>) -> Self {
Self {
tokens: DashMap::new(),
limits: Limits::new(),
writer,
counters: Counters::default(),
ts_window_s,
revocation_master,
}
}
pub fn insert_token(&self, slot: TokenSlot) {
self.tokens.insert(slot.token_id, Arc::new(slot));
}
/// Called by revoke, by password change, and by the staleness sweep.
///
/// The slot stays, flagged. The token stops authorising anything
/// immediately; what survives is only the ability to tell that one device it
/// is finished, in a message nobody else could have sealed.
pub fn mark_revoked(&self, token_id: u64, reason: RevokeReason) {
if let Some(existing) = self.tokens.get(&token_id).map(|s| Arc::clone(&s)) {
if existing.revoked.is_some() {
return;
}
self.tokens.insert(
token_id,
Arc::new(TokenSlot {
revoked: Some(reason),
..*existing
}),
);
}
}
/// Active tokens only — a revoked slot is bookkeeping, not a live device.
pub fn active_token_count(&self) -> usize {
self.tokens.iter().filter(|s| s.revoked.is_none()).count()
}
pub fn limits(&self) -> &Limits {
&self.limits
}
/// Handle one datagram. Returns the bytes to send back, if any.
///
/// `now` is passed in rather than read from the clock so this is testable
/// without sleeping.
pub fn handle(&self, datagram: &[u8], peer: Peer, now: i64) -> Option<Vec<u8>> {
Counters::bump(&self.counters.received);
// 1. Structure. No state, no allocation, no crypto.
let header = match Header::peek(datagram) {
Ok(h) => h,
Err(e) => {
Counters::bump(&self.counters.malformed);
trace!(?e, "dropping malformed datagram");
return self.silent();
}
};
// A downlink type arriving on the uplink is either a bug or someone
// replaying our own traffic back at us. It can never be legitimate.
if !header.msg_type.is_uplink() {
Counters::bump(&self.counters.malformed);
return self.silent();
}
// 2. Per-IP budget and bans.
if let Err(reason) = self.limits.check_ip(peer.ip()) {
Counters::bump(&self.counters.rate_limited);
trace!(?reason, "dropping rate-limited datagram");
return self.silent();
}
// 3. Does this token exist?
let Some(slot) = self.tokens.get(&header.token_id).map(|s| Arc::clone(&s)) else {
Counters::bump(&self.counters.unknown_token);
self.limits.note_unknown_token(peer.ip());
// No key, so no way to verify this datagram — which makes any reply a
// reply to an address the sender merely claimed. See
// [`Self::unverified_notice`] for what makes that tolerable.
return self.unverified_notice(header.token_id, peer, datagram.len());
};
// 4. AEAD. The first expensive step, ~1 µs for a 62-byte packet.
//
// Everything below this line may answer; nothing above it ever does.
// That is not a style rule, it is the anti-reflection defence. UDP source
// addresses are trivially forged, and `token_id` travels in cleartext, so
// anyone who has seen one datagram can name a valid token. If the server
// replied before verifying, an attacker could spoof a victim's address
// and have us send them a packet per junk datagram — laundering the
// attacker's origin and firing at whatever rate they choose.
//
// Which is why the per-token budget is checked *after* this and not
// before, even though that costs an AEAD open on every flooded packet.
// The per-IP budget above absorbs the bulk at no crypto cost.
let payload = match otproto::open(&slot.k_up, datagram) {
Ok((_, payload)) => payload,
Err(DecodeError::AuthFailed) => {
Counters::bump(&self.counters.auth_failed);
self.limits.note_aead_failure(peer.ip());
// Never answer this. The server cannot know who sent it, so a
// reply would be both a forgery oracle and a reflector.
return self.silent();
}
Err(_) => {
Counters::bump(&self.counters.malformed);
return self.silent();
}
};
// 5. Is this token still alive? Checked after AEAD, so the answer is a
// sealed message the real device can trust and nobody else can forge.
// This is the whole reason revoked slots keep their keys.
if let Some(reason) = slot.revoked {
Counters::bump(&self.counters.revoked_notices_sent);
debug!(token_id = slot.token_id, ?reason, "revoked token reported");
return self.seal_reply(
&slot,
Message::Nack(Nack {
nonce: header.nonce,
reason: NackReason::UnknownToken,
retry_after_s: 0,
}),
);
}
// 6. Per-token budget. Authenticated, so this NACK reaches the device
// that actually sent the datagram and nobody else.
if self.limits.check_token(header.token_id).is_err() {
Counters::bump(&self.counters.rate_limited);
return self.nack(&slot, header.nonce, NackReason::RateLimited, 5);
}
let msg = match Message::decode_payload(header.msg_type, &payload) {
Ok(m) => m,
Err(e) => {
Counters::bump(&self.counters.malformed);
debug!(
token_id = header.token_id,
?e,
"authenticated but malformed payload"
);
return self.nack(&slot, header.nonce, NackReason::Malformed, 0);
}
};
// From here the packet is authenticated, so telemetry is safe to record.
self.record_seen(&slot, peer, now);
match msg {
Message::Loc(points) => self.handle_loc(&slot, header, points, now),
Message::Hello(hello) => {
self.writer.try_send(WriteOp::TokenHello {
token_id: slot.token_id as i64,
app_version: i64::from(hello.app_version_code),
os_api_level: i64::from(hello.os_api_level),
});
self.ack(&slot, vec![header.nonce], hello.config_version)
}
Message::ConfigGet(_) => {
// CONFIG is served by the HTTP/state path in this build; a device
// asking gets silence rather than a stale answer, and retries.
// Wired up with the config editor in a later step.
self.silent()
}
Message::Ping(ping) => {
// The echo is opaque to us; copying it back is the whole job.
let pong = Message::Pong(otproto::Pong {
echo: ping.echo,
seq: ping.seq,
});
self.seal_reply(&slot, pong)
}
// Unreachable: the direction check above rejected every downlink type.
Message::Ack(_)
| Message::Nack(_)
| Message::Config(_)
| Message::Pong(_)
| Message::Revoked(_) => {
Counters::bump(&self.counters.malformed);
self.silent()
}
}
}
fn handle_loc(
&self,
slot: &TokenSlot,
header: Header,
points: Vec<Point>,
now: i64,
) -> Option<Vec<u8>> {
// Semantic validation. A point far outside the timestamp window would
// land where the retention sweep never reaches it, so it is dropped
// rather than stored — but the rest of the batch is still kept, because
// one bad fix must not cost the user a whole journey.
let before = points.len();
let accepted: Vec<Point> = points
.into_iter()
.filter(|p| p.validate(now as u32, self.ts_window_s).is_ok())
.collect();
let rejected = before - accepted.len();
Counters::add(&self.counters.points_rejected, rejected as u64);
if accepted.is_empty() {
Counters::bump(&self.counters.malformed);
return self.nack(slot, header.nonce, NackReason::Malformed, 0);
}
Counters::add(&self.counters.points_accepted, accepted.len() as u64);
let accepted_count = accepted.len();
match self.writer.try_send(WriteOp::Points {
user_id: slot.user_id,
src_token_id: slot.token_id as i64,
points: accepted,
recv_at: now,
}) {
Accepted::Yes => {}
Accepted::Saturated => {
// Do not ack what we did not store: the client keeps the points
// queued and retries. THROTTLE tells it to slow down first.
Counters::bump(&self.counters.throttled);
return self.throttled(slot, header.nonce);
}
Accepted::Closed => return self.silent(),
}
trace!(
token_id = slot.token_id,
user_id = slot.user_id,
accepted_count,
"stored points"
);
self.ack(slot, vec![header.nonce], slot.config_version)
}
fn record_seen(&self, slot: &TokenSlot, peer: Peer, now: i64) {
// Best effort: if the writer is saturated, telemetry is the first thing
// worth dropping.
self.writer.try_send(WriteOp::TokenSeen {
token_id: slot.token_id as i64,
at: now,
src_ip: peer.ip().to_string(),
src_port: peer.addr.port(),
transport: peer.transport.as_str(),
});
}
fn ack(
&self,
slot: &TokenSlot,
nonces: Vec<[u8; 12]>,
device_config_version: u16,
) -> Option<Vec<u8>> {
let flags = if device_config_version < slot.config_version {
AckFlags::CONFIG_PENDING
} else {
AckFlags::NONE
};
debug_assert!(nonces.len() <= MAX_POINTS);
let ack = Message::Ack(Ack { nonces, flags });
Counters::bump(&self.counters.acks_sent);
self.seal_reply(slot, ack)
}
/// The writer channel is full, so the points were not stored.
///
/// This is a `NACK`, not an `ACK` with the `THROTTLE` flag, because an `ACK`
/// retires the nonces it names: acking here would tell the client to delete
/// points that never reached the database. `RateLimited` with a retry hint is
/// exactly the "keep them and back off" semantic the client already
/// implements, so saturation costs a delay rather than data.
fn throttled(&self, slot: &TokenSlot, nonce: [u8; 12]) -> Option<Vec<u8>> {
self.nack(slot, nonce, NackReason::RateLimited, 10)
}
fn nack(
&self,
slot: &TokenSlot,
nonce: [u8; 12],
reason: NackReason,
retry_after_s: u8,
) -> Option<Vec<u8>> {
Counters::bump(&self.counters.nacks_sent);
self.seal_reply(
slot,
Message::Nack(Nack {
nonce,
reason,
retry_after_s,
}),
)
}
/// The one reply this server sends without having verified the request.
///
/// The server has no record of this `token_id`, so it cannot open the
/// datagram and cannot know who really sent it. `peer` is whatever the source
/// address claimed, which on UDP is forgeable. Answering therefore makes this
/// port a reflector, and three things are what keep that from mattering:
///
/// 1. **It cannot amplify.** [`Revoked`] is 38 bytes, the smallest message in
/// the protocol, and a request shorter than that gets nothing.
/// 2. **It is rate limited by destination.** The budget is keyed on the
/// address the reply would go to — the victim, for a spoofed packet — at
/// one per minute, under a global ceiling for distributed attempts.
/// 3. **It is optional.** No revocation master configured, no reply.
///
/// It cannot be forged, which is the other half. `K_rev` is derived from a
/// server master and this `token_id`, so a third party cannot produce one and
/// neither can another legitimate device — each only ever learns its own.
///
/// This path exists for the cases where the row is genuinely gone: a database
/// restored from a backup that predates the login, or a rotated server key.
/// The ordinary revocation path keeps the slot and answers with a sealed
/// `NACK`, which needs none of the above.
fn unverified_notice(&self, token_id: u64, peer: Peer, request_len: usize) -> Option<Vec<u8>> {
let Some(master) = self.revocation_master else {
return self.silent();
};
if !otproto::may_answer_unverified(request_len) {
return self.silent();
}
if self.limits.check_notice(peer.ip()).is_err() {
Counters::bump(&self.counters.notices_suppressed);
return self.silent();
}
let mut nonce = [0u8; 12];
if OsRng.try_fill_bytes(&mut nonce).is_err() {
return self.silent();
}
let msg = Message::Revoked(Revoked {
reason: RevokeReason::Unknown,
});
let reply = otproto::seal_message(
&otproto::revocation_key(&master, token_id),
token_id,
nonce,
&msg,
);
debug_assert!(
reply.len() <= request_len,
"an unverified reply must never exceed its request"
);
Counters::bump(&self.counters.unverified_notices_sent);
Some(reply)
}
/// Seal a reply.
///
/// Anti-amplification is not enforced here, because by this point it is
/// already established: a reply is only reached after the datagram passed AEAD
/// verification, so a reflection attacker must hold a live token key, and
/// every reply this server can construct fits in [`otproto::MAX_REPLY`] bytes
/// — a ratio near 1 against any request. The `debug_assert` is a development
/// guard against a future message type outgrowing that budget, not a runtime
/// control.
fn seal_reply(&self, slot: &TokenSlot, msg: Message) -> Option<Vec<u8>> {
debug_assert!(
otproto::fits_reply_budget(otproto::datagram_len(msg.payload_len())),
"{:?} exceeds the reply budget of {} bytes",
msg.msg_type(),
otproto::MAX_REPLY,
);
let mut nonce = [0u8; 12];
if OsRng.try_fill_bytes(&mut nonce).is_err() {
// Without fresh randomness we must not seal anything: reusing a nonce
// under ChaCha20-Poly1305 leaks the keystream.
return self.silent();
}
Some(otproto::seal_message(
&slot.k_down,
slot.token_id,
nonce,
&msg,
))
}
fn silent(&self) -> Option<Vec<u8>> {
Counters::bump(&self.counters.silent_drops);
None
}
/// Derived key for a direction. Test-only: the live paths look up the slot and
/// use both keys, and exposing a key by id elsewhere would invite misuse.
#[cfg(test)]
pub fn key_for(&self, token_id: u64, dir: Direction) -> Option<Key> {
self.tokens.get(&token_id).map(|s| match dir {
Direction::Up => s.k_up,
Direction::Down => s.k_down,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Db;
use otproto::point::Flags;
use otproto::{HEADER_LEN, TAG_LEN};
const TOKEN_ID: u64 = 0x0123_4567_89AB_CDEF;
const TOKEN_KEY: Key = [0x5A; 32];
const REVOCATION_MASTER: Key = [0xC3; 32];
const NOW: i64 = 1_785_000_042;
fn peer() -> Peer {
Peer {
addr: "203.0.113.5:40000".parse().expect("literal"),
transport: Transport::Udp,
}
}
async fn fixture() -> (Ingest, Db, tempfile::TempDir, tokio::task::JoinHandle<()>) {
let dir = tempfile::tempdir().expect("temp dir");
let db = Db::open(&dir.path().join("t.db")).await.expect("open");
sqlx::query(
"INSERT INTO users (id, username, pw_hash, display_name, created_at, pw_changed_at) \
VALUES (1, 'a', 'x', 'A', 0, 0)",
)
.execute(&db.write)
.await
.expect("user");
let (writer, task) = crate::writer::spawn(db.write.clone());
let ingest = Ingest::new(writer, 30 * 86_400, Some(REVOCATION_MASTER));
ingest.insert_token(TokenSlot::new(TOKEN_ID, 1, &TOKEN_KEY, 1));
(ingest, db, dir, task)
}
fn seal(msg: &Message, nonce_seed: u8) -> Vec<u8> {
let k_up = kdf::derive(&TOKEN_KEY, Direction::Up);
otproto::seal_message(&k_up, TOKEN_ID, [nonce_seed; 12], msg)
}
fn loc(ts: u32) -> Message {
Message::Loc(vec![Point {
acc_dm: Some(80),
flags: Flags::NONE,
..Point::new(ts, 525_200_080, 134_050_000)
}])
}
/// The anti-reflection invariant, stated as a test: nothing that fails the
/// AEAD check may produce a reply.
///
/// UDP source addresses are forgeable, so any reply to an unverified
/// datagram is a packet an attacker can aim at a third party. `token_id` is
/// cleartext, so naming a real token costs nothing — which is what makes the
/// per-token rate limit the interesting case here rather than a theoretical
/// one. Its budget is small enough that a flood trips it immediately.
#[tokio::test]
async fn nothing_that_fails_aead_ever_gets_a_reply() {
let (ingest, _db, _dir, _task) = fixture().await;
let valid = seal(&loc(NOW as u32), 1);
// Far past any per-token budget, so the pre-AEAD ordering bug would show
// up here as a rate-limit NACK sent to an unauthenticated sender.
for i in 0..200 {
// A real header naming a real token, with a corrupted tag.
let mut forged = valid.clone();
let last = forged.len() - 1;
forged[last] ^= 1;
forged[HEADER_LEN] ^= i as u8;
assert_eq!(
ingest.handle(&forged, peer(), NOW),
None,
"a datagram that fails AEAD was answered on attempt {i}"
);
// And a header-only datagram, which cannot authenticate at all.
let stub = valid[..HEADER_LEN + TAG_LEN].to_vec();
assert_eq!(
ingest.handle(&stub, peer(), NOW),
None,
"a truncated datagram was answered on attempt {i}"
);
}
assert_eq!(
ingest.counters.acks_sent.load(Ordering::Relaxed)
+ ingest.counters.nacks_sent.load(Ordering::Relaxed),
0,
"the server sent something in response to unauthenticated traffic"
);
}
#[tokio::test]
async fn a_valid_loc_is_acked_and_stored() {
let (ingest, db, _dir, _task) = fixture().await;
let datagram = seal(&loc(NOW as u32), 1);
let reply = ingest.handle(&datagram, peer(), NOW).expect("should ack");
let k_down = kdf::derive(&TOKEN_KEY, Direction::Down);
let (_, msg) = otproto::open_message(&k_down, &reply).expect("ack opens");
match msg {
Message::Ack(ack) => {
assert_eq!(
ack.nonces,
vec![[1u8; 12]],
"the ack must echo the request nonce"
);
}
other => panic!("expected an ACK, got {other:?}"),
}
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM points WHERE user_id = 1")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(count, 1);
}
#[tokio::test]
async fn an_unknown_token_is_counted_and_struck() {
let (ingest, _db, _dir, _task) = fixture().await;
let k_up = kdf::derive(&TOKEN_KEY, Direction::Up);
let datagram = otproto::seal_message(&k_up, 0xDEAD_BEEF, [2; 12], &loc(NOW as u32));
// The notice itself is covered by
// `an_unknown_token_draws_one_small_rate_limited_notice`; what matters
// here is that answering did not stop the sender being treated as a
// scanner. Naming unknown ids still earns strikes and eventually a ban.
let _ = ingest.handle(&datagram, peer(), NOW);
assert_eq!(ingest.counters.unknown_token.load(Ordering::Relaxed), 1);
assert_eq!(ingest.counters.points_accepted.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn a_forged_datagram_gets_silence() {
let (ingest, _db, _dir, _task) = fixture().await;
let mut datagram = seal(&loc(NOW as u32), 3);
let last = datagram.len() - 1;
datagram[last] ^= 1;
assert!(
ingest.handle(&datagram, peer(), NOW).is_none(),
"a failed tag must never be answered"
);
assert_eq!(ingest.counters.auth_failed.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn a_downlink_message_arriving_on_the_uplink_is_dropped() {
let (ingest, _db, _dir, _task) = fixture().await;
let k_down = kdf::derive(&TOKEN_KEY, Direction::Down);
let pong = Message::Pong(otproto::Pong { echo: 1, seq: 3 });
let datagram = otproto::seal_message(&k_down, TOKEN_ID, [4; 12], &pong);
assert!(ingest.handle(&datagram, peer(), NOW).is_none());
}
#[tokio::test]
async fn a_ping_is_answered_with_a_pong_of_no_greater_size() {
let (ingest, _db, _dir, _task) = fixture().await;
let ping = Message::Ping(otproto::Ping {
echo: 0xDEAD_BEEF,
seq: 7,
});
let datagram = seal(&ping, 5);
let reply = ingest.handle(&datagram, peer(), NOW).expect("pong");
assert!(reply.len() <= datagram.len(), "PONG amplified the PING");
let k_down = kdf::derive(&TOKEN_KEY, Direction::Down);
match otproto::open_message(&k_down, &reply).expect("opens").1 {
Message::Pong(p) => {
assert_eq!(p.seq, 7);
assert_eq!(
p.echo, 0xDEAD_BEEF,
"the PING's opaque echo must come back untouched"
);
}
other => panic!("expected a PONG, got {other:?}"),
}
}
#[tokio::test]
async fn a_point_outside_the_timestamp_window_is_rejected() {
let (ingest, db, _dir, _task) = fixture().await;
// Year 2100: far beyond anything the retention sweep would ever reach.
let datagram = seal(&loc(4_102_444_800), 6);
let reply = ingest.handle(&datagram, peer(), NOW).expect("nack");
let k_down = kdf::derive(&TOKEN_KEY, Direction::Down);
match otproto::open_message(&k_down, &reply).expect("opens").1 {
Message::Nack(n) => assert_eq!(n.reason, NackReason::Malformed),
other => panic!("expected a NACK, got {other:?}"),
}
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM points")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(count, 0);
}
#[tokio::test]
async fn one_bad_point_does_not_cost_the_whole_batch() {
let (ingest, db, _dir, _task) = fixture().await;
let good = Point {
acc_dm: Some(80),
..Point::new(NOW as u32, 525_200_080, 134_050_000)
};
let bad = Point::new(NOW as u32, 910_000_000, 0); // impossible latitude
let datagram = seal(&Message::Loc(vec![good, bad]), 7);
assert!(
ingest.handle(&datagram, peer(), NOW).is_some(),
"should still ack"
);
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM points")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(count, 1, "the good point must survive its bad neighbour");
assert_eq!(ingest.counters.points_rejected.load(Ordering::Relaxed), 1);
}
/// A revoked token keeps its key so this answer is possible at all.
///
/// The alternative — dropping the slot — leaves silence as the only safe
/// reply, and the phone keeps reporting into nothing until someone notices.
#[tokio::test]
async fn a_revoked_token_is_told_so_in_a_message_it_can_verify() {
let (ingest, _db, _dir, _task) = fixture().await;
ingest.mark_revoked(TOKEN_ID, RevokeReason::Revoked);
let datagram = seal(&loc(NOW as u32), 8);
let reply = ingest
.handle(&datagram, peer(), NOW)
.expect("a revoked token must be told, not ignored");
let k_down = kdf::derive(&TOKEN_KEY, Direction::Down);
let (_, msg) = otproto::open_message(&k_down, &reply).expect("sealed under K_down");
match msg {
Message::Nack(nack) => assert_eq!(nack.reason, NackReason::UnknownToken),
other => panic!("expected a NACK, got {other:?}"),
}
// And the points did not land.
assert_eq!(ingest.counters.points_accepted.load(Ordering::Relaxed), 0);
}
/// The unauthenticated path, and everything that bounds it.
#[tokio::test]
async fn an_unknown_token_draws_one_small_rate_limited_notice() {
let (ingest, _db, _dir, _task) = fixture().await;
let stranger = 0xDEAD_BEEF_CAFE_F00D_u64;
let k_up = kdf::derive(&TOKEN_KEY, Direction::Up);
let datagram = otproto::seal_message(&k_up, stranger, [9; 12], &loc(NOW as u32));
let reply = ingest
.handle(&datagram, peer(), NOW)
.expect("an unknown token should draw a notice");
// Never larger than what provoked it: that is what keeps a reflector from
// being an amplifier.
assert!(
reply.len() <= datagram.len(),
"reply {} B for a {} B request",
reply.len(),
datagram.len()
);
// Sealed under this token id's K_rev, which no other device can derive.
let k_rev = otproto::revocation_key(&REVOCATION_MASTER, stranger);
let (header, msg) = otproto::open_message(&k_rev, &reply).expect("sealed under K_rev");
assert_eq!(header.token_id, stranger);
assert_eq!(
msg,
Message::Revoked(Revoked {
reason: RevokeReason::Unknown
})
);
assert!(
otproto::open(
&otproto::revocation_key(&REVOCATION_MASTER, stranger ^ 1),
&reply
)
.is_err(),
"a notice opened under another token's K_rev"
);
// One per destination per minute. Everything after is silence, so a
// spoofed victim is sent a message, not a flood.
for i in 0..50 {
assert!(
ingest.handle(&datagram, peer(), NOW).is_none(),
"a second notice went out on attempt {i}"
);
}
assert_eq!(
ingest
.counters
.unverified_notices_sent
.load(Ordering::Relaxed),
1
);
}
/// A datagram too short to have cost the sender anything earns nothing.
#[tokio::test]
async fn a_minimum_size_datagram_never_draws_a_notice() {
let (ingest, _db, _dir, _task) = fixture().await;
let mut runt = vec![0u8; otproto::MIN_DATAGRAM];
runt[0] = 0x11; // version 1, type LOC
runt[1..9].copy_from_slice(&0xDEAD_BEEF_u64.to_be_bytes());
assert!(ingest.handle(&runt, peer(), NOW).is_none());
assert_eq!(
ingest
.counters
.unverified_notices_sent
.load(Ordering::Relaxed),
0
);
}
/// With no master configured, the server has no way to reply to something it
/// cannot verify — which is the whole of the config switch.
#[tokio::test]
async fn notices_are_off_without_a_revocation_master() {
let dir = tempfile::tempdir().expect("temp dir");
let db = Db::open(&dir.path().join("t.db")).await.expect("open");
let (writer, _task) = crate::writer::spawn(db.write.clone());
let ingest = Ingest::new(writer, 30 * 86_400, None);
let k_up = kdf::derive(&TOKEN_KEY, Direction::Up);
let datagram = otproto::seal_message(&k_up, 0x1234, [4; 12], &loc(NOW as u32));
assert!(ingest.handle(&datagram, peer(), NOW).is_none());
}
#[tokio::test]
async fn a_config_pending_flag_appears_when_the_device_is_behind() {
let (ingest, _db, _dir, _task) = fixture().await;
ingest.insert_token(TokenSlot::new(TOKEN_ID, 1, &TOKEN_KEY, 5));
let hello = Message::Hello(otproto::Hello {
app_version_code: 1,
os_api_level: 34,
flags: otproto::HelloFlags::NONE,
config_version: 2, // behind the server's 5
});
let datagram = seal(&hello, 9);
let reply = ingest.handle(&datagram, peer(), NOW).expect("ack");
let k_down = kdf::derive(&TOKEN_KEY, Direction::Down);
match otproto::open_message(&k_down, &reply).expect("opens").1 {
Message::Ack(a) => assert!(
a.flags.contains(AckFlags::CONFIG_PENDING),
"the device is behind and must be told to fetch config"
),
other => panic!("expected an ACK, got {other:?}"),
}
}
/// The anti-amplification property, as it actually is.
///
/// Not `reply <= request` — that rule was paid for with reserved padding on
/// every `HELLO` and `PING`, to defend against a threat authentication already
/// removes. What holds instead: every reply fits the reply budget, and the
/// resulting ratio is nowhere near enough leverage to be worth reflecting
/// through — and the sender needed a valid token key to get a reply at all,
/// which the silence tests above cover.
#[tokio::test]
async fn replies_stay_inside_the_reply_budget() {
let (ingest, _db, _dir, _task) = fixture().await;
let requests = [
seal(&loc(NOW as u32), 10),
seal(
&Message::Loc(
(0..MAX_POINTS as u32)
.map(|i| Point::new(NOW as u32 - i, 1, 2))
.collect(),
),
11,
),
seal(&Message::Ping(otproto::Ping { echo: 1, seq: 1 }), 12),
seal(
&Message::Hello(otproto::Hello {
app_version_code: 1,
os_api_level: 29,
flags: otproto::HelloFlags::NONE,
config_version: 1,
}),
13,
),
];
for datagram in requests {
if let Some(reply) = ingest.handle(&datagram, peer(), NOW) {
assert!(
otproto::fits_reply_budget(reply.len()),
"a {}-byte request drew a {}-byte reply, over the {}-byte budget",
datagram.len(),
reply.len(),
otproto::MAX_REPLY,
);
let ratio = reply.len() as f64 / datagram.len() as f64;
assert!(
ratio <= 1.5,
"a {}-byte request drew a {}-byte reply, {ratio:.2}x amplification",
datagram.len(),
reply.len(),
);
}
}
}
/// Garbage never panics, and never draws anything but a bounded notice.
///
/// A run of `0x11` bytes parses as a well-formed header for token
/// `0x1111111111111111`, which the server does not have — so the notice path
/// is reachable from pure garbage by construction. That is expected. What
/// must hold is that the reply is never larger than the request and that the
/// budget stops it almost immediately.
#[tokio::test]
async fn garbage_never_panics_and_never_amplifies() {
let (ingest, _db, _dir, _task) = fixture().await;
for len in [0usize, 1, 20, 36, 37, 100, 1200, 1201] {
for fill in [0u8, 0x11, 0xFF] {
let datagram = vec![fill; len];
if let Some(reply) = ingest.handle(&datagram, peer(), NOW) {
assert!(
reply.len() <= datagram.len(),
"len {len} fill {fill} drew a {} B reply",
reply.len()
);
}
}
}
assert!(
ingest
.counters
.unverified_notices_sent
.load(Ordering::Relaxed)
<= 1,
"the per-destination budget should have stopped after the first notice"
);
}
}
Dcrates/otserver/src/keys.rs-257
@@ -1,257 +0,0 @@
//! Wrapping of token secrets at rest.
//!
//! Every `token_key` is stored encrypted with a server key, with the `token_id`
//! as additional data. A stolen `.db` therefore yields no working keys, and a
//! wrapped blob cannot be moved from one token row to another.
//!
//! The server refuses to start without a key, and accepts an old one for a
//! one-shot rotation.
use anyhow::{Context, Result, bail};
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
use hkdf::Hkdf;
use rand::TryRngCore;
use rand::rngs::OsRng;
use sha2::Sha256;
const KEY_LEN: usize = 32;
const NONCE_LEN: usize = 12;
/// HKDF label separating the revocation master from the wrapping key. They come
/// from the same secret and must never be the same value.
const REVOCATION_MASTER_INFO: &[u8] = b"otp/1/revoke-master";
/// The server's key-wrapping key(s).
pub struct KeyVault {
current: ChaCha20Poly1305,
/// Accepted for unwrapping only, so a rotation can re-wrap lazily.
previous: Option<ChaCha20Poly1305>,
/// Master for per-token revocation keys. Derived from the same secret rather
/// than configured separately: one required environment variable is enough,
/// and an operator who has to manage two will eventually lose one.
///
/// The consequence is that rotating `OT_SECRET_KEY` also invalidates every
/// `K_rev` already issued. Devices that logged in beforehand stop being able
/// to verify a revocation notice and fall back to silence until their next
/// login — the behaviour they would have had anyway without this mechanism.
revocation_master: otproto::Key,
}
impl KeyVault {
/// Reads `OT_SECRET_KEY` (base64, 32 bytes) or the file it names, plus the
/// optional `OT_SECRET_KEY_OLD`.
pub fn from_env() -> Result<Self> {
let current = load("OT_SECRET_KEY")?.ok_or_else(|| {
anyhow::anyhow!(
"OT_SECRET_KEY is not set. It must be 32 random bytes, base64-encoded, or the path \
to a 0600 file containing them. Generate one with:\n \
head -c32 /dev/urandom | base64\n\
Without it, token keys would sit in the database in the clear."
)
})?;
let previous = load("OT_SECRET_KEY_OLD")?;
Ok(Self {
revocation_master: derive_revocation_master(¤t),
current: ChaCha20Poly1305::new((¤t).into()),
previous: previous.map(|k| ChaCha20Poly1305::new((&k).into())),
})
}
#[cfg(test)]
pub fn for_test(key: [u8; KEY_LEN]) -> Self {
Self {
revocation_master: derive_revocation_master(&key),
current: ChaCha20Poly1305::new((&key).into()),
previous: None,
}
}
/// The master from which every `K_rev` is derived.
///
/// Handed to [`crate::ingest::Ingest`] so the hot path can seal a notice for
/// a `token_id` it has never seen, without a database round trip and without
/// storing anything per token.
pub fn revocation_master(&self) -> otproto::Key {
self.revocation_master
}
/// The key sealing revocation notices for one token. Also what the login
/// response hands the device.
pub fn revocation_key(&self, token_id: u64) -> otproto::Key {
otproto::revocation_key(&self.revocation_master, token_id)
}
/// `nonce || ciphertext || tag`, with the token id as AAD.
pub fn wrap(&self, token_id: u64, token_key: &[u8; KEY_LEN]) -> Result<Vec<u8>> {
let mut nonce = [0u8; NONCE_LEN];
OsRng.try_fill_bytes(&mut nonce).context("OS RNG failed")?;
let sealed = self
.current
.encrypt(
Nonce::from_slice(&nonce),
Payload {
msg: token_key,
aad: &token_id.to_be_bytes(),
},
)
.map_err(|_| anyhow::anyhow!("wrapping token key failed"))?;
let mut out = Vec::with_capacity(NONCE_LEN + sealed.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&sealed);
Ok(out)
}
pub fn unwrap(&self, token_id: u64, blob: &[u8]) -> Result<[u8; KEY_LEN]> {
if blob.len() < NONCE_LEN + 16 {
bail!(
"wrapped key for token {token_id} is truncated ({} bytes)",
blob.len()
);
}
let (nonce, sealed) = blob.split_at(NONCE_LEN);
let aad = token_id.to_be_bytes();
for cipher in [Some(&self.current), self.previous.as_ref()]
.into_iter()
.flatten()
{
if let Ok(plain) = cipher.decrypt(
Nonce::from_slice(nonce),
Payload {
msg: sealed,
aad: &aad,
},
) {
return plain.try_into().map_err(|v: Vec<u8>| {
anyhow::anyhow!("token key is {} bytes, want {KEY_LEN}", v.len())
});
}
}
bail!(
"cannot unwrap the key for token {token_id}: neither OT_SECRET_KEY nor \
OT_SECRET_KEY_OLD decrypts it"
)
}
}
fn load(var: &str) -> Result<Option<[u8; KEY_LEN]>> {
let Ok(raw) = std::env::var(var) else {
return Ok(None);
};
if raw.is_empty() {
return Ok(None);
}
// A path is more likely than base64 to contain a '/', so decide on whether
// the value names an existing file rather than on its shape.
let text = if std::path::Path::new(&raw).is_file() {
std::fs::read_to_string(&raw).with_context(|| format!("{var}: reading {raw}"))?
} else {
raw
};
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(text.trim())
.with_context(|| format!("{var} is not valid base64"))?;
if bytes.len() != KEY_LEN {
bail!("{var} decodes to {} bytes, want {KEY_LEN}", bytes.len());
}
Ok(Some(bytes.try_into().expect("length checked")))
}
/// 32 fresh random bytes for a new token secret.
pub fn random_token_key() -> Result<[u8; KEY_LEN]> {
let mut k = [0u8; KEY_LEN];
OsRng.try_fill_bytes(&mut k).context("OS RNG failed")?;
Ok(k)
}
/// A random, non-zero `token_id`.
///
/// Random rather than sequential because the id travels in cleartext in every
/// datagram header: a guessable one would let an attacker enumerate which tokens
/// exist by watching for the absence of a reply.
pub fn random_token_id() -> Result<u64> {
loop {
let mut b = [0u8; 8];
OsRng.try_fill_bytes(&mut b).context("OS RNG failed")?;
let id = u64::from_be_bytes(b);
// 0 is reserved as "unset" in a few places; rejecting it costs nothing.
if id != 0 {
return Ok(id);
}
}
}
fn derive_revocation_master(secret: &[u8; KEY_LEN]) -> otproto::Key {
let hk = Hkdf::<Sha256>::from_prk(secret).expect("32-byte PRK is valid for HKDF-SHA256");
let mut out = [0u8; KEY_LEN];
hk.expand(REVOCATION_MASTER_INFO, &mut out)
.expect("32 bytes is well under HKDF-SHA256's output limit");
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrap_then_unwrap_round_trips() {
let vault = KeyVault::for_test([7; KEY_LEN]);
let key = [0x42; KEY_LEN];
let blob = vault.wrap(99, &key).expect("wrap");
assert_eq!(vault.unwrap(99, &blob).expect("unwrap"), key);
}
#[test]
fn a_blob_cannot_be_moved_to_another_token() {
// The token id is AAD, so a row-swap in the database is detected rather
// than silently cloning a credential onto another token.
let vault = KeyVault::for_test([7; KEY_LEN]);
let blob = vault.wrap(99, &[0x42; KEY_LEN]).expect("wrap");
assert!(vault.unwrap(100, &blob).is_err());
}
#[test]
fn a_tampered_blob_is_rejected() {
let vault = KeyVault::for_test([7; KEY_LEN]);
let mut blob = vault.wrap(1, &[1; KEY_LEN]).expect("wrap");
let last = blob.len() - 1;
blob[last] ^= 1;
assert!(vault.unwrap(1, &blob).is_err());
}
#[test]
fn a_wrong_server_key_cannot_unwrap() {
let blob = KeyVault::for_test([7; KEY_LEN])
.wrap(1, &[1; KEY_LEN])
.expect("wrap");
assert!(KeyVault::for_test([8; KEY_LEN]).unwrap(1, &blob).is_err());
}
#[test]
fn truncated_blobs_fail_with_a_clear_error() {
let vault = KeyVault::for_test([7; KEY_LEN]);
assert!(vault.unwrap(1, &[]).is_err());
assert!(vault.unwrap(1, &[0; NONCE_LEN]).is_err());
}
#[test]
fn wrapping_is_randomised() {
// Same key, same token, different ciphertext: the nonce is fresh each
// time, so the database never reveals that two tokens share a secret.
let vault = KeyVault::for_test([7; KEY_LEN]);
let a = vault.wrap(1, &[1; KEY_LEN]).expect("wrap");
let b = vault.wrap(1, &[1; KEY_LEN]).expect("wrap");
assert_ne!(a, b);
}
#[test]
fn token_ids_are_never_zero() {
for _ in 0..100 {
assert_ne!(random_token_id().expect("id"), 0);
}
}
}
Dcrates/otserver/src/limits.rs-332
@@ -1,332 +0,0 @@
//! Abuse handling for the open UDP port.
//!
//! Layered in packet-touch order, cheapest check first, so that spending CPU on a
//! packet is always justified by the packet having survived everything cheaper:
//!
//! 1. length / version / type filter (in `otproto`, no state at all)
//! 2. per-IP token bucket
//! 3. unknown-token blocklist
//! 4. per-token token bucket
//! 5. AEAD verification — the first genuinely expensive step (~1 µs)
//! 6. AEAD-failure blocklist
//! 7. writer saturation → `THROTTLE`
//!
//! Two cross-cutting rules:
//!
//! * **Never reply to an unauthenticated packet.** Not with an error, not with a
//! `NACK`. Anything else makes the port a reflector and a forgery oracle.
//! * **The rate limiter must not itself be a memory-exhaustion vector.** Every map
//! here is capacity-capped and swept, because an attacker chooses the keys.
use std::net::IpAddr;
use std::num::NonZeroU32;
use std::time::{Duration, Instant};
use dashmap::DashMap;
use governor::clock::DefaultClock;
use governor::state::keyed::DefaultKeyedStateStore;
use governor::state::{InMemoryState, NotKeyed};
use governor::{Quota, RateLimiter};
/// Sustained packets per second from one IP, and how many may arrive at once.
/// Generous: a single IP can be a whole household behind NAT, all flushing queues
/// after a dead spot.
const IP_RATE: u32 = 25;
const IP_BURST: u32 = 75;
/// Per token. One report per second is already far above any profile; the burst
/// covers a queue flush after an offline stretch.
const TOKEN_RATE: u32 = 3;
const TOKEN_BURST: u32 = 40;
/// Revocation notices, the one reply sent without authenticating the request.
///
/// These numbers are the entire mitigation, so they are deliberately mean. The
/// key is the *destination* address, which for a spoofed datagram is the victim:
/// one packet a minute is a message, not a flood. A revoked device needs exactly
/// one to act, and it retries on its own schedule anyway.
///
/// The global ceiling bounds a distributed spoof, where each victim stays under
/// the per-address limit but the server is still made to emit broadly.
const NOTICE_PER_ADDR_PER_MINUTE: u32 = 1;
const NOTICE_GLOBAL_RATE: u32 = 10;
const NOTICE_GLOBAL_BURST: u32 = 20;
/// Strikes within [`STRIKE_WINDOW`] before an IP is banned for [`BAN_DURATION`].
const UNKNOWN_TOKEN_STRIKES: u32 = 50;
const AEAD_FAIL_STRIKES: u32 = 50;
const STRIKE_WINDOW: Duration = Duration::from_secs(60);
const BAN_DURATION: Duration = Duration::from_secs(600);
/// Hard ceiling on tracked IPs. Reached only under attack; when it is, the map is
/// swept rather than grown, because unbounded growth is the vulnerability.
const MAX_TRACKED_IPS: usize = 100_000;
type Keyed<K> = RateLimiter<K, DefaultKeyedStateStore<K>, DefaultClock>;
type Direct = RateLimiter<NotKeyed, InMemoryState, DefaultClock>;
fn quota(rate: u32, burst: u32) -> Quota {
Quota::per_second(NonZeroU32::new(rate).expect("rate is a non-zero literal"))
.allow_burst(NonZeroU32::new(burst).expect("burst is a non-zero literal"))
}
#[derive(Debug, Default)]
struct Strikes {
count: u32,
window_started: Option<Instant>,
banned_until: Option<Instant>,
}
pub struct Limits {
per_ip: Keyed<IpAddr>,
per_token: Keyed<u64>,
notice_per_addr: Keyed<IpAddr>,
notice_global: Direct,
unknown_token: DashMap<IpAddr, Strikes>,
aead_fail: DashMap<IpAddr, Strikes>,
}
/// Why a packet was dropped. Used for aggregate counters only — never logged per
/// packet, because per-packet logging is itself a denial-of-service amplifier.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Drop {
IpRate,
TokenRate,
Banned,
/// The unauthenticated-notice budget, per destination or global.
NoticeRate,
}
impl Default for Limits {
fn default() -> Self {
Self::new()
}
}
impl Limits {
pub fn new() -> Self {
Self {
per_ip: RateLimiter::keyed(quota(IP_RATE, IP_BURST)),
per_token: RateLimiter::keyed(quota(TOKEN_RATE, TOKEN_BURST)),
notice_per_addr: RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(NOTICE_PER_ADDR_PER_MINUTE).expect("non-zero literal"),
)),
notice_global: RateLimiter::direct(quota(NOTICE_GLOBAL_RATE, NOTICE_GLOBAL_BURST)),
unknown_token: DashMap::new(),
aead_fail: DashMap::new(),
}
}
/// Step 2 and 3: is this source allowed to send anything right now?
pub fn check_ip(&self, ip: IpAddr) -> Result<(), Drop> {
if self.is_banned(ip) {
return Err(Drop::Banned);
}
self.per_ip.check_key(&ip).map_err(|_| Drop::IpRate)
}
/// Per-credential budget, applied *after* the AEAD check.
///
/// Deliberately not before it, even though that would be cheaper. Exceeding
/// this budget produces a NACK, and a reply to an unverified datagram is a
/// reflector: `token_id` is cleartext, so an attacker can name a real token,
/// spoof a victim's source address, and have the server packet the victim.
/// The per-IP budget is the cheap pre-crypto filter; this one is not.
pub fn check_token(&self, token_id: u64) -> Result<(), Drop> {
self.per_token
.check_key(&token_id)
.map_err(|_| Drop::TokenRate)
}
/// Called when a datagram names a token that does not exist.
///
/// Scanning for valid token ids is the cheapest attack against this design —
/// no key needed, just 2⁶⁴ guesses — so it is met with a ban rather than a
/// reply. The port stays silent either way.
pub fn note_unknown_token(&self, ip: IpAddr) {
self.strike(&self.unknown_token, ip, UNKNOWN_TOKEN_STRIKES);
}
/// Called when a datagram named a real token but failed AEAD. Either
/// corruption or forgery; both mean stop listening to this source.
pub fn note_aead_failure(&self, ip: IpAddr) {
self.strike(&self.aead_fail, ip, AEAD_FAIL_STRIKES);
}
fn is_banned(&self, ip: IpAddr) -> bool {
let now = Instant::now();
let banned = |m: &DashMap<IpAddr, Strikes>| {
m.get(&ip)
.and_then(|s| s.banned_until)
.is_some_and(|t| t > now)
};
banned(&self.unknown_token) || banned(&self.aead_fail)
}
fn strike(&self, map: &DashMap<IpAddr, Strikes>, ip: IpAddr, threshold: u32) {
// Sweep before inserting a new key, so the map can never exceed the cap.
if map.len() >= MAX_TRACKED_IPS && !map.contains_key(&ip) {
sweep(map);
if map.len() >= MAX_TRACKED_IPS {
// Still full after sweeping: we are under a wide attack. Dropping
// the strike is the right failure mode — the per-IP rate limiter
// is still holding, and growing this map is what the attacker
// wants.
return;
}
}
let now = Instant::now();
let mut entry = map.entry(ip).or_default();
match entry.window_started {
Some(started) if now.duration_since(started) <= STRIKE_WINDOW => entry.count += 1,
_ => {
entry.window_started = Some(now);
entry.count = 1;
}
}
if entry.count >= threshold {
entry.banned_until = Some(now + BAN_DURATION);
entry.count = 0;
entry.window_started = None;
}
}
/// May we send an unauthenticated revocation notice to `addr` right now?
///
/// Both budgets are checked, and both must pass. This is called with the
/// address the datagram *claimed* to come from, which is exactly the point:
/// an attacker spoofing a victim gets the victim's budget, not their own.
pub fn check_notice(&self, addr: IpAddr) -> Result<(), Drop> {
if self.notice_global.check().is_err() {
return Err(Drop::NoticeRate);
}
self.notice_per_addr
.check_key(&addr)
.map_err(|_| Drop::NoticeRate)
}
/// Periodic maintenance. Drops expired bans and stale rate-limiter state so
/// idle keys do not accumulate.
pub fn gc(&self) {
self.per_ip.retain_recent();
self.per_token.retain_recent();
self.notice_per_addr.retain_recent();
sweep(&self.unknown_token);
sweep(&self.aead_fail);
}
/// For `/metrics`.
pub fn tracked_ips(&self) -> usize {
self.unknown_token.len() + self.aead_fail.len()
}
}
fn sweep(map: &DashMap<IpAddr, Strikes>) {
let now = Instant::now();
map.retain(|_, s| {
let ban_active = s.banned_until.is_some_and(|t| t > now);
let window_active = s
.window_started
.is_some_and(|t| now.duration_since(t) <= STRIKE_WINDOW);
ban_active || window_active
});
}
#[cfg(test)]
mod tests {
use super::*;
const IP: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 5));
#[test]
fn a_burst_is_allowed_then_the_rate_bites() {
let limits = Limits::new();
for i in 0..IP_BURST {
assert!(
limits.check_ip(IP).is_ok(),
"packet {i} of the burst was dropped"
);
}
assert_eq!(limits.check_ip(IP), Err(Drop::IpRate));
}
#[test]
fn per_token_limits_are_independent_of_each_other() {
let limits = Limits::new();
for _ in 0..TOKEN_BURST {
assert!(limits.check_token(1).is_ok());
}
assert_eq!(limits.check_token(1), Err(Drop::TokenRate));
// A different token is unaffected: one noisy phone must not silence a
// whole household behind the same NAT.
assert!(limits.check_token(2).is_ok());
}
#[test]
fn token_scanning_earns_a_ban() {
let limits = Limits::new();
for _ in 0..UNKNOWN_TOKEN_STRIKES {
limits.note_unknown_token(IP);
}
assert_eq!(limits.check_ip(IP), Err(Drop::Banned));
}
#[test]
fn forged_packets_earn_a_ban() {
let limits = Limits::new();
for _ in 0..AEAD_FAIL_STRIKES {
limits.note_aead_failure(IP);
}
assert_eq!(limits.check_ip(IP), Err(Drop::Banned));
}
#[test]
fn a_ban_is_specific_to_the_offending_address() {
let limits = Limits::new();
for _ in 0..UNKNOWN_TOKEN_STRIKES {
limits.note_unknown_token(IP);
}
let other = IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, 9));
assert!(limits.check_ip(other).is_ok());
}
#[test]
fn strikes_below_the_threshold_do_not_ban() {
let limits = Limits::new();
for _ in 0..UNKNOWN_TOKEN_STRIKES - 1 {
limits.note_unknown_token(IP);
}
assert!(limits.check_ip(IP).is_ok());
}
#[test]
fn gc_drops_entries_with_nothing_left_to_remember() {
let limits = Limits::new();
limits.note_unknown_token(IP);
assert_eq!(limits.tracked_ips(), 1);
// Force the window to look expired rather than sleeping for a minute.
limits
.unknown_token
.get_mut(&IP)
.expect("entry")
.window_started = Some(Instant::now() - STRIKE_WINDOW * 2);
limits.gc();
assert_eq!(limits.tracked_ips(), 0);
}
#[test]
fn a_ban_survives_gc_until_it_expires() {
let limits = Limits::new();
for _ in 0..UNKNOWN_TOKEN_STRIKES {
limits.note_unknown_token(IP);
}
limits.gc();
assert_eq!(
limits.check_ip(IP),
Err(Drop::Banned),
"gc must not lift an active ban"
);
}
}
Dcrates/otserver/src/main.rs-303
@@ -1,303 +0,0 @@
//! opentracker server: one binary, one SQLite file.
//!
//! Wiring, in dependency order:
//!
//! ```text
//! Db ──▶ writer task ──▶ Ingest ──▶ UDP workers
//! │ │
//! └──▶ axum HTTP ─────────┘ (login mints tokens straight into Ingest)
//! ```
//!
//! The one operational trap worth repeating: **HTTP reverse proxies do not forward
//! UDP.** The HTTP listener belongs behind nginx or Caddy; the UDP port needs its
//! own firewall rule.
mod api;
mod auth;
mod config;
mod db;
mod ingest;
mod keys;
mod limits;
mod polyline;
mod retention;
mod simulate;
mod tiles;
mod udp;
mod web;
mod writer;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use tower_sessions::cookie::SameSite;
use tower_sessions::{Expiry, SessionManagerLayer};
use tower_sessions_sqlx_store::SqliteStore;
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;
use crate::api::AppState;
use crate::config::Config;
use crate::db::Db;
use crate::ingest::Ingest;
use crate::keys::KeyVault;
/// Rolling session lifetime.
const SESSION_DAYS: i64 = 30;
struct Args {
config: Option<PathBuf>,
/// Relaxes the cookie's `Secure` requirement so the Vite dev server works
/// over plain HTTP on localhost. Never for production.
dev: bool,
simulate_device: bool,
/// `--create-admin <user> <password>`: bootstrap the first account.
create_admin: Option<(String, String)>,
}
fn parse_args() -> Result<Args> {
let mut args = Args {
config: None,
dev: false,
simulate_device: false,
create_admin: None,
};
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
match arg.as_str() {
"--config" | "-c" => {
args.config = Some(PathBuf::from(it.next().context("--config needs a path")?));
}
"--dev" => args.dev = true,
"--simulate-device" => args.simulate_device = true,
"--create-admin" => {
let user = it.next().context("--create-admin needs a username")?;
let pass = it.next().context("--create-admin needs a password")?;
args.create_admin = Some((user, pass));
}
"--help" | "-h" => {
println!(
"opentracker {}\n\n\
USAGE:\n \
otserver [--config FILE] [--dev] [--simulate-device]\n \
otserver --create-admin USERNAME PASSWORD\n\n\
ENVIRONMENT:\n \
OT_SECRET_KEY required; 32 random bytes, base64, or a path to them\n \
OT_SECRET_KEY_OLD accepted for unwrapping during a key rotation\n \
OT_* override any config field (see config.rs)\n",
env!("CARGO_PKG_VERSION")
);
std::process::exit(0);
}
other => bail!("unknown argument {other:?} (try --help)"),
}
}
Ok(args)
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_env("OT_LOG")
.unwrap_or_else(|_| EnvFilter::new("info,otserver=debug")),
)
.init();
let args = parse_args()?;
let cfg = Config::load(args.config.as_deref())?;
cfg.validate()?;
let vault = KeyVault::from_env()?;
let db = Db::open(&cfg.db_path).await?;
info!(path = %cfg.db_path.display(), "database ready");
let (writer, writer_task) = writer::spawn(db.write.clone());
// Handing over the master is what enables replies to datagrams naming tokens
// this server has no record of. Withheld unless config asks for it, so the
// one reflective path cannot be switched on by accident.
let revocation_master = cfg.revocation_notices.then(|| vault.revocation_master());
if revocation_master.is_some() {
info!(
"revocation_notices enabled: an unknown token draws a rate-limited 38-byte reply. \
See config.rs for the trade."
);
}
let ingest = Arc::new(Ingest::new(
writer.clone(),
cfg.ts_window_days * 86_400,
revocation_master,
));
// One-shot bootstrap, before anything starts listening.
if let Some((username, password)) = args.create_admin {
let hash = auth::hash_password(&cfg, password).await?;
let at = db::now();
sqlx::query(
"INSERT INTO users (username, pw_hash, display_name, is_admin, created_at, pw_changed_at) \
VALUES (?, ?, ?, 1, ?, ?)",
)
.bind(&username)
.bind(hash)
.bind(&username)
.bind(at)
.bind(at)
.execute(&db.write)
.await
.with_context(|| format!("creating admin {username}"))?;
println!("created admin account {username}");
db.checkpoint().await?;
db.close().await;
return Ok(());
}
let loaded = auth::load_tokens(&db.read, &vault, &ingest).await?;
info!(
tokens = loaded,
"loaded device tokens into the ingest cache"
);
// Sessions live in the same SQLite file, on the writer pool. They are
// low-traffic enough not to disturb the batching that exists to protect the
// position write path.
let session_store = SqliteStore::new(db.write.clone());
session_store
.migrate()
.await
.context("migrating the session store")?;
if args.dev {
warn!("--dev: session cookies will not require HTTPS. Never use this in production.");
}
let session_layer = SessionManagerLayer::new(session_store)
.with_name(if args.dev { "otsid" } else { "__Host-otsid" })
.with_http_only(true)
.with_secure(!args.dev)
.with_same_site(SameSite::Lax)
.with_expiry(Expiry::OnInactivity(time::Duration::days(SESSION_DAYS)));
let http_addr = cfg.http_addr;
let udp_addr = cfg.udp_addr;
let workers = cfg.udp_worker_count();
let retention_days = cfg.retention_days;
let token_stale_days = cfg.token_stale_days;
let throttle = Arc::new(auth::LoginThrottle::default());
let state: api::Shared = Arc::new(AppState {
db,
cfg,
vault,
ingest: Arc::clone(&ingest),
writer,
throttle: Arc::clone(&throttle),
});
let udp_tasks = udp::spawn(udp_addr, workers, Arc::clone(&ingest))?;
let retention_task = retention::Retention {
pool: state.db.write.clone(),
retention_days,
token_stale_days,
ingest: Arc::clone(&ingest),
}
.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);
let listener = tokio::net::TcpListener::bind(http_addr)
.await
.with_context(|| format!("binding {http_addr}"))?;
info!(addr = %http_addr, "HTTP listener started (put a TLS-terminating proxy in front)");
if args.simulate_device {
// After the listeners are up, so the login cannot race them.
simulate::SimulatedDevice {
base_url: format!("http://{http_addr}"),
username: std::env::var("OT_SIM_USER").unwrap_or_else(|_| "sim".into()),
password: std::env::var("OT_SIM_PASSWORD").unwrap_or_else(|_| "simsimsimsim".into()),
udp_addr: loopback_of(udp_addr),
}
.spawn();
}
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await
.context("HTTP server failed")?;
// Stop the periodic tasks and the receive loops, then let the writer drain so
// nothing already acknowledged is lost, then checkpoint so the .db file on
// disk is self-contained.
info!("shutting down");
retention_task.abort();
limits_task.abort();
eviction_task.abort();
for task in udp_tasks {
task.abort();
}
if let Err(e) = state.db.checkpoint().await {
warn!(error = %e, "WAL checkpoint failed");
}
drop(state);
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), writer_task).await;
Ok(())
}
/// The simulated device must talk to a routable address; the listener is usually
/// bound to the wildcard, which cannot be a destination.
fn loopback_of(addr: SocketAddr) -> String {
if addr.ip().is_unspecified() {
format!("127.0.0.1:{}", addr.port())
} else {
addr.to_string()
}
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c().await.ok();
};
#[cfg(unix)]
let terminate = async {
if let Ok(mut sig) =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
{
sig.recv().await;
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
() = ctrl_c => {},
() = terminate => {},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_wildcard_address_is_rewritten_for_the_simulated_device() {
assert_eq!(
loopback_of("0.0.0.0:7373".parse().expect("literal")),
"127.0.0.1:7373"
);
assert_eq!(
loopback_of("192.0.2.1:7373".parse().expect("literal")),
"192.0.2.1:7373"
);
}
}
Dcrates/otserver/src/polyline.rs-351
@@ -1,351 +0,0 @@
//! Trail decimation and encoding.
//!
//! A day of walking at one fix per 25 s is ~3500 points. Sent as JSON floats that
//! is ~100 kB; as a Ramer–Douglas–Peucker-simplified encoded polyline it is a few
//! kilobytes, and the browser has proportionally less to draw. Both halves matter:
//! simplification removes points that would render on top of each other anyway,
//! and the encoding removes the per-number punctuation.
//!
//! Coordinates arrive as degrees × 1e7 (the protocol and database representation)
//! and are encoded at 1e5, which is the polyline format's fixed precision — about
//! 1.1 m, well under the accuracy of any consumer GPS fix.
/// Points are `(lat_e7, lon_e7)`.
type Pt = (i64, i64);
/// Simplify to at most `max` points using Ramer–Douglas–Peucker.
///
/// The tolerance is searched rather than fixed, because the useful question is
/// "how much detail fits in the budget?" and the answer depends on how far the
/// person actually travelled. A fixed ε either keeps too much on a road trip or
/// destroys a walk around a park.
pub fn simplify(points: &[Pt], max: usize) -> Vec<Pt> {
if points.len() <= max {
return points.to_vec();
}
// Ramer–Douglas–Peucker is O(n log n) on a typical track but O(n²) in the
// worst case — a stationary phone reporting a heartbeat all week is exactly
// that case — and the tolerance search below runs it several times. Uniformly
// subsampling first bounds the work at a size where that no longer matters.
// At 8× the budget the subsampling itself removes no visible detail: anything
// it drops would have been RDP's next victim anyway.
let owned;
let points = if points.len() > max.saturating_mul(PRE_DECIMATE_FACTOR) {
owned = uniform(points, max * PRE_DECIMATE_FACTOR);
owned.as_slice()
} else {
points
};
// ~15 m expressed in units of 1e-7 degrees.
let base: f64 = 15.0 * 1e7 / 111_320.0;
// Grow until something fits, to bracket the answer. Plain doubling would stop
// at the *first* tolerance that fits, and on a track with real large-scale
// shape that first hit overshoots: a meander can collapse to its two
// endpoints, which is a straight line where there was a walk. So this phase
// only establishes an upper bound.
let mut lo = 0.0f64; // known to yield more than `max`
let mut hi = base;
let mut best: Option<Vec<Pt>> = None;
for _ in 0..24 {
let out = rdp(points, hi);
if out.len() <= max {
best = Some(out);
break;
}
lo = hi;
hi *= 4.0;
}
// Then bisect, keeping the largest result that still fits: the most detail the
// budget allows, rather than the first thing under it.
if let Some(mut candidate) = best {
for _ in 0..8 {
let mid = (lo + hi) / 2.0;
let out = rdp(points, mid);
if out.len() <= max {
if out.len() >= candidate.len() {
candidate = out;
}
hi = mid;
} else {
lo = mid;
}
}
return candidate;
}
// No tolerance in 24 quadruplings fit the budget. Unreachable for real data;
// uniform sampling keeps the shape far better than an infinite tolerance.
uniform(points, max)
}
/// How much larger than the budget the RDP input may be.
const PRE_DECIMATE_FACTOR: usize = 8;
/// Evenly spaced subsample of at most `max` points, always keeping the last one so
/// a trail still ends where the person is.
fn uniform(points: &[Pt], max: usize) -> Vec<Pt> {
if points.len() <= max {
return points.to_vec();
}
let step = points.len().div_ceil(max);
let mut out: Vec<Pt> = points.iter().copied().step_by(step).collect();
if out.last() != points.last() {
out.push(*points.last().expect("non-empty"));
}
out
}
fn rdp(points: &[Pt], epsilon: f64) -> Vec<Pt> {
if points.len() < 3 {
return points.to_vec();
}
let mut keep = vec![false; points.len()];
keep[0] = true;
keep[points.len() - 1] = true;
// Iterative rather than recursive: a 100k-point input would otherwise be able
// to blow the stack, and this runs on attacker-influenced data volumes.
let mut stack = vec![(0usize, points.len() - 1)];
while let Some((start, end)) = stack.pop() {
if end <= start + 1 {
continue;
}
let mut worst = 0.0;
let mut worst_i = start;
for i in (start + 1)..end {
let d = perpendicular_distance(points[i], points[start], points[end]);
if d > worst {
worst = d;
worst_i = i;
}
}
if worst > epsilon {
keep[worst_i] = true;
stack.push((start, worst_i));
stack.push((worst_i, end));
}
}
points
.iter()
.zip(keep)
.filter_map(|(p, k)| k.then_some(*p))
.collect()
}
fn perpendicular_distance(p: Pt, a: Pt, b: Pt) -> f64 {
let (px, py) = (p.1 as f64, p.0 as f64);
let (ax, ay) = (a.1 as f64, a.0 as f64);
let (bx, by) = (b.1 as f64, b.0 as f64);
let dx = bx - ax;
let dy = by - ay;
let len_sq = dx * dx + dy * dy;
if len_sq == 0.0 {
return ((px - ax).powi(2) + (py - ay).powi(2)).sqrt();
}
((dx * (ay - py) - (ax - px) * dy).abs()) / len_sq.sqrt()
}
/// Google's encoded polyline algorithm at 1e5 precision.
pub fn encode(points: &[Pt]) -> String {
let mut out = String::with_capacity(points.len() * 6);
let mut prev_lat = 0i64;
let mut prev_lon = 0i64;
for &(lat_e7, lon_e7) in points {
// 1e7 -> 1e5, rounding rather than truncating so error stays centred.
let lat = div_round(lat_e7, 100);
let lon = div_round(lon_e7, 100);
encode_value(lat - prev_lat, &mut out);
encode_value(lon - prev_lon, &mut out);
prev_lat = lat;
prev_lon = lon;
}
out
}
fn div_round(v: i64, d: i64) -> i64 {
if v >= 0 {
(v + d / 2) / d
} else {
-((-v + d / 2) / d)
}
}
fn encode_value(value: i64, out: &mut String) {
let mut v = if value < 0 { !(value << 1) } else { value << 1 };
while v >= 0x20 {
out.push(char::from(((0x20 | (v & 0x1f)) + 63) as u8));
v >>= 5;
}
out.push(char::from((v + 63) as u8));
}
/// Decode. Used by the tests to prove the encoder is reversible, and kept public
/// because a Rust consumer of `/api/users/:id/track` needs it.
#[cfg_attr(not(test), allow(dead_code))]
pub fn decode(s: &str) -> Vec<Pt> {
let bytes = s.as_bytes();
let mut out = Vec::new();
let mut i = 0;
let mut lat = 0i64;
let mut lon = 0i64;
while i < bytes.len() {
let Some(dlat) = decode_value(bytes, &mut i) else {
break;
};
let Some(dlon) = decode_value(bytes, &mut i) else {
break;
};
lat += dlat;
lon += dlon;
out.push((lat * 100, lon * 100));
}
out
}
#[cfg_attr(not(test), allow(dead_code))]
fn decode_value(bytes: &[u8], i: &mut usize) -> Option<i64> {
let mut shift = 0;
let mut result = 0i64;
loop {
let b = *bytes.get(*i)? as i64 - 63;
*i += 1;
result |= (b & 0x1f) << shift;
shift += 5;
if b < 0x20 {
break;
}
if shift > 60 {
return None; // malformed; refuse to shift forever
}
}
Some(if result & 1 != 0 {
!(result >> 1)
} else {
result >> 1
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_known_vector_matches_the_reference_implementation() {
// The example from Google's polyline documentation, in 1e7 units.
// (38.5, -120.2), (40.7, -120.95), (43.252, -126.453)
let points = vec![
(385_000_000, -1_202_000_000),
(407_000_000, -1_209_500_000),
(432_520_000, -1_264_530_000),
];
assert_eq!(encode(&points), "_p~iF~ps|U_ulLnnqC_mqNvxq`@");
}
#[test]
fn encode_decode_round_trips_within_the_formats_precision() {
let points = vec![
(525_200_080, 134_050_000),
(525_210_000, 134_060_000),
(-338_688_000, -1_754_500_000),
];
let back = decode(&encode(&points));
assert_eq!(back.len(), points.len());
for (a, b) in points.iter().zip(&back) {
// 1e5 precision means the last two 1e7 digits are lost: ≤ 50 units,
// about 0.5 cm. Well inside any GPS accuracy.
assert!((a.0 - b.0).abs() <= 50, "lat drifted: {a:?} vs {b:?}");
assert!((a.1 - b.1).abs() <= 50, "lon drifted: {a:?} vs {b:?}");
}
}
#[test]
fn an_empty_track_encodes_to_an_empty_string() {
assert_eq!(encode(&[]), "");
assert_eq!(decode(""), Vec::<Pt>::new());
}
#[test]
fn decoding_garbage_does_not_panic_or_hang() {
for s in ["~", "?????", "\u{1}\u{2}\u{3}", &"~".repeat(1000)] {
let _ = decode(s);
}
}
#[test]
fn simplify_keeps_short_tracks_untouched() {
let points: Vec<Pt> = (0..10).map(|i| (i * 1000, i * 1000)).collect();
assert_eq!(simplify(&points, 2000), points);
}
#[test]
fn simplify_respects_the_budget() {
// A 20k-point meander: a long sinusoidal path with per-fix jitter on top.
// Large-scale shape matters here — a straight line with noise legitimately
// simplifies to two points, so it would not test anything.
let points: Vec<Pt> = (0..20_000i64)
.map(|i| {
let jitter = if i % 2 == 0 { 300 } else { -300 };
let wave = (5_000_000.0 * ((i as f64) / 300.0).sin()) as i64;
(525_200_000 + i * 40 + jitter, 134_050_000 + wave - jitter)
})
.collect();
let out = simplify(&points, 500);
assert!(out.len() <= 500, "budget exceeded: {} points", out.len());
// And it must actually *use* the budget. Returning two endpoints would
// satisfy the limit while turning a wander into a straight line.
assert!(
out.len() > 250,
"budget under-used: only {} points",
out.len()
);
}
#[test]
fn simplify_keeps_the_endpoints() {
let points: Vec<Pt> = (0..5_000)
.map(|i| (525_200_000 + i * 100, 134_050_000))
.collect();
let out = simplify(&points, 100);
assert_eq!(
out.first(),
points.first(),
"the start of a trail must survive"
);
assert_eq!(out.last(), points.last(), "the end of a trail must survive");
}
#[test]
fn simplify_drops_collinear_interior_points() {
// A dead-straight line: everything between the ends is redundant.
let points: Vec<Pt> = (0..3_000)
.map(|i| (525_200_000 + i * 1_000, 134_050_000))
.collect();
let out = simplify(&points, 2_000);
assert!(
out.len() < 50,
"a straight line should collapse, got {}",
out.len()
);
}
#[test]
fn simplify_survives_a_track_that_never_moves() {
// Every point identical: len_sq == 0 in the distance function.
let points: Vec<Pt> = std::iter::repeat_n((525_200_000, 134_050_000), 5_000).collect();
let out = simplify(&points, 100);
assert!(out.len() <= 100);
}
#[test]
fn negative_coordinates_encode_correctly() {
// The zig-zag encoding of negatives is the classic place to get an
// off-by-one, and half the planet is at a negative longitude.
let points = vec![(-338_688_000, -1_754_500_000)];
let back = decode(&encode(&points));
assert_eq!(back.len(), 1);
assert!(back[0].0 < 0 && back[0].1 < 0);
}
}
Dcrates/otserver/src/retention.rs-466
@@ -1,466 +0,0 @@
//! Periodic housekeeping: point thinning, hard retention, token staleness.
//!
//! Runs every 15 minutes in bounded batches. Bounded matters: an unbounded
//! `DELETE` on a large table holds the write lock for as long as it takes, which
//! on the single-writer design means every device is stalled meanwhile.
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use sqlx::SqlitePool;
use tracing::{debug, info, warn};
use crate::db::now;
use crate::ingest::Ingest;
use otproto::RevokeReason;
const INTERVAL: Duration = Duration::from_secs(15 * 60);
/// Rows touched per statement, so the write lock is never held for long.
const BATCH: i64 = 5_000;
/// Points older than this are thinned to [`THIN_SPACING_S`] apart.
const THIN_AFTER_S: i64 = 24 * 3_600;
const THIN_SPACING_S: i64 = 5 * 60;
pub struct Retention {
pub pool: SqlitePool,
pub retention_days: u32,
pub token_stale_days: u32,
pub ingest: Arc<Ingest>,
}
impl Retention {
pub fn spawn(self) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
// Sleep first: startup already has enough to do.
let mut ticker = tokio::time::interval(INTERVAL);
ticker.tick().await;
loop {
ticker.tick().await;
if let Err(e) = self.run_once().await {
warn!(error = %e, "retention sweep failed; will retry next tick");
}
}
})
}
pub async fn run_once(&self) -> Result<()> {
let now = now();
let dropped = self.hard_drop(now).await?;
let thinned = self.thin(now).await?;
let tokens = self.expire_tokens(now).await?;
if dropped + thinned > 0 || !tokens.is_empty() {
info!(
dropped,
thinned,
tokens_expired = tokens.len(),
"retention sweep"
);
}
self.incremental_vacuum().await?;
Ok(())
}
/// Points beyond the retention horizon.
///
/// `user_latest` is a separate table precisely so this cannot delete a live
/// marker: a user who has not moved in a fortnight still has a position on the
/// map, even with no surviving `points` row.
async fn hard_drop(&self, now: i64) -> Result<u64> {
let cutoff = now - i64::from(self.retention_days) * 86_400;
let mut total = 0;
loop {
let affected = sqlx::query(
// `points` is WITHOUT ROWID, so there is no rowid to select on;
// the row-value form addresses the primary key directly.
"DELETE FROM points WHERE (user_id, ts) IN \
(SELECT user_id, ts FROM points WHERE ts < ? LIMIT ?)",
)
.bind(cutoff)
.bind(BATCH)
.execute(&self.pool)
.await
.context("dropping expired points")?
.rows_affected();
total += affected;
if affected < BATCH as u64 {
break;
}
}
Ok(total)
}
/// Thin points older than a day down to roughly one per five minutes.
///
/// Keeps the first point of each 5-minute bucket. A day-old trail does not
/// need per-second resolution, and this is where most of the space goes.
async fn thin(&self, now: i64) -> Result<u64> {
let cutoff = now - THIN_AFTER_S;
let mut total = 0;
loop {
let affected = sqlx::query(
"DELETE FROM points WHERE (user_id, ts) IN ( \
SELECT p.user_id, p.ts FROM points p WHERE p.ts < ? AND EXISTS ( \
SELECT 1 FROM points q \
WHERE q.user_id = p.user_id \
AND q.ts / ? = p.ts / ? \
AND q.ts < p.ts \
) LIMIT ? )",
)
.bind(cutoff)
.bind(THIN_SPACING_S)
.bind(THIN_SPACING_S)
.bind(BATCH)
.execute(&self.pool)
.await
.context("thinning points")?
.rows_affected();
total += affected;
if affected < BATCH as u64 {
break;
}
}
Ok(total)
}
/// Delete tokens with no activity for `token_stale_days`.
///
/// A phone genuinely idle for a month has to log in again — the same contract
/// as an expiring browser session. Removing the slot from the ingest cache is
/// the part that actually takes effect immediately; the database row is just
/// bookkeeping.
///
/// `created_at` stands in for `last_seen_at` when a token was minted and never
/// used, so an abandoned login does not live forever.
async fn expire_tokens(&self, now: i64) -> Result<Vec<i64>> {
let cutoff = now - i64::from(self.token_stale_days) * 86_400;
let stale: Vec<i64> = sqlx::query_scalar(
"SELECT token_id FROM tokens \
WHERE COALESCE(last_seen_at, created_at) < ? AND revoked_at IS NULL LIMIT ?",
)
.bind(cutoff)
.bind(BATCH)
.fetch_all(&self.pool)
.await
.context("finding stale tokens")?;
for token_id in &stale {
// Marked, not deleted. A phone that wakes up after a month must be
// told to log in again, and the only message it can trust is one
// sealed with its own key — which deleting the row would destroy.
// A token row is about a hundred bytes; a device that cannot be told
// why it stopped working is a support ticket.
sqlx::query(
"UPDATE tokens SET revoked_at = ? WHERE token_id = ? AND revoked_at IS NULL",
)
.bind(now)
.bind(token_id)
.execute(&self.pool)
.await
.context("expiring stale token")?;
// History survives regardless: points.src_token_id is deliberately
// not a cascading foreign key, because positions belong to the
// account rather than to the phone that reported them.
self.ingest
.mark_revoked(*token_id as u64, RevokeReason::Expired);
}
Ok(stale)
}
/// Return freed pages to the filesystem a little at a time.
async fn incremental_vacuum(&self) -> Result<()> {
sqlx::query("PRAGMA incremental_vacuum(1000)")
.execute(&self.pool)
.await
.context("incremental vacuum")?;
debug!("incremental vacuum done");
Ok(())
}
}
/// Also on a timer: sweep the state that attacker-chosen keys accumulate in — the
/// UDP rate limiter and the failed-login throttle. Cheap, and the reason neither
/// can become a memory-exhaustion vector itself.
pub fn spawn_limits_gc(
ingest: Arc<Ingest>,
throttle: Arc<crate::auth::LoginThrottle>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(Duration::from_secs(60));
loop {
ticker.tick().await;
ingest.limits().gc();
throttle.gc();
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Db;
async fn fixture() -> (Db, Arc<Ingest>, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir");
let db = Db::open(&dir.path().join("t.db")).await.expect("open");
sqlx::query(
"INSERT INTO users (id, username, pw_hash, display_name, created_at, pw_changed_at) \
VALUES (1, 'a', 'x', 'A', 0, 0)",
)
.execute(&db.write)
.await
.expect("user");
let (writer, _task) = crate::writer::spawn(db.write.clone());
let ingest = Arc::new(Ingest::new(writer, 30 * 86_400, None));
(db, ingest, dir)
}
async fn insert_point(db: &Db, ts: i64) {
sqlx::query("INSERT INTO points (user_id, ts, lat, lon, recv_at) VALUES (1, ?, 1, 2, ?)")
.bind(ts)
.bind(ts)
.execute(&db.write)
.await
.expect("point");
}
fn retention(db: &Db, ingest: Arc<Ingest>) -> Retention {
Retention {
pool: db.write.clone(),
retention_days: 7,
token_stale_days: 30,
ingest,
}
}
#[tokio::test]
async fn points_beyond_the_horizon_are_dropped_and_recent_ones_kept() {
let (db, ingest, _dir) = fixture().await;
let now = now();
insert_point(&db, now - 8 * 86_400).await; // too old
insert_point(&db, now - 60).await; // fresh
retention(&db, ingest).run_once().await.expect("sweep");
let remaining: Vec<i64> = sqlx::query_scalar("SELECT ts FROM points ORDER BY ts")
.fetch_all(&db.read)
.await
.expect("points");
assert_eq!(remaining, vec![now - 60]);
}
#[tokio::test]
async fn the_live_marker_survives_the_retention_horizon() {
let (db, ingest, _dir) = fixture().await;
let ancient = now() - 30 * 86_400;
insert_point(&db, ancient).await;
sqlx::query(
"INSERT INTO user_latest (user_id, ts, lat, lon, recv_at) VALUES (1, ?, 1, 2, ?)",
)
.bind(ancient)
.bind(ancient)
.execute(&db.write)
.await
.expect("latest");
retention(&db, ingest).run_once().await.expect("sweep");
let points: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM points")
.fetch_one(&db.read)
.await
.expect("count");
let latest: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_latest")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(points, 0, "the old point should be gone");
assert_eq!(
latest, 1,
"a separate table for the live marker is the whole point: GC must not erase someone \
from the map for standing still"
);
}
#[tokio::test]
async fn day_old_points_are_thinned_to_one_per_bucket() {
let (db, ingest, _dir) = fixture().await;
let base = now() - 2 * 86_400;
// Ten points inside a single 5-minute bucket.
for i in 0..10 {
insert_point(&db, base + i * 10).await;
}
// And one in the next bucket, which must be kept too.
insert_point(&db, base + THIN_SPACING_S).await;
retention(&db, ingest).run_once().await.expect("sweep");
let remaining: Vec<i64> = sqlx::query_scalar("SELECT ts FROM points ORDER BY ts")
.fetch_all(&db.read)
.await
.expect("points");
assert_eq!(
remaining.len(),
2,
"each 5-minute bucket should keep exactly one point, got {remaining:?}"
);
assert_eq!(
remaining[0], base,
"the first point of a bucket is the one kept"
);
}
#[tokio::test]
async fn recent_points_are_not_thinned() {
let (db, ingest, _dir) = fixture().await;
let base = now() - 600;
for i in 0..5 {
insert_point(&db, base + i * 10).await;
}
retention(&db, ingest).run_once().await.expect("sweep");
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM points")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(count, 5, "points inside the last 24h keep full resolution");
}
#[tokio::test]
async fn a_token_idle_for_a_month_is_revoked_but_keeps_its_key() {
let (db, ingest, _dir) = fixture().await;
let stale_id = 1234i64;
let fresh_id = 5678i64;
let now = now();
for (id, last_seen) in [(stale_id, now - 31 * 86_400), (fresh_id, now - 60)] {
sqlx::query(
"INSERT INTO tokens (token_id, user_id, key_wrapped, name, created_at, last_seen_at) \
VALUES (?, 1, x'00', 'phone', 0, ?)",
)
.bind(id)
.bind(last_seen)
.execute(&db.write)
.await
.expect("token");
ingest.insert_token(crate::ingest::TokenSlot::new(id as u64, 1, &[1; 32], 1));
}
assert_eq!(ingest.active_token_count(), 2);
retention(&db, Arc::clone(&ingest))
.run_once()
.await
.expect("sweep");
// The row survives, marked. Deleting it would destroy the only key that
// can seal a message this phone will believe — and a phone idle for a
// month is exactly the one that needs telling.
let expired: Vec<i64> =
sqlx::query_scalar("SELECT token_id FROM tokens WHERE revoked_at IS NOT NULL")
.fetch_all(&db.read)
.await
.expect("tokens");
assert_eq!(expired, vec![stale_id]);
let live: Vec<i64> =
sqlx::query_scalar("SELECT token_id FROM tokens WHERE revoked_at IS NULL")
.fetch_all(&db.read)
.await
.expect("tokens");
assert_eq!(live, vec![fresh_id]);
assert_eq!(
ingest.active_token_count(),
1,
"an expired token must stop authorising writes immediately"
);
}
/// The sweep must not keep re-expiring what it already expired, or every run
/// would rewrite the same rows forever.
#[tokio::test]
async fn expiring_is_idempotent() {
let (db, ingest, _dir) = fixture().await;
sqlx::query(
"INSERT INTO tokens (token_id, user_id, key_wrapped, name, created_at) \
VALUES (7, 1, x'00', 'phone', ?)",
)
.bind(now() - 31 * 86_400)
.execute(&db.write)
.await
.expect("token");
let sweep = retention(&db, Arc::clone(&ingest));
sweep.run_once().await.expect("first sweep");
let first: i64 = sqlx::query_scalar("SELECT revoked_at FROM tokens WHERE token_id = 7")
.fetch_one(&db.read)
.await
.expect("revoked_at");
retention(&db, ingest)
.run_once()
.await
.expect("second sweep");
let second: i64 = sqlx::query_scalar("SELECT revoked_at FROM tokens WHERE token_id = 7")
.fetch_one(&db.read)
.await
.expect("revoked_at");
assert_eq!(
first, second,
"the sweep rewrote a token it had already expired"
);
}
#[tokio::test]
async fn a_token_minted_and_never_used_still_expires() {
let (db, ingest, _dir) = fixture().await;
sqlx::query(
"INSERT INTO tokens (token_id, user_id, key_wrapped, name, created_at) \
VALUES (99, 1, x'00', 'phone', ?)",
)
.bind(now() - 31 * 86_400)
.execute(&db.write)
.await
.expect("token");
retention(&db, ingest).run_once().await.expect("sweep");
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM tokens WHERE revoked_at IS NOT NULL")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(count, 1, "created_at must stand in for a never-used token");
}
#[tokio::test]
async fn expiring_a_token_does_not_delete_history() {
let (db, ingest, _dir) = fixture().await;
let now = now();
sqlx::query(
"INSERT INTO tokens (token_id, user_id, key_wrapped, name, created_at, last_seen_at) \
VALUES (42, 1, x'00', 'phone', 0, ?)",
)
.bind(now - 31 * 86_400)
.execute(&db.write)
.await
.expect("token");
sqlx::query(
"INSERT INTO points (user_id, ts, lat, lon, recv_at, src_token_id) \
VALUES (1, ?, 1, 2, ?, 42)",
)
.bind(now - 60)
.bind(now)
.execute(&db.write)
.await
.expect("point");
retention(&db, ingest).run_once().await.expect("sweep");
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM points")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(
count, 1,
"positions belong to the account, so losing the token must not lose the trail"
);
}
}
Dcrates/otserver/src/simulate.rs-201
@@ -1,201 +0,0 @@
//! `--simulate-device`: an in-process fake phone.
//!
//! It logs in over the real HTTP API, then walks a synthetic route sending real
//! OTP/1 datagrams over a real UDP socket on loopback. Nothing is stubbed: the
//! same codec, the same AEAD, the same ingest path, the same writer.
//!
//! This is the fastest feedback loop for everything downstream of the protocol —
//! the web UI can be built against a moving marker before a line of Kotlin runs —
//! and because it exercises the genuine encoder, a protocol mistake shows up here
//! rather than on a phone.
use std::time::Duration;
use anyhow::{Context, Result, bail};
use otproto::msg::Direction;
use otproto::point::Flags;
use otproto::{AckFlags, Key, Message, Point, kdf};
use rand::TryRngCore;
use rand::rngs::OsRng;
use tokio::net::UdpSocket;
use tracing::{info, warn};
/// How often the simulated phone reports. Matches the Balanced profile's walking
/// cadence closely enough to be representative.
const REPORT_INTERVAL: Duration = Duration::from_secs(5);
/// Roughly walking pace, in units of 1e-7 degrees per report.
const STEP_E7: i32 = 1_200;
pub struct SimulatedDevice {
pub base_url: String,
pub username: String,
pub password: String,
pub udp_addr: String,
}
struct Credentials {
token_id: u64,
k_up: Key,
k_down: Key,
}
impl SimulatedDevice {
pub fn spawn(self) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
if let Err(e) = self.run().await {
warn!(error = %e, "simulated device stopped");
}
})
}
async fn run(self) -> Result<()> {
let creds = self.login().await?;
let socket = UdpSocket::bind("0.0.0.0:0")
.await
.context("binding a client socket")?;
socket
.connect(&self.udp_addr)
.await
.with_context(|| format!("connecting to {}", self.udp_addr))?;
info!(token_id = creds.token_id, addr = %self.udp_addr, "simulated device connected");
// Say hello once, exactly as the app does, so the server has a version
// recorded against the token.
let hello = Message::Hello(otproto::Hello {
app_version_code: 0,
os_api_level: 0,
flags: otproto::HelloFlags::FIRST_LAUNCH,
config_version: 1,
});
self.send(&socket, &creds, &hello).await?;
// A lap around a small block near the Brandenburg Gate.
let mut lat = 525_200_080i32;
let mut lon = 134_050_000i32;
let mut leg = 0u32;
let mut ticker = tokio::time::interval(REPORT_INTERVAL);
loop {
ticker.tick().await;
// Four legs of 20 reports each: north, east, south, west.
let (dlat, dlon) = match (leg / 20) % 4 {
0 => (STEP_E7, 0),
1 => (0, STEP_E7),
2 => (-STEP_E7, 0),
_ => (0, -STEP_E7),
};
lat += dlat;
lon += dlon;
leg = leg.wrapping_add(1);
let bearing = match (leg / 20) % 4 {
0 => 0u16,
1 => 9_000,
2 => 18_000,
_ => 27_000,
};
let point = Point {
acc_dm: Some(60),
alt_m: Some(34),
spd_cms: Some(140),
brg_cdeg: Some(bearing),
bat_pct: Some(80u8.saturating_sub((leg / 60) as u8)),
flags: Flags::NONE,
..Point::new(crate::db::now() as u32, lat, lon)
};
self.send(&socket, &creds, &Message::Loc(vec![point]))
.await?;
}
}
async fn login(&self) -> Result<Credentials> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.context("building an HTTP client")?;
let response = client
.post(format!("{}/api/login", self.base_url))
// The server requires this on every state-changing request. The
// value is irrelevant; a cross-origin browser cannot set it.
.header("X-OT-CSRF", "1")
.json(&serde_json::json!({
"username": self.username,
"password": self.password,
"purpose": "device",
"device_name": "simulated",
"platform": "sim",
}))
.send()
.await
.context("posting to /api/login")?;
let status = response.status();
let body: serde_json::Value = response
.json()
.await
.context("reading the login response")?;
if !status.is_success() {
bail!("login failed with {status}: {body}");
}
let device = body
.get("device")
.ok_or_else(|| anyhow::anyhow!("login response has no device credentials"))?;
let token_id = device
.get("token_id")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| anyhow::anyhow!("login response has no token_id"))?;
use base64::Engine as _;
let key_b64 = device
.get("token_key")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| anyhow::anyhow!("login response has no token_key"))?;
let key: Key = base64::engine::general_purpose::STANDARD
.decode(key_b64)
.context("token_key is not base64")?
.try_into()
.map_err(|_| anyhow::anyhow!("token_key is not 32 bytes"))?;
Ok(Credentials {
token_id,
k_up: kdf::derive(&key, Direction::Up),
k_down: kdf::derive(&key, Direction::Down),
})
}
/// Send one message and wait briefly for its ACK.
///
/// A missing ACK is logged and otherwise ignored: the simulated device has no
/// durable queue, and the point of running it is to watch the server, not to
/// re-implement the client's retry engine here.
async fn send(&self, socket: &UdpSocket, creds: &Credentials, msg: &Message) -> Result<()> {
let mut nonce = [0u8; 12];
OsRng.try_fill_bytes(&mut nonce).context("OS RNG failed")?;
let datagram = otproto::seal_message(&creds.k_up, creds.token_id, nonce, msg);
socket.send(&datagram).await.context("sending a datagram")?;
let mut buf = vec![0u8; otproto::MAX_DATAGRAM];
match tokio::time::timeout(Duration::from_secs(2), socket.recv(&mut buf)).await {
Ok(Ok(len)) => match otproto::open_message(&creds.k_down, &buf[..len]) {
Ok((_, Message::Ack(ack))) => {
if ack.nonces.first() != Some(&nonce) {
warn!("ACK did not echo the nonce we sent");
}
if ack.flags.contains(AckFlags::CONFIG_PENDING) {
info!("server has a config update pending");
}
}
Ok((_, Message::Nack(nack))) => {
warn!(reason = ?nack.reason, retry_after_s = nack.retry_after_s, "NACK");
}
Ok((_, other)) => warn!(?other, "unexpected reply"),
Err(e) => warn!(error = %e, "could not open the reply"),
},
Ok(Err(e)) => warn!(error = %e, "recv failed"),
Err(_) => warn!("no reply within 2s"),
}
Ok(())
}
}
Dcrates/otserver/src/tiles.rs-712
@@ -1,712 +0,0 @@
//! 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.
Dcrates/otserver/src/udp.rs-177
@@ -1,177 +0,0 @@
//! The UDP receive loop.
//!
//! `SO_REUSEPORT` lets several tasks bind the *same* port and have the kernel
//! spread datagrams across them, which is how one port scales past one core
//! without a dispatcher task in the middle. Each worker owns its own socket, so
//! there is no shared state on the hot path at all.
//!
//! The loop deliberately contains no `.await` between receiving and replying: the
//! whole of [`Ingest::handle`] is synchronous, and a full writer channel is
//! answered rather than waited on. Applying backpressure here would turn one slow
//! disk into packet loss for every device at once.
use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::{Context, Result};
use socket2::{Domain, Protocol, Socket, Type};
use tokio::net::UdpSocket;
use tracing::{error, info, warn};
use crate::db::now;
use crate::ingest::{Ingest, Peer, Transport};
/// Receive buffer per socket. A queue flush from every device at once arrives as
/// a burst, and the kernel's default (a few hundred kB) drops it on the floor
/// before this process ever sees it.
const RECV_BUFFER_BYTES: usize = 2 * 1024 * 1024;
/// One more than the largest datagram, so an oversized packet is *seen* to be
/// oversized instead of being silently truncated into something that might parse.
const READ_BUFFER: usize = otproto::MAX_DATAGRAM + 1;
fn bind_reuseport(addr: SocketAddr) -> Result<UdpSocket> {
let domain = if addr.is_ipv6() {
Domain::IPV6
} else {
Domain::IPV4
};
let socket =
Socket::new(domain, Type::DGRAM, Some(Protocol::UDP)).context("creating socket")?;
socket.set_reuse_address(true).context("SO_REUSEADDR")?;
socket.set_reuse_port(true).context("SO_REUSEPORT")?;
// Best effort: on Linux the kernel doubles the requested value and caps it at
// net.core.rmem_max, so a smaller buffer than asked for is normal and not
// worth failing startup over.
if let Err(e) = socket.set_recv_buffer_size(RECV_BUFFER_BYTES) {
warn!(error = %e, "could not enlarge the UDP receive buffer; bursts may be dropped");
}
socket.set_nonblocking(true).context("set_nonblocking")?;
socket
.bind(&addr.into())
.with_context(|| format!("binding {addr}/udp"))?;
UdpSocket::from_std(socket.into()).context("handing the socket to tokio")
}
/// Spawn `workers` receive tasks on `addr`.
pub fn spawn(
addr: SocketAddr,
workers: usize,
ingest: Arc<Ingest>,
) -> Result<Vec<tokio::task::JoinHandle<()>>> {
let mut tasks = Vec::with_capacity(workers);
for id in 0..workers {
let socket = bind_reuseport(addr)?;
tasks.push(tokio::spawn(run(id, socket, Arc::clone(&ingest))));
}
info!(%addr, workers, "OTP/1 UDP listener started");
// The trap worth stating in the log, because operators hit it on day one and
// the symptom (everything falls back to TLS) is far from the cause.
info!("reminder: HTTP reverse proxies do not forward UDP — {addr} needs its own firewall rule");
Ok(tasks)
}
async fn run(id: usize, socket: UdpSocket, ingest: Arc<Ingest>) {
let mut buf = vec![0u8; READ_BUFFER];
loop {
let (len, peer_addr) = match socket.recv_from(&mut buf).await {
Ok(v) => v,
Err(e) => {
// On UDP a send error can surface here as ICMP-driven
// ECONNREFUSED for a *previous* send. It says nothing about the
// socket's health, so log and keep going rather than exiting the
// worker and silently losing a quarter of the capacity.
warn!(worker = id, error = %e, "recv_from failed");
continue;
}
};
let peer = Peer {
addr: peer_addr,
transport: Transport::Udp,
};
if let Some(reply) = ingest.handle(&buf[..len], peer, now())
&& let Err(e) = socket.send_to(&reply, peer_addr).await
{
// The phone will retry; there is nothing to recover here.
error!(worker = id, %peer_addr, error = %e, "sending reply failed");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use otproto::msg::Direction;
use otproto::{Key, Message, Point, kdf};
use std::time::Duration;
const TOKEN_ID: u64 = 0xABCD_0123_4567_89EF;
const TOKEN_KEY: Key = [0x33; 32];
/// Round trip a real datagram over a real loopback socket. This is the only
/// test that exercises the socket options and the reply path together.
#[tokio::test]
async fn a_datagram_over_loopback_is_acked() {
let dir = tempfile::tempdir().expect("temp dir");
let db = crate::db::Db::open(&dir.path().join("t.db"))
.await
.expect("open");
sqlx::query(
"INSERT INTO users (id, username, pw_hash, display_name, created_at, pw_changed_at) \
VALUES (1, 'a', 'x', 'A', 0, 0)",
)
.execute(&db.write)
.await
.expect("user");
let (writer, _task) = crate::writer::spawn(db.write.clone());
let ingest = Arc::new(Ingest::new(writer, 30 * 86_400, None));
ingest.insert_token(crate::ingest::TokenSlot::new(TOKEN_ID, 1, &TOKEN_KEY, 1));
// Port 0 lets the OS choose; then read it back, because SO_REUSEPORT
// workers must all bind the *same* concrete port.
let probe = bind_reuseport("127.0.0.1:0".parse().expect("literal")).expect("bind");
let addr = probe.local_addr().expect("local addr");
drop(probe);
let _tasks = spawn(addr, 2, Arc::clone(&ingest)).expect("spawn");
let client = UdpSocket::bind("127.0.0.1:0").await.expect("client bind");
let k_up = kdf::derive(&TOKEN_KEY, Direction::Up);
let msg = Message::Loc(vec![Point {
acc_dm: Some(50),
..Point::new(now() as u32, 525_200_080, 134_050_000)
}]);
let datagram = otproto::seal_message(&k_up, TOKEN_ID, [0x77; 12], &msg);
client.send_to(&datagram, addr).await.expect("send");
let mut buf = vec![0u8; READ_BUFFER];
let len = tokio::time::timeout(Duration::from_secs(2), client.recv(&mut buf))
.await
.expect("no reply within 2s")
.expect("recv");
let k_down = kdf::derive(&TOKEN_KEY, Direction::Down);
match otproto::open_message(&k_down, &buf[..len])
.expect("ack opens")
.1
{
Message::Ack(ack) => assert_eq!(ack.nonces, vec![[0x77u8; 12]]),
other => panic!("expected an ACK, got {other:?}"),
}
assert!(len <= datagram.len(), "the reply amplified the request");
}
#[tokio::test]
async fn several_workers_can_share_one_port() {
let probe = bind_reuseport("127.0.0.1:0".parse().expect("literal")).expect("first bind");
let addr = probe.local_addr().expect("local addr");
// The second bind on the same concrete port is the thing SO_REUSEPORT
// makes legal, and the thing the whole multi-worker design rests on.
let second = bind_reuseport(addr).expect("SO_REUSEPORT should allow a second bind");
assert_eq!(second.local_addr().expect("addr"), addr);
}
}
Dcrates/otserver/src/web.rs-54
@@ -1,54 +0,0 @@
//! Serving the built web UI out of the binary.
//!
//! `rust-embed` compiles `web/dist` in for release builds, so a deployment is
//! still one file. In debug builds it reads from disk instead, so `vite build`
//! output is picked up without a `cargo` rebuild.
//!
//! Anything that is not an embedded asset falls back to `index.html`, because the
//! UI routes client-side and a deep link must not 404.
use axum::http::{StatusCode, Uri, header};
use axum::response::{IntoResponse, Response};
#[derive(rust_embed::Embed)]
#[folder = "../../web/dist"]
struct Assets;
/// The message shown when the binary was built without a web UI. Silence here
/// would look like a broken deployment rather than a missing build step.
const NO_BUILD: &str = "the web UI is not built. Run `cd web && bun install && bun run build`.";
pub async fn serve(uri: Uri) -> Response {
let path = uri.path().trim_start_matches('/');
if let Some(response) = asset(path) {
return response;
}
// Never fall back for asset-shaped requests: a missing chunk answered with
// HTML turns a clear 404 into a confusing MIME-type error in the console.
if path.starts_with("assets/") {
return (StatusCode::NOT_FOUND, "not found").into_response();
}
asset("index.html").unwrap_or_else(|| (StatusCode::NOT_FOUND, NO_BUILD).into_response())
}
fn asset(path: &str) -> Option<Response> {
let file = Assets::get(path)?;
let mime = mime_guess::from_path(path).first_or_octet_stream();
// Vite content-hashes everything under assets/, so those are immutable.
// index.html must not be, or a deploy would never reach an open tab.
let cache = if path.starts_with("assets/") {
"public, max-age=31536000, immutable"
} else {
"no-cache"
};
Some(
(
[
(header::CONTENT_TYPE, mime.as_ref()),
(header::CACHE_CONTROL, cache),
],
file.data.into_owned(),
)
.into_response(),
)
}
Dcrates/otserver/src/writer.rs-489
@@ -1,489 +0,0 @@
//! The single writer task.
//!
//! Everything that writes to SQLite sends a [`WriteOp`] down an `mpsc` channel.
//! One task drains it, batching everything that arrives within 250 ms (or 512
//! operations, whichever comes first) into one transaction.
//!
//! Three properties follow, and all three matter:
//!
//! * **No `SQLITE_BUSY`, ever.** There is exactly one writer, so there is nothing
//! to contend with.
//! * **fsyncs are amortised.** 100 devices reporting once a minute is ~4
//! transactions per second, not 100.
//! * **The UDP loop never blocks on the database.** A full channel is answered
//! with `THROTTLE` and the datagram is dropped; the client will retry. Applying
//! backpressure to the receive loop instead would turn a storage stall into
//! packet loss for *every* device, including the ones the server could still
//! serve.
use std::time::Duration;
use anyhow::{Context, Result};
use otproto::Point;
use sqlx::{Sqlite, SqlitePool, Transaction};
use tokio::sync::mpsc;
use tracing::{debug, error, warn};
/// Channel depth. Deep enough to absorb a burst of queue flushes from every
/// device at once, shallow enough that a stalled disk is noticed in seconds
/// rather than after the process has eaten a gigabyte of positions.
const CHANNEL_DEPTH: usize = 8192;
/// Longest a write waits to be committed.
const BATCH_WINDOW: Duration = Duration::from_millis(250);
/// Most operations in one transaction.
const BATCH_MAX: usize = 512;
#[derive(Debug)]
pub enum WriteOp {
/// Points from one authenticated `LOC`, all belonging to one account.
Points {
user_id: i64,
/// Provenance only.
src_token_id: i64,
points: Vec<Point>,
recv_at: i64,
},
/// Per-datagram telemetry for a token. Written on every authenticated
/// packet, which is why it must be batched rather than done inline.
TokenSeen {
token_id: i64,
at: i64,
src_ip: String,
src_port: u16,
transport: &'static str,
},
/// Recorded from `HELLO`.
TokenHello {
token_id: i64,
app_version: i64,
os_api_level: i64,
},
Audit {
user_id: Option<i64>,
at: i64,
action: String,
detail: String,
src_ip: Option<String>,
},
}
/// Send handle. Cheap to clone; hand one to every task that needs to write.
#[derive(Clone)]
pub struct WriteHandle {
tx: mpsc::Sender<WriteOp>,
}
/// Whether a write was accepted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Accepted {
Yes,
/// The channel is full. The caller should signal `THROTTLE` and drop.
Saturated,
/// The writer task is gone — we are shutting down.
Closed,
}
impl WriteHandle {
/// Never awaits, never blocks. This is what the UDP loop calls.
pub fn try_send(&self, op: WriteOp) -> Accepted {
match self.tx.try_send(op) {
Ok(()) => Accepted::Yes,
Err(mpsc::error::TrySendError::Full(_)) => Accepted::Saturated,
Err(mpsc::error::TrySendError::Closed(_)) => Accepted::Closed,
}
}
/// For HTTP handlers, which can afford to wait for a slot.
pub async fn send(&self, op: WriteOp) -> Result<()> {
self.tx.send(op).await.context("writer task has stopped")
}
/// Approximate free capacity, for `/metrics` and for deciding when to warn.
pub fn capacity(&self) -> usize {
self.tx.capacity()
}
}
/// Start the writer task. Returns the handle and a join handle for shutdown.
pub fn spawn(pool: SqlitePool) -> (WriteHandle, tokio::task::JoinHandle<()>) {
let (tx, rx) = mpsc::channel(CHANNEL_DEPTH);
let task = tokio::spawn(run(pool, rx));
(WriteHandle { tx }, task)
}
async fn run(pool: SqlitePool, mut rx: mpsc::Receiver<WriteOp>) {
let mut batch: Vec<WriteOp> = Vec::with_capacity(BATCH_MAX);
loop {
// Block until there is something to do — no idle polling.
let Some(first) = rx.recv().await else {
break; // all senders dropped: shutdown
};
batch.push(first);
// Then take whatever else shows up inside the window.
let deadline = tokio::time::Instant::now() + BATCH_WINDOW;
while batch.len() < BATCH_MAX {
match tokio::time::timeout_at(deadline, rx.recv()).await {
Ok(Some(op)) => batch.push(op),
Ok(None) => break, // channel closed; commit what we have
Err(_) => break, // window elapsed
}
}
if let Err(e) = commit(&pool, &mut batch).await {
// Losing a batch of positions is bad but survivable; the clients
// still hold them unacked and will retry. Dying here is not
// survivable, so log loudly and carry on.
error!(error = %e, "write batch failed; clients will retry");
batch.clear();
}
}
// Drain whatever is left so a graceful SIGTERM does not lose points.
while let Ok(op) = rx.try_recv() {
batch.push(op);
if batch.len() >= BATCH_MAX
&& let Err(e) = commit(&pool, &mut batch).await
{
error!(error = %e, "final write batch failed");
batch.clear();
}
}
if !batch.is_empty()
&& let Err(e) = commit(&pool, &mut batch).await
{
error!(error = %e, "final write batch failed");
}
debug!("writer task stopped");
}
async fn commit(pool: &SqlitePool, batch: &mut Vec<WriteOp>) -> Result<()> {
let count = batch.len();
let mut tx = pool.begin().await.context("begin transaction")?;
for op in batch.drain(..) {
apply(&mut tx, op).await?;
}
tx.commit().await.context("commit transaction")?;
if count > BATCH_MAX / 2 {
warn!(
count,
"large write batch — the writer may be falling behind"
);
} else {
debug!(count, "committed write batch");
}
Ok(())
}
async fn apply(tx: &mut Transaction<'_, Sqlite>, op: WriteOp) -> Result<()> {
match op {
WriteOp::Points {
user_id,
src_token_id,
points,
recv_at,
} => {
for p in points {
insert_point(tx, user_id, src_token_id, &p, recv_at).await?;
}
}
WriteOp::TokenSeen {
token_id,
at,
src_ip,
src_port,
transport,
} => {
// The source address is recorded but never used for authentication,
// which is exactly why a Wi-Fi to LTE handoff needs no protocol work.
sqlx::query(
"UPDATE tokens SET last_seen_at = ?, last_src_ip = ?, last_src_port = ?, \
last_transport = ? WHERE token_id = ?",
)
.bind(at)
.bind(src_ip)
.bind(i64::from(src_port))
.bind(transport)
.bind(token_id)
.execute(&mut **tx)
.await
.context("updating token telemetry")?;
}
WriteOp::TokenHello {
token_id,
app_version,
os_api_level,
} => {
sqlx::query("UPDATE tokens SET app_version = ?, os_api_level = ? WHERE token_id = ?")
.bind(app_version)
.bind(os_api_level)
.bind(token_id)
.execute(&mut **tx)
.await
.context("recording HELLO")?;
}
WriteOp::Audit {
user_id,
at,
action,
detail,
src_ip,
} => {
sqlx::query(
"INSERT INTO audit_log (at, user_id, action, detail, src_ip) VALUES (?, ?, ?, ?, ?)",
)
.bind(at)
.bind(user_id)
.bind(action)
.bind(detail)
.bind(src_ip)
.execute(&mut **tx)
.await
.context("writing audit log")?;
}
}
Ok(())
}
/// Insert one point and, if it is newer than what we have, update the live marker.
///
/// The `ON CONFLICT` clause is the load-bearing part of the whole storage design:
///
/// * A **retry or replay** carries a `ts` that already exists, so it collapses
/// into the existing row. This is why the protocol needs no replay window.
/// * **Two phones on one account reporting in the same second** would otherwise
/// resolve to whichever packet landed last, which is arbitrary. The
/// `WHERE excluded.acc_dm < points.acc_dm` guard keeps the better fix.
async fn insert_point(
tx: &mut Transaction<'_, Sqlite>,
user_id: i64,
src_token_id: i64,
p: &Point,
recv_at: i64,
) -> Result<()> {
let ts = i64::from(p.ts);
let acc = p.acc_dm.map(i64::from);
sqlx::query(
"INSERT INTO points \
(user_id, ts, lat, lon, acc_dm, alt_m, spd_cms, brg_cdeg, bat_pct, flags, recv_at, src_token_id) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT (user_id, ts) DO UPDATE SET \
lat = excluded.lat, lon = excluded.lon, acc_dm = excluded.acc_dm, \
alt_m = excluded.alt_m, spd_cms = excluded.spd_cms, brg_cdeg = excluded.brg_cdeg, \
bat_pct = excluded.bat_pct, flags = excluded.flags, recv_at = excluded.recv_at, \
src_token_id = excluded.src_token_id \
WHERE excluded.acc_dm IS NOT NULL \
AND (points.acc_dm IS NULL OR excluded.acc_dm < points.acc_dm)",
)
.bind(user_id)
.bind(ts)
.bind(p.lat_e7)
.bind(p.lon_e7)
.bind(acc)
.bind(p.alt_m.map(i64::from))
.bind(p.spd_cms.map(i64::from))
.bind(p.brg_cdeg.map(i64::from))
.bind(p.bat_pct.map(i64::from))
.bind(i64::from(p.flags.0))
.bind(recv_at)
.bind(src_token_id)
.execute(&mut **tx)
.await
.context("inserting point")?;
// The live marker only moves forward in client time. A phone that has been
// in a drawer and wakes up with an old queued fix must not drag the dot back.
sqlx::query(
"INSERT INTO user_latest \
(user_id, ts, lat, lon, acc_dm, alt_m, spd_cms, brg_cdeg, bat_pct, flags, recv_at, src_token_id) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT (user_id) DO UPDATE SET \
ts = excluded.ts, lat = excluded.lat, lon = excluded.lon, acc_dm = excluded.acc_dm, \
alt_m = excluded.alt_m, spd_cms = excluded.spd_cms, brg_cdeg = excluded.brg_cdeg, \
bat_pct = excluded.bat_pct, flags = excluded.flags, recv_at = excluded.recv_at, \
src_token_id = excluded.src_token_id \
WHERE excluded.ts > user_latest.ts",
)
.bind(user_id)
.bind(ts)
.bind(p.lat_e7)
.bind(p.lon_e7)
.bind(acc)
.bind(p.alt_m.map(i64::from))
.bind(p.spd_cms.map(i64::from))
.bind(p.brg_cdeg.map(i64::from))
.bind(p.bat_pct.map(i64::from))
.bind(i64::from(p.flags.0))
.bind(recv_at)
.bind(src_token_id)
.execute(&mut **tx)
.await
.context("updating live position")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use otproto::point::Flags;
async fn seeded() -> (crate::db::Db, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir");
let db = crate::db::Db::open(&dir.path().join("t.db"))
.await
.expect("open");
sqlx::query(
"INSERT INTO users (id, username, pw_hash, display_name, created_at, pw_changed_at) \
VALUES (1, 'a', 'x', 'A', 0, 0)",
)
.execute(&db.write)
.await
.expect("user");
(db, dir)
}
fn point(ts: u32, lat: i32, acc: Option<u16>) -> Point {
Point {
acc_dm: acc,
flags: Flags::NONE,
..Point::new(ts, lat, 0)
}
}
async fn drain(handle: &WriteHandle, task: tokio::task::JoinHandle<()>) {
drop(handle.clone());
// Dropping the last handle ends the task; the caller keeps one, so use an
// explicit timeout instead of awaiting forever.
let _ = tokio::time::timeout(Duration::from_millis(50), task).await;
}
#[tokio::test]
async fn a_batch_of_points_lands_in_one_transaction() {
let (db, _dir) = seeded().await;
let (handle, task) = spawn(db.write.clone());
handle
.send(WriteOp::Points {
user_id: 1,
src_token_id: 7,
points: (0..40)
.map(|i| point(1000 + i, i as i32, Some(50)))
.collect(),
recv_at: 2000,
})
.await
.expect("send");
tokio::time::sleep(Duration::from_millis(400)).await;
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM points")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(count, 40);
drain(&handle, task).await;
}
#[tokio::test]
async fn the_live_marker_only_moves_forward_in_client_time() {
let (db, _dir) = seeded().await;
let (handle, task) = spawn(db.write.clone());
// Newest first, then an older queued fix — the drawer-phone scenario.
for (ts, lat) in [(2000u32, 100), (1000, 999)] {
handle
.send(WriteOp::Points {
user_id: 1,
src_token_id: 7,
points: vec![point(ts, lat, Some(50))],
recv_at: 3000,
})
.await
.expect("send");
}
tokio::time::sleep(Duration::from_millis(400)).await;
let (ts, lat): (i64, i64) =
sqlx::query_as("SELECT ts, lat FROM user_latest WHERE user_id = 1")
.fetch_one(&db.read)
.await
.expect("latest");
assert_eq!(
(ts, lat),
(2000, 100),
"an older fix must not drag the marker back"
);
// Both points are still in the history, though.
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM points")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(count, 2);
drain(&handle, task).await;
}
#[tokio::test]
async fn a_replayed_point_is_idempotent() {
let (db, _dir) = seeded().await;
let (handle, task) = spawn(db.write.clone());
for _ in 0..3 {
handle
.send(WriteOp::Points {
user_id: 1,
src_token_id: 7,
points: vec![point(1000, 42, Some(80))],
recv_at: 2000,
})
.await
.expect("send");
}
tokio::time::sleep(Duration::from_millis(400)).await;
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM points")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(count, 1, "replay must not create duplicate rows");
drain(&handle, task).await;
}
#[tokio::test]
async fn a_same_second_collision_keeps_the_better_accuracy() {
let (db, _dir) = seeded().await;
let (handle, task) = spawn(db.write.clone());
for (acc, lat) in [(200u16, 1), (30, 2), (500, 3)] {
handle
.send(WriteOp::Points {
user_id: 1,
src_token_id: 7,
points: vec![point(1000, lat, Some(acc))],
recv_at: 2000,
})
.await
.expect("send");
}
tokio::time::sleep(Duration::from_millis(400)).await;
let (acc, lat): (i64, i64) =
sqlx::query_as("SELECT acc_dm, lat FROM points WHERE user_id = 1 AND ts = 1000")
.fetch_one(&db.read)
.await
.expect("point");
assert_eq!(
(acc, lat),
(30, 2),
"the better fix must win, whatever the arrival order"
);
drain(&handle, task).await;
}
}
Acrates/server/Cargo.toml
@@ -0,0 +1,26 @@
[package]
name = "server"
version.workspace = true
edition.workspace = true
[[bin]]
name = "otserver"
path = "src/main.rs"
[dependencies]
api.workspace = true
argon2 = "0.6.0"
axum = "0.8.9"
clap = { version = "4.6.7", features = ["derive", "env"] }
getrandom = "0.4.3"
hex = "0.4.3"
rusqlite = { version = "0.40.2", features = ["bundled"] }
serde.workspace = true
serde_json.workspace = true
sha2 = "0.11.0"
tokio = { version = "1.53.1", features = ["full"] }
tower-http = { version = "0.7.1", features = ["fs"] }
uuid = { version = "1.26.1", features = ["v4"] }
webauthn-rs = { version = "0.5.5", default-features = false, features = ["conditional-ui"] }
# Keep equal to the webauthn-rs version. A second copy would compile as different types.
webauthn-rs-proto = "0.5.5"
Acrates/server/src/auth.rs
@@ -0,0 +1,308 @@
use std::collections::HashMap;
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::http::request::Parts;
use rusqlite::{OptionalExtension, params};
use sha2::{Digest, Sha256};
use crate::{AppState, Error, now};
pub const SESSION_COOKIE: &str = "ot_session";
pub const SESSION_SECS: i64 = 30 * 86400;
pub fn hash_password(password: &str) -> String {
Argon2::default()
.hash_password(password.as_bytes())
.expect("argon2 with default parameters")
.to_string()
}
pub fn verify_password(password: &str, hash: &str) -> bool {
PasswordHash::new(hash).is_ok_and(|h| {
Argon2::default()
.verify_password(password.as_bytes(), &h)
.is_ok()
})
}
pub fn check_new_password(password: &str) -> Result<(), &'static str> {
if password.chars().count() < 8 {
return Err("password needs at least 8 characters");
}
Ok(())
}
/// A random secret for a session cookie or a device token, and the hash to store for it.
pub fn new_secret() -> (String, Vec<u8>) {
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).expect("OS random number generator");
let secret = hex::encode(bytes);
let hash = hash_secret(&secret);
(secret, hash)
}
/// The secrets are 256 random bits, so a fast hash is enough. Argon2 is only for passwords.
fn hash_secret(secret: &str) -> Vec<u8> {
Sha256::digest(secret.as_bytes()).to_vec()
}
static DUMMY_HASH: LazyLock<String> = LazyLock::new(|| hash_password("not a real password"));
pub struct PasswordOk {
pub id: i64,
pub two_factor: bool,
}
/// Checks a username and password. An account without a password always fails.
pub async fn check_password(
state: &AppState,
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(
"SELECT id, pw_hash, two_factor FROM users WHERE username = ?1",
[username],
|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 {
Some((id, Some(hash), two_factor)) => {
verify_password(&password, &hash).then_some(PasswordOk { id, two_factor })
}
_ => {
// Do the same work as for a real password, so timing does not reveal which accounts exist.
verify_password(&password, &DUMMY_HASH);
None
}
})
.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)
}
}
}
pub async fn hash_password_async(password: String) -> Result<String, Error> {
tokio::task::spawn_blocking(move || hash_password(&password))
.await
.map_err(|e| Error::Internal(e.to_string()))
}
fn cookie(state: &AppState, value: &str, max_age: i64) -> String {
let secure = if state.https() { "; Secure" } else { "" };
format!(
"{SESSION_COOKIE}={value}; Path=/; Max-Age={max_age}; HttpOnly; SameSite=Strict{secure}"
)
}
/// Creates a session and returns its Set-Cookie value.
pub fn create_session(state: &AppState, user_id: i64) -> Result<String, Error> {
let (token, hash) = new_secret();
state.db().execute(
"INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?1, ?2, ?3)",
params![hash, user_id, now() + SESSION_SECS],
)?;
Ok(cookie(state, &token, SESSION_SECS))
}
pub fn clear_session(state: &AppState) -> String {
cookie(state, "", 0)
}
/// A credential changed. Sessions that existed before must not outlive it.
pub fn end_other_sessions(state: &AppState, user: &User) -> Result<(), Error> {
state.db().execute(
"DELETE FROM sessions WHERE user_id = ?1 AND token_hash <> ?2",
params![user.id, user.session_hash],
)?;
Ok(())
}
const MAX_FAILURES: u32 = 5;
const LOCKOUT_SECS: i64 = 15 * 60;
/// Failed login attempts per username.
#[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
{
return Err(Error::TooManyRequests);
}
Ok(())
}
pub fn fail(&self, key: &str) {
let now = now();
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);
}
entry.0 += 1;
}
pub fn clear(&self, key: &str) {
self.0.lock().unwrap().remove(key);
}
/// Attackers choose the usernames, so old entries must go.
pub fn prune(&self) {
let now = now();
self.0
.lock()
.unwrap()
.retain(|_, (_, since)| now - *since < LOCKOUT_SECS);
}
}
/// A logged-in web user, from the session cookie.
pub struct User {
pub id: i64,
pub username: String,
pub is_admin: bool,
pub session_hash: Vec<u8>,
}
impl FromRequestParts<AppState> for User {
type Rejection = Error;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
let token = parts
.headers
.get_all(header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(';'))
.find_map(|c| c.trim().strip_prefix(SESSION_COOKIE)?.strip_prefix('='))
.ok_or(Error::Unauthorized)?;
let session_hash = hash_secret(token);
let (id, username, is_admin) = state
.db()
.query_row(
"SELECT u.id, u.username, u.is_admin FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ?1 AND s.expires_at > ?2",
params![session_hash, now()],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()?
.ok_or(Error::Unauthorized)?;
Ok(User {
id,
username,
is_admin,
session_hash,
})
}
}
/// A logged-in admin.
pub struct Admin(pub User);
impl FromRequestParts<AppState> for Admin {
type Rejection = Error;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
let user = User::from_request_parts(parts, state).await?;
if !user.is_admin {
return Err(Error::Forbidden);
}
Ok(Admin(user))
}
}
/// 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,
}
impl FromRequestParts<AppState> for Uploader {
type Rejection = Error;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
let Some(auth) = parts.headers.get(header::AUTHORIZATION) else {
let user = User::from_request_parts(parts, state).await?;
let db = state.db();
db.execute(
"INSERT OR IGNORE INTO devices (user_id, name, created_at) VALUES (?1, 'Web', ?2)",
params![user.id, now()],
)?;
let device_id = db.query_row(
"SELECT id FROM devices WHERE user_id = ?1 AND token_hash IS NULL",
[user.id],
|r| r.get(0),
)?;
return Ok(Uploader {
user_id: user.id,
device_id,
});
};
let token = auth
.to_str()
.ok()
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(Error::Unauthorized)?;
let (id, user_id) = state
.db()
.query_row(
"SELECT id, user_id FROM devices WHERE token_hash = ?1",
[hash_secret(token)],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?
.ok_or(Error::Unauthorized)?;
Ok(Uploader {
user_id,
device_id: id,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn limiter_locks_after_max_failures() {
let l = Limiter::default();
for _ in 0..MAX_FAILURES {
assert!(l.check("a").is_ok());
l.fail("a");
}
assert!(matches!(l.check("a"), Err(Error::TooManyRequests)));
assert!(l.check("b").is_ok());
l.clear("a");
assert!(l.check("a").is_ok());
}
#[test]
fn password_roundtrip() {
let h = hash_password("correct horse");
assert!(verify_password("correct horse", &h));
assert!(!verify_password("wrong horse", &h));
}
}
Acrates/server/src/guest.rs
@@ -0,0 +1,232 @@
//! Guest links: a share for anyone who has the link, optionally behind a password.
use api::{GuestAuth, GuestKey, GuestTrack, GuestUnlock, GuestView, Link, NewLink, Point};
use axum::Json;
use axum::extract::{Path, State};
use rusqlite::{Connection, OptionalExtension, params};
use sha2::{Digest, Sha256};
use crate::auth::{self, User};
use crate::routes::{ACCESS_COLS, Access, access_at, check_settings, save_settings, settings_at};
use crate::{AppState, Error, now};
type Result<T> = std::result::Result<T, Error>;
struct Guest {
access: Access,
expires_at: Option<i64>,
pw_hash: Option<String>,
}
/// An unknown or expired token is 404, a missing or wrong key 401.
fn open(db: &Connection, auth: &GuestAuth) -> Result<Guest> {
let guest = db
.query_row(
&format!(
"SELECT s.owner_id, u.username, {ACCESS_COLS}, s.expires_at, s.pw_hash
FROM shares s JOIN users u ON u.id = s.owner_id
WHERE s.token = ?1 AND (s.expires_at IS NULL OR s.expires_at > ?2)"
),
params![auth.token, now()],
|r| {
Ok(Guest {
access: access_at(r)?,
expires_at: r.get(7)?,
pw_hash: r.get(8)?,
})
},
)
.optional()?
.ok_or(Error::NotFound)?;
if let Some(hash) = &guest.pw_hash {
let expected = key(&auth.token, hash);
// Comparing digests keeps the timing independent of how much of the key is right.
let digest = |s: &str| Sha256::digest(s.as_bytes());
if auth.key.as_deref().map(digest) != Some(digest(&expected)) {
return Err(Error::Unauthorized);
}
}
Ok(guest)
}
/// The password hash carries a random salt, so only the server can derive this.
fn key(token: &str, pw_hash: &str) -> String {
hex::encode(Sha256::digest(format!("{token}\n{pw_hash}").as_bytes()))
}
pub async fn view(State(s): State<AppState>, Json(b): Json<GuestAuth>) -> Result<Json<GuestView>> {
let db = s.db();
let g = open(&db, &b)?;
Ok(Json(GuestView {
expires_at: g.expires_at,
person: crate::routes::person_for(&db, g.access)?,
}))
}
pub async fn track(
State(s): State<AppState>,
Json(b): Json<GuestTrack>,
) -> Result<Json<Vec<Point>>> {
let db = s.db();
let g = open(&db, &b.auth)?;
Ok(Json(crate::routes::track_points(
&db, &g.access, b.device, b.from, b.to,
)?))
}
pub async fn unlock(
State(s): State<AppState>,
Json(b): Json<GuestUnlock>,
) -> Result<Json<GuestKey>> {
let auth = GuestAuth {
token: b.token,
key: None,
};
let opened = open(&s.db(), &auth);
let hash = match opened {
Ok(_) => return Err(Error::BadRequest("this link has no password".into())),
Err(Error::Unauthorized) => s.db().query_row(
"SELECT pw_hash FROM shares WHERE token = ?1",
[&auth.token],
|r| r.get::<_, String>(0),
)?,
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);
Ok(Json(GuestKey {
key: key(&auth.token, &hash),
}))
}
pub async fn list(State(s): State<AppState>, user: User) -> Result<Json<Vec<Link>>> {
let db = s.db();
let links = db
.prepare_cached(&format!(
"SELECT s.name, s.token, s.expires_at, s.created_at, s.pw_hash IS NOT NULL, {ACCESS_COLS}
FROM shares s WHERE s.owner_id = ?1 AND s.viewer_id IS NULL ORDER BY s.created_at DESC"
))?
.query_map([user.id], |r| {
Ok(Link {
id: r.get(5)?,
name: r.get(0)?,
token: r.get(1)?,
expires_at: r.get(2)?,
created_at: r.get(3)?,
has_password: r.get(4)?,
settings: settings_at(&db, r, 5)?,
})
})?
.collect::<rusqlite::Result<_>>()?;
Ok(Json(links))
}
pub async fn create(
State(s): State<AppState>,
user: User,
Json(b): Json<NewLink>,
) -> Result<Json<Link>> {
check_settings(&b.settings, b.expires_at)?;
let name = b.name.trim().to_owned();
if name.chars().count() > 100 {
return Err(Error::BadRequest(
"the name can have at most 100 characters".into(),
));
}
let pw_hash = match b.password.filter(|p| !p.is_empty()) {
Some(p) => {
auth::check_new_password(&p).map_err(|m| Error::BadRequest(m.into()))?;
Some(auth::hash_password_async(p).await?)
}
None => None,
};
let (token, _) = auth::new_secret();
let now = now();
let mut db = s.db();
let tx = db.transaction()?;
tx.execute(
"INSERT INTO shares (owner_id, expires_at, created_at, name, token, pw_hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![user.id, b.expires_at, now, name, token, pw_hash],
)?;
let id = tx.last_insert_rowid();
save_settings(&tx, id, user.id, &b.settings)?;
tx.commit()?;
Ok(Json(Link {
id,
name,
token,
expires_at: b.expires_at,
created_at: now,
has_password: pw_hash.is_some(),
settings: b.settings,
}))
}
pub async fn delete(
State(s): State<AppState>,
user: User,
Path(id): Path<i64>,
) -> Result<Json<()>> {
let n = s.db().execute(
"DELETE FROM shares WHERE id = ?1 AND owner_id = ?2 AND viewer_id IS NULL",
[id, user.id],
)?;
if n == 0 {
return Err(Error::NotFound);
}
Ok(Json(()))
}
#[cfg(test)]
mod tests {
use super::*;
use api::Trail;
#[test]
fn password_links_need_the_key() {
let db = crate::test_db();
db.execute_batch(
"INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0);
INSERT INTO devices (id, user_id, name, token_hash, created_at) VALUES (1, 1, 'p', x'01', 0);
INSERT INTO points (device_id, ts, lat, lon) VALUES (1, 100, 0, 0), (1, 200, 0, 0);
INSERT INTO shares (owner_id, created_at, token, pw_hash, trail_since) VALUES (1, 0, 'open', NULL, 150), (1, 0, 'locked', 'h', NULL);
INSERT INTO shares (owner_id, created_at, token, expires_at) VALUES (1, 0, 'old', 1);",
)
.unwrap();
let auth = |token: &str, key: Option<String>| GuestAuth {
token: token.into(),
key,
};
let g = open(&db, &auth("open", None)).unwrap();
assert_eq!(g.access.trail, Trail::Since(150));
let points = crate::routes::track_points(&db, &g.access, 1, 0, 300).unwrap();
assert_eq!(points.iter().map(|p| p.ts).collect::<Vec<_>>(), [200]);
assert!(matches!(
open(&db, &auth("locked", None)),
Err(Error::Unauthorized)
));
assert!(matches!(
open(&db, &auth("locked", Some("x".into()))),
Err(Error::Unauthorized)
));
assert!(open(&db, &auth("locked", Some(key("locked", "h")))).is_ok());
assert!(matches!(
open(&db, &auth("old", None)),
Err(Error::NotFound)
));
assert!(matches!(
open(&db, &auth("nope", None)),
Err(Error::NotFound)
));
}
}
Acrates/server/src/main.rs
@@ -0,0 +1,598 @@
//! opentracker server: one binary, one SQLite file.
mod auth;
mod guest;
mod passkeys;
mod routes;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use clap::{Parser, Subcommand};
use rusqlite::{Connection, OptionalExtension, params};
use webauthn_rs::prelude::Url;
/// Schema steps, applied in order. `PRAGMA user_version` counts the applied ones.
/// Never edit a released step. Append a new one.
const MIGRATIONS: &[&str] = &[
// IF NOT EXISTS: databases from before migrations have these tables at version 0.
"
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
-- NULL when the account signs in with passkeys only.
pw_hash TEXT,
is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0, 1)),
two_factor INTEGER NOT NULL DEFAULT 0 CHECK (two_factor IN (0, 1)),
-- The WebAuthn user handle. Random, so a passkey does not reveal the user id.
webauthn_id TEXT NOT NULL UNIQUE,
retention_days INTEGER CHECK (retention_days > 0),
created_at INTEGER NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS sessions (
token_hash BLOB PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS passkeys (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
cred_id BLOB NOT NULL UNIQUE,
-- webauthn_rs::prelude::Passkey as JSON.
passkey TEXT NOT NULL,
name TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER
) STRICT;
CREATE TABLE IF NOT EXISTS devices (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash BLOB NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
last_seen_at INTEGER
) STRICT;
-- The key makes uploads idempotent: a retried batch collapses into the rows already there.
CREATE TABLE IF NOT EXISTS points (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ts INTEGER NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL,
acc REAL,
alt REAL,
speed REAL,
bearing REAL,
battery INTEGER,
PRIMARY KEY (user_id, ts)
) STRICT, WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS points_ts ON points(ts);
CREATE TABLE IF NOT EXISTS shares (
id INTEGER PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
viewer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER,
created_at INTEGER NOT NULL,
UNIQUE (owner_id, viewer_id),
CHECK (owner_id <> viewer_id)
) STRICT;
",
// Points belong to a device. The web UI uploads as one device per user, which has no token.
"
CREATE TABLE devices_new (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
-- NULL for the web device.
token_hash BLOB UNIQUE,
created_at INTEGER NOT NULL,
last_seen_at INTEGER
) STRICT;
INSERT INTO devices_new SELECT * FROM devices;
DROP TABLE devices;
ALTER TABLE devices_new RENAME TO devices;
CREATE UNIQUE INDEX devices_web ON devices(user_id) WHERE token_hash IS NULL;
-- Older points go to the device that uploaded last, or to a new web device.
INSERT INTO devices (user_id, name, created_at)
SELECT DISTINCT user_id, 'Web', unixepoch() FROM points
WHERE user_id NOT IN (SELECT user_id FROM devices);
-- The key makes uploads idempotent: a retried batch collapses into the rows already there.
CREATE TABLE points_new (
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
ts INTEGER NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL,
acc REAL,
alt REAL,
speed REAL,
bearing REAL,
battery INTEGER,
PRIMARY KEY (device_id, ts)
) STRICT, WITHOUT ROWID;
INSERT INTO points_new
SELECT (SELECT d.id FROM devices d WHERE d.user_id = p.user_id
ORDER BY d.last_seen_at DESC NULLS LAST, d.id LIMIT 1),
ts, lat, lon, acc, alt, speed, bearing, battery
FROM points p;
DROP TABLE points;
ALTER TABLE points_new RENAME TO points;
CREATE INDEX points_ts ON points(ts);
ALTER TABLE shares ADD COLUMN all_devices INTEGER NOT NULL DEFAULT 1 CHECK (all_devices IN (0, 1));
ALTER TABLE shares ADD COLUMN trail INTEGER NOT NULL DEFAULT 1 CHECK (trail IN (0, 1));
ALTER TABLE shares ADD COLUMN precision_m INTEGER NOT NULL DEFAULT 0 CHECK (precision_m >= 0);
-- Used only when all_devices is 0.
CREATE TABLE share_devices (
share_id INTEGER NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
PRIMARY KEY (share_id, device_id)
) STRICT, WITHOUT ROWID;
",
// Trails can start at a time. Guest links are shares without a viewer.
"
CREATE TABLE shares_new (
id INTEGER PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- NULL for a guest link.
viewer_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER,
created_at INTEGER NOT NULL,
all_devices INTEGER NOT NULL DEFAULT 1 CHECK (all_devices IN (0, 1)),
trail INTEGER NOT NULL DEFAULT 1 CHECK (trail IN (0, 1)),
trail_since INTEGER,
precision_m INTEGER NOT NULL DEFAULT 0 CHECK (precision_m >= 0),
-- Guest links only. Kept readable, so the owner can copy the link again.
name TEXT,
token TEXT UNIQUE,
pw_hash TEXT,
UNIQUE (owner_id, viewer_id),
CHECK (owner_id <> viewer_id),
CHECK ((viewer_id IS NULL) = (token IS NOT NULL))
) STRICT;
INSERT INTO shares_new (id, owner_id, viewer_id, expires_at, created_at, all_devices, trail, precision_m)
SELECT id, owner_id, viewer_id, expires_at, created_at, all_devices, trail, precision_m FROM shares;
DROP TABLE shares;
ALTER TABLE shares_new RENAME TO shares;
",
];
/// Opens the database and brings its schema up to date.
pub fn open(path: &std::path::Path) -> Result<Connection, Box<dyn std::error::Error>> {
let mut db = Connection::open(path)?;
db.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")?;
migrate(&mut db)?;
Ok(db)
}
fn migrate(db: &mut Connection) -> Result<(), Box<dyn std::error::Error>> {
let version: i64 = db.pragma_query_value(None, "user_version", |r| r.get(0))?;
let version = usize::try_from(version)?;
if version > MIGRATIONS.len() {
return Err(format!(
"the database has schema version {version}, newer than this server knows"
)
.into());
}
// Table rebuilds drop tables that others reference. SQLite ignores this pragma inside a transaction.
db.pragma_update(None, "foreign_keys", false)?;
for (i, sql) in MIGRATIONS.iter().enumerate().skip(version) {
let tx = db.transaction()?;
tx.execute_batch(sql)?;
let broken: bool = tx.query_row(
"SELECT EXISTS (SELECT 1 FROM pragma_foreign_key_check)",
[],
|r| r.get(0),
)?;
if broken {
return Err(format!("migration {} breaks a foreign key", i + 1).into());
}
tx.pragma_update(None, "user_version", i as i64 + 1)?;
tx.commit()?;
}
db.pragma_update(None, "foreign_keys", true)?;
Ok(())
}
#[derive(Parser)]
#[command(about = "opentracker server")]
struct Cli {
#[arg(long, env = "OT_ADDR", default_value = "127.0.0.1:8080")]
addr: String,
#[arg(long, env = "OT_DB", default_value = "ot.db")]
db: PathBuf,
/// The built web UI.
#[arg(long, env = "OT_WEB_DIR", default_value = "web/dist")]
web_dir: PathBuf,
/// The address browsers use, for example https://track.example.com.
/// Passkeys need it behind a reverse proxy. An https URL also marks the session cookie Secure.
#[arg(long, env = "OT_PUBLIC_URL", value_parser = parse_public_url)]
public_url: Option<Url>,
/// 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,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
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.
Passwd { username: String },
}
fn parse_public_url(s: &str) -> Result<Url, String> {
let url = Url::parse(s).map_err(|e| e.to_string())?;
if !matches!(url.scheme(), "http" | "https") || url.path() != "/" {
return Err("must be http(s)://host[:port] without a path".into());
}
Ok(url)
}
#[derive(Clone)]
pub struct AppState {
// ponytail: one connection behind a global lock. Fine for a few users; use a pool if requests queue up.
db: Arc<Mutex<Connection>>,
limiter: Arc<auth::Limiter>,
ceremonies: Arc<passkeys::Ceremonies>,
public_url: Option<Url>,
/// 0 means forever.
max_retention_days: i64,
}
impl AppState {
pub fn db(&self) -> MutexGuard<'_, Connection> {
self.db.lock().unwrap_or_else(PoisonError::into_inner)
}
pub fn https(&self) -> bool {
self.public_url
.as_ref()
.is_some_and(|u| u.scheme() == "https")
}
}
#[derive(Debug)]
pub enum Error {
BadRequest(String),
Unauthorized,
Forbidden,
NotFound,
Conflict(String),
TooManyRequests,
Internal(String),
}
impl IntoResponse for Error {
fn into_response(self) -> Response {
match self {
Error::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
Error::Unauthorized => StatusCode::UNAUTHORIZED.into_response(),
Error::Forbidden => StatusCode::FORBIDDEN.into_response(),
Error::NotFound => StatusCode::NOT_FOUND.into_response(),
Error::Conflict(msg) => (StatusCode::CONFLICT, msg).into_response(),
Error::TooManyRequests => StatusCode::TOO_MANY_REQUESTS.into_response(),
Error::Internal(msg) => {
eprintln!("internal error: {msg}");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
}
impl From<rusqlite::Error> for Error {
fn from(e: rusqlite::Error) -> Self {
Error::Internal(e.to_string())
}
}
pub fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let db = open(&cli.db).unwrap_or_else(|e| {
eprintln!("cannot open {}: {e}", cli.db.display());
std::process::exit(1);
});
match &cli.command {
Some(Command::Passwd { username }) => passwd(&db, username),
None => serve(cli, db).await,
}
}
/// Inserts a user. The first user ever becomes the admin.
pub fn insert_user(
db: &Connection,
username: &str,
pw_hash: &str,
is_admin: bool,
) -> Result<i64, Error> {
let first: bool = db.query_row("SELECT NOT EXISTS (SELECT 1 FROM users)", [], |r| r.get(0))?;
db.execute(
"INSERT INTO users (username, pw_hash, is_admin, webauthn_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![username, pw_hash, is_admin || first, uuid::Uuid::new_v4().to_string(), now()],
)
.map_err(|e| match e {
rusqlite::Error::SqliteFailure(f, _) if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE => {
Error::Conflict("that username is taken".into())
}
e => e.into(),
})?;
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.
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",
params![pw_hash, user_id],
)?;
db.execute("DELETE FROM passkeys WHERE user_id = ?1", [user_id])?;
db.execute("DELETE FROM sessions WHERE user_id = ?1", [user_id])?;
Ok(())
}
pub fn check_username(name: &str) -> Result<&str, Error> {
let name = name.trim();
if name.is_empty() || name.chars().count() > 64 {
return Err(Error::BadRequest(
"username must have 1 to 64 characters".into(),
));
}
Ok(name)
}
fn passwd(db: &Connection, username: &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()
});
if let Err(msg) = auth::check_new_password(&password) {
eprintln!("{msg}");
std::process::exit(1);
}
let hash = auth::hash_password(&password);
let existing: Option<i64> = db
.query_row(
"SELECT id FROM users WHERE username = ?1",
[username],
|r| r.get(0),
)
.optional()
.expect("query user");
match existing {
Some(id) => {
reset_password(db, id, &hash).expect("update user");
println!("password reset for {username}, passkeys removed, two-factor sign-in off");
}
None => {
let username = check_username(username).unwrap_or_else(|e| {
eprintln!("{e:?}");
std::process::exit(1);
});
insert_user(db, username, &hash, false).expect("insert user");
println!("created user {username}");
}
}
}
async fn serve(cli: Cli, db: Connection) {
let state = AppState {
db: Arc::new(Mutex::new(db)),
limiter: Arc::default(),
ceremonies: Arc::default(),
public_url: cli.public_url,
max_retention_days: cli.retention_days.max(0),
};
tokio::spawn(cleanup(state.clone()));
let listener = tokio::net::TcpListener::bind(&cli.addr)
.await
.expect("bind");
println!(
"listening on http://{}, serving {}",
cli.addr,
cli.web_dir.display()
);
if state
.db()
.query_row("SELECT NOT EXISTS (SELECT 1 FROM users)", [], |r| {
r.get::<_, bool>(0)
})
.unwrap_or(false)
{
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");
}
/// The days of points to keep for a user, or None for forever.
pub fn effective_retention(user_days: Option<i64>, max_days: i64) -> Option<i64> {
match (user_days, max_days) {
(None, 0) => None,
(None, max) => Some(max),
(Some(days), 0) => Some(days),
(Some(days), max) => Some(days.min(max)),
}
}
/// Deletes points older than the user's retention. Each device keeps its newest point, so it stays on the map.
pub fn purge_points(
db: &Connection,
user_id: i64,
user_days: Option<i64>,
max_days: i64,
) -> rusqlite::Result<usize> {
match effective_retention(user_days, max_days) {
None => Ok(0),
Some(days) => db.execute(
"DELETE FROM points
WHERE device_id IN (SELECT id FROM devices WHERE user_id = ?1) AND ts < ?2
AND ts < (SELECT MAX(ts) FROM points p WHERE p.device_id = points.device_id)",
[user_id, now() - days * 86400],
),
}
}
async fn cleanup(state: AppState) {
let mut tick = tokio::time::interval(Duration::from_secs(3600));
loop {
tick.tick().await;
let now = now();
let db = state.db();
let result = db
.execute_batch(&format!(
"DELETE FROM sessions WHERE expires_at <= {now};
DELETE FROM shares WHERE expires_at <= {now};"
))
.and_then(|()| {
let users: Vec<(i64, Option<i64>)> = db
.prepare("SELECT id, retention_days FROM users")?
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<rusqlite::Result<_>>()?;
for (id, days) in users {
purge_points(&db, id, days, state.max_retention_days)?;
}
Ok(())
});
if let Err(e) = result {
eprintln!("cleanup failed: {e}");
}
drop(db);
state.limiter.prune();
}
}
#[cfg(test)]
pub fn test_db() -> Connection {
let mut db = Connection::open_in_memory().unwrap();
migrate(&mut db).unwrap();
db
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retention_never_exceeds_the_server_limit() {
assert_eq!(effective_retention(None, 0), None);
assert_eq!(effective_retention(None, 30), Some(30));
assert_eq!(effective_retention(Some(7), 0), Some(7));
assert_eq!(effective_retention(Some(7), 30), Some(7));
assert_eq!(effective_retention(Some(90), 30), Some(30));
}
#[test]
fn public_url_is_an_origin() {
assert!(parse_public_url("https://track.example.com").is_ok());
assert!(parse_public_url("http://localhost:8080/").is_ok());
assert!(parse_public_url("https://example.com/track").is_err());
assert!(parse_public_url("ftp://example.com").is_err());
}
#[test]
fn reset_removes_every_other_way_in() {
let db = test_db();
let id = insert_user(&db, "a", "old", false).unwrap();
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);",
)
.unwrap();
reset_password(&db, id, "new").unwrap();
let count = |sql: &str| -> i64 { db.query_row(sql, [], |r| r.get(0)).unwrap() };
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);
}
#[test]
fn migrations_keep_old_points() {
let mut db = Connection::open_in_memory().unwrap();
db.execute_batch(MIGRATIONS[0]).unwrap();
db.execute_batch(
"INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0), (2, 'b', '2', 0);
INSERT INTO devices (id, user_id, name, token_hash, created_at, last_seen_at)
VALUES (7, 1, 'old', x'01', 0, 5), (8, 1, 'new', x'02', 0, 9);
INSERT INTO points (user_id, ts, lat, lon) VALUES (1, 100, 1, 1), (2, 100, 2, 2);",
)
.unwrap();
migrate(&mut db).unwrap();
migrate(&mut db).unwrap();
let owners: Vec<(i64, String)> = db
.prepare("SELECT d.user_id, d.name FROM points p JOIN devices d ON d.id = p.device_id ORDER BY d.user_id")
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap()
.collect::<rusqlite::Result<_>>()
.unwrap();
assert_eq!(owners, [(1, "new".into()), (2, "Web".into())]);
}
#[test]
fn retention_keeps_each_devices_newest_point() {
let db = test_db();
db.execute_batch(
"INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0);
INSERT INTO devices (id, user_id, name, token_hash, created_at) VALUES (1, 1, 'p', x'01', 0), (2, 1, 'q', x'02', 0);
INSERT INTO points (device_id, ts, lat, lon) VALUES (1, 10, 0, 0), (1, 20, 0, 0), (2, 15, 0, 0);",
)
.unwrap();
purge_points(&db, 1, Some(1), 30).unwrap();
let left: Vec<(i64, i64)> = db
.prepare("SELECT device_id, ts FROM points ORDER BY device_id")
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap()
.collect::<rusqlite::Result<_>>()
.unwrap();
assert_eq!(left, [(1, 20), (2, 15)]);
}
#[test]
fn first_user_is_admin() {
let db = test_db();
let a = insert_user(&db, "a", "", false).unwrap();
let b = insert_user(&db, "b", "", false).unwrap();
let admin = |id: i64| -> bool {
db.query_row("SELECT is_admin FROM users WHERE id = ?1", [id], |r| {
r.get(0)
})
.unwrap()
};
assert!(admin(a));
assert!(!admin(b));
assert!(matches!(
insert_user(&db, "A", "", false),
Err(Error::Conflict(_))
));
}
}
Acrates/server/src/passkeys.rs
@@ -0,0 +1,438 @@
//! Passkeys (WebAuthn): registration, sign-in, and the second factor after a password.
//!
//! Sign-in uses discoverable credentials only, so the user types no name. That keeps the
//! unauthenticated part free of anything that could tell whether a username exists.
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use api::{Challenge, ChallengeAnswer, LoginResult};
use axum::Json;
use axum::extract::{FromRequestParts, Path as UrlPath, State};
use axum::http::request::Parts;
use axum::http::{HeaderMap, Uri, header};
use axum::response::{IntoResponse, Response};
use rusqlite::{Connection, OptionalExtension, params};
use webauthn_rs::prelude::*;
use webauthn_rs_proto::ResidentKeyRequirement;
use crate::auth::{self, User};
use crate::{AppState, Error, now};
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.
const MAX_ANONYMOUS: usize = 1000;
pub enum Pending {
Register {
user_id: i64,
state: Box<PasskeyRegistration>,
},
SignIn(Box<DiscoverableAuthentication>),
/// The password passed. The account also needs a passkey.
SecondFactor {
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.
#[derive(Default)]
pub struct Ceremonies(Mutex<HashMap<String, (Pending, Instant)>>);
impl Ceremonies {
pub fn put(&self, pending: Pending) -> Result<String, Error> {
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);
}
let (id, _) = auth::new_secret();
map.insert(id.clone(), (pending, Instant::now()));
Ok(id)
}
/// One handle answers one challenge.
pub fn take(&self, id: &str) -> Option<Pending> {
let mut map = self.0.lock().unwrap();
map.retain(|_, (_, at)| at.elapsed() < TTL);
map.remove(id).map(|(p, _)| p)
}
}
pub fn expired() -> Error {
Error::BadRequest("that took too long, please try again".into())
}
/// The details go to the log. To the user every failure is the same.
fn failed(e: WebauthnError) -> Error {
eprintln!("webauthn ceremony failed: {e:?}");
Error::BadRequest("that passkey could not be used".into())
}
fn challenge<T: serde::Serialize>(
state: &AppState,
pending: Pending,
options: &T,
) -> 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)?,
options,
})
}
/// The relying party for the address the browser is on.
pub struct Rp(Webauthn);
impl FromRequestParts<AppState> for Rp {
type Rejection = Error;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
relying_party(state, &parts.uri, &parts.headers).map(Rp)
}
}
pub fn relying_party(state: &AppState, uri: &Uri, headers: &HeaderMap) -> Result<Webauthn, Error> {
let origin = match &state.public_url {
Some(url) => url.clone(),
None => {
// HTTP/2 carries the host in the URI, HTTP/1.1 in the Host header.
let host = match uri.authority() {
Some(a) => a.as_str().to_owned(),
None => headers
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| Error::BadRequest("no Host header".into()))?
.to_owned(),
};
Url::parse(&format!("http://{host}")).map_err(|e| Error::BadRequest(e.to_string()))?
}
};
// The browser signs its own origin. A mismatch would only fail later, with no useful message.
let expected = origin.origin().ascii_serialization();
if let Some(browser) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok())
&& browser != expected
{
return Err(Error::BadRequest(format!(
"passkeys are set up for {expected}, but this page is {browser}. Set --public-url to the address you use."
)));
}
let rp_id = origin.domain().ok_or_else(|| {
Error::BadRequest("passkeys need a domain name, not an IP address".into())
})?;
WebauthnBuilder::new(rp_id, &origin)
.and_then(|b| b.rp_name("opentracker").build())
.map_err(failed)
}
/// The stored passkeys of a user. An unreadable row is skipped, so it cannot lock the user out of the others.
pub fn load(db: &Connection, user_id: i64) -> Result<Vec<(i64, Passkey)>, Error> {
let rows: Vec<(i64, String)> = db
.prepare_cached("SELECT id, passkey FROM passkeys WHERE user_id = ?1")?
.query_map([user_id], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<rusqlite::Result<_>>()?;
Ok(rows
.into_iter()
.filter_map(|(id, json)| match serde_json::from_str(&json) {
Ok(key) => Some((id, key)),
Err(e) => {
eprintln!("passkey {id} is unreadable: {e}");
None
}
})
.collect())
}
pub fn count(db: &Connection, user_id: i64) -> Result<i64, Error> {
Ok(db.query_row(
"SELECT COUNT(*) FROM passkeys WHERE user_id = ?1",
[user_id],
|r| r.get(0),
)?)
}
/// Stores the new signature counter and the time of use.
fn record_use(db: &Connection, user_id: i64, result: &AuthenticationResult) -> Result<(), Error> {
for (id, mut key) in load(db, user_id)? {
if key.cred_id() == result.cred_id() {
key.update_credential(result);
let json = serde_json::to_string(&key).map_err(|e| Error::Internal(e.to_string()))?;
db.execute(
"UPDATE passkeys SET passkey = ?1, last_used_at = ?2 WHERE id = ?3",
params![json, now(), id],
)?;
}
}
Ok(())
}
pub fn sign_in(state: &AppState, user_id: i64) -> Result<Response, Error> {
let cookie = auth::create_session(state, user_id)?;
Ok((
[(header::SET_COOKIE, cookie)],
Json(LoginResult {
ok: true,
..Default::default()
}),
)
.into_response())
}
/// The password passed. Asks for one of the user's passkeys next.
pub fn second_factor(
state: &AppState,
uri: &Uri,
headers: &HeaderMap,
user_id: i64,
) -> Result<Response, Error> {
let rp = relying_party(state, uri, headers)?;
let keys: Vec<Passkey> = load(&state.db(), user_id)?
.into_iter()
.map(|(_, k)| k)
.collect();
if keys.is_empty() {
return Err(Error::Internal(format!(
"user {user_id} needs a passkey but has none"
)));
}
let (options, auth_state) = rp.start_passkey_authentication(&keys).map_err(failed)?;
let ch = challenge(
state,
Pending::SecondFactor {
user_id,
state: Box::new(auth_state),
},
&options,
)?;
Ok(Json(LoginResult {
ok: false,
passkey_challenge: Some(ch),
..Default::default()
})
.into_response())
}
pub async fn login_begin(State(s): State<AppState>, Rp(rp): Rp) -> Result<Json<Challenge>, Error> {
let (mut options, auth_state) = rp.start_discoverable_authentication().map_err(failed)?;
// Without this the browser waits for the autofill dropdown instead of showing its dialog.
options.mediation = None;
Ok(Json(challenge(
&s,
Pending::SignIn(Box::new(auth_state)),
&options,
)?))
}
pub async fn login_finish(
State(s): State<AppState>,
Rp(rp): Rp,
Json(b): Json<ChallengeAnswer>,
) -> Result<Response, Error> {
let pending = s.ceremonies.take(&b.state_id).ok_or_else(expired)?;
let cred: PublicKeyCredential = serde_json::from_str(&b.credential)
.map_err(|_| Error::BadRequest("unreadable credential".into()))?;
let db = s.db();
let (user_id, password_done) = match pending {
Pending::SignIn(auth_state) => {
// The user handle is only a claim until the signature checks out against that user's keys.
let (handle, _) = rp
.identify_discoverable_authentication(&cred)
.map_err(failed)?;
let user_id: i64 = db
.query_row(
"SELECT id FROM users WHERE webauthn_id = ?1",
[handle.to_string()],
|r| r.get(0),
)
.optional()?
.ok_or_else(|| failed(WebauthnError::CredentialNotFound))?;
let keys: Vec<DiscoverableKey> =
load(&db, user_id)?.iter().map(|(_, k)| k.into()).collect();
let result = rp
.finish_discoverable_authentication(&cred, *auth_state, &keys)
.map_err(failed)?;
record_use(&db, user_id, &result)?;
(user_id, false)
}
Pending::SecondFactor {
user_id,
state: auth_state,
} => {
let result = rp
.finish_passkey_authentication(&cred, &auth_state)
.map_err(failed)?;
record_use(&db, user_id, &result)?;
(user_id, true)
}
_ => return Err(expired()),
};
let two_factor: bool = db.query_row(
"SELECT two_factor FROM users WHERE id = ?1",
[user_id],
|r| r.get(0),
)?;
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());
}
sign_in(&s, user_id)
}
pub async fn list(State(s): State<AppState>, user: User) -> Result<Json<Vec<api::Passkey>>, Error> {
let keys = s
.db()
.prepare_cached("SELECT id, name, created_at, last_used_at FROM passkeys WHERE user_id = ?1 ORDER BY id")?
.query_map([user.id], |r| {
Ok(api::Passkey { id: r.get(0)?, name: r.get(1)?, created_at: r.get(2)?, last_used_at: r.get(3)? })
})?
.collect::<rusqlite::Result<_>>()?;
Ok(Json(keys))
}
fn too_many() -> Error {
Error::BadRequest(format!("you can have at most {PASSKEY_LIMIT} passkeys"))
}
pub async fn register_begin(
State(s): State<AppState>,
user: User,
Rp(rp): Rp,
) -> Result<Json<Challenge>, Error> {
let db = s.db();
if count(&db, user.id)? >= PASSKEY_LIMIT {
return Err(too_many());
}
let handle: String = db.query_row(
"SELECT webauthn_id FROM users WHERE id = ?1",
[user.id],
|r| r.get(0),
)?;
let handle = Uuid::parse_str(&handle).map_err(|e| Error::Internal(e.to_string()))?;
// The authenticator then refuses a second credential for the same account.
let existing: Vec<CredentialID> = load(&db, user.id)?
.iter()
.map(|(_, k)| k.cred_id().clone())
.collect();
drop(db);
let (mut options, reg) = rp
.start_passkey_registration(handle, &user.username, &user.username, Some(existing))
.map_err(failed)?;
// webauthn-rs asks for a non-discoverable credential, but sign-in without a username needs a discoverable one.
if let Some(sel) = options.public_key.authenticator_selection.as_mut() {
sel.resident_key = Some(ResidentKeyRequirement::Required);
}
Ok(Json(challenge(
&s,
Pending::Register {
user_id: user.id,
state: Box::new(reg),
},
&options,
)?))
}
pub async fn register_finish(
State(s): State<AppState>,
user: User,
Rp(rp): Rp,
Json(b): Json<ChallengeAnswer>,
) -> Result<Json<api::Passkey>, Error> {
let Some(Pending::Register {
user_id,
state: reg,
}) = s.ceremonies.take(&b.state_id)
else {
return Err(expired());
};
if user_id != user.id {
return Err(expired());
}
let cred: RegisterPublicKeyCredential = serde_json::from_str(&b.credential)
.map_err(|_| Error::BadRequest("unreadable credential".into()))?;
let key = rp
.finish_passkey_registration(&cred, ®)
.map_err(failed)?;
let json = serde_json::to_string(&key).map_err(|e| Error::Internal(e.to_string()))?;
let name = match b.name.trim() {
"" => "Passkey",
n => n,
};
let name: String = name.chars().take(100).collect();
let db = s.db();
if count(&db, user.id)? >= PASSKEY_LIMIT {
return Err(too_many());
}
let created_at = now();
db.execute(
"INSERT INTO passkeys (user_id, cred_id, passkey, name, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![user.id, key.cred_id().as_ref(), json, name, created_at],
)
.map_err(|e| match e {
rusqlite::Error::SqliteFailure(f, _) if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE => {
Error::Conflict("that passkey is already registered".into())
}
e => e.into(),
})?;
let id = db.last_insert_rowid();
drop(db);
auth::end_other_sessions(&s, &user)?;
Ok(Json(api::Passkey {
id,
name,
created_at,
last_used_at: None,
}))
}
pub async fn delete(
State(s): State<AppState>,
user: User,
UrlPath(id): UrlPath<i64>,
) -> Result<Json<()>, Error> {
let db = s.db();
let (has_password, two_factor): (bool, bool) = db.query_row(
"SELECT pw_hash IS NOT NULL, two_factor FROM users WHERE id = ?1",
[user.id],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
if count(&db, user.id)? == 1 {
if two_factor {
return Err(Error::BadRequest(
"turn off two-factor sign-in before removing your last passkey".into(),
));
}
if !has_password {
return Err(Error::BadRequest(
"set a password before removing your last passkey".into(),
));
}
}
if db.execute(
"DELETE FROM passkeys WHERE id = ?1 AND user_id = ?2",
[id, user.id],
)? == 0
{
return Err(Error::NotFound);
}
drop(db);
auth::end_other_sessions(&s, &user)?;
Ok(Json(()))
}
Acrates/server/src/routes.rs
@@ -0,0 +1,940 @@
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,
};
use axum::extract::{Path as UrlPath, Query, State};
use axum::http::{HeaderMap, Uri, header};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post, put};
use axum::{Json, Router};
use rusqlite::{Connection, OptionalExtension, Row, params};
use serde::Deserialize;
use tower_http::services::ServeDir;
use crate::auth::{self, Admin, User};
use crate::guest;
use crate::passkeys::{self, Pending};
use crate::{AppState, Error, now};
type Result<T> = std::result::Result<T, Error>;
pub fn router(state: AppState, web_dir: &Path) -> Router {
Router::new()
.route("/api/setup", get(setup_status).post(setup))
.route("/api/login", post(login))
.route("/api/logout", post(logout))
.route("/api/passkey/login", post(passkeys::login_begin))
.route("/api/passkey/login/finish", post(passkeys::login_finish))
.route("/api/me", get(me))
.route(
"/api/me/password",
post(change_password).delete(delete_password),
)
.route("/api/me/two-factor", put(set_two_factor))
.route("/api/me/retention", put(set_retention))
.route("/api/passkeys", get(passkeys::list))
.route("/api/passkeys/register", post(passkeys::register_begin))
.route(
"/api/passkeys/register/finish",
post(passkeys::register_finish),
)
.route("/api/passkeys/{id}", delete(passkeys::delete))
.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/{id}", delete(delete_device))
.route("/api/points", post(upload))
.route("/api/shares", get(list_shares).post(create_share))
.route("/api/shares/{id}", delete(delete_share))
.route("/api/usernames", get(usernames))
.route("/api/users", get(list_users).post(create_user))
.route("/api/users/{id}", delete(delete_user))
.route("/api/users/{id}/role", put(set_role))
.route("/api/users/{id}/password", post(reset_user_password))
.route("/api/links", get(guest::list).post(guest::create))
.route("/api/links/{id}", delete(guest::delete))
.route("/api/guest", post(guest::view))
.route("/api/guest/track", post(guest::track))
.route("/api/guest/unlock", post(guest::unlock))
.route("/healthz", get(healthz))
.fallback_service(ServeDir::new(web_dir))
.with_state(state)
}
async fn healthz(State(s): State<AppState>) -> Result<&'static str> {
s.db().query_row("SELECT 1", [], |_| Ok(()))?;
Ok("ok")
}
fn no_users(db: &Connection) -> rusqlite::Result<bool> {
db.query_row("SELECT NOT EXISTS (SELECT 1 FROM users)", [], |r| r.get(0))
}
async fn setup_status(State(s): State<AppState>) -> Result<Json<SetupStatus>> {
Ok(Json(SetupStatus {
needed: no_users(&s.db())?,
}))
}
/// Creates the first account, an admin. Only works while no user exists.
async fn setup(State(s): State<AppState>, Json(b): Json<Credentials>) -> Result<Response> {
let username = crate::check_username(&b.username)?.to_owned();
auth::check_new_password(&b.password).map_err(|m| Error::BadRequest(m.into()))?;
let already = || Error::Conflict("the server is already set up".into());
// Checked before hashing, so a request to a set-up server costs no Argon2 work.
if !no_users(&s.db())? {
return Err(already());
}
let hash = auth::hash_password_async(b.password).await?;
let id = {
let db = s.db();
// Checked again under the same lock as the insert, so two setups cannot both win.
if !no_users(&db)? {
return Err(already());
}
crate::insert_user(&db, &username, &hash, true)?
};
passkeys::sign_in(&s, id)
}
async fn login(
State(s): State<AppState>,
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)
}
}
}
async fn logout(State(s): State<AppState>, user: User) -> Result<impl IntoResponse> {
s.db().execute(
"DELETE FROM sessions WHERE token_hash = ?1",
[user.session_hash],
)?;
Ok(([(header::SET_COOKIE, auth::clear_session(&s))], Json(())))
}
async fn me(State(s): State<AppState>, user: User) -> Result<Json<Me>> {
let (has_password, two_factor, retention_days) = s.db().query_row(
"SELECT pw_hash IS NOT NULL, two_factor, retention_days FROM users WHERE id = ?1",
[user.id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)?;
Ok(Json(Me {
id: user.id,
username: user.username,
is_admin: user.is_admin,
has_password,
two_factor,
retention_days,
max_retention_days: (s.max_retention_days > 0).then_some(s.max_retention_days),
public_url: s
.public_url
.as_ref()
.map(|u| u.as_str().trim_end_matches('/').to_owned()),
}))
}
/// Sets or changes the password. Changing an existing one needs the old one.
async fn change_password(
State(s): State<AppState>,
user: User,
Json(b): Json<ChangePassword>,
) -> Result<Json<()>> {
auth::check_new_password(&b.new).map_err(|m| Error::BadRequest(m.into()))?;
let has_password: bool = s.db().query_row(
"SELECT pw_hash IS NOT NULL FROM users WHERE id = ?1",
[user.id],
|r| r.get(0),
)?;
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())
.await
.map_err(|e| match e {
Error::Unauthorized => Error::BadRequest("wrong current password".into()),
e => e,
})?;
}
let hash = auth::hash_password_async(b.new).await?;
s.db().execute(
"UPDATE users SET pw_hash = ?1 WHERE id = ?2",
params![hash, user.id],
)?;
auth::end_other_sessions(&s, &user)?;
Ok(Json(()))
}
/// Leaves the account on passkeys alone.
async fn delete_password(State(s): State<AppState>, user: User) -> Result<Json<()>> {
let db = s.db();
if passkeys::count(&db, user.id)? == 0 {
return Err(Error::BadRequest(
"add a passkey before removing your password".into(),
));
}
let two_factor: bool = db.query_row(
"SELECT two_factor FROM users WHERE id = ?1",
[user.id],
|r| r.get(0),
)?;
if two_factor {
return Err(Error::BadRequest(
"turn off two-factor sign-in before removing your password".into(),
));
}
db.execute("UPDATE users SET pw_hash = NULL WHERE id = ?1", [user.id])?;
drop(db);
auth::end_other_sessions(&s, &user)?;
Ok(Json(()))
}
async fn set_two_factor(
State(s): State<AppState>,
user: User,
Json(b): Json<SetTwoFactor>,
) -> Result<Json<()>> {
let db = s.db();
if b.enabled {
let has_password: bool = db.query_row(
"SELECT pw_hash IS NOT NULL FROM users WHERE id = ?1",
[user.id],
|r| r.get(0),
)?;
if !has_password {
return Err(Error::BadRequest(
"set a password before turning on two-factor sign-in".into(),
));
}
if passkeys::count(&db, user.id)? == 0 {
return Err(Error::BadRequest(
"add a passkey before turning on two-factor sign-in".into(),
));
}
}
db.execute(
"UPDATE users SET two_factor = ?1 WHERE id = ?2",
params![b.enabled, user.id],
)?;
drop(db);
auth::end_other_sessions(&s, &user)?;
Ok(Json(()))
}
/// Users can only keep their points for less time than the server allows, never longer.
async fn set_retention(
State(s): State<AppState>,
user: User,
Json(b): Json<SetRetention>,
) -> Result<Json<()>> {
if let Some(days) = b.days {
let max = s.max_retention_days;
if days < 1 || (max > 0 && days > max) {
let range = if max > 0 {
format!("1 to {max}")
} else {
"at least 1".into()
};
return Err(Error::BadRequest(format!("retention must be {range} days")));
}
}
let db = s.db();
db.execute(
"UPDATE users SET retention_days = ?1 WHERE id = ?2",
params![b.days, user.id],
)?;
crate::purge_points(&db, user.id, b.days, s.max_retention_days)?;
Ok(Json(()))
}
const POINT_COLS: &str = "ts, lat, lon, acc, alt, speed, bearing, battery";
/// Reads the POINT_COLS columns, starting at column `i`.
fn point_at(r: &Row, i: usize) -> rusqlite::Result<Point> {
Ok(Point {
ts: r.get(i)?,
lat: r.get(i + 1)?,
lon: r.get(i + 2)?,
acc: r.get(i + 3)?,
alt: r.get(i + 4)?,
speed: r.get(i + 5)?,
bearing: r.get(i + 6)?,
battery: r.get(i + 7)?,
})
}
/// Snaps a point to a grid of about `m` metres and drops the fields that would reveal more.
fn coarsen(p: &mut Point, m: u32) {
if m == 0 {
return;
}
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.
let lon_step = step / p.lat.to_radians().cos().max(0.01);
p.lon = ((p.lon / lon_step).round() * lon_step).clamp(-180.0, 180.0);
p.acc = Some(p.acc.unwrap_or(0.0).max(m as f32));
p.alt = None;
p.speed = None;
p.bearing = None;
}
/// What a viewer may see of one owner.
pub struct Access {
pub owner: i64,
pub username: String,
/// None for the viewer's own account.
pub share: Option<i64>,
pub all_devices: bool,
pub trail: Trail,
pub precision_m: u32,
}
/// Columns of `shares s` that `access_at` reads, after the owner id and username.
pub const ACCESS_COLS: &str = "s.id, s.all_devices, s.trail, s.trail_since, s.precision_m";
/// Reads an owner id, a username and ACCESS_COLS.
pub fn access_at(r: &Row) -> rusqlite::Result<Access> {
Ok(Access {
owner: r.get(0)?,
username: r.get(1)?,
share: r.get(2)?,
all_devices: r.get(3)?,
trail: trail_at(r, 4)?,
precision_m: r.get(6)?,
})
}
/// Limits `devices d` to the ones an Access allows. Binds ?2 = share, ?3 = all_devices.
const DEVICE_ALLOWED: &str =
"(?3 OR d.id IN (SELECT device_id FROM share_devices WHERE share_id = ?2))";
/// The viewer first, then everyone with an active share to the viewer.
fn accesses(db: &Connection, viewer: i64) -> rusqlite::Result<Vec<Access>> {
let mut list: Vec<Access> = db
.prepare_cached(&format!(
"SELECT id, username, NULL, 1, 1, NULL, 0 FROM users WHERE id = ?1
UNION ALL
SELECT u.id, u.username, {ACCESS_COLS}
FROM shares s JOIN users u ON u.id = s.owner_id
WHERE s.viewer_id = ?1 AND (s.expires_at IS NULL OR s.expires_at > ?2)"
))?
.query_map(params![viewer, now()], access_at)?
.collect::<rusqlite::Result<_>>()?;
list.sort_by_key(|a| (a.owner != viewer, a.username.to_lowercase()));
Ok(list)
}
/// The owner's allowed devices with their newest point.
pub fn person_for(db: &Connection, a: Access) -> rusqlite::Result<Person> {
let devices = db
.prepare_cached(&format!(
"SELECT d.id, d.name, {POINT_COLS} FROM devices d
JOIN points p ON p.device_id = d.id AND p.ts = (SELECT MAX(ts) FROM points WHERE device_id = d.id)
WHERE d.user_id = ?1 AND {DEVICE_ALLOWED}
ORDER BY p.ts DESC"
))?
.query_map(params![a.owner, a.share, a.all_devices], |r| {
let mut last = point_at(r, 2)?;
coarsen(&mut last, a.precision_m);
Ok(PersonDevice {
id: r.get(0)?,
name: r.get(1)?,
last,
})
})?
.collect::<rusqlite::Result<_>>()?;
Ok(Person {
id: a.owner,
username: a.username,
devices,
trail: a.trail,
precision_m: a.precision_m,
})
}
fn people_for(db: &Connection, viewer: i64) -> rusqlite::Result<Vec<Person>> {
accesses(db, viewer)?
.into_iter()
.map(|a| person_for(db, a))
.collect()
}
async fn people(State(s): State<AppState>, user: User) -> Result<Json<Vec<Person>>> {
Ok(Json(people_for(&s.db(), user.id)?))
}
#[derive(Deserialize)]
struct TrackQuery {
from: i64,
to: i64,
device: i64,
}
const MAX_TRACK_POINTS: i64 = 50_000;
async fn track(
State(s): State<AppState>,
user: User,
UrlPath(id): UrlPath<i64>,
Query(q): Query<TrackQuery>,
) -> Result<Json<Vec<Point>>> {
let db = s.db();
let a = accesses(&db, user.id)?
.into_iter()
.find(|a| a.owner == id)
.ok_or(Error::NotFound)?;
Ok(Json(track_points(&db, &a, q.device, q.from, q.to)?))
}
/// One device's points in a time range, as far as the access allows.
pub fn track_points(
db: &Connection,
a: &Access,
device: i64,
from: i64,
to: i64,
) -> Result<Vec<Point>> {
if to < from || to - from > MAX_TRACK_SECS {
return Err(Error::BadRequest("range must be 0 to 31 days".into()));
}
let from = match a.trail {
Trail::None => return Err(Error::Forbidden),
Trail::Since(since) => from.max(since),
Trail::All => from,
};
let allowed: bool = db.query_row(
&format!(
"SELECT EXISTS (SELECT 1 FROM devices d WHERE d.id = ?4 AND d.user_id = ?1 AND {DEVICE_ALLOWED})"
),
params![a.owner, a.share, a.all_devices, device],
|r| r.get(0),
)?;
if !allowed {
return Err(Error::NotFound);
}
let points = 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}"
))?
.query_map(params![device, from, to], |r| {
let mut p = point_at(r, 0)?;
coarsen(&mut p, a.precision_m);
Ok(p)
})?
.collect::<rusqlite::Result<_>>()?;
Ok(points)
}
async fn list_devices(State(s): State<AppState>, user: User) -> Result<Json<Vec<Device>>> {
let devices = s
.db()
.prepare_cached(
"SELECT id, name, token_hash IS NULL, created_at, last_seen_at FROM devices
WHERE user_id = ?1 ORDER BY created_at",
)?
.query_map([user.id], |r| {
Ok(Device {
id: r.get(0)?,
name: r.get(1)?,
web: r.get(2)?,
created_at: r.get(3)?,
last_seen_at: r.get(4)?,
})
})?
.collect::<rusqlite::Result<_>>()?;
Ok(Json(devices))
}
fn insert_device(db: &Connection, user_id: i64, name: &str) -> Result<DeviceToken> {
let name = name.trim();
if name.is_empty() || name.chars().count() > 100 {
return Err(Error::BadRequest(
"device name must have 1 to 100 characters".into(),
));
}
let (token, hash) = auth::new_secret();
db.execute(
"INSERT INTO devices (user_id, name, token_hash, created_at) VALUES (?1, ?2, ?3, ?4)",
params![user_id, name, hash, now()],
)?;
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,
Json(b): Json<NewDevice>,
) -> Result<Json<DeviceToken>> {
Ok(Json(insert_device(&s.db(), user.id, &b.name)?))
}
async fn delete_device(
State(s): State<AppState>,
user: User,
UrlPath(id): UrlPath<i64>,
) -> Result<Json<()>> {
let n = s.db().execute(
"DELETE FROM devices WHERE id = ?1 AND user_id = ?2",
[id, user.id],
)?;
if n == 0 {
return Err(Error::NotFound);
}
Ok(Json(()))
}
/// Clock skew we accept from a device, so a wrong clock cannot write far into the future.
const MAX_FUTURE_SECS: i64 = 86400;
fn check_point(p: &Point, now: i64) -> std::result::Result<(), String> {
if !(-90.0..=90.0).contains(&p.lat) || !(-180.0..=180.0).contains(&p.lon) {
return Err(format!("point {}: coordinates out of range", p.ts));
}
if p.ts <= 0 || p.ts > now + MAX_FUTURE_SECS {
return Err(format!("point {}: timestamp out of range", p.ts));
}
if p.battery.is_some_and(|b| b > 100) {
return Err(format!("point {}: battery above 100", p.ts));
}
Ok(())
}
async fn upload(
State(s): State<AppState>,
uploader: auth::Uploader,
Json(points): Json<Vec<Point>>,
) -> Result<Json<Uploaded>> {
if points.len() > MAX_BATCH {
return Err(Error::BadRequest(format!(
"at most {MAX_BATCH} points per request"
)));
}
let now = now();
for p in &points {
check_point(p, now).map_err(Error::BadRequest)?;
}
let mut db = s.db();
let tx = db.transaction()?;
let mut stored = 0;
{
let mut insert = tx.prepare_cached(&format!(
"INSERT OR IGNORE INTO points (device_id, {POINT_COLS}) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"
))?;
for p in &points {
stored += insert.execute(params![
uploader.device_id,
p.ts,
p.lat,
p.lon,
p.acc,
p.alt,
p.speed,
p.bearing,
p.battery
])?;
}
}
tx.execute(
"UPDATE devices SET last_seen_at = ?1 WHERE id = ?2",
[now, uploader.device_id],
)?;
tx.commit()?;
let people = people_for(&db, uploader.user_id)?;
Ok(Json(Uploaded { stored, people }))
}
fn trail_at(r: &Row, i: usize) -> rusqlite::Result<Trail> {
Ok(match (r.get::<_, bool>(i)?, r.get(i + 1)?) {
(false, _) => Trail::None,
(true, Some(since)) => Trail::Since(since),
(true, None) => Trail::All,
})
}
/// The `trail` and `trail_since` columns.
fn trail_columns(t: Trail) -> (bool, Option<i64>) {
match t {
Trail::None => (false, None),
Trail::Since(since) => (true, Some(since)),
Trail::All => (true, None),
}
}
/// Reads ACCESS_COLS from column `i` on.
pub fn settings_at(db: &Connection, r: &Row, i: usize) -> rusqlite::Result<ShareSettings> {
let id: i64 = r.get(i)?;
let devices = match r.get::<_, bool>(i + 1)? {
true => None,
false => Some(
db.prepare_cached("SELECT device_id FROM share_devices WHERE share_id = ?1")?
.query_map([id], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?,
),
};
Ok(ShareSettings {
devices,
trail: trail_at(r, i + 2)?,
precision_m: r.get(i + 4)?,
})
}
pub fn check_settings(set: &ShareSettings, expires_at: Option<i64>) -> Result<()> {
if expires_at.is_some_and(|t| t <= now()) {
return Err(Error::BadRequest("expiry must be in the future".into()));
}
if set.precision_m > MAX_PRECISION_M {
return Err(Error::BadRequest(format!(
"precision must be at most {MAX_PRECISION_M} metres"
)));
}
if set.devices.as_ref().is_some_and(Vec::is_empty) {
return Err(Error::BadRequest("select at least one device".into()));
}
Ok(())
}
/// Writes the settings columns and the device selection of a share or link.
pub fn save_settings(db: &Connection, id: i64, owner: i64, set: &ShareSettings) -> Result<()> {
let (trail, since) = trail_columns(set.trail);
db.execute(
"UPDATE shares SET all_devices = ?2, trail = ?3, trail_since = ?4, precision_m = ?5 WHERE id = ?1",
params![id, set.devices.is_none(), trail, since, set.precision_m],
)?;
db.execute("DELETE FROM share_devices WHERE share_id = ?1", [id])?;
for device in set.devices.iter().flatten() {
let added = db.execute(
"INSERT OR IGNORE INTO share_devices SELECT ?1, id FROM devices WHERE id = ?2 AND user_id = ?3",
[id, *device, owner],
)?;
if added == 0 {
return Err(Error::BadRequest("no such device".into()));
}
}
Ok(())
}
async fn list_shares(State(s): State<AppState>, user: User) -> Result<Json<Shares>> {
let db = s.db();
let query = |other: &str, me: &str| -> rusqlite::Result<Vec<Share>> {
db.prepare_cached(&format!(
"SELECT u.username, s.expires_at, s.created_at, {ACCESS_COLS}
FROM shares s JOIN users u ON u.id = s.{other} WHERE s.{me} = ?1 ORDER BY u.username"
))?
.query_map([user.id], |r| {
Ok(Share {
id: r.get(3)?,
username: r.get(0)?,
expires_at: r.get(1)?,
created_at: r.get(2)?,
settings: settings_at(&db, r, 3)?,
})
})?
.collect()
};
Ok(Json(Shares {
outgoing: query("viewer_id", "owner_id")?,
incoming: query("owner_id", "viewer_id")?,
}))
}
async fn create_share(
State(s): State<AppState>,
user: User,
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(
"SELECT id, username FROM users WHERE username = ?1",
[b.viewer.trim()],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?
.ok_or_else(|| Error::BadRequest("no such user".into()))?;
if viewer_id == user.id {
return Err(Error::BadRequest("you cannot share with yourself".into()));
}
let tx = db.transaction()?;
let id = 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),
)?;
save_settings(&tx, id, user.id, &b.settings)?;
tx.commit()?;
Ok(Json(Share {
id,
username,
expires_at: b.expires_at,
created_at: now,
settings: b.settings,
}))
}
/// Either side can end a share. Guest links have their own endpoint.
async fn delete_share(
State(s): State<AppState>,
user: User,
UrlPath(id): UrlPath<i64>,
) -> Result<Json<()>> {
let n = s.db().execute(
"DELETE FROM shares WHERE id = ?1 AND viewer_id IS NOT NULL AND (owner_id = ?2 OR viewer_id = ?2)",
[id, user.id],
)?;
if n == 0 {
return Err(Error::NotFound);
}
Ok(Json(()))
}
/// Everyone else's username, for picking whom to share with.
async fn usernames(State(s): State<AppState>, user: User) -> Result<Json<Vec<String>>> {
let names = s
.db()
.prepare_cached("SELECT username FROM users WHERE id <> ?1 ORDER BY username")?
.query_map([user.id], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?;
Ok(Json(names))
}
async fn list_users(State(s): State<AppState>, _: Admin) -> Result<Json<Vec<api::User>>> {
let users = s
.db()
.prepare_cached("SELECT id, username, is_admin, created_at FROM users ORDER BY username")?
.query_map([], |r| {
Ok(api::User {
id: r.get(0)?,
username: r.get(1)?,
is_admin: r.get(2)?,
created_at: r.get(3)?,
})
})?
.collect::<rusqlite::Result<_>>()?;
Ok(Json(users))
}
async fn create_user(
State(s): State<AppState>,
_: Admin,
Json(b): Json<NewUser>,
) -> Result<Json<api::User>> {
let username = crate::check_username(&b.username)?.to_owned();
auth::check_new_password(&b.password).map_err(|m| Error::BadRequest(m.into()))?;
let hash = auth::hash_password_async(b.password).await?;
let db = s.db();
let id = crate::insert_user(&db, &username, &hash, b.is_admin)?;
Ok(Json(api::User {
id,
username,
is_admin: b.is_admin,
created_at: now(),
}))
}
/// Admins cannot change their own role, so at least one admin always remains.
async fn set_role(
State(s): State<AppState>,
Admin(admin): Admin,
UrlPath(id): UrlPath<i64>,
Json(b): Json<SetRole>,
) -> Result<Json<()>> {
if id == admin.id {
return Err(Error::BadRequest("you cannot change your own role".into()));
}
if s.db().execute(
"UPDATE users SET is_admin = ?1 WHERE id = ?2",
params![b.is_admin, id],
)? == 0
{
return Err(Error::NotFound);
}
Ok(Json(()))
}
async fn delete_user(
State(s): State<AppState>,
Admin(admin): Admin,
UrlPath(id): UrlPath<i64>,
) -> Result<Json<()>> {
if id == admin.id {
return Err(Error::BadRequest(
"you cannot delete your own account".into(),
));
}
if s.db().execute("DELETE FROM users WHERE id = ?1", [id])? == 0 {
return Err(Error::NotFound);
}
Ok(Json(()))
}
/// The recovery path for a user who lost their password or passkey.
async fn reset_user_password(
State(s): State<AppState>,
_: Admin,
UrlPath(id): UrlPath<i64>,
Json(b): Json<ResetPassword>,
) -> Result<Json<()>> {
auth::check_new_password(&b.password).map_err(|m| Error::BadRequest(m.into()))?;
let hash = auth::hash_password_async(b.password).await?;
let db = s.db();
if db
.query_row("SELECT 1 FROM users WHERE id = ?1", [id], |_| Ok(()))
.optional()?
.is_none()
{
return Err(Error::NotFound);
}
crate::reset_password(&db, id, &hash)?;
Ok(Json(()))
}
#[cfg(test)]
mod tests {
use super::*;
fn pt(ts: i64, lat: f64, lon: f64) -> Point {
Point {
ts,
lat,
lon,
acc: None,
alt: None,
speed: None,
bearing: None,
battery: None,
}
}
#[test]
fn point_validation() {
let now = 1_800_000_000;
assert!(check_point(&pt(now, 48.1, 11.5), now).is_ok());
assert!(check_point(&pt(now, 91.0, 0.0), now).is_err());
assert!(check_point(&pt(now, 0.0, -180.1), now).is_err());
assert!(check_point(&pt(now + 2 * MAX_FUTURE_SECS, 0.0, 0.0), now).is_err());
assert!(
check_point(
&Point {
battery: Some(101),
..pt(now, 0.0, 0.0)
},
now
)
.is_err()
);
}
#[test]
fn coarse_points_stay_near_and_hide_motion() {
let exact = Point {
acc: Some(5.0),
speed: Some(3.0),
..pt(1, 48.137_15, 11.575_49)
};
let mut p = exact.clone();
coarsen(&mut p, 0);
assert_eq!(p, exact);
coarsen(&mut p, 1000);
let (dy, dx) = (
(p.lat - exact.lat) * 111_320.0,
(p.lon - exact.lon) * 111_320.0 * exact.lat.to_radians().cos(),
);
assert!(
dy.abs() <= 500.0 && dx.abs() <= 510.0,
"moved {dy} m, {dx} m"
);
assert_eq!((p.acc, p.speed), (Some(1000.0), None));
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));
}
#[test]
fn people_shows_only_shared_devices() {
let db = crate::test_db();
db.execute_batch(
"INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0), (2, 'b', '2', 0);
INSERT INTO devices (id, user_id, name, token_hash, created_at) VALUES (10, 2, 'phone', x'01', 0), (11, 2, 'car', x'02', 0);
INSERT INTO points (device_id, ts, lat, lon) VALUES (10, 100, 1, 1), (11, 200, 2, 2);
INSERT INTO shares (id, owner_id, viewer_id, created_at, all_devices) VALUES (5, 2, 1, 0, 1);",
)
.unwrap();
let devices = |db: &Connection| -> Vec<String> {
people_for(db, 1).unwrap()[1]
.devices
.iter()
.map(|d| d.name.clone())
.collect()
};
assert_eq!(devices(&db), ["car", "phone"]);
db.execute_batch(
"UPDATE shares SET all_devices = 0; INSERT INTO share_devices VALUES (5, 10);",
)
.unwrap();
assert_eq!(devices(&db), ["phone"]);
}
#[test]
fn people_respects_share_expiry() {
let db = crate::test_db();
db.execute_batch(
"INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0), (2, 'b', '2', 0), (3, 'c', '3', 0);
INSERT INTO shares (owner_id, viewer_id, expires_at, created_at) VALUES (2, 1, NULL, 0), (3, 1, 1, 0);",
)
.unwrap();
let names: Vec<_> = people_for(&db, 1)
.unwrap()
.into_iter()
.map(|p| p.username)
.collect();
assert_eq!(names, ["a", "b"]);
}
}
Djustfile-160
@@ -1,160 +0,0 @@
# opentracker task runner. `just --list` for the menu.
sdk := env("ANDROID_HOME", env("HOME") / "android-sdk")
scratch := "dev"
default:
@just --list
# --- checks ------------------------------------------------------------------
# Everything CI would run.
check: test clippy fmt-check web-build android-test android-lint
test:
cargo test --workspace
clippy:
cargo clippy --workspace --all-targets -- -D warnings
fmt:
cargo fmt --all
fmt-check:
cargo fmt --all -- --check
# --- protocol ----------------------------------------------------------------
# Regenerate the golden vectors that are the Rust <-> Kotlin contract.
# `just test` then verifies them, and the Android suite decodes the same file.
gen-vectors:
cargo run -p otproto --features serde --example gen_vectors
# Coverage-guided fuzzing. Needs a nightly toolchain and cargo-fuzz:
# rustup toolchain install nightly && cargo install cargo-fuzz
fuzz target="decode" secs="60":
cd crates/otproto/fuzz && cargo +nightly fuzz run {{target}} -- -max_total_time={{secs}} -rss_limit_mb=4096
# --- server ------------------------------------------------------------------
# One-time: a server key and an admin account in ./{{scratch}}.
dev-setup user="sim" password="simsimsimsim":
mkdir -p {{scratch}}
test -f {{scratch}}/secret.key || (head -c32 /dev/urandom | base64 > {{scratch}}/secret.key && chmod 600 {{scratch}}/secret.key)
OT_SECRET_KEY={{scratch}}/secret.key OT_ADMIN_EMAIL=ops@example.net OT_DB_PATH={{scratch}}/ot.db \
cargo run -p otserver -- --create-admin {{user}} {{password}}
# The server plus an in-process fake phone walking a synthetic route over real
# OTP/1 on loopback. The fastest way to see the whole pipeline move.
#
# Serves the UI from web/dist if it has been built. For live reload, run
# `just web-dev` in a second terminal and use http://localhost:5173 instead.
dev:
OT_SECRET_KEY={{scratch}}/secret.key OT_ADMIN_EMAIL=ops@example.net OT_DB_PATH={{scratch}}/ot.db \
OT_HTTP_ADDR=127.0.0.1:7372 OT_UDP_ADDR=127.0.0.1:7373 \
cargo run -p otserver -- --dev --simulate-device
# --- web ---------------------------------------------------------------------
web-install:
cd web && bun install
# Vite with hot reload on :5173, proxying /api to the backend on :7372.
web-dev: web-install
cd web && bun run dev
# Type-check and bundle into web/dist, which the server embeds in release builds.
web-build: web-install
cd web && bun run build
# --- docker ------------------------------------------------------------------
docker-build:
docker build -f Containerfile -t opentracker:dev .
# The whole thing in one container, with the simulated phone walking its route:
# open http://127.0.0.1:7372 and sign in as {{user}}.
#
# 127.0.0.1, not localhost: the port is published on IPv4 only, and localhost
# resolves to ::1 first on this machine.
#
# The key below is a literal on purpose — this is a throwaway demo database. A
# real deployment passes OT_SECRET_KEY from a file or a secret store, and losing
# it makes every stored token key unrecoverable.
docker-demo user="sim" password="simsimsimsim": docker-build
docker volume create opentracker-demo >/dev/null
-docker run --rm -v opentracker-demo:/data \
-e OT_SECRET_KEY=ZGVtby1vbmx5LW5vdC1hLXJlYWwtc2VjcmV0LWtleSE= -e OT_ADMIN_EMAIL=ops@example.net \
opentracker:dev --create-admin {{user}} {{password}}
docker run --rm --name opentracker-demo -p 7372:7372 -p 7373:7373/udp -v opentracker-demo:/data \
-e OT_SECRET_KEY=ZGVtby1vbmx5LW5vdC1hLXJlYWwtc2VjcmV0LWtleSE= -e OT_ADMIN_EMAIL=ops@example.net \
-e OT_SIM_USER={{user}} -e OT_SIM_PASSWORD={{password}} \
opentracker:dev --dev --simulate-device
# Wipes the demo database so `just docker-demo` starts from nothing.
docker-demo-reset:
-docker rm -f opentracker-demo
docker volume rm opentracker-demo
# --- android -----------------------------------------------------------------
# JVM tests, including the golden-vector cross-check against otproto.
android-test:
cd android && ANDROID_HOME={{sdk}} ./gradlew :app:testDebugUnitTest
android-lint:
cd android && ANDROID_HOME={{sdk}} ./gradlew :app:lintDebug
android-build:
cd android && ANDROID_HOME={{sdk}} ./gradlew :app:assembleDebug
android-install: android-build
cd android && ANDROID_HOME={{sdk}} ./gradlew :app:installDebug
adb shell am start -n net.lexcom.opentracker.debug/net.lexcom.opentracker.MainActivity
# Only our own tags, at verbose. Everything else silenced.
logcat:
adb logcat -c && adb logcat OpenTracker:V AndroidRuntime:E '*:S'
# The Compose compiler plugin version must equal the KGP version AGP bundles.
# Run this after any AGP bump and update app/build.gradle.kts to match.
android-kotlin-version:
cd android && ANDROID_HOME={{sdk}} ./gradlew :app:buildEnvironment | grep -i kotlin-gradle-plugin
# --- emulator ----------------------------------------------------------------
# The AVDs, oldest to newest. ot29 is minSdk and the one that finds the
# API-level bugs; ot36 is targetSdk and the one that finds the policy bugs.
avd := "ot36"
# Create the AVD. Needs the matching system image installed first:
# sdkmanager "system-images;android-36;default;x86_64"
#
# Create an AVD if `just emu` says there is none.
emu-create name=avd api="36":
{{sdk}}/cmdline-tools/latest/bin/avdmanager create avd -n {{name}} -k "system-images;android-{{api}};default;x86_64" -d pixel_6
# -no-snapshot so every run is a cold boot: a snapshot restores stale
# permission grants and a stale credentials file, which is exactly what you are
# trying to test.
#
# Start the emulator and wait until it has booted.
emu name=avd:
{{sdk}}/emulator/emulator -avd {{name}} -no-boot-anim -no-snapshot &
adb wait-for-device shell 'while [ "$(getprop sys.boot_completed)" != 1 ]; do sleep 1; done'
@echo "booted"
# Defaults to Marienplatz, Munich. Repeat with new values to simulate walking.
#
# Move the emulated GPS. Longitude first, that is the order `geo fix` wants.
emu-geo lon="11.5754" lat="48.1372":
adb emu geo fix {{lon}} {{lat}}
# 10.0.2.2 is the emulator's alias for the host's loopback, so the server itself
# still binds 127.0.0.1 and stays off the LAN.
#
# The dev server, handing out the UDP address the emulator can reach.
dev-emu:
OT_SECRET_KEY={{scratch}}/secret.key OT_ADMIN_EMAIL=ops@example.net OT_DB_PATH={{scratch}}/ot.db \
OT_HTTP_ADDR=127.0.0.1:7372 OT_UDP_ADDR=127.0.0.1:7373 OT_PUBLIC_UDP_HOST=10.0.2.2 \
cargo run -p otserver -- --dev
Dopentracker.toml.example-88
@@ -1,88 +0,0 @@
# opentracker configuration. Every field can also be set as OT_<UPPERCASE>.
# Copy to opentracker.toml and edit. All values shown are the defaults.
# HTTP API and web UI. Localhost by default: nginx or Caddy terminates TLS.
http_addr = "127.0.0.1:7372"
# OTP/1 UDP listener.
#
# THE TRAP: HTTP reverse proxies do not forward UDP. This port must be exposed
# directly by a firewall or NAT rule. If it is not, every phone silently falls
# back to TLS-over-TCP and you lose the whole point of the protocol.
udp_addr = "0.0.0.0:7373"
# TLS-over-TCP fallback for UDP-hostile networks. Also needs its own rule.
# tls_addr = "0.0.0.0:7374"
# What the login response tells phones to connect to. These are the *public*
# names, which are usually not the same as the bind addresses above.
public_udp_host = "localhost"
public_udp_port = 7373
# public_tls_url = "tls://track.example.com:7374"
base_url = "http://localhost:7372"
# REQUIRED. The OSM tile usage policy demands a contactable User-Agent, and an
# unidentified tile proxy is blocked without notice. The server refuses to start
# while this is the placeholder.
admin_email = "you@example.com"
db_path = "opentracker.db"
cache_dir = "cache"
# Tile cache ceiling in bytes; eviction runs down to 90% of it. 1 GiB.
max_cache_bytes = 1073741824
# Point at your own renderer here if you ever run one.
tile_upstream_url = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
# Hard drop for points older than this. Points older than 24 h are also thinned
# to roughly one per 5 minutes. The live marker lives in its own table and is
# never affected.
retention_days = 7
# Mark tokens with no activity for this long as revoked. A phone idle for a
# month has to log in again — the same contract as an expiring browser session.
#
# The row and its wrapped key are kept, not deleted. The key is the only thing
# that can seal a message the phone will believe, and a phone idle for a month is
# exactly the one that needs telling. A token row is about a hundred bytes.
token_stale_days = 30
# Accept client timestamps within ±this many days.
#
# This is the only server-side handling of a client timestamp anywhere. Nothing
# corrects a ts, nothing measures clock skew, and no message in the protocol
# carries the server's clock. This bound exists purely so a phone whose clock says
# 2106 cannot write rows the retention sweep will never reach.
ts_window_days = 30
# Answer a datagram naming an unknown token with a sealed REVOKED notice instead
# of silence, so a phone whose token the server has forgotten lands on the login
# screen instead of reporting into nothing.
#
# THE TRADE: this is the one reply the server sends without having verified the
# request, which makes the UDP port a reflector — UDP source addresses are
# forgeable, so the reply goes wherever the sender claimed to be. Three things
# bound it: the notice is 38 bytes and is refused to any shorter request, so it
# can never amplify; it is limited to one per destination address per minute
# under a global ceiling of 10/s; and naming unknown tokens still earns strikes
# and a ban either way. It cannot be forged, because it is sealed with a key
# derived from the server master and that token_id.
#
# Turning it off costs little. A revoked token keeps its row and its key, so the
# ordinary "you are logged out" answer is a fully authenticated NACK that never
# takes this path. This switch only matters when the row is genuinely gone: a
# restored backup predating the login, or a rotated OT_SECRET_KEY. With it off,
# those devices get silence until someone opens the app.
revocation_notices = true
# Argon2id. 19 MiB / 2 passes / 1 lane is OWASP's second recommended profile.
# Raising these later does not invalidate existing hashes: each hash carries its
# own parameters and is transparently upgraded on the next successful login.
argon2_memory_kib = 19456
argon2_iterations = 2
argon2_parallelism = 1
# Number of SO_REUSEPORT receive tasks. Defaults to min(cpus, 4).
# udp_workers = 4
Aweb/Cargo.toml
@@ -0,0 +1,16 @@
[package]
name = "web"
version.workspace = true
edition.workspace = true
[dependencies]
api.workspace = true
console_error_panic_hook = "0.1.7"
gloo-net = { version = "0.6", default-features = false, features = ["http", "json"] }
js-sys = "0.3.106"
leptos = { version = "0.8", features = ["csr"] }
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"] }
Aweb/Trunk.toml
@@ -0,0 +1,6 @@
# `trunk serve`: live reload on :8081. The API runs on the server at :8080.
[[proxy]]
backend = "http://127.0.0.1:8080/api/"
[serve]
port = 8081
Dweb/bun.lock-269
@@ -1,269 +0,0 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "opentracker-web",
"dependencies": {
"@solidjs/router": "^1.0.0",
"leaflet": "1.9.4",
"solid-js": "1.9.15",
},
"devDependencies": {
"@types/leaflet": "1.9.22",
"typescript": "7.0.2",
"vite": "8.2.2",
"vite-plugin-solid": "2.11.14",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@oxc-project/types": ["@oxc-project/types@0.147.0", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="],
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.6", "", { "os": "android", "cpu": "arm" }, "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.6", "", { "os": "android", "cpu": "arm64" }, "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.6", "", { "os": "linux", "cpu": "arm" }, "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.6", "", { "os": "none", "cpu": "arm64" }, "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@solidjs/router": ["@solidjs/router@1.0.0", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-cCSk1hvgCowiMa9bzzYWHiLu1U4E22+DfJe6/rOwAyECKrxc3jrd5QnoW3sDDJtW+e077cz/M67bPl3DqOBw1Q=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
"@types/leaflet": ["@types/leaflet@1.9.22", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA=="],
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
"@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
"@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
"@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
"@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
"@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
"@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
"@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
"@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
"@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
"@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
"@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
"@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
"@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
"@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
"@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
"@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
"@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.10", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-lxve6Y02YiZTldB7efKpnbf1BH00XCFZNYYW235jSGsYaJNFtHrYlKV6/O+miHbjqpIr9FTe5+0no4hofAMbfA=="],
"babel-preset-solid": ["babel-preset-solid@1.9.15", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.10" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.15" }, "optionalPeers": ["solid-js"] }, "sha512-GBmg1OiPb+OwcH51XbDAKPtvrPfQW7rCJTJxcp8+yhtWwN+kqnbEJk2SgVybd+uhTxTKAvjaFyiQSr/eUZBwzg=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.20", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw=="],
"browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.416", "", {}, "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
"is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="],
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
"node-releases": ["node-releases@2.0.54", "", {}, "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
"rolldown": ["rolldown@1.2.6", "", { "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.6", "@rolldown/binding-android-arm64": "1.2.6", "@rolldown/binding-darwin-arm64": "1.2.6", "@rolldown/binding-darwin-x64": "1.2.6", "@rolldown/binding-freebsd-x64": "1.2.6", "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", "@rolldown/binding-linux-arm64-gnu": "1.2.6", "@rolldown/binding-linux-arm64-musl": "1.2.6", "@rolldown/binding-linux-ppc64-gnu": "1.2.6", "@rolldown/binding-linux-s390x-gnu": "1.2.6", "@rolldown/binding-linux-x64-gnu": "1.2.6", "@rolldown/binding-linux-x64-musl": "1.2.6", "@rolldown/binding-openharmony-arm64": "1.2.6", "@rolldown/binding-win32-arm64-msvc": "1.2.6", "@rolldown/binding-win32-x64-msvc": "1.2.6" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"seroval": ["seroval@1.5.6", "", {}, "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA=="],
"seroval-plugins": ["seroval-plugins@1.5.6", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ=="],
"solid-js": ["solid-js@1.9.15", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg=="],
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
"update-browserslist-db": ["update-browserslist-db@1.3.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw=="],
"vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="],
"vite-plugin-solid": ["vite-plugin-solid@2.11.14", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.0.0 || ^7.0.0", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-7ZVBt8rpoyqmlwin2kRIUveaHoF6/kulY7gsnD+qFh4nS29V4OPAnw+ojoAspXIjObiL9o1xh9a/nTuYHm02Rw=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
}
}
Aweb/icon.svg
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<mask id="gap">
<rect width="32" height="32" fill="#fff"/>
<path d="M12.5 30L4.78 17.13A9 9 0 1 1 20.22 17.13Z" stroke="#000" stroke-width="3.5" stroke-linejoin="round"/>
</mask>
<path d="M22 21.5L16.1 12.27A7 7 0 1 1 27.9 12.27Z" fill="#38bdf8" mask="url(#gap)"/>
<path d="M12.5 30L4.78 17.13A9 9 0 1 1 20.22 17.13Z" fill="#2563eb"/>
<circle cx="12.5" cy="12.5" r="3.6" fill="#fff"/>
</svg>
Mweb/index.html
@@ -1,12 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>opentracker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>opentracker</title>
<link data-trunk rel="copy-file" href="icon.svg">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<!-- Runs before the first paint, so a stored theme does not flash the other one. -->
<script>const theme = localStorage.getItem("theme"); if (theme) document.documentElement.dataset.theme = theme;</script>
<link data-trunk rel="css" href="vendor/leaflet.css">
<link data-trunk rel="css" href="style.css">
<script data-trunk src="vendor/leaflet.js"></script>
<!-- The features rustc enables by default for wasm32. Trunk's bundled wasm-opt does not assume them. -->
<link data-trunk rel="rust" data-wasm-opt="z"
data-wasm-opt-params="--enable-bulk-memory --enable-nontrapping-float-to-int --enable-sign-ext --enable-mutable-globals --enable-reference-types --enable-multivalue">
</head>
<body></body>
</html>
Dweb/package.json-21
@@ -1,21 +0,0 @@
{
"name": "opentracker-web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@solidjs/router": "^1.0.0",
"leaflet": "1.9.4",
"solid-js": "1.9.15"
},
"devDependencies": {
"@types/leaflet": "1.9.22",
"typescript": "7.0.2",
"vite": "8.2.2",
"vite-plugin-solid": "2.11.14"
}
}
Dweb/src/App.tsx-148
@@ -1,148 +0,0 @@
import { createResource, createSignal, Show, onCleanup, type JSX } from "solid-js";
import { A, Route, Router, useNavigate } from "@solidjs/router";
import { api, ApiError, decodePolyline, UNAUTHORIZED, type Me } from "./api";
import { createLiveStore } from "./live";
import { SessionContext } from "./session";
import MapPage from "./MapPage";
import Settings from "./Settings";
const TRAIL_SECONDS = 24 * 3600;
const TRAIL_REFRESH_MS = 60_000;
export default function App() {
return (
<Router root={Shell}>
<Route path="/" component={MapPage} />
<Route path="/settings" component={Settings} />
<Route path="*" component={NotFound} />
</Router>
);
}
function NotFound() {
return (
<main class="center">
<p class="muted">no such page</p>
<A href="/">back to the map</A>
</main>
);
}
/**
* The router root: session, navigation, and the one polling loop.
*
* Signing out is not a redirect. The signed-out shell renders the login form in
* place, so a deep link to /settings survives it — you land where you were
* heading rather than back at the map.
*/
function Shell(props: { children?: JSX.Element }) {
// undefined while the initial /api/me is in flight, null when signed out.
const [me, setMe] = createSignal<Me | null | undefined>(undefined);
api.me()
.then(setMe)
.catch(() => setMe(null));
return (
<Show when={me() !== undefined} fallback={<div class="center">loading…</div>}>
<Show when={me()} fallback={<Login onSignedIn={setMe} />} keyed>
{(user) => (
<SignedIn me={user} onSignedOut={() => setMe(null)}>
{props.children}
</SignedIn>
)}
</Show>
</Show>
);
}
function SignedIn(props: { me: Me; onSignedOut: () => void; children?: JSX.Element }) {
const live = createLiveStore(props.onSignedOut);
const [selected, setSelected] = createSignal<number | null>(props.me.id);
// The trail for whoever is selected. A 403 means the share does not grant
// trail visibility, which is a normal answer, not an error to surface.
const [trail, { refetch }] = createResource(selected, async (id) => {
const to = live.serverTime();
try {
const res = await api.track(id, to - TRAIL_SECONDS, to);
return decodePolyline(res.polyline);
} catch (e) {
if (e instanceof ApiError && (e.status === 403 || e.status === UNAUTHORIZED)) return null;
throw e;
}
});
const timer = setInterval(() => void refetch(), TRAIL_REFRESH_MS);
onCleanup(() => clearInterval(timer));
async function signOut() {
await api.logout().catch(() => {});
props.onSignedOut();
}
return (
<SessionContext.Provider
value={{
me: props.me,
people: live.people,
order: live.order,
serverTime: live.serverTime,
selected,
select: setSelected,
trail: () => trail() ?? null,
}}
>
<div class="shell">
<header>
<strong>opentracker</strong>
<nav>
<A href="/" end activeClass="active">
map
</A>
<A href="/settings" activeClass="active">
settings
</A>
</nav>
<span class="who">{props.me.display_name}</span>
<button onClick={signOut}>sign out</button>
</header>
<Show when={live.error()}>{(msg) => <div class="banner">{msg()}</div>}</Show>
{props.children}
</div>
</SessionContext.Provider>
);
}
function Login(props: { onSignedIn: (me: Me) => void }) {
const [username, setUsername] = createSignal("");
const [password, setPassword] = createSignal("");
const [error, setError] = createSignal<string | null>(null);
const [busy, setBusy] = createSignal(false);
const navigate = useNavigate();
async function submit(e: Event) {
e.preventDefault();
setBusy(true);
setError(null);
try {
const { user } = await api.login(username(), password());
// A stale bookmark to a route that no longer exists would otherwise sign
// in and land on the 404 page.
if (window.location.pathname === "/login") navigate("/", { replace: true });
props.onSignedIn(user);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
return (
<form class="center card login" onSubmit={submit}>
<h1>opentracker</h1>
<input placeholder="username" autocomplete="username" value={username()} onInput={(e) => setUsername(e.currentTarget.value)} />
<input type="password" placeholder="password" autocomplete="current-password" value={password()} onInput={(e) => setPassword(e.currentTarget.value)} />
<Show when={error()}>{(msg) => <p class="error">{msg()}</p>}</Show>
<button disabled={busy()}>{busy() ? "signing in…" : "sign in"}</button>
</form>
);
}
Dweb/src/Map.tsx-97
@@ -1,97 +0,0 @@
import { onMount, onCleanup, createEffect } from "solid-js";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import type { PersonState } from "./api";
// 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 {
people: Record<number, PersonState>;
order: () => number[];
selected: () => number | null;
onSelect: (id: number) => void;
trail: () => [number, number][] | null;
}
export default function MapView(props: Props) {
let container!: HTMLDivElement;
// Leaflet is imperative and Solid is fine-grained, so none of this may live in
// reactive state: the map is created once and driven by effects. Wrapping
// Leaflet in components that re-render is the one thing that must not happen.
let map: L.Map | undefined;
let trailLine: L.Polyline | undefined;
const markers = new Map<number, { dot: L.CircleMarker; halo: L.Circle }>();
let fitted = false;
onMount(() => {
map = L.map(container, { zoomControl: true }).setView([52.52, 13.405], 13);
L.tileLayer(TILE_URL, { maxZoom: 19, attribution: ATTRIBUTION }).addTo(map);
});
onCleanup(() => map?.remove());
createEffect(() => {
const ids = props.order();
if (!map) return;
for (const id of ids) {
const person = props.people[id];
const pos = person?.position;
if (!pos) continue;
const latlng = L.latLng(pos.lat_e7 / 1e7, pos.lon_e7 / 1e7);
const colour = person.is_self ? "#1d6fd8" : "#d8461d";
let entry = markers.get(id);
if (!entry) {
const halo = L.circle(latlng, { radius: 0, color: colour, weight: 1, opacity: 0.35, fillOpacity: 0.08 }).addTo(map);
const dot = L.circleMarker(latlng, { radius: 7, color: "#fff", weight: 2, fillColor: colour, fillOpacity: 1 })
.addTo(map)
.on("click", () => props.onSelect(id));
entry = { dot, halo };
markers.set(id, entry);
}
entry.dot.setLatLng(latlng).setStyle({ fillColor: colour });
entry.dot.bindTooltip(person.display_name, { direction: "top", offset: [0, -8] });
entry.halo.setLatLng(latlng).setRadius(pos.acc_dm === null ? 0 : pos.acc_dm / 10);
}
for (const [id, entry] of markers) {
if (props.people[id]?.position) continue;
entry.dot.remove();
entry.halo.remove();
markers.delete(id);
}
// Fit once. Re-fitting on every poll would fight the user's panning.
if (!fitted && markers.size > 0) {
fitted = true;
map.fitBounds(L.latLngBounds([...markers.values()].map((m) => m.dot.getLatLng())).pad(0.3), { maxZoom: 16 });
}
});
createEffect(() => {
const points = props.trail();
if (!map) return;
trailLine?.remove();
trailLine = undefined;
if (points && points.length > 1) {
trailLine = L.polyline(points, { color: "#1d6fd8", weight: 3, opacity: 0.55 }).addTo(map);
}
});
// Recentre when the selection changes.
createEffect(() => {
const id = props.selected();
if (id === null || !map) return;
const entry = markers.get(id);
if (entry) map.panTo(entry.dot.getLatLng());
});
return <div class="map" ref={container} />;
}
Dweb/src/MapPage.tsx-37
@@ -1,37 +0,0 @@
import { For, Show } from "solid-js";
import { FLAG_CHARGING } from "./api";
import { ago, staleness } from "./live";
import MapView from "./Map";
import { useSession } from "./session";
export default function MapPage() {
const s = useSession();
return (
<main class="split">
<aside>
<For each={s.order()} fallback={<p class="muted">nobody is sharing with you yet</p>}>
{(id) => {
const person = () => s.people[id];
return (
<button class="person" classList={{ active: s.selected() === id }} onClick={() => s.select(id)} disabled={!person()?.position}>
<span class="name">{person()?.display_name}</span>
<Show when={person()?.position} fallback={<span class="muted">no fix yet</span>}>
{(pos) => (
<span class="meta">
<span class={`dot ${staleness(pos().ts, s.serverTime())}`} />
{ago(pos().ts, s.serverTime())} ago
<Show when={pos().acc_dm !== null}>{" · ±" + Math.round(pos().acc_dm! / 10) + " m"}</Show>
<Show when={pos().bat_pct !== null}>{" · " + pos().bat_pct + "%" + (pos().flags & FLAG_CHARGING ? " ⚡" : "")}</Show>
</span>
)}
</Show>
</button>
);
}}
</For>
</aside>
<MapView people={s.people} order={s.order} selected={s.selected} onSelect={s.select} trail={s.trail} />
</main>
);
}
Dweb/src/Settings.tsx-205
@@ -1,205 +0,0 @@
import { createResource, createSignal, For, Show } from "solid-js";
import { api, type ShareInfo, type TokenInfo } from "./api";
import { ago, until } from "./live";
import { useSession } from "./session";
/**
* The only place individual phones are visible.
*
* Positions belong to the account, so a token is a credential and nothing else.
* Listing them with last-seen is what makes a forgotten phone in a drawer — the
* one that drags the live marker back and forth — visible and revocable.
*/
export default function Settings() {
const me = useSession().me;
const [tokens, { refetch }] = createResource(() => api.tokens());
const [message, setMessage] = createSignal<string | null>(null);
const now = Math.floor(Date.now() / 1000);
async function revoke(t: TokenInfo) {
if (!confirm(`Revoke "${t.name}"? That phone has to log in again.`)) return;
await api.revokeToken(t.token_id);
void refetch();
}
async function revokeOthers() {
if (!confirm("Revoke every device token on this account?")) return;
const { revoked } = await api.revokeOthers();
setMessage(`${revoked} token(s) revoked`);
void refetch();
}
return (
<main class="settings">
<section class="card">
<h2>account</h2>
<p class="muted">
{me.display_name} ({me.username}){me.is_admin ? " · admin" : ""}
</p>
<PasswordForm />
</section>
<section class="card">
<h2>devices</h2>
<Show when={message()}>{(m) => <p class="muted">{m()}</p>}</Show>
<For each={tokens()} fallback={<p class="muted">no devices logged in</p>}>
{(t) => (
<div class="token">
<div>
<strong>{t.name}</strong> <span class="muted">{t.platform}</span>
<div class="muted">
{t.last_seen_at ? `seen ${ago(t.last_seen_at, now)} ago` : "never seen"}
{t.last_src_ip ? ` · ${t.last_src_ip}` : ""}
{t.last_transport ? ` · ${t.last_transport}` : ""}
{t.os_api_level ? ` · API ${t.os_api_level}` : ""}
</div>
</div>
<button onClick={() => revoke(t)}>revoke</button>
</div>
)}
</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("");
const [status, setStatus] = createSignal<string | null>(null);
async function submit(e: Event) {
e.preventDefault();
try {
await api.changePassword(current(), next());
// Changing the password revokes every device token, by design.
setStatus("changed — every device must log in again");
setCurrent("");
setNext("");
} catch (e) {
setStatus(e instanceof Error ? e.message : String(e));
}
}
return (
<form class="password" onSubmit={submit}>
<input type="password" placeholder="current password" autocomplete="current-password" value={current()} onInput={(e) => setCurrent(e.currentTarget.value)} />
<input type="password" placeholder="new password (10+ characters)" autocomplete="new-password" value={next()} onInput={(e) => setNext(e.currentTarget.value)} />
<button>change password</button>
<Show when={status()}>{(s) => <p class="muted">{s()}</p>}</Show>
</form>
);
}
Dweb/src/api.ts-181
@@ -1,181 +0,0 @@
// The whole server contract, in one file.
//
// Types are hand-written to mirror the `Serialize` structs in
// `crates/otserver/src/api.rs`. A Rust test (`api::tests::the_json_shape_is_the
// _one_the_web_ui_expects`) asserts the exact JSON key set of every response
// type, so a renamed field fails `cargo test` instead of failing in a browser.
export interface Me {
id: number;
username: string;
display_name: string;
is_admin: boolean;
server_time: number;
}
export interface Position {
ts: number;
/** Degrees x 1e7. Integers end to end, so no float-formatting drift. */
lat_e7: number;
lon_e7: number;
acc_dm: number | null;
alt_m: number | null;
spd_cms: number | null;
brg_cdeg: number | null;
bat_pct: number | null;
flags: number;
recv_at: number;
}
export interface PersonState {
user_id: number;
display_name: string;
is_self: boolean;
position?: Position;
}
export interface StateResponse {
server_time: number;
people: PersonState[];
}
export interface TokenInfo {
/** A decimal string: a u64 does not fit exactly in a JS number. */
token_id: string;
name: string;
platform: string;
app_version: number | null;
os_api_level: number | null;
last_seen_at: number | null;
last_src_ip: string | null;
last_transport: string | null;
created_at: number;
}
export interface TrackResponse {
user_id: number;
from: number;
to: number;
polyline: string;
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;
export const FLAG_LOW_ACCURACY = 4;
export const FLAG_MOCK = 8;
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
/** Thrown-away sentinel: the caller shows the login screen on a 401. */
export const UNAUTHORIZED = 401;
async function request<T>(method: string, path: string, body?: unknown, etag?: string): Promise<{ data: T | null; etag: string | null }> {
const headers: Record<string, string> = {};
// A custom header a cross-origin page cannot set without a CORS preflight we
// never grant. Combined with SameSite=Lax on the session cookie that is the
// whole CSRF defence; there is no token to store or rotate.
if (method !== "GET") headers["X-OT-CSRF"] = "1";
if (body !== undefined) headers["Content-Type"] = "application/json";
if (etag) headers["If-None-Match"] = etag;
const res = await fetch(path, {
method,
headers,
credentials: "same-origin",
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status === 304) return { data: null, etag: etag ?? null };
if (!res.ok) {
const message = await res
.json()
.then((b) => (b as { error?: string }).error ?? res.statusText)
.catch(() => res.statusText);
throw new ApiError(res.status, message);
}
const responseEtag = res.headers.get("ETag");
if (res.status === 204) return { data: null, etag: responseEtag };
return { data: (await res.json()) as T, etag: responseEtag };
}
async function json<T>(method: string, path: string, body?: unknown): Promise<T> {
const { data } = await request<T>(method, path, body);
return data as T;
}
export const api = {
login: (username: string, password: string) => json<{ user: Me }>("POST", "/api/login", { username, password }),
logout: () => json<void>("POST", "/api/logout"),
me: () => json<Me>("GET", "/api/me"),
/** Returns null when the ETag matched, meaning nothing changed. */
state: (etag?: string) => request<StateResponse>("GET", "/api/state", undefined, etag),
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) =>
json<void>("POST", "/api/me/password", { current_password, new_password }),
};
/**
* Google encoded polyline at 1e5, the format `/track` returns.
*
* The server encodes; nothing here re-encodes, so this is the only half that
* has to exist.
*/
export function decodePolyline(encoded: string): [number, number][] {
const out: [number, number][] = [];
let index = 0;
let lat = 0;
let lon = 0;
while (index < encoded.length) {
for (let i = 0; i < 2; i++) {
let result = 0;
let shift = 0;
let byte: number;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
const delta = result & 1 ? ~(result >> 1) : result >> 1;
if (i === 0) lat += delta;
else lon += delta;
}
out.push([lat / 1e5, lon / 1e5]);
}
return out;
}
Aweb/src/guest.rs
@@ -0,0 +1,106 @@
//! The page behind a guest link: `/#l=<token>`.
use api::{GuestAuth, GuestKey, GuestUnlock, GuestView};
use leptos::prelude::*;
use leptos::task::spawn_local;
use crate::{ThemeToggle, http, map};
#[derive(Clone, PartialEq)]
enum State {
Loading,
Locked,
Open(String),
Gone,
Failed(String),
}
fn storage() -> Option<web_sys::Storage> {
window().session_storage().ok().flatten()
}
#[component]
pub fn GuestPage(token: String) -> impl IntoView {
let storage_key = format!("link {token}");
let key = RwSignal::new(storage().and_then(|s| s.get_item(&storage_key).ok().flatten()));
let token = StoredValue::new(token);
let auth = move || GuestAuth {
token: token.get_value(),
key: key.get_untracked(),
};
let state = RwSignal::new(State::Loading);
let check = move || {
spawn_local(async move {
state.set(match http::post::<GuestView>("/api/guest", &auth()).await {
Ok(v) => State::Open(v.person.username),
Err(e) if e.status == 401 => State::Locked,
Err(e) if e.status == 404 => State::Gone,
Err(e) => State::Failed(e.to_string()),
})
})
};
check();
let password = RwSignal::new(String::new());
let error = RwSignal::new(None::<String>);
let unlock = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let body = GuestUnlock {
token: token.get_value(),
password: password.get_untracked(),
};
let storage_key = storage_key.clone();
spawn_local(async move {
match http::post::<GuestKey>("/api/guest/unlock", &body).await {
Ok(k) => {
if let Some(s) = storage() {
let _ = s.set_item(&storage_key, &k.key);
}
key.set(Some(k.key));
error.set(None);
check();
}
Err(e) => error.set(Some(match e.status {
401 => "Wrong password.".into(),
429 => "Too many failed attempts. Try again in 15 minutes.".into(),
_ => e.to_string(),
})),
}
});
};
let title = move || match state.get() {
State::Open(name) => format!("Location of {name}"),
_ => "Shared location".into(),
};
view! {
<header>
<img class="brand" src="/icon.svg" alt="opentracker" width="28" height="28" />
<span class="guest-title">{title}</span>
<ThemeToggle />
</header>
{move || match state.get() {
State::Loading => ().into_any(),
State::Locked => {
view! {
<form class="login" on:submit=unlock.clone()>
<p>"This shared location is protected with a password."</p>
<label>
"Password"
<input type="password" autocomplete="current-password" required bind:value=password />
</label>
<button class="primary">"Show location"</button>
<p class="error">{move || error.get()}</p>
</form>
}
.into_any()
}
State::Open(_) => view! { <main><map::MapPage guest=auth() /></main> }.into_any(),
State::Gone => {
view! { <p class="login">"This link is no longer valid. It may have expired or been deleted."</p> }
.into_any()
}
State::Failed(msg) => view! { <p class="login error">{msg}</p> }.into_any(),
}}
}
}
Aweb/src/http.rs
@@ -0,0 +1,74 @@
use std::fmt;
use gloo_net::http::{Request, Response};
use serde::Serialize;
use serde::de::DeserializeOwned;
#[derive(Clone)]
pub struct Error {
pub status: u16,
pub msg: String,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(&self.msg)
}
}
pub async fn get<T: DeserializeOwned>(path: &str) -> Result<T, Error> {
read(path, Request::get(path).send().await).await
}
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
}
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 {
status: 0,
msg: e.to_string(),
})?;
read(path, req.send().await).await
}
pub async fn delete(path: &str) -> Result<(), Error> {
read(path, Request::delete(path).send().await).await
}
async fn read<T: DeserializeOwned>(
path: &str,
res: Result<Response, gloo_net::Error>,
) -> Result<T, Error> {
let res = res.map_err(|e| Error {
status: 0,
msg: format!("network error: {e}"),
})?;
// The session has ended. A reload shows the login form.
if res.status() == 401
&& !matches!(path, "/api/login" | "/api/me")
&& !path.starts_with("/api/guest")
{
let _ = web_sys::window().unwrap().location().reload();
}
if !res.ok() {
let body = res.text().await.unwrap_or_default();
let msg = if body.is_empty() {
res.status_text()
} else {
body
};
return Err(Error {
status: res.status(),
msg,
});
}
res.json().await.map_err(|e| Error {
status: 0,
msg: e.to_string(),
})
}
Dweb/src/index.tsx-5
@@ -1,5 +0,0 @@
import { render } from "solid-js/web";
import App from "./App";
import "./styles.css";
render(() => <App />, document.getElementById("root")!);
Dweb/src/live.ts-98
@@ -1,98 +0,0 @@
import { createStore } from "solid-js/store";
import { createSignal, onCleanup } from "solid-js";
import { api, ApiError, UNAUTHORIZED, type PersonState } from "./api";
const POLL_MS = 5_000;
/**
* The polling live view.
*
* A store rather than a signal so a position update for one person only touches
* the DOM that reads that person. Polling pauses while the tab is hidden, and
* an unchanged poll is a 304 with no body thanks to the endpoint's ETag.
*/
export function createLiveStore(onUnauthorized: () => void) {
const [people, setPeople] = createStore<Record<number, PersonState>>({});
const [order, setOrder] = createSignal<number[]>([]);
const [serverTime, setServerTime] = createSignal(Math.floor(Date.now() / 1000));
const [error, setError] = createSignal<string | null>(null);
let etag: string | undefined;
let timer: number | undefined;
let stopped = false;
async function poll() {
try {
const res = await api.state(etag);
etag = res.etag ?? undefined;
setError(null);
if (res.data) {
setServerTime(res.data.server_time);
const seen = new Set<number>();
for (const p of res.data.people) {
seen.add(p.user_id);
setPeople(p.user_id, p);
}
for (const id of Object.keys(people).map(Number)) {
if (!seen.has(id)) setPeople(id, undefined!);
}
setOrder(res.data.people.map((p) => p.user_id));
}
} catch (e) {
if (e instanceof ApiError && e.status === UNAUTHORIZED) {
stop();
onUnauthorized();
return;
}
setError(e instanceof Error ? e.message : String(e));
}
schedule();
}
function schedule() {
if (stopped || document.hidden) return;
timer = window.setTimeout(poll, POLL_MS);
}
function stop() {
stopped = true;
clearTimeout(timer);
}
const onVisibility = () => {
clearTimeout(timer);
// Poll immediately on becoming visible: waiting 5 s to refresh a view the
// user just looked at is exactly when staleness is most noticeable.
if (!document.hidden && !stopped) void poll();
};
document.addEventListener("visibilitychange", onVisibility);
onCleanup(() => {
stop();
document.removeEventListener("visibilitychange", onVisibility);
});
void poll();
return { people, order, serverTime, error, refresh: () => void poll() };
}
/** "12s", "4m", "2h", "3d" — compared against the server clock, never the browser's. */
export function ago(ts: number, now: number): string {
const d = Math.max(0, now - ts);
if (d < 60) return `${d}s`;
if (d < 3600) return `${Math.floor(d / 60)}m`;
if (d < 86400) return `${Math.floor(d / 3600)}h`;
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;
return d < 900 ? "fresh" : d < 3600 ? "stale" : "old";
}
Aweb/src/locate.rs
@@ -0,0 +1,167 @@
//! Sending this browser's own position, once or on a timer.
use std::time::Duration;
use api::{Point, Uploaded};
use leptos::prelude::*;
use leptos::task::spawn_local;
use wasm_bindgen::prelude::*;
use crate::http;
#[wasm_bindgen(inline_js = r#"
export function locate() {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) return reject("This browser cannot share its location.");
navigator.geolocation.getCurrentPosition(async (p) => {
let battery = null;
try {
if (navigator.getBattery) battery = Math.round((await navigator.getBattery()).level * 100);
} catch (e) {}
const c = p.coords;
// JSON.stringify turns a NaN heading (standing still) into null.
resolve(JSON.stringify({
ts: Math.floor(p.timestamp / 1000), lat: c.latitude, lon: c.longitude, acc: c.accuracy,
alt: c.altitude, speed: c.speed, bearing: c.heading, battery,
}));
}, (e) => reject(e.message || "The location is not available."),
{ enableHighAccuracy: true, timeout: 20000, maximumAge: 0 });
});
}
"#)]
extern "C" {
#[wasm_bindgen(catch)]
async fn locate() -> Result<JsValue, JsValue>;
}
const STORAGE_KEY: &str = "autoSendSecs";
const MIN_SECS: u32 = 5;
const DEFAULT_SECS: u32 = 30;
/// Lives in the shell, so automatic sending goes on while the settings are open.
#[derive(Clone, Copy)]
pub struct Locator {
pub auto: RwSignal<bool>,
pub secs: RwSignal<String>,
pub status: RwSignal<Option<Result<String, String>>>,
busy: RwSignal<bool>,
}
fn storage() -> Option<web_sys::Storage> {
window().local_storage().ok().flatten()
}
impl Locator {
/// The choice is kept in localStorage, so automatic sending resumes after a reload.
pub fn provide() -> Self {
let saved = storage().and_then(|s| s.get_item(STORAGE_KEY).ok().flatten());
let locator = Locator {
auto: RwSignal::new(saved.is_some()),
secs: RwSignal::new(saved.unwrap_or_else(|| DEFAULT_SECS.to_string())),
status: RwSignal::new(None),
busy: RwSignal::new(false),
};
provide_context(locator);
let timer = StoredValue::new(None::<IntervalHandle>);
Effect::new(move |was_on: Option<bool>| {
if let Some(handle) = timer.get_value() {
handle.clear();
}
timer.set_value(None);
let secs = locator.interval();
let on = locator.auto.get();
if let Some(s) = storage() {
let _ = match on {
true => s.set_item(STORAGE_KEY, &secs.to_string()),
false => s.remove_item(STORAGE_KEY),
};
}
if on {
// Send at once when switched on, but not on every edit of the interval.
if was_on != Some(true) {
locator.send();
}
let handle = set_interval_with_handle(
move || locator.send(),
Duration::from_secs(secs.into()),
);
timer.set_value(handle.ok());
}
on
});
on_cleanup(move || {
if let Some(handle) = timer.get_value() {
handle.clear();
}
});
locator
}
fn interval(&self) -> u32 {
self.secs
.get()
.trim()
.parse()
.unwrap_or(DEFAULT_SECS)
.max(MIN_SECS)
}
pub fn send(self) {
// A slow GPS fix must not pile up requests behind it.
if self.busy.get_untracked() {
return;
}
self.busy.set(true);
spawn_local(async move {
let result = async {
let json = locate().await.map_err(|e| {
e.as_string()
.unwrap_or_else(|| "The location is not available.".into())
})?;
let point: Point = serde_json::from_str(&json.as_string().unwrap_or_default())
.map_err(|e| e.to_string())?;
http::post::<Uploaded>("/api/points", &[&point])
.await
.map_err(|e| e.to_string())?;
Ok::<_, String>(point)
}
.await;
self.status.set(Some(result.map(|p| {
let acc = p.acc.map(|a| format!(", ±{a:.0} m")).unwrap_or_default();
let time: String = js_sys::Date::new(&JsValue::from_f64(p.ts as f64 * 1000.0))
.to_locale_time_string("default")
.into();
format!("Last sent at {time}{acc}.")
})));
self.busy.set(false);
});
}
}
#[component]
pub fn LocationControls() -> impl IntoView {
let locator = expect_context::<Locator>();
view! {
<div class="send-location">
<h3>"Share from this browser"</h3>
<button on:click=move |_| locator.send() disabled=move || locator.busy.get()>
"Send my location now"
</button>
<div class="auto-send">
<label class="radio">
<input type="checkbox" bind:checked=locator.auto />
"Send every"
</label>
<input type="number" min=MIN_SECS aria-label="Seconds between sends" bind:value=locator.secs />
"seconds"
</div>
{move || {
locator.status.get().map(|s| match s {
Ok(text) => view! { <p class="hint">{text}</p> }.into_any(),
Err(text) => view! { <p class="error">{text}</p> }.into_any(),
})
}}
</div>
}
}
Aweb/src/main.rs
@@ -0,0 +1,365 @@
mod guest;
mod http;
mod locate;
mod map;
mod passkey;
mod settings;
use api::{Challenge, ChallengeAnswer, Credentials, Login, LoginResult, Me, SetupStatus};
use leptos::prelude::*;
use leptos::task::spawn_local;
use wasm_bindgen::JsValue;
fn main() {
console_error_panic_hook::set_once();
leptos::mount::mount_to_body(App);
}
#[derive(Clone, PartialEq)]
enum Session {
Loading,
Setup,
LoggedOut,
LoggedIn(Me),
}
/// The signed-in user, shared through context. `reload` fetches it again after a change.
#[derive(Clone, Copy)]
pub struct Account {
session: RwSignal<Session>,
}
impl Account {
pub fn me(&self) -> Option<Me> {
match self.session.get() {
Session::LoggedIn(me) => Some(me),
_ => None,
}
}
pub fn reload(self) {
spawn_local(async move {
let next = match http::get::<Me>("/api/me").await {
Ok(me) => Session::LoggedIn(me),
Err(_) => match http::get::<SetupStatus>("/api/setup").await {
Ok(s) if s.needed => Session::Setup,
_ => Session::LoggedOut,
},
};
self.session.set(next);
});
}
}
#[derive(Clone, Copy, PartialEq)]
enum Page {
Loading,
Setup,
Login,
Main,
}
#[component]
fn App() -> impl IntoView {
let hash = window().location().hash().unwrap_or_default();
match hash.strip_prefix("#l=") {
Some(token) => view! { <guest::GuestPage token=token.to_owned() /> }.into_any(),
None => view! { <AccountApp /> }.into_any(),
}
}
#[component]
fn AccountApp() -> impl IntoView {
let account = Account {
session: RwSignal::new(Session::Loading),
};
provide_context(account);
account.reload();
// Only a change of state swaps the page. A reloaded Me must not reset the open tab.
let page = Memo::new(move |_| match account.session.get() {
Session::Loading => Page::Loading,
Session::Setup => Page::Setup,
Session::LoggedOut => Page::Login,
Session::LoggedIn(_) => Page::Main,
});
move || match page.get() {
Page::Loading => ().into_any(),
Page::Setup => view! { <SetupForm /> }.into_any(),
Page::Login => view! { <LoginForm /> }.into_any(),
Page::Main => view! { <Shell /> }.into_any(),
}
}
#[component]
fn SetupForm() -> impl IntoView {
let account = expect_context::<Account>();
let username = RwSignal::new(String::new());
let password = RwSignal::new(String::new());
let repeat = RwSignal::new(String::new());
let error = RwSignal::new(None::<String>);
let submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
if password.get_untracked() != repeat.get_untracked() {
error.set(Some("The passwords do not match.".into()));
return;
}
let body = Credentials {
username: username.get_untracked(),
password: password.get_untracked(),
};
spawn_local(async move {
match http::post::<LoginResult>("/api/setup", &body).await {
Ok(_) => account.reload(),
Err(e) => error.set(Some(e.to_string())),
}
});
};
view! {
<form class="login" on:submit=submit>
<h1>"opentracker"</h1>
<p>"Welcome. Create the admin account to finish the setup."</p>
<label>"Username" <input autocomplete="username" required bind:value=username /></label>
<label>
"Password"
<input type="password" autocomplete="new-password" minlength="8" required bind:value=password />
</label>
<label>
"Repeat password"
<input type="password" autocomplete="new-password" minlength="8" required bind:value=repeat />
</label>
<button class="primary">"Create admin account"</button>
<p class="error">{move || error.get()}</p>
</form>
}
}
/// Answers a passkey challenge in the browser and sends the answer to the server.
async fn answer(challenge: Challenge) -> Result<LoginResult, String> {
let credential = passkey::get(&challenge.options).await?;
let body = ChallengeAnswer {
state_id: challenge.state_id,
credential,
name: String::new(),
};
http::post::<LoginResult>("/api/passkey/login/finish", &body)
.await
.map_err(|e| e.to_string())
}
#[component]
fn LoginForm() -> impl IntoView {
let account = expect_context::<Account>();
let username = RwSignal::new(String::new());
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.
let handle = move |result: Result<LoginResult, String>| {
spawn_local(async move {
let mut result = result;
if let Ok(LoginResult {
passkey_challenge: Some(ch),
..
}) = result
{
result = answer(ch).await;
}
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)),
}
busy.set(false);
});
};
let submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
busy.set(true);
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)
.await
.map_err(|e| match e.status {
401 => "Wrong username or password.".to_string(),
429 => "Too many failed attempts. Try again in 15 minutes.".to_string(),
_ => e.to_string(),
});
handle(result);
});
};
let with_passkey = move |_| {
busy.set(true);
spawn_local(async move {
let result = match http::post::<Challenge>("/api/passkey/login", &()).await {
Ok(ch) => answer(ch).await,
Err(e) => Err(e.to_string()),
};
handle(result);
});
};
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>
"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()>
<button type="button" disabled=busy on:click=with_passkey>"Sign in with a passkey"</button>
</Show>
<p class="error">{move || error.get()}</p>
</form>
}
}
#[derive(Clone, Copy, PartialEq)]
enum Tab {
Map,
Settings,
}
#[component]
fn Shell() -> impl IntoView {
let account = expect_context::<Account>();
locate::Locator::provide();
let tab = RwSignal::new(Tab::Map);
let logout = move |_| {
spawn_local(async move {
let _ = http::post::<()>("/api/logout", &()).await;
account.reload();
})
};
view! {
<header>
<img class="brand" src="/icon.svg" alt="opentracker" width="28" height="28" />
<nav>
<button class:active=move || tab.get() == Tab::Map on:click=move |_| tab.set(Tab::Map)>
"Map"
</button>
<button
class:active=move || tab.get() == Tab::Settings
on:click=move |_| tab.set(Tab::Settings)
>
"Settings"
</button>
</nav>
<ThemeToggle />
<span class="user">{move || account.me().map(|m| m.username)}</span>
<button on:click=logout>"Log out"</button>
</header>
<main>
{move || match tab.get() {
Tab::Map => view! { <map::MapPage /> }.into_any(),
Tab::Settings => view! { <settings::Settings /> }.into_any(),
}}
</main>
}
}
/// Auto follows the system setting. The choice lives in localStorage, where index.html reads it at load.
#[component]
pub fn ThemeToggle() -> impl IntoView {
let storage = || window().local_storage().ok().flatten();
let initial = storage()
.and_then(|s| s.get_item("theme").ok().flatten())
.unwrap_or_default();
let theme = RwSignal::new(initial);
let choose = move |value: &'static str| {
theme.set(value.to_owned());
let root = document().document_element().unwrap();
let _ = match value {
"" => root.remove_attribute("data-theme"),
v => root.set_attribute("data-theme", v),
};
if let Some(s) = storage() {
let _ = match value {
"" => s.remove_item("theme"),
v => s.set_item("theme", v),
};
}
};
// Each click moves to the next theme: auto, light, dark, auto.
let next = move || match theme.get().as_str() {
"" => "light",
"light" => "dark",
_ => "",
};
let label = move || match theme.get().as_str() {
"light" => "light",
"dark" => "dark",
_ => "auto",
};
view! {
<button
class="theme"
title=move || format!("Theme: {}", label())
aria-label=move || format!("Theme: {}. Click to change.", label())
on:click=move |_| choose(next())
inner_html=move || {
icon(match theme.get().as_str() {
"light" => SUN,
"dark" => MOON,
_ => AUTO,
})
}
></button>
}
}
const SUN: &str = r#"<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>"#;
const MOON: &str = r#"<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>"#;
/// A half-filled circle: the system decides.
const AUTO: &str =
r#"<circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor"/>"#;
fn icon(body: &str) -> String {
format!(
r#"<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">{body}</svg>"#
)
}
pub fn now_secs() -> i64 {
(js_sys::Date::now() / 1000.0) as i64
}
pub fn fmt_time(ts: i64) -> String {
js_sys::Date::new(&JsValue::from_f64(ts as f64 * 1000.0))
.to_locale_string("default", &JsValue::UNDEFINED)
.into()
}
pub fn ago(ts: i64) -> String {
match (now_secs() - ts).max(0) {
d @ 0..60 => format!("{d} s ago"),
d @ 60..3600 => format!("{} min ago", d / 60),
d @ 3600..86400 => format!("{} h ago", d / 3600),
_ => fmt_time(ts),
}
}
Aweb/src/map.rs
@@ -0,0 +1,527 @@
use std::collections::HashMap;
use std::time::Duration;
use api::{GuestAuth, GuestTrack, GuestView, MAX_TRACK_SECS, Person, Point, Trail};
use js_sys::{Array, Object, Reflect};
use leptos::prelude::*;
use leptos::task::spawn_local;
use wasm_bindgen::JsValue;
use wasm_bindgen::prelude::*;
use crate::{ago, fmt_time, http, now_secs};
const TILES: &str = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const ATTRIBUTION: &str =
"© <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors";
const POLL: Duration = Duration::from_secs(10);
/// Positions older than this are drawn faded.
const STALE_SECS: i64 = 3600;
const COLORS: [&str; 8] = [
"#2563eb", "#dc2626", "#16a34a", "#9333ea", "#ea580c", "#0891b2", "#db2777", "#65a30d",
];
#[wasm_bindgen]
extern "C" {
type LMap;
type Layer;
#[wasm_bindgen(js_namespace = L, js_name = map)]
fn leaflet_map(el: &web_sys::HtmlElement) -> LMap;
#[wasm_bindgen(method, js_name = setView)]
fn set_view(this: &LMap, center: &Array, zoom: f64);
#[wasm_bindgen(method, js_name = fitBounds)]
fn fit_bounds(this: &LMap, bounds: &Array, opts: &JsValue);
#[wasm_bindgen(method, js_name = panTo)]
fn pan_to(this: &LMap, center: &Array);
#[wasm_bindgen(method, js_name = remove)]
fn remove_map(this: &LMap);
#[wasm_bindgen(js_namespace = L, js_name = tileLayer)]
fn tile_layer(url: &str, opts: &JsValue) -> Layer;
#[wasm_bindgen(js_namespace = L, js_name = circleMarker)]
fn circle_marker(at: &Array, opts: &JsValue) -> Layer;
#[wasm_bindgen(js_namespace = L, js_name = circle)]
fn circle(at: &Array, opts: &JsValue) -> Layer;
#[wasm_bindgen(js_namespace = L, js_name = polyline)]
fn polyline(points: &Array, opts: &JsValue) -> Layer;
#[wasm_bindgen(method, js_name = addTo)]
fn add_to(this: &Layer, map: &LMap);
#[wasm_bindgen(method, js_name = remove)]
fn remove_layer(this: &Layer);
#[wasm_bindgen(method, js_name = setLatLng)]
fn set_lat_lng(this: &Layer, at: &Array);
#[wasm_bindgen(method, js_name = setRadius)]
fn set_radius(this: &Layer, metres: f64);
#[wasm_bindgen(method, js_name = setStyle)]
fn set_style(this: &Layer, opts: &JsValue);
#[wasm_bindgen(method, js_name = bindTooltip)]
fn bind_tooltip(this: &Layer, text: &str, opts: &JsValue);
#[wasm_bindgen(method)]
fn on(this: &Layer, event: &str, f: &JsValue);
}
fn obj(pairs: &[(&str, JsValue)]) -> JsValue {
let o = Object::new();
for (k, v) in pairs {
Reflect::set(&o, &JsValue::from_str(k), v).unwrap();
}
o.into()
}
fn latlng(lat: f64, lon: f64) -> Array {
Array::of2(&lat.into(), &lon.into())
}
fn color(id: i64) -> &'static str {
COLORS[id.rem_euclid(COLORS.len() as i64) as usize]
}
/// A person's position on the map: a dot and a circle for the accuracy.
struct Marker {
dot: Layer,
accuracy: Layer,
}
fn sync_markers(
map: &LMap,
markers: &mut HashMap<i64, Marker>,
people: &[Person],
selected: RwSignal<Option<i64>>,
) {
markers.retain(|id, m| {
let keep = people.iter().any(|p| p.id == *id && p.last().is_some());
if !keep {
m.dot.remove_layer();
m.accuracy.remove_layer();
}
keep
});
for p in people {
let Some(last) = p.last().map(|d| &d.last) else {
continue;
};
let at = latlng(last.lat, last.lon);
let m = markers.entry(p.id).or_insert_with(|| {
let c = color(p.id);
let accuracy = circle(
&at,
&obj(&[
("radius", 0.into()),
("color", c.into()),
("weight", 1.into()),
("fillOpacity", 0.1.into()),
("interactive", false.into()),
]),
);
let dot = circle_marker(
&at,
&obj(&[
("radius", 8.into()),
("color", "white".into()),
("weight", 2.into()),
("fillColor", c.into()),
("fillOpacity", 1.into()),
]),
);
dot.bind_tooltip(
&p.username,
&obj(&[
("permanent", true.into()),
("direction", "right".into()),
("offset", Array::of2(&10.into(), &0.into()).into()),
]),
);
let id = p.id;
dot.on(
"click",
&Closure::<dyn Fn()>::new(move || selected.set(Some(id))).into_js_value(),
);
accuracy.add_to(map);
dot.add_to(map);
Marker { dot, accuracy }
});
m.dot.set_lat_lng(&at);
m.accuracy.set_lat_lng(&at);
m.accuracy.set_radius(last.acc.unwrap_or(0.0) as f64);
let opacity = if now_secs() - last.ts > STALE_SECS {
0.35
} else {
1.0
};
m.dot.set_style(&obj(&[
("fillOpacity", opacity.into()),
("opacity", opacity.into()),
]));
}
}
/// `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) {
let day = format!(
"{:04}-{:02}-{:02}",
d.get_full_year(),
d.get_month() + 1,
d.get_date()
);
let time = format!("{day}T{:02}:{:02}", d.get_hours(), d.get_minutes());
(day, time)
}
/// Local midnight to midnight, in unix seconds, for a `YYYY-MM-DD` string.
fn day_range(day: &str) -> Option<(i64, i64)> {
let mut it = day.split('-').map(|s| s.parse::<i32>().ok());
let (y, m, d) = (it.next()??, it.next()??, it.next()??);
let start = js_sys::Date::new_with_year_month_day(y as u32, m - 1, d).get_time();
let end = js_sys::Date::new_with_year_month_day(y as u32, m - 1, d + 1).get_time();
Some(((start / 1000.0) as i64, (end / 1000.0) as i64 - 1))
}
/// Unix seconds for a `YYYY-MM-DDTHH:MM` string. JavaScript reads that format as local time.
pub fn local_time(value: &str) -> Option<i64> {
let ms = js_sys::Date::new(&JsValue::from_str(value)).get_time();
(!ms.is_nan()).then_some((ms / 1000.0) as i64)
}
fn trail_range(span: &str, day: &str, from: &str, to: &str) -> Result<(i64, i64), &'static str> {
match span {
"day" => day_range(day).ok_or("Pick a day."),
"range" => match (local_time(from), local_time(to)) {
(Some(f), Some(t)) if f >= t => Err("The start must be before the end."),
(Some(f), Some(t)) if t - f > MAX_TRACK_SECS => {
Err("The range can be at most 31 days.")
}
(Some(f), Some(t)) => Ok((f, t)),
_ => Err("Pick a start and an end."),
},
_ => Ok((now_secs() - 86400, now_secs())),
}
}
/// Shows the map to a guest link instead of the signed-in user.
#[component]
pub fn MapPage(#[prop(optional)] guest: Option<GuestAuth>) -> impl IntoView {
let is_guest = guest.is_some();
let guest = StoredValue::new(guest);
let expires = RwSignal::new(None::<i64>);
let people = RwSignal::new(Vec::<Person>::new());
let selected = RwSignal::new(None::<i64>);
let trail_device = RwSignal::new(None::<i64>);
let person = move || {
let id = selected.get()?;
people.with(|list| list.iter().find(|p| p.id == id).cloned())
};
// The chosen device, or the newest one if the chosen one is gone.
let device = move || {
let p = person()?;
let chosen = trail_device.get();
chosen
.filter(|id| p.devices.iter().any(|d| d.id == *id))
.or(p.last().map(|d| d.id))
};
let (today, now) = input_values(&js_sys::Date::new_0());
let span = RwSignal::new("none".to_string());
let day = RwSignal::new(today.clone());
let from = RwSignal::new(format!("{today}T00:00"));
let to = RwSignal::new(now);
let range = move || trail_range(&span.get(), &day.get(), &from.get(), &to.get());
// Identifies the chosen filter. The last 24 hours move with the clock but stay one view.
let filter_key = move || match span.get().as_str() {
"day" => format!("day {}", day.get()),
"range" => format!("range {} {}", from.get(), to.get()),
s => s.to_string(),
};
let track = RwSignal::new(Vec::<Point>::new());
let error = RwSignal::new(None::<String>);
let refresh = move || {
if document().hidden() {
return;
}
spawn_local(async move {
let result = match guest.get_value() {
None => http::get::<Vec<Person>>("/api/people").await,
Some(g) => http::post::<GuestView>("/api/guest", &g).await.map(|v| {
expires.set(v.expires_at);
vec![v.person]
}),
};
match result {
Ok(p) => {
// A guest sees one person, so their details open at once.
if is_guest && selected.get_untracked().is_none() {
selected.set(p.first().map(|p| p.id));
}
people.set(p);
error.set(None);
}
Err(e) if e.status == 404 && is_guest => {
error.set(Some("This link is no longer valid.".into()))
}
Err(e) => error.set(Some(e.to_string())),
}
})
};
refresh();
if let Ok(handle) = set_interval_with_handle(refresh, POLL) {
on_cleanup(move || handle.clear());
}
let visible = window_event_listener(leptos::ev::visibilitychange, move |_| refresh());
on_cleanup(move || visible.remove());
let el = NodeRef::<leptos::html::Div>::new();
let map = StoredValue::new_local(None::<LMap>);
let markers = StoredValue::new_local(HashMap::<i64, Marker>::new());
let trail = StoredValue::new_local(None::<Layer>);
let ready = RwSignal::new(false);
let fitted = StoredValue::new(false);
let trail_fitted_for = StoredValue::new(None::<(i64, String)>);
Effect::new(move |_| {
let Some(div) = el.get() else { return };
if ready.get_untracked() {
return;
}
let m = leaflet_map(&div);
m.set_view(&latlng(50.0, 10.0), 4.0);
tile_layer(
TILES,
&obj(&[("maxZoom", 19.into()), ("attribution", ATTRIBUTION.into())]),
)
.add_to(&m);
map.set_value(Some(m));
ready.set(true);
});
on_cleanup(move || {
map.with_value(|m| m.as_ref().map(LMap::remove_map))
.unwrap_or_default()
});
Effect::new(move |_| {
let list = people.get();
if !ready.get() {
return;
}
map.with_value(|m| {
let m = m.as_ref().unwrap();
markers.update_value(|mk| sync_markers(m, mk, &list, selected));
let positions: Vec<_> = list
.iter()
.filter_map(|p| p.last().map(|d| &d.last))
.collect();
if !fitted.get_value() && !positions.is_empty() {
fitted.set_value(true);
match positions[..] {
[p] => m.set_view(&latlng(p.lat, p.lon), 15.0),
_ => m.fit_bounds(
&positions.iter().map(|p| latlng(p.lat, p.lon)).collect(),
&fit_opts(),
),
}
}
});
});
// Reloads on every poll too, so a current trail grows while you watch.
Effect::new(move |_| {
let (Some(p), Some(dev), Ok((from, to))) = (person(), device(), range()) else {
track.set(Vec::new());
return;
};
if p.trail == Trail::None || span.get() == "none" {
track.set(Vec::new());
return;
}
let id = p.id;
spawn_local(async move {
let result = match guest.get_value() {
None => {
http::get::<Vec<Point>>(&format!(
"/api/people/{id}/track?from={from}&to={to}&device={dev}"
))
.await
}
Some(auth) => {
let body = GuestTrack {
auth,
device: dev,
from,
to,
};
http::post::<Vec<Point>>("/api/guest/track", &body).await
}
};
match result {
Ok(t) => track.set(t),
Err(e) => error.set(Some(e.to_string())),
}
});
});
Effect::new(move |_| {
let points = track.get();
if !ready.get() {
return;
}
map.with_value(|m| {
let m = m.as_ref().unwrap();
trail.update_value(|old| {
if let Some(line) = old.take() {
line.remove_layer();
}
let (Some(id), Some(dev)) = (selected.get_untracked(), untrack(device)) else {
return;
};
if points.is_empty() {
return;
}
let coords: Array = points.iter().map(|p| latlng(p.lat, p.lon)).collect();
let line = polyline(
&coords,
&obj(&[
("color", color(id).into()),
("weight", 4.into()),
("opacity", 0.7.into()),
]),
);
line.add_to(m);
*old = Some(line);
// Fit once per selection, not on every poll, so the user can pan freely.
let key = Some((dev, untrack(filter_key)));
if trail_fitted_for.get_value() != key {
trail_fitted_for.set_value(key);
m.fit_bounds(&coords, &fit_opts());
}
});
});
});
let select = move |p: &Person| {
let id = p.id;
if selected.get_untracked() == Some(id) {
selected.set(None);
return;
}
selected.set(Some(id));
trail_device.set(p.last().map(|d| d.id));
if let Some(last) = p.last().map(|d| &d.last) {
map.with_value(|m| m.as_ref().map(|m| m.pan_to(&latlng(last.lat, last.lon))));
}
};
view! {
<div class="map-page">
<aside>
<p class="error">{move || error.get()}</p>
<ul class="people">
{move || {
people
.get()
.into_iter()
.enumerate()
.map(|(i, p)| {
let id = p.id;
let detail = describe(&p);
let name = if i == 0 && !is_guest { format!("{} (you)", p.username) } else { p.username.clone() };
view! {
<li class:selected=move || selected.get() == Some(id) on:click=move |_| select(&p)>
<span class="dot" style:background=color(id)></span>
<span class="name">{name}</span>
<div class="hint">{detail}</div>
</li>
}
})
.collect_view()
}}
</ul>
{move || {
let p = person().filter(|p| p.trail != Trail::None && !p.devices.is_empty())?;
let current = device();
Some(view! {
<label>
"Device"
<select on:change:target=move |ev| trail_device.set(ev.target().value().parse().ok())>
{p
.devices
.into_iter()
.map(|d| view! { <option value=d.id selected=current == Some(d.id)>{d.name}</option> })
.collect_view()}
</select>
</label>
})
}}
<Show when=move || person().is_none_or(|p| p.trail != Trail::None)>
<label>
"Trail"
<select bind:value=span>
<option value="none">"None, current position only"</option>
<option value="24h">"Last 24 hours"</option>
<option value="day">"One day"</option>
<option value="range">"Time range"</option>
</select>
</label>
<Show when=move || span.get() == "day">
<label>"Day" <input type="date" bind:value=day /></label>
</Show>
<Show when=move || span.get() == "range">
<label>"From" <input type="datetime-local" bind:value=from /></label>
<label>"To" <input type="datetime-local" bind:value=to /></label>
</Show>
</Show>
<p class="hint">
{move || match (person(), range()) {
(_, Err(msg)) => msg.to_string(),
(None, _) => "Select a person to show their trail.".to_string(),
(Some(p), _) if p.trail == Trail::None => format!("{} shares only their current position.", p.username),
_ if span.get() == "none" => String::new(),
(Some(Person { trail: Trail::Since(t), username, .. }), _) => {
format!("{} {username} shares the trail since {}.", count(track.get().len()), fmt_time(t))
}
(Some(_), _) => count(track.get().len()),
}}
</p>
{move || expires.get().map(|t| view! { <p class="hint">"This link works until " {fmt_time(t)} "."</p> })}
{(!is_guest).then(|| view! { <crate::locate::LocationControls /> })}
</aside>
<div class="map" node_ref=el></div>
</div>
}
}
fn count(n: usize) -> String {
match n {
1 => "1 point.".into(),
n => format!("{n} points."),
}
}
fn fit_opts() -> JsValue {
obj(&[
("padding", Array::of2(&40.into(), &40.into()).into()),
("maxZoom", 16.into()),
])
}
fn describe(p: &Person) -> String {
let Some(d) = p.last() else {
return "no position yet".into();
};
let l = &d.last;
let mut parts = vec![ago(l.ts)];
if p.devices.len() > 1 {
parts.push(d.name.clone());
}
match (p.precision_m, l.acc) {
(0, Some(acc)) => parts.push(format!("±{acc:.0} m")),
(0, None) => {}
(m @ ..1000, _) => parts.push(format!("approximate, {m} m")),
(m, _) => parts.push(format!("approximate, {} km", m / 1000)),
}
if let Some(speed) = l.speed {
parts.push(format!("{:.0} km/h", speed * 3.6));
}
if let Some(b) = l.battery {
parts.push(format!("battery {b}%"));
}
parts.join(" · ")
}
Aweb/src/passkey.rs
@@ -0,0 +1,65 @@
//! The browser half of WebAuthn.
//!
//! The browser converts between the base64url wire format and ArrayBuffers itself
//! (`parse*OptionsFromJSON` and `toJSON()`), so this is a thin shim. web-sys has WebAuthn
//! bindings only behind `--cfg web_sys_unstable_apis`.
use wasm_bindgen::prelude::*;
#[wasm_bindgen(inline_js = r#"
export function passkeySupported() {
return typeof window.PublicKeyCredential === "function"
&& typeof PublicKeyCredential.parseRequestOptionsFromJSON === "function"
&& typeof PublicKeyCredential.parseCreationOptionsFromJSON === "function";
}
export async function passkeyCreate(optionsJson) {
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(JSON.parse(optionsJson).publicKey);
const cred = await navigator.credentials.create({ publicKey });
return JSON.stringify(cred.toJSON());
}
export async function passkeyGet(optionsJson) {
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(JSON.parse(optionsJson).publicKey);
const cred = await navigator.credentials.get({ publicKey });
const json = cred.toJSON();
// webauthn-rs wants the key present, and some browsers leave it out.
if (json.response && !("userHandle" in json.response)) json.response.userHandle = null;
return JSON.stringify(json);
}
"#)]
extern "C" {
#[wasm_bindgen(js_name = passkeySupported)]
pub fn supported() -> bool;
#[wasm_bindgen(js_name = passkeyCreate, catch)]
async fn js_create(options: &str) -> Result<JsValue, JsValue>;
#[wasm_bindgen(js_name = passkeyGet, catch)]
async fn js_get(options: &str) -> Result<JsValue, JsValue>;
}
pub async fn create(options: &str) -> Result<String, String> {
js_create(options)
.await
.map_err(error_text)
.map(|v| v.as_string().unwrap_or_default())
}
pub async fn get(options: &str) -> Result<String, String> {
js_get(options)
.await
.map_err(error_text)
.map(|v| v.as_string().unwrap_or_default())
}
/// The browser reports "cancelled" and "no matching passkey" as the same NotAllowedError.
fn error_text(e: JsValue) -> String {
match js_sys::Reflect::get(&e, &"name".into())
.ok()
.and_then(|v| v.as_string())
{
Some(name) if name != "NotAllowedError" => format!("The passkey was not used ({name})."),
_ => "The passkey was not used.".into(),
}
}
Dweb/src/session.ts-28
@@ -1,28 +0,0 @@
import { createContext, useContext } from "solid-js";
import type { Accessor } from "solid-js";
import type { Me, PersonState } from "./api";
/**
* What the routed pages need from the shell.
*
* A context rather than props because route components are constructed by the
* router and cannot be handed anything. It holds the *live* store, so both pages
* share one polling loop rather than starting one each.
*/
export interface SessionValue {
me: Me;
people: Record<number, PersonState>;
order: Accessor<number[]>;
serverTime: Accessor<number>;
selected: Accessor<number | null>;
select: (id: number) => void;
trail: Accessor<[number, number][] | null>;
}
export const SessionContext = createContext<SessionValue>();
export function useSession(): SessionValue {
const value = useContext(SessionContext);
if (!value) throw new Error("useSession outside the shell");
return value;
}
Aweb/src/settings.rs
@@ -0,0 +1,1068 @@
use api::{
Challenge, ChallengeAnswer, ChangePassword, Device, DeviceToken, Link, NewDevice, NewLink,
NewShare, NewUser, Passkey, ResetPassword, SetRetention, SetRole, SetTwoFactor, Share,
ShareSettings, Shares, Trail, User,
};
use leptos::prelude::*;
use leptos::task::spawn_local;
use wasm_bindgen::prelude::*;
use crate::{Account, ago, fmt_time, http, map, now_secs, passkey};
#[derive(Clone, Copy, PartialEq)]
enum Tab {
Sharing,
Links,
Devices,
History,
Security,
Users,
}
#[component]
pub fn Settings() -> impl IntoView {
let account = expect_context::<Account>();
let tab = RwSignal::new(Tab::Sharing);
let is_admin = move || account.me().is_some_and(|m| m.is_admin);
let tab_button = move |t: Tab, label: &'static str| {
view! { <button class:active=move || tab.get() == t on:click=move |_| tab.set(t)>{label}</button> }
};
view! {
<div class="settings">
<nav class="tabs">
{tab_button(Tab::Sharing, "Sharing")}
{tab_button(Tab::Links, "Guest links")}
{tab_button(Tab::Devices, "Devices")}
{tab_button(Tab::History, "History")}
{tab_button(Tab::Security, "Security")}
<Show when=is_admin>{tab_button(Tab::Users, "Users")}</Show>
</nav>
{move || match tab.get() {
Tab::Sharing => view! { <SharesSection /> }.into_any(),
Tab::Links => view! { <Links /> }.into_any(),
Tab::Devices => view! { <Devices /> }.into_any(),
Tab::History => view! { <History /> }.into_any(),
Tab::Security => view! { <Security /> }.into_any(),
Tab::Users => view! { <Users /> }.into_any(),
}}
</div>
}
}
fn confirm(msg: &str) -> bool {
window().confirm_with_message(msg).unwrap_or(false)
}
fn expiry(e: Option<i64>) -> String {
match e {
None => "never".into(),
Some(t) => fmt_time(t),
}
}
/// The base URL devices connect to.
fn server_url(account: Account) -> String {
account
.me()
.and_then(|m| m.public_url)
.unwrap_or_else(|| window().location().origin().unwrap_or_default())
}
/// Shows an Ok message or an Err message.
fn status(message: RwSignal<Option<Result<String, String>>>) -> impl IntoView {
move || {
message.get().map(|m| match m {
Ok(text) => view! { <p>{text}</p> }.into_any(),
Err(text) => view! { <p class="error">{text}</p> }.into_any(),
})
}
}
#[component]
fn Devices() -> impl IntoView {
let account = expect_context::<Account>();
let version = RwSignal::new(0);
let error = RwSignal::new(None::<String>);
let name = RwSignal::new(String::new());
let new_token = RwSignal::new(None::<String>);
let devices = LocalResource::new(move || {
version.track();
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();
let body = NewDevice {
name: name.get_untracked(),
};
spawn_local(async move {
match http::post::<DeviceToken>("/api/devices", &body).await {
Ok(t) => {
new_token.set(Some(t.token));
name.set(String::new());
error.set(None);
}
Err(e) => error.set(Some(e.to_string())),
}
version.update(|v| *v += 1);
});
};
let remove = move |d: &Device| {
let question = if d.web {
"Delete the location history of the web app? It starts again when a browser sends a position.".to_string()
} else {
format!(
"Remove device \"{}\" and delete its location history? It can no longer upload positions.",
d.name
)
};
if !confirm(&question) {
return;
}
let id = d.id;
spawn_local(async move {
match http::delete(&format!("/api/devices/{id}")).await {
Ok(()) => error.set(None),
Err(e) => error.set(Some(e.to_string())),
}
version.update(|v| *v += 1);
});
};
let token_url = url.clone();
view! {
<section>
<h2>"Add a device"</h2>
<form on:submit=add>
<label>"Name" <input placeholder="phone" required bind:value=name /></label>
<button class="primary">"Create token"</button>
</form>
{move || {
new_token
.get()
.map(|token| {
view! {
<p>"The token is shown only once. Set up the device with:"</p>
<p><code class="copy">{format!("ot use-token {token_url} {token}")}</code></p>
}
})
}}
<p class="hint">
"Or register with your password: " <code>{format!("ot login {url} {username}")}</code>
</p>
<p class="error">{move || error.get()}</p>
</section>
<section>
<h2>"Devices"</h2>
{move || {
devices
.get()
.map(|r| match r {
Err(e) => view! { <p class="error">{e.to_string()}</p> }.into_any(),
Ok(list) if list.is_empty() => view! { <p class="hint">"No devices yet."</p> }.into_any(),
Ok(list) => {
view! {
<table>
<tr><th>"Name"</th><th>"Added"</th><th>"Last upload"</th><th></th></tr>
{list
.into_iter()
.map(|d| {
view! {
<tr>
<td>
{d.name.clone()}
{d.web.then(|| view! { <div class="hint">"The web app, in any browser"</div> })}
</td>
<td>{fmt_time(d.created_at)}</td>
<td>{d.last_seen_at.map_or("never".into(), ago)}</td>
<td class="actions"><button on:click=move |_| remove(&d)>"Remove"</button></td>
</tr>
}
})
.collect_view()}
</table>
}
.into_any()
}
})
}}
</section>
}
}
/// The form fields that shares and guest links have in common.
#[derive(Clone, Copy)]
struct ShareOptions {
duration: RwSignal<String>,
precision: RwSignal<String>,
all_devices: RwSignal<bool>,
chosen: RwSignal<Vec<i64>>,
history: RwSignal<String>,
since: RwSignal<String>,
}
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()
);
ShareOptions {
duration: RwSignal::new("0".into()),
precision: RwSignal::new("0".into()),
all_devices: RwSignal::new(true),
chosen: RwSignal::new(Vec::new()),
history: RwSignal::new("none".into()),
since: RwSignal::new(today),
}
}
fn expires_at(&self) -> Option<i64> {
let secs: i64 = self.duration.get_untracked().parse().unwrap_or(0);
(secs > 0).then(|| now_secs() + secs)
}
fn settings(&self) -> Result<ShareSettings, String> {
let trail = match self.history.get_untracked().as_str() {
"none" => Trail::None,
"now" => Trail::Since(now_secs()),
"since" => Trail::Since(
map::local_time(&self.since.get_untracked())
.ok_or("Pick when the trail starts.")?,
),
_ => Trail::All,
};
Ok(ShareSettings {
devices: (!self.all_devices.get_untracked()).then(|| self.chosen.get_untracked()),
trail,
precision_m: self.precision.get_untracked().parse().unwrap_or(0),
})
}
}
#[component]
fn ShareOptionsFields(
opts: ShareOptions,
#[prop(into)] devices: Signal<Vec<Device>>,
) -> impl IntoView {
let ShareOptions {
duration,
precision,
all_devices,
chosen,
history,
since,
} = opts;
view! {
<label>
"For"
<select bind:value=duration>
<option value="0">"until I stop it"</option>
<option value="3600">"1 hour"</option>
<option value="28800">"8 hours"</option>
<option value="86400">"1 day"</option>
<option value="604800">"1 week"</option>
<option value="2592000">"1 month"</option>
</select>
</label>
<label>
"Precision"
<select bind:value=precision>
<option value="0">"exact"</option>
<option value="100">"about 100 m"</option>
<option value="1000">"about 1 km"</option>
<option value="10000">"about 10 km"</option>
</select>
</label>
<label>
"Trail"
<select bind:value=history>
<option value="none">"none, current position only"</option>
<option value="now">"from now on"</option>
<option value="since">"since a time"</option>
<option value="all">"full history"</option>
</select>
</label>
<Show when=move || history.get() == "since">
<label>"Since" <input type="datetime-local" bind:value=since /></label>
</Show>
<fieldset>
<legend>"Devices"</legend>
<label class="radio">
<input type="radio" name="share-devices" prop:checked=all_devices on:change=move |_| all_devices.set(true) />
"All devices, also ones I add later"
</label>
<label class="radio">
<input type="radio" name="share-devices" prop:checked=move || !all_devices.get() on:change=move |_| all_devices.set(false) />
"Only these devices:"
</label>
<div class="device-choice">
{move || {
devices
.get()
.into_iter()
.map(|d| {
let id = d.id;
view! {
<label class="radio">
<input
type="checkbox"
disabled=all_devices
prop:checked=move || chosen.with(|c| c.contains(&id))
on:change:target=move |ev| {
let on = ev.target().checked();
chosen.update(|c| {
c.retain(|x| *x != id);
if on {
c.push(id);
}
});
}
/>
{d.name}
</label>
}
})
.collect_view()
}}
</div>
</fieldset>
}
}
/// A short summary of what a share shows. Device names are known only for the caller's own devices.
fn describe(s: &ShareSettings, mine: Option<&[Device]>) -> String {
let devices = match (&s.devices, mine) {
(None, _) => "all devices".to_string(),
(Some(ids), Some(mine)) => ids
.iter()
.filter_map(|id| mine.iter().find(|d| d.id == *id).map(|d| d.name.clone()))
.collect::<Vec<_>>()
.join(", "),
(Some(_), None) => "some devices".to_string(),
};
let trail = match s.trail {
Trail::None => "current position only".to_string(),
Trail::Since(t) => format!("trail since {}", fmt_time(t)),
Trail::All => "full trail".to_string(),
};
let precision = match s.precision_m {
0 => "exact".to_string(),
m @ ..1000 => format!("about {m} m"),
m => format!("about {} km", m / 1000),
};
format!("{devices} · {trail} · {precision}")
}
fn my_devices() -> Signal<Vec<Device>> {
let devices = LocalResource::new(|| http::get::<Vec<Device>>("/api/devices"));
Signal::derive(move || devices.get().and_then(Result::ok).unwrap_or_default())
}
#[component]
fn SharesSection() -> impl IntoView {
let version = RwSignal::new(0);
let error = RwSignal::new(None::<String>);
let viewer = RwSignal::new(String::new());
let opts = ShareOptions::new();
let usernames = LocalResource::new(|| http::get::<Vec<String>>("/api/usernames"));
let devices = my_devices();
let shares = LocalResource::new(move || {
version.track();
http::get::<Shares>("/api/shares")
});
let submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let settings = match opts.settings() {
Ok(s) => s,
Err(e) => return error.set(Some(e)),
};
let body = NewShare {
viewer: viewer.get_untracked(),
expires_at: opts.expires_at(),
settings,
};
spawn_local(async move {
match http::post::<Share>("/api/shares", &body).await {
Ok(_) => {
viewer.set(String::new());
error.set(None);
}
Err(e) => error.set(Some(e.to_string())),
}
version.update(|v| *v += 1);
});
};
let remove = move |s: &Share, question: String| {
if !confirm(&question) {
return;
}
let id = s.id;
spawn_local(async move {
if let Err(e) = http::delete(&format!("/api/shares/{id}")).await {
error.set(Some(e.to_string()));
}
version.update(|v| *v += 1);
});
};
let table = move |list: Vec<Share>, outgoing: bool| {
if list.is_empty() {
return view! { <p class="hint">"Nobody."</p> }.into_any();
}
let mine = devices.get();
view! {
<table>
<tr><th>"User"</th><th>"Shows"</th><th>"Expires"</th><th></th></tr>
{list
.into_iter()
.map(|s| {
let (label, question) = if outgoing {
("Stop", format!("Stop sharing your location with {}?", s.username))
} else {
("Remove", format!("Stop seeing the location of {}?", s.username))
};
view! {
<tr>
<td>{s.username.clone()}</td>
<td>{describe(&s.settings, outgoing.then_some(&mine[..]))}</td>
<td>{expiry(s.expires_at)}</td>
<td class="actions"><button on:click=move |_| remove(&s, question.clone())>{label}</button></td>
</tr>
}
})
.collect_view()}
</table>
}
.into_any()
};
view! {
<section>
<h2>"Share my location"</h2>
<form on:submit=submit>
<label>
"With"
<input placeholder="username" list="usernames" autocomplete="off" required bind:value=viewer />
</label>
<datalist id="usernames">
{move || {
usernames
.get()
.and_then(Result::ok)
.unwrap_or_default()
.into_iter()
.map(|name| view! { <option value=name></option> })
.collect_view()
}}
</datalist>
<ShareOptionsFields opts devices />
<button class="primary">"Share"</button>
</form>
<p class="hint">"Sharing again with the same person replaces the settings."</p>
<p class="error">{move || error.get()}</p>
</section>
{move || {
shares
.get()
.map(|r| match r {
Err(e) => view! { <section><p class="error">{e.to_string()}</p></section> }.into_any(),
Ok(s) => {
view! {
<section>
<h2>"I share my location with"</h2>
{table(s.outgoing, true)}
</section>
<section>
<h2>"Shared with me"</h2>
{table(s.incoming, false)}
</section>
}
.into_any()
}
})
}}
}
}
#[wasm_bindgen(inline_js = r#"
export function copyText(text) {
if (!navigator.clipboard) return Promise.resolve(false);
return navigator.clipboard.writeText(text).then(() => true, () => false);
}
"#)]
extern "C" {
#[wasm_bindgen(js_name = copyText)]
async fn copy_text(text: &str) -> JsValue;
}
/// The clipboard needs HTTPS or localhost. Elsewhere the link appears in a prompt to copy by hand.
async fn copy(text: String) -> bool {
let copied = copy_text(&text).await.as_bool() == Some(true);
if !copied {
let _ = window().prompt_with_message_and_default("Copy this link:", &text);
}
copied
}
#[component]
fn Links() -> impl IntoView {
let account = expect_context::<Account>();
let version = RwSignal::new(0);
let message = RwSignal::new(None::<Result<String, String>>);
let name = RwSignal::new(String::new());
let password = RwSignal::new(String::new());
let opts = ShareOptions::new();
let devices = my_devices();
let links = LocalResource::new(move || {
version.track();
http::get::<Vec<Link>>("/api/links")
});
let base = StoredValue::new(server_url(account));
let url = move |token: &str| format!("{}/#l={token}", base.get_value());
let create = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let settings = match opts.settings() {
Ok(s) => s,
Err(e) => return message.set(Some(Err(e))),
};
let pw = password.get_untracked();
let body = NewLink {
name: name.get_untracked(),
expires_at: opts.expires_at(),
settings,
password: (!pw.is_empty()).then_some(pw),
};
spawn_local(async move {
match http::post::<Link>("/api/links", &body).await {
Ok(link) => {
name.set(String::new());
password.set(String::new());
let copied = copy(url(&link.token)).await;
message.set(Some(Ok(if copied {
"Link created and copied.".into()
} else {
"Link created.".into()
})));
}
Err(e) => message.set(Some(Err(e.to_string()))),
}
version.update(|v| *v += 1);
});
};
let remove = move |l: &Link| {
if !confirm("Delete this link? Everyone who has it loses access.") {
return;
}
let id = l.id;
spawn_local(async move {
if let Err(e) = http::delete(&format!("/api/links/{id}")).await {
message.set(Some(Err(e.to_string())));
}
version.update(|v| *v += 1);
});
};
view! {
<section>
<h2>"Create a guest link"</h2>
<p class="hint">"Anyone with the link sees your location, without an account."</p>
<form on:submit=create>
<label>"Name" <input placeholder="for Anna" maxlength="100" bind:value=name /></label>
<ShareOptionsFields opts devices />
<label>
"Password (optional)"
<input type="password" autocomplete="new-password" minlength="8" bind:value=password />
</label>
<button class="primary">"Create link"</button>
</form>
{status(message)}
</section>
<section>
<h2>"Guest links"</h2>
{move || {
links
.get()
.map(|r| match r {
Err(e) => view! { <p class="error">{e.to_string()}</p> }.into_any(),
Ok(list) if list.is_empty() => view! { <p class="hint">"No links yet."</p> }.into_any(),
Ok(list) => {
let mine = devices.get();
view! {
<table>
<tr><th>"Name"</th><th>"Shows"</th><th>"Expires"</th><th></th></tr>
{list
.into_iter()
.map(|l| {
let link = url(&l.token);
let mut shows = describe(&l.settings, Some(&mine));
if l.has_password {
shows.push_str(" · password");
}
view! {
<tr>
<td>{if l.name.is_empty() { "unnamed".to_string() } else { l.name.clone() }}</td>
<td>{shows}</td>
<td>{expiry(l.expires_at)}</td>
<td class="actions">
<button on:click=move |_| {
let link = link.clone();
spawn_local(async move {
if copy(link).await {
message.set(Some(Ok("Link copied.".into())));
}
});
}>"Copy"</button>
" "
<button on:click=move |_| remove(&l)>"Delete"</button>
</td>
</tr>
}
})
.collect_view()}
</table>
}
.into_any()
}
})
}}
</section>
}
}
#[component]
fn History() -> impl IntoView {
let account = expect_context::<Account>();
let me = account.me().unwrap();
let max = me.max_retention_days;
let days = RwSignal::new(me.retention_days.map(|d| d.to_string()).unwrap_or_default());
let message = RwSignal::new(None::<Result<String, String>>);
let submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let text = days.get_untracked();
let value = match text.trim() {
"" => None,
t => match t.parse::<i64>() {
Ok(d) => Some(d),
Err(_) => {
message.set(Some(Err("Enter a number of days.".into())));
return;
}
},
};
let shorter = match (value, account.me().and_then(|m| m.retention_days).or(max)) {
(Some(new), Some(old)) => new < old,
(Some(_), None) => true,
_ => false,
};
if shorter && !confirm("Points older than this are deleted now. Continue?") {
return;
}
spawn_local(async move {
match http::put::<()>("/api/me/retention", &SetRetention { days: value }).await {
Ok(()) => {
message.set(Some(Ok("Saved.".into())));
account.reload();
}
Err(e) => message.set(Some(Err(e.to_string()))),
}
});
};
let server_limit = match max {
Some(d) => format!("The server keeps points for {d} days. You can choose a shorter time."),
None => "The server keeps points forever. You can choose a limit.".into(),
};
let placeholder = max.map_or("forever".into(), |d| d.to_string());
view! {
<section>
<h2>"Location history"</h2>
<p class="hint">{server_limit}</p>
<form on:submit=submit>
<label>
"Keep my points for (days)"
<input type="number" min="1" max=max placeholder=placeholder bind:value=days />
</label>
<button class="primary">"Save"</button>
</form>
<p class="hint">"Leave the field empty to use the server setting."</p>
{status(message)}
</section>
}
}
#[component]
fn Security() -> impl IntoView {
let account = expect_context::<Account>();
let version = RwSignal::new(0);
let passkeys = LocalResource::new(move || {
version.track();
http::get::<Vec<Passkey>>("/api/passkeys")
});
// Credential changes alter what the account allows, so Me is fetched again after each one.
let changed = move || {
version.update(|v| *v += 1);
account.reload();
};
let has_password = move || account.me().is_some_and(|m| m.has_password);
let two_factor = move || account.me().is_some_and(|m| m.two_factor);
let passkey_count = move || passkeys.get().and_then(|r| r.ok()).map_or(0, |l| l.len());
let old = RwSignal::new(String::new());
let new = RwSignal::new(String::new());
let repeat = RwSignal::new(String::new());
let pw_message = RwSignal::new(None::<Result<String, String>>);
let change_password = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
if new.get_untracked() != repeat.get_untracked() {
pw_message.set(Some(Err("The new passwords do not match.".into())));
return;
}
let body = ChangePassword {
old: has_password().then(|| old.get_untracked()),
new: new.get_untracked(),
};
spawn_local(async move {
match http::post::<()>("/api/me/password", &body).await {
Ok(()) => {
for s in [old, new, repeat] {
s.set(String::new());
}
pw_message.set(Some(Ok(
"Password saved. Your other sessions are logged out.".into(),
)));
changed();
}
Err(e) => pw_message.set(Some(Err(e.to_string()))),
}
});
};
let remove_password = move |_| {
if !confirm("Remove your password? You then sign in with a passkey only.") {
return;
}
spawn_local(async move {
match http::delete("/api/me/password").await {
Ok(()) => {
pw_message.set(Some(Ok("Password removed.".into())));
changed();
}
Err(e) => pw_message.set(Some(Err(e.to_string()))),
}
});
};
let key_name = RwSignal::new(String::new());
let key_message = RwSignal::new(None::<Result<String, String>>);
let add_passkey = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
spawn_local(async move {
let result = async {
let ch = http::post::<Challenge>("/api/passkeys/register", &())
.await
.map_err(|e| e.to_string())?;
let credential = passkey::create(&ch.options).await?;
let body = ChallengeAnswer {
state_id: ch.state_id,
credential,
name: key_name.get_untracked(),
};
http::post::<Passkey>("/api/passkeys/register/finish", &body)
.await
.map_err(|e| e.to_string())
}
.await;
match result {
Ok(k) => {
key_name.set(String::new());
key_message.set(Some(Ok(format!("Added \"{}\".", k.name))));
changed();
}
Err(e) => key_message.set(Some(Err(e))),
}
});
};
let remove_passkey = move |k: &Passkey| {
if !confirm(&format!("Remove the passkey \"{}\"?", k.name)) {
return;
}
let id = k.id;
spawn_local(async move {
match http::delete(&format!("/api/passkeys/{id}")).await {
Ok(()) => key_message.set(None),
Err(e) => key_message.set(Some(Err(e.to_string()))),
}
changed();
});
};
let mode_message = RwSignal::new(None::<Result<String, String>>);
let set_mode = move |enabled: bool| {
spawn_local(async move {
match http::put::<()>("/api/me/two-factor", &SetTwoFactor { enabled }).await {
Ok(()) => mode_message.set(None),
Err(e) => mode_message.set(Some(Err(e.to_string()))),
}
changed();
});
};
view! {
<section>
<h2>"Password"</h2>
<form on:submit=change_password>
<Show when=has_password>
<label>
"Current"
<input type="password" autocomplete="current-password" required bind:value=old />
</label>
</Show>
<label>
"New"
<input type="password" autocomplete="new-password" minlength="8" required bind:value=new />
</label>
<label>
"Repeat new"
<input type="password" autocomplete="new-password" minlength="8" required bind:value=repeat />
</label>
<button class="primary">{move || if has_password() { "Change" } else { "Set password" }}</button>
</form>
<Show
when=has_password
fallback=|| view! { <p class="hint">"You have no password. You sign in with a passkey."</p> }
>
<p>
<button
disabled=move || passkey_count() == 0 || two_factor()
on:click=remove_password
>
"Remove password"
</button>
" "
<span class="hint">"Needs a passkey, and two-factor sign-in turned off."</span>
</p>
</Show>
{status(pw_message)}
</section>
<section>
<h2>"Passkeys"</h2>
{move || {
passkeys
.get()
.map(|r| match r {
Err(e) => view! { <p class="error">{e.to_string()}</p> }.into_any(),
Ok(list) if list.is_empty() => view! { <p class="hint">"No passkeys yet."</p> }.into_any(),
Ok(list) => {
view! {
<table>
<tr><th>"Name"</th><th>"Added"</th><th>"Last used"</th><th></th></tr>
{list
.into_iter()
.map(|k| {
view! {
<tr>
<td>{k.name.clone()}</td>
<td>{fmt_time(k.created_at)}</td>
<td>{k.last_used_at.map_or("never".into(), ago)}</td>
<td class="actions"><button on:click=move |_| remove_passkey(&k)>"Remove"</button></td>
</tr>
}
})
.collect_view()}
</table>
}
.into_any()
}
})
}}
<Show
when=passkey::supported
fallback=|| view! { <p class="hint">"This browser does not support passkeys."</p> }
>
<form on:submit=add_passkey>
<label>"Name" <input placeholder="laptop, phone, key" bind:value=key_name /></label>
<button class="primary">"Add passkey"</button>
</form>
</Show>
{status(key_message)}
</section>
<section>
<h2>"Sign-in"</h2>
<label class="radio">
<input type="radio" name="mode" prop:checked=move || !two_factor() on:change=move |_| set_mode(false) />
"Password or passkey"
</label>
<label class="radio">
<input
type="radio"
name="mode"
prop:checked=two_factor
disabled=move || !has_password() || passkey_count() == 0
on:change=move |_| set_mode(true)
/>
"Password and passkey (two-factor)"
</label>
<p class="hint">"Two-factor sign-in needs a password and at least one passkey."</p>
{status(mode_message)}
</section>
}
}
#[component]
fn Users() -> impl IntoView {
let account = expect_context::<Account>();
let my_id = account.me().map(|m| m.id);
let version = RwSignal::new(0);
let message = RwSignal::new(None::<Result<String, String>>);
let users = LocalResource::new(move || {
version.track();
http::get::<Vec<User>>("/api/users")
});
let username = RwSignal::new(String::new());
let password = RwSignal::new(String::new());
let is_admin = RwSignal::new(false);
let create = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let body = NewUser {
username: username.get_untracked(),
password: password.get_untracked(),
is_admin: is_admin.get_untracked(),
};
spawn_local(async move {
match http::post::<User>("/api/users", &body).await {
Ok(u) => {
username.set(String::new());
password.set(String::new());
is_admin.set(false);
message.set(Some(Ok(format!("Created {}.", u.username))));
}
Err(e) => message.set(Some(Err(e.to_string()))),
}
version.update(|v| *v += 1);
});
};
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.",
u.username
);
let Ok(Some(password)) = window().prompt_with_message(&prompt) else {
return;
};
let (id, name) = (u.id, u.username.clone());
spawn_local(async move {
match http::post::<()>(
&format!("/api/users/{id}/password"),
&ResetPassword { password },
)
.await
{
Ok(()) => message.set(Some(Ok(format!("Password reset for {name}.")))),
Err(e) => message.set(Some(Err(e.to_string()))),
}
});
};
let set_role = move |u: &User, is_admin: bool| {
let (id, name) = (u.id, u.username.clone());
spawn_local(async move {
match http::put::<()>(&format!("/api/users/{id}/role"), &SetRole { is_admin }).await {
Ok(()) => message.set(Some(Ok(format!(
"{name} is now {}.",
if is_admin { "an admin" } else { "a user" }
)))),
Err(e) => message.set(Some(Err(e.to_string()))),
}
version.update(|v| *v += 1);
});
};
let remove = move |u: &User| {
if !confirm(&format!("Delete {} and all their data?", u.username)) {
return;
}
let id = u.id;
spawn_local(async move {
if let Err(e) = http::delete(&format!("/api/users/{id}")).await {
message.set(Some(Err(e.to_string())));
}
version.update(|v| *v += 1);
});
};
view! {
<section>
<h2>"Add a user"</h2>
<form on:submit=create>
<label>"Username" <input autocomplete="off" required bind:value=username /></label>
<label>
"Password"
<input type="password" autocomplete="new-password" minlength="8" required bind:value=password />
</label>
<label class="radio"><input type="checkbox" bind:checked=is_admin />"Admin"</label>
<button class="primary">"Add"</button>
</form>
{status(message)}
</section>
<section>
<h2>"Users"</h2>
{move || {
users
.get()
.map(|r| match r {
Err(e) => view! { <p class="error">{e.to_string()}</p> }.into_any(),
Ok(list) => {
view! {
<table>
<tr><th>"Username"</th><th>"Role"</th><th>"Created"</th><th></th></tr>
{list
.into_iter()
.map(|u| {
let me = Some(u.id) == my_id;
let u2 = u.clone();
let u3 = u.clone();
view! {
<tr>
<td>{u.username.clone()}</td>
<td>
<select
aria-label="Role"
disabled=me
on:change:target=move |ev| set_role(&u3, ev.target().value() == "admin")
>
<option value="user" selected=!u.is_admin>"user"</option>
<option value="admin" selected=u.is_admin>"admin"</option>
</select>
</td>
<td>{fmt_time(u.created_at)}</td>
<td class="actions">
<button on:click=move |_| reset(&u2)>"Reset password"</button>
" "
<button disabled=me on:click=move |_| remove(&u)>"Delete"</button>
</td>
</tr>
}
})
.collect_view()}
</table>
}
.into_any()
}
})
}}
</section>
}
}
Dweb/src/styles.css-103
@@ -1,103 +0,0 @@
/* One stylesheet, system fonts, dark by preference. No framework. */
:root {
--bg: #fff;
--fg: #16181d;
--muted: #6a7280;
--line: #dde1e7;
--accent: #1d6fd8;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root { --bg: #14161a; --fg: #e6e8ec; --muted: #949aa5; --line: #2a2e36; }
}
* { box-sizing: border-box; }
body {
margin: 0;
font: 14px/1.45 system-ui, sans-serif;
background: var(--bg);
color: var(--fg);
}
button, input {
font: inherit;
color: inherit;
background: transparent;
border: 1px solid var(--line);
border-radius: 6px;
padding: 6px 10px;
}
button { cursor: pointer; }
button:hover:not(:disabled) { border-color: var(--accent); }
button:disabled { cursor: default; opacity: 0.6; }
button.active { border-color: var(--accent); color: var(--accent); }
a { color: var(--accent); }
/* The nav is <a> now that routes are real URLs, so it has to look like the
buttons it replaced: middle-click and "copy link" only work on an anchor. */
header nav a {
border: 1px solid var(--line);
border-radius: 6px;
padding: 6px 10px;
color: inherit;
text-decoration: none;
}
header nav a:hover { border-color: var(--accent); }
header nav a.active { border-color: var(--accent); color: var(--accent); }
.center { min-height: 100dvh; display: grid; place-content: center; gap: 10px; }
.card { border: 1px solid var(--line); border-radius: 10px; padding: 16px; }
.login { width: min(320px, 90vw); }
.login h1 { margin: 0 0 4px; font-size: 18px; }
.error { color: #d8461d; margin: 0; }
.muted { color: var(--muted); }
.banner { padding: 6px 12px; background: #d8461d22; border-bottom: 1px solid var(--line); }
.shell { display: flex; flex-direction: column; height: 100dvh; }
header {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-bottom: 1px solid var(--line);
}
header nav { display: flex; gap: 6px; }
header .who { margin-left: auto; color: var(--muted); }
.split { flex: 1; display: grid; grid-template-columns: 260px 1fr; min-height: 0; }
aside { border-right: 1px solid var(--line); overflow-y: auto; padding: 8px; display: grid; gap: 6px; align-content: start; }
.map { height: 100%; }
.person { display: grid; gap: 2px; text-align: left; width: 100%; }
.person .name { font-weight: 600; }
.person .meta { color: var(--muted); font-size: 12px; display: flex; align-items: center; gap: 4px; }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.dot.fresh { background: #2e9e4f; }
.dot.stale { background: #d8a11d; }
.dot.old { background: #d8461d; }
.settings { padding: 16px; display: grid; gap: 16px; max-width: 640px; }
.settings h2 { margin: 0 0 8px; font-size: 15px; }
.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; }
.leaflet-control-attribution a { color: #1d6fd8 !important; }
Aweb/style.css
@@ -0,0 +1,149 @@
/* data-theme on <html> forces a theme. Without it the system setting decides. */
:root {
color-scheme: light dark;
--bg: light-dark(#f9fafb, #111827);
--surface: light-dark(white, #1f2937);
--fg: light-dark(#1f2937, #e5e7eb);
--muted: light-dark(#6b7280, #9ca3af);
--border: light-dark(#d1d5db, #4b5563);
--line: light-dark(#e5e7eb, #374151);
--hover: light-dark(#f3f4f6, #374151);
--accent-bg: light-dark(#eff6ff, #1e3a8a);
--accent-fg: light-dark(#1d4ed8, #bfdbfe);
--error: light-dark(#b91c1c, #f87171);
}
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font: 15px/1.4 system-ui, sans-serif;
color: var(--fg);
background: var(--bg);
display: flex;
flex-direction: column;
}
button, input, select { font: inherit; }
button {
padding: 0.35em 0.9em;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
cursor: pointer;
}
button:hover { background: var(--hover); }
button.primary { background: #2563eb; border-color: #2563eb; color: white; }
button:disabled { opacity: 0.5; cursor: default; }
input, select { padding: 0.35em 0.6em; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); }
.error { color: var(--error); }
.hint { color: var(--muted); font-size: 0.9em; }
code { background: var(--hover); padding: 0.1em 0.3em; border-radius: 4px; }
header {
display: flex;
align-items: center;
gap: 0.75em;
padding: 0.5em 1em;
background: var(--surface);
border-bottom: 1px solid var(--line);
}
header .brand { display: block; margin-right: 0.5em; }
header > * { flex-shrink: 0; }
header button { white-space: nowrap; }
header nav { display: flex; gap: 0.25em; flex: 1 1 auto; }
header nav button { border-color: transparent; }
header nav button.active { background: var(--accent-bg); color: var(--accent-fg); }
header .user { color: var(--muted); }
main { flex: 1; min-height: 0; display: flex; }
.login {
margin: 15vh auto;
width: min(320px, 90vw);
display: flex;
flex-direction: column;
gap: 0.75em;
}
.login label, .settings label { display: flex; flex-direction: column; gap: 0.2em; }
.map-page { flex: 1; display: flex; min-height: 0; }
.map-page aside {
width: 300px;
overflow-y: auto;
padding: 0.75em;
background: var(--surface);
border-right: 1px solid var(--line);
}
.map-page .map { flex: 1; }
.people { list-style: none; margin: 0 0 1em; padding: 0; }
.people li { padding: 0.5em; border-radius: 6px; cursor: pointer; }
.people li:hover { background: var(--hover); }
.people li.selected { background: var(--accent-bg); }
.people .name { font-weight: 600; }
.people .dot {
display: inline-block;
width: 0.7em;
height: 0.7em;
border-radius: 50%;
margin-right: 0.4em;
}
.settings { flex: 1; overflow-y: auto; padding: 1em; }
.settings section {
max-width: 640px;
margin: 0 auto 1.5em;
padding: 1em;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
}
.settings h2 { margin-top: 0; font-size: 1.1em; }
.settings h3 { font-size: 1em; margin-bottom: 0.3em; }
.settings form { display: flex; flex-wrap: wrap; gap: 0.5em; align-items: end; }
.settings table { width: 100%; border-collapse: collapse; }
.settings td, .settings th { text-align: left; padding: 0.3em 0.5em 0.3em 0; }
.settings th { color: var(--muted); font-weight: normal; font-size: 0.9em; }
@media (max-width: 700px) {
.map-page { flex-direction: column; }
.map-page aside { width: auto; max-height: 35vh; border-right: 0; border-bottom: 1px solid var(--line); }
header { gap: 0.5em; padding: 0.5em; }
header .user { display: none; }
}
.settings .tabs {
display: flex;
flex-wrap: wrap;
gap: 0.25em;
max-width: 640px;
margin: 0 auto 1em;
}
.settings .tabs button { border-color: transparent; background: transparent; }
.settings .tabs button.active { background: var(--accent-bg); color: var(--accent-fg); }
.settings label.radio { flex-direction: row; align-items: center; gap: 0.4em; margin: 0.3em 0; }
.settings td.actions { text-align: right; white-space: nowrap; }
code.copy { word-break: break-all; user-select: all; }
.map-page aside label { display: flex; flex-direction: column; gap: 0.2em; margin-bottom: 0.5em; }
button.theme { display: flex; align-items: center; padding: 0.4em; }
/* OSM only has light tiles. Inverting them gives a usable dark map. */
:root[data-theme="dark"] .leaflet-tile-pane { filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9); }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .leaflet-tile-pane { filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9); }
}
.leaflet-container .leaflet-bar a,
.leaflet-container .leaflet-control-attribution,
.leaflet-tooltip { background: var(--surface); color: var(--fg); border-color: var(--line); }
.leaflet-container .leaflet-control-attribution a { color: var(--accent-fg); }
.send-location { border-top: 1px solid var(--line); margin-top: 1em; padding-top: 0.5em; }
.send-location h3 { font-size: 1em; margin: 0.3em 0 0.6em; }
.send-location .auto-send { display: flex; align-items: center; gap: 0.4em; margin-top: 0.6em; }
.send-location .auto-send input[type="number"] { width: 5em; }
.map-page aside .send-location label.radio { flex-direction: row; align-items: center; gap: 0.4em; margin: 0; }
.settings fieldset { flex-basis: 100%; border: 1px solid var(--border); border-radius: 6px; margin: 0; padding: 0.3em 0.8em 0.5em; }
.settings fieldset legend { padding: 0 0.3em; }
.settings .device-choice { padding-left: 1.6em; }
.settings .device-choice label:has(input:disabled) { color: var(--muted); }
.guest-title { flex: 1 1 auto; min-width: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
Dweb/tsconfig.json-15
@@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "preserve",
"jsxImportSource": "solid-js",
"strict": true,
"noUnusedLocals": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
Aweb/vendor/leaflet.css
@@ -0,0 +1,661 @@
/* required styles */^M
^M
.leaflet-pane,^M
.leaflet-tile,^M
.leaflet-marker-icon,^M
.leaflet-marker-shadow,^M
.leaflet-tile-container,^M
.leaflet-pane > svg,^M
.leaflet-pane > canvas,^M
.leaflet-zoom-box,^M
.leaflet-image-layer,^M
.leaflet-layer {^M
position: absolute;^M
left: 0;^M
top: 0;^M
}^M
.leaflet-container {^M
overflow: hidden;^M
}^M
.leaflet-tile,^M
.leaflet-marker-icon,^M
.leaflet-marker-shadow {^M
-webkit-user-select: none;^M
-moz-user-select: none;^M
user-select: none;^M
-webkit-user-drag: none;^M
}^M
/* Prevents IE11 from highlighting tiles in blue */^M
.leaflet-tile::selection {^M
background: transparent;^M
}^M
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */^M
.leaflet-safari .leaflet-tile {^M
image-rendering: -webkit-optimize-contrast;^M
}^M
/* hack that prevents hw layers "stretching" when loading new tiles */^M
.leaflet-safari .leaflet-tile-container {^M
width: 1600px;^M
height: 1600px;^M
-webkit-transform-origin: 0 0;^M
}^M
.leaflet-marker-icon,^M
.leaflet-marker-shadow {^M
display: block;^M
}^M
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */^M
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */^M
.leaflet-container .leaflet-overlay-pane svg {^M
max-width: none !important;^M
max-height: none !important;^M
}^M
.leaflet-container .leaflet-marker-pane img,^M
.leaflet-container .leaflet-shadow-pane img,^M
.leaflet-container .leaflet-tile-pane img,^M
.leaflet-container img.leaflet-image-layer,^M
.leaflet-container .leaflet-tile {^M
max-width: none !important;^M
max-height: none !important;^M
width: auto;^M
padding: 0;^M
}^M
^M
.leaflet-container img.leaflet-tile {^M
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */^M
mix-blend-mode: plus-lighter;^M
}^M
^M
.leaflet-container.leaflet-touch-zoom {^M
-ms-touch-action: pan-x pan-y;^M
touch-action: pan-x pan-y;^M
}^M
.leaflet-container.leaflet-touch-drag {^M
-ms-touch-action: pinch-zoom;^M
/* Fallback for FF which doesn't support pinch-zoom */^M
touch-action: none;^M
touch-action: pinch-zoom;^M
}^M
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {^M
-ms-touch-action: none;^M
touch-action: none;^M
}^M
.leaflet-container {^M
-webkit-tap-highlight-color: transparent;^M
}^M
.leaflet-container a {^M
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);^M
}^M
.leaflet-tile {^M
filter: inherit;^M
visibility: hidden;^M
}^M
.leaflet-tile-loaded {^M
visibility: inherit;^M
}^M
.leaflet-zoom-box {^M
width: 0;^M
height: 0;^M
-moz-box-sizing: border-box;^M
box-sizing: border-box;^M
z-index: 800;^M
}^M
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */^M
.leaflet-overlay-pane svg {^M
-moz-user-select: none;^M
}^M
^M
.leaflet-pane { z-index: 400; }^M
^M
.leaflet-tile-pane { z-index: 200; }^M
.leaflet-overlay-pane { z-index: 400; }^M
.leaflet-shadow-pane { z-index: 500; }^M
.leaflet-marker-pane { z-index: 600; }^M
.leaflet-tooltip-pane { z-index: 650; }^M
.leaflet-popup-pane { z-index: 700; }^M
^M
.leaflet-map-pane canvas { z-index: 100; }^M
.leaflet-map-pane svg { z-index: 200; }^M
^M
.leaflet-vml-shape {^M
width: 1px;^M
height: 1px;^M
}^M
.lvml {^M
behavior: url(#default#VML);^M
display: inline-block;^M
position: absolute;^M
}^M
^M
^M
/* control positioning */^M
^M
.leaflet-control {^M
position: relative;^M
z-index: 800;^M
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */^M
pointer-events: auto;^M
}^M
.leaflet-top,^M
.leaflet-bottom {^M
position: absolute;^M
z-index: 1000;^M
pointer-events: none;^M
}^M
.leaflet-top {^M
top: 0;^M
}^M
.leaflet-right {^M
right: 0;^M
}^M
.leaflet-bottom {^M
bottom: 0;^M
}^M
.leaflet-left {^M
left: 0;^M
}^M
.leaflet-control {^M
float: left;^M
clear: both;^M
}^M
.leaflet-right .leaflet-control {^M
float: right;^M
}^M
.leaflet-top .leaflet-control {^M
margin-top: 10px;^M
}^M
.leaflet-bottom .leaflet-control {^M
margin-bottom: 10px;^M
}^M
.leaflet-left .leaflet-control {^M
margin-left: 10px;^M
}^M
.leaflet-right .leaflet-control {^M
margin-right: 10px;^M
}^M
^M
^M
/* zoom and fade animations */^M
^M
.leaflet-fade-anim .leaflet-popup {^M
opacity: 0;^M
-webkit-transition: opacity 0.2s linear;^M
-moz-transition: opacity 0.2s linear;^M
transition: opacity 0.2s linear;^M
}^M
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {^M
opacity: 1;^M
}^M
.leaflet-zoom-animated {^M
-webkit-transform-origin: 0 0;^M
-ms-transform-origin: 0 0;^M
transform-origin: 0 0;^M
}^M
svg.leaflet-zoom-animated {^M
will-change: transform;^M
}^M
^M
.leaflet-zoom-anim .leaflet-zoom-animated {^M
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);^M
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);^M
transition: transform 0.25s cubic-bezier(0,0,0.25,1);^M
}^M
.leaflet-zoom-anim .leaflet-tile,^M
.leaflet-pan-anim .leaflet-tile {^M
-webkit-transition: none;^M
-moz-transition: none;^M
transition: none;^M
}^M
^M
.leaflet-zoom-anim .leaflet-zoom-hide {^M
visibility: hidden;^M
}^M
^M
^M
/* cursors */^M
^M
.leaflet-interactive {^M
cursor: pointer;^M
}^M
.leaflet-grab {^M
cursor: -webkit-grab;^M
cursor: -moz-grab;^M
cursor: grab;^M
}^M
.leaflet-crosshair,^M
.leaflet-crosshair .leaflet-interactive {^M
cursor: crosshair;^M
}^M
.leaflet-popup-pane,^M
.leaflet-control {^M
cursor: auto;^M
}^M
.leaflet-dragging .leaflet-grab,^M
.leaflet-dragging .leaflet-grab .leaflet-interactive,^M
.leaflet-dragging .leaflet-marker-draggable {^M
cursor: move;^M
cursor: -webkit-grabbing;^M
cursor: -moz-grabbing;^M
cursor: grabbing;^M
}^M
^M
/* marker & overlays interactivity */^M
.leaflet-marker-icon,^M
.leaflet-marker-shadow,^M
.leaflet-image-layer,^M
.leaflet-pane > svg path,^M
.leaflet-tile-container {^M
pointer-events: none;^M
}^M
^M
.leaflet-marker-icon.leaflet-interactive,^M
.leaflet-image-layer.leaflet-interactive,^M
.leaflet-pane > svg path.leaflet-interactive,^M
svg.leaflet-image-layer.leaflet-interactive path {^M
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */^M
pointer-events: auto;^M
}^M
^M
/* visual tweaks */^M
^M
.leaflet-container {^M
background: #ddd;^M
outline-offset: 1px;^M
}^M
.leaflet-container a {^M
color: #0078A8;^M
}^M
.leaflet-zoom-box {^M
border: 2px dotted #38f;^M
background: rgba(255,255,255,0.5);^M
}^M
^M
^M
/* general typography */^M
.leaflet-container {^M
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;^M
font-size: 12px;^M
font-size: 0.75rem;^M
line-height: 1.5;^M
}^M
^M
^M
/* general toolbar styles */^M
^M
.leaflet-bar {^M
box-shadow: 0 1px 5px rgba(0,0,0,0.65);^M
border-radius: 4px;^M
}^M
.leaflet-bar a {^M
background-color: #fff;^M
border-bottom: 1px solid #ccc;^M
width: 26px;^M
height: 26px;^M
line-height: 26px;^M
display: block;^M
text-align: center;^M
text-decoration: none;^M
color: black;^M
}^M
.leaflet-bar a,^M
.leaflet-control-layers-toggle {^M
background-position: 50% 50%;^M
background-repeat: no-repeat;^M
display: block;^M
}^M
.leaflet-bar a:hover,^M
.leaflet-bar a:focus {^M
background-color: #f4f4f4;^M
}^M
.leaflet-bar a:first-child {^M
border-top-left-radius: 4px;^M
border-top-right-radius: 4px;^M
}^M
.leaflet-bar a:last-child {^M
border-bottom-left-radius: 4px;^M
border-bottom-right-radius: 4px;^M
border-bottom: none;^M
}^M
.leaflet-bar a.leaflet-disabled {^M
cursor: default;^M
background-color: #f4f4f4;^M
color: #bbb;^M
}^M
^M
.leaflet-touch .leaflet-bar a {^M
width: 30px;^M
height: 30px;^M
line-height: 30px;^M
}^M
.leaflet-touch .leaflet-bar a:first-child {^M
border-top-left-radius: 2px;^M
border-top-right-radius: 2px;^M
}^M
.leaflet-touch .leaflet-bar a:last-child {^M
border-bottom-left-radius: 2px;^M
border-bottom-right-radius: 2px;^M
}^M
^M
/* zoom control */^M
^M
.leaflet-control-zoom-in,^M
.leaflet-control-zoom-out {^M
font: bold 18px 'Lucida Console', Monaco, monospace;^M
text-indent: 1px;^M
}^M
^M
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {^M
font-size: 22px;^M
}^M
^M
^M
/* layers control */^M
^M
.leaflet-control-layers {^M
box-shadow: 0 1px 5px rgba(0,0,0,0.4);^M
background: #fff;^M
border-radius: 5px;^M
}^M
.leaflet-control-layers-toggle {^M
background-image: url(images/layers.png);^M
width: 36px;^M
height: 36px;^M
}^M
.leaflet-retina .leaflet-control-layers-toggle {^M
background-image: url(images/layers-2x.png);^M
background-size: 26px 26px;^M
}^M
.leaflet-touch .leaflet-control-layers-toggle {^M
width: 44px;^M
height: 44px;^M
}^M
.leaflet-control-layers .leaflet-control-layers-list,^M
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {^M
display: none;^M
}^M
.leaflet-control-layers-expanded .leaflet-control-layers-list {^M
display: block;^M
position: relative;^M
}^M
.leaflet-control-layers-expanded {^M
padding: 6px 10px 6px 6px;^M
color: #333;^M
background: #fff;^M
}^M
.leaflet-control-layers-scrollbar {^M
overflow-y: scroll;^M
overflow-x: hidden;^M
padding-right: 5px;^M
}^M
.leaflet-control-layers-selector {^M
margin-top: 2px;^M
position: relative;^M
top: 1px;^M
}^M
.leaflet-control-layers label {^M
display: block;^M
font-size: 13px;^M
font-size: 1.08333em;^M
}^M
.leaflet-control-layers-separator {^M
height: 0;^M
border-top: 1px solid #ddd;^M
margin: 5px -10px 5px -6px;^M
}^M
^M
/* Default icon URLs */^M
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */^M
background-image: url(images/marker-icon.png);^M
}^M
^M
^M
/* attribution and scale controls */^M
^M
.leaflet-container .leaflet-control-attribution {^M
background: #fff;^M
background: rgba(255, 255, 255, 0.8);^M
margin: 0;^M
}^M
.leaflet-control-attribution,^M
.leaflet-control-scale-line {^M
padding: 0 5px;^M
color: #333;^M
line-height: 1.4;^M
}^M
.leaflet-control-attribution a {^M
text-decoration: none;^M
}^M
.leaflet-control-attribution a:hover,^M
.leaflet-control-attribution a:focus {^M
text-decoration: underline;^M
}^M
.leaflet-attribution-flag {^M
display: inline !important;^M
vertical-align: baseline !important;^M
width: 1em;^M
height: 0.6669em;^M
}^M
.leaflet-left .leaflet-control-scale {^M
margin-left: 5px;^M
}^M
.leaflet-bottom .leaflet-control-scale {^M
margin-bottom: 5px;^M
}^M
.leaflet-control-scale-line {^M
border: 2px solid #777;^M
border-top: none;^M
line-height: 1.1;^M
padding: 2px 5px 1px;^M
white-space: nowrap;^M
-moz-box-sizing: border-box;^M
box-sizing: border-box;^M
background: rgba(255, 255, 255, 0.8);^M
text-shadow: 1px 1px #fff;^M
}^M
.leaflet-control-scale-line:not(:first-child) {^M
border-top: 2px solid #777;^M
border-bottom: none;^M
margin-top: -2px;^M
}^M
.leaflet-control-scale-line:not(:first-child):not(:last-child) {^M
border-bottom: 2px solid #777;^M
}^M
^M
.leaflet-touch .leaflet-control-attribution,^M
.leaflet-touch .leaflet-control-layers,^M
.leaflet-touch .leaflet-bar {^M
box-shadow: none;^M
}^M
.leaflet-touch .leaflet-control-layers,^M
.leaflet-touch .leaflet-bar {^M
border: 2px solid rgba(0,0,0,0.2);^M
background-clip: padding-box;^M
}^M
^M
^M
/* popup */^M
^M
.leaflet-popup {^M
position: absolute;^M
text-align: center;^M
margin-bottom: 20px;^M
}^M
.leaflet-popup-content-wrapper {^M
padding: 1px;^M
text-align: left;^M
border-radius: 12px;^M
}^M
.leaflet-popup-content {^M
margin: 13px 24px 13px 20px;^M
line-height: 1.3;^M
font-size: 13px;^M
font-size: 1.08333em;^M
min-height: 1px;^M
}^M
.leaflet-popup-content p {^M
margin: 17px 0;^M
margin: 1.3em 0;^M
}^M
.leaflet-popup-tip-container {^M
width: 40px;^M
height: 20px;^M
position: absolute;^M
left: 50%;^M
margin-top: -1px;^M
margin-left: -20px;^M
overflow: hidden;^M
pointer-events: none;^M
}^M
.leaflet-popup-tip {^M
width: 17px;^M
height: 17px;^M
padding: 1px;^M
^M
margin: -10px auto 0;^M
pointer-events: auto;^M
^M
-webkit-transform: rotate(45deg);^M
-moz-transform: rotate(45deg);^M
-ms-transform: rotate(45deg);^M
transform: rotate(45deg);^M
}^M
.leaflet-popup-content-wrapper,^M
.leaflet-popup-tip {^M
background: white;^M
color: #333;^M
box-shadow: 0 3px 14px rgba(0,0,0,0.4);^M
}^M
.leaflet-container a.leaflet-popup-close-button {^M
position: absolute;^M
top: 0;^M
right: 0;^M
border: none;^M
text-align: center;^M
width: 24px;^M
height: 24px;^M
font: 16px/24px Tahoma, Verdana, sans-serif;^M
color: #757575;^M
text-decoration: none;^M
background: transparent;^M
}^M
.leaflet-container a.leaflet-popup-close-button:hover,^M
.leaflet-container a.leaflet-popup-close-button:focus {^M
color: #585858;^M
}^M
.leaflet-popup-scrolled {^M
overflow: auto;^M
}^M
^M
.leaflet-oldie .leaflet-popup-content-wrapper {^M
-ms-zoom: 1;^M
}^M
.leaflet-oldie .leaflet-popup-tip {^M
width: 24px;^M
margin: 0 auto;^M
^M
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";^M
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);^M
}^M
^M
.leaflet-oldie .leaflet-control-zoom,^M
.leaflet-oldie .leaflet-control-layers,^M
.leaflet-oldie .leaflet-popup-content-wrapper,^M
.leaflet-oldie .leaflet-popup-tip {^M
border: 1px solid #999;^M
}^M
^M
^M
/* div icon */^M
^M
.leaflet-div-icon {^M
background: #fff;^M
border: 1px solid #666;^M
}^M
^M
^M
/* Tooltip */^M
/* Base styles for the element that has a tooltip */^M
.leaflet-tooltip {^M
position: absolute;^M
padding: 6px;^M
background-color: #fff;^M
border: 1px solid #fff;^M
border-radius: 3px;^M
color: #222;^M
white-space: nowrap;^M
-webkit-user-select: none;^M
-moz-user-select: none;^M
-ms-user-select: none;^M
user-select: none;^M
pointer-events: none;^M
box-shadow: 0 1px 3px rgba(0,0,0,0.4);^M
}^M
.leaflet-tooltip.leaflet-interactive {^M
cursor: pointer;^M
pointer-events: auto;^M
}^M
.leaflet-tooltip-top:before,^M
.leaflet-tooltip-bottom:before,^M
.leaflet-tooltip-left:before,^M
.leaflet-tooltip-right:before {^M
position: absolute;^M
pointer-events: none;^M
border: 6px solid transparent;^M
background: transparent;^M
content: "";^M
}^M
^M
/* Directions */^M
^M
.leaflet-tooltip-bottom {^M
margin-top: 6px;^M
}^M
.leaflet-tooltip-top {^M
margin-top: -6px;^M
}^M
.leaflet-tooltip-bottom:before,^M
.leaflet-tooltip-top:before {^M
left: 50%;^M
margin-left: -6px;^M
}^M
.leaflet-tooltip-top:before {^M
bottom: 0;^M
margin-bottom: -12px;^M
border-top-color: #fff;^M
}^M
.leaflet-tooltip-bottom:before {^M
top: 0;^M
margin-top: -12px;^M
margin-left: -6px;^M
border-bottom-color: #fff;^M
}^M
.leaflet-tooltip-left {^M
margin-left: -6px;^M
}^M
.leaflet-tooltip-right {^M
margin-left: 6px;^M
}^M
.leaflet-tooltip-left:before,^M
.leaflet-tooltip-right:before {^M
top: 50%;^M
margin-top: -6px;^M
}^M
.leaflet-tooltip-left:before {^M
right: 0;^M
margin-right: -12px;^M
border-left-color: #fff;^M
}^M
.leaflet-tooltip-right:before {^M
left: 0;^M
margin-left: -12px;^M
border-right-color: #fff;^M
}^M
^M
/* Printing */^M
^M
@media print {^M
/* Prevent printers from removing background-images of controls. */^M
.leaflet-control {^M
-webkit-print-color-adjust: exact;^M
print-color-adjust: exact;^M
}^M
}^M
Aweb/vendor/leaflet.js
@@ -0,0 +1,6 @@
/* @preserve
* Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com
* (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n<o;n++)for(e in i=arguments[n])t[e]=i[e];return t}var R=Object.create||function(t){return N.prototype=t,new N};function N(){}function a(t,e){var i,n=Array.prototype.slice;return t.bind?t.bind.apply(t,n.call(arguments,1)):(i=n.call(arguments,2),function(){return t.apply(e,i.length?i.concat(n.call(arguments)):arguments)})}var D=0;function h(t){return"_leaflet_id"in t||(t._leaflet_id=++D),t._leaflet_id}function j(t,e,i){var n,o,s=function(){n=!1,o&&(r.apply(i,o),o=!1)},r=function(){n?o=arguments:(t.apply(i,arguments),setTimeout(s,e),n=!0)};return r}function H(t,e,i){var n=e[1],e=e[0],o=n-e;return t===n&&i?t:((t-e)%o+o)%o+e}function u(){return!1}function i(t,e){return!1===e?t:(e=Math.pow(10,void 0===e?6:e),Math.round(t*e)/e)}function W(t){return t.trim?t.trim():t.replace(/^\s+|\s+$/g,"")}function F(t){return W(t).split(/\s+/)}function c(t,e){for(var i in Object.prototype.hasOwnProperty.call(t,"options")||(t.options=t.options?R(t.options):{}),e)t.options[i]=e[i];return t.options}function U(t,e,i){var n,o=[];for(n in t)o.push(encodeURIComponent(i?n.toUpperCase():n)+"="+encodeURIComponent(t[n]));return(e&&-1!==e.indexOf("?")?"&":"?")+o.join("&")}var V=/\{ *([\w_ -]+) *\}/g;function q(t,i){return t.replace(V,function(t,e){e=i[e];if(void 0===e)throw new Error("No value provided for variable "+t);return e="function"==typeof e?e(i):e})}var d=Array.isArray||function(t){return"[object Array]"===Object.prototype.toString.call(t)};function G(t,e){for(var i=0;i<t.length;i++)if(t[i]===e)return i;return-1}var K="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";function Y(t){return window["webkit"+t]||window["moz"+t]||window["ms"+t]}var X=0;function J(t){var e=+new Date,i=Math.max(0,16-(e-X));return X=e+i,window.setTimeout(t,i)}var $=window.requestAnimationFrame||Y("RequestAnimationFrame")||J,Q=window.cancelAnimationFrame||Y("CancelAnimationFrame")||Y("CancelRequestAnimationFrame")||function(t){window.clearTimeout(t)};function x(t,e,i){if(!i||$!==J)return $.call(window,a(t,e));t.call(e)}function r(t){t&&Q.call(window,t)}var tt={__proto__:null,extend:l,create:R,bind:a,get lastId(){return D},stamp:h,throttle:j,wrapNum:H,falseFn:u,formatNum:i,trim:W,splitWords:F,setOptions:c,getParamString:U,template:q,isArray:d,indexOf:G,emptyImageUrl:K,requestFn:$,cancelFn:Q,requestAnimFrame:x,cancelAnimFrame:r};function et(){}et.extend=function(t){function e(){c(this),this.initialize&&this.initialize.apply(this,arguments),this.callInitHooks()}var i,n=e.__super__=this.prototype,o=R(n);for(i in(o.constructor=e).prototype=o,this)Object.prototype.hasOwnProperty.call(this,i)&&"prototype"!==i&&"__super__"!==i&&(e[i]=this[i]);if(t.statics&&l(e,t.statics),t.includes){var s=t.includes;if("undefined"!=typeof L&&L&&L.Mixin){s=d(s)?s:[s];for(var r=0;r<s.length;r++)s[r]===L.Mixin.Events&&console.warn("Deprecated include of L.Mixin.Events: this property will be removed in future releases, please inherit from L.Evented instead.",(new Error).stack)}l.apply(null,[o].concat(t.includes))}return l(o,t),delete o.statics,delete o.includes,o.options&&(o.options=n.options?R(n.options):{},l(o.options,t.options)),o._initHooks=[],o.callInitHooks=function(){if(!this._initHooksCalled){n.callInitHooks&&n.callInitHooks.call(this),this._initHooksCalled=!0;for(var t=0,e=o._initHooks.length;t<e;t++)o._initHooks[t].call(this)}},e},et.include=function(t){var e=this.prototype.options;return l(this.prototype,t),t.options&&(this.prototype.options=e,this.mergeOptions(t.options)),this},et.mergeOptions=function(t){return l(this.prototype.options,t),this},et.addInitHook=function(t){var e=Array.prototype.slice.call(arguments,1),i="function"==typeof t?t:function(){this[t].apply(this,e)};return this.prototype._initHooks=this.prototype._initHooks||[],this.prototype._initHooks.push(i),this};var e={on:function(t,e,i){if("object"==typeof t)for(var n in t)this._on(n,t[n],e);else for(var o=0,s=(t=F(t)).length;o<s;o++)this._on(t[o],e,i);return this},off:function(t,e,i){if(arguments.length)if("object"==typeof t)for(var n in t)this._off(n,t[n],e);else{t=F(t);for(var o=1===arguments.length,s=0,r=t.length;s<r;s++)o?this._off(t[s]):this._off(t[s],e,i)}else delete this._events;return this},_on:function(t,e,i,n){"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1===this._listens(t,e,i)&&(e={fn:e,ctx:i=i===this?void 0:i},n&&(e.once=!0),this._events=this._events||{},this._events[t]=this._events[t]||[],this._events[t].push(e))},_off:function(t,e,i){var n,o,s;if(this._events&&(n=this._events[t]))if(1===arguments.length){if(this._firingCount)for(o=0,s=n.length;o<s;o++)n[o].fn=u;delete this._events[t]}else"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1!==(e=this._listens(t,e,i))&&(i=n[e],this._firingCount&&(i.fn=u,this._events[t]=n=n.slice()),n.splice(e,1))},fire:function(t,e,i){if(this.listens(t,i)){var n=l({},e,{type:t,target:this,sourceTarget:e&&e.sourceTarget||this});if(this._events){var o=this._events[t];if(o){this._firingCount=this._firingCount+1||1;for(var s=0,r=o.length;s<r;s++){var a=o[s],h=a.fn;a.once&&this.off(t,h,a.ctx),h.call(a.ctx||this,n)}this._firingCount--}}i&&this._propagateEvent(n)}return this},listens:function(t,e,i,n){"string"!=typeof t&&console.warn('"string" type argument expected');var o=e,s=("function"!=typeof e&&(n=!!e,i=o=void 0),this._events&&this._events[t]);if(s&&s.length&&!1!==this._listens(t,o,i))return!0;if(n)for(var r in this._eventParents)if(this._eventParents[r].listens(t,e,i,n))return!0;return!1},_listens:function(t,e,i){if(this._events){var n=this._events[t]||[];if(!e)return!!n.length;i===this&&(i=void 0);for(var o=0,s=n.length;o<s;o++)if(n[o].fn===e&&n[o].ctx===i)return o}return!1},once:function(t,e,i){if("object"==typeof t)for(var n in t)this._on(n,t[n],e,!0);else for(var o=0,s=(t=F(t)).length;o<s;o++)this._on(t[o],e,i,!0);return this},addEventParent:function(t){return this._eventParents=this._eventParents||{},this._eventParents[h(t)]=t,this},removeEventParent:function(t){return this._eventParents&&delete this._eventParents[h(t)],this},_propagateEvent:function(t){for(var e in this._eventParents)this._eventParents[e].fire(t.type,l({layer:t.target,propagatedFrom:t.target},t),!0)}},it=(e.addEventListener=e.on,e.removeEventListener=e.clearAllEventListeners=e.off,e.addOneTimeEventListener=e.once,e.fireEvent=e.fire,e.hasEventListeners=e.listens,et.extend(e));function p(t,e,i){this.x=i?Math.round(t):t,this.y=i?Math.round(e):e}var nt=Math.trunc||function(t){return 0<t?Math.floor(t):Math.ceil(t)};function m(t,e,i){return t instanceof p?t:d(t)?new p(t[0],t[1]):null==t?t:"object"==typeof t&&"x"in t&&"y"in t?new p(t.x,t.y):new p(t,e,i)}function f(t,e){if(t)for(var i=e?[t,e]:t,n=0,o=i.length;n<o;n++)this.extend(i[n])}function _(t,e){return!t||t instanceof f?t:new f(t,e)}function s(t,e){if(t)for(var i=e?[t,e]:t,n=0,o=i.length;n<o;n++)this.extend(i[n])}function g(t,e){return t instanceof s?t:new s(t,e)}function v(t,e,i){if(isNaN(t)||isNaN(e))throw new Error("Invalid LatLng object: ("+t+", "+e+")");this.lat=+t,this.lng=+e,void 0!==i&&(this.alt=+i)}function w(t,e,i){return t instanceof v?t:d(t)&&"object"!=typeof t[0]?3===t.length?new v(t[0],t[1],t[2]):2===t.length?new v(t[0],t[1]):null:null==t?t:"object"==typeof t&&"lat"in t?new v(t.lat,"lng"in t?t.lng:t.lon,t.alt):void 0===e?null:new v(t,e,i)}p.prototype={clone:function(){return new p(this.x,this.y)},add:function(t){return this.clone()._add(m(t))},_add:function(t){return this.x+=t.x,this.y+=t.y,this},subtract:function(t){return this.clone()._subtract(m(t))},_subtract:function(t){return this.x-=t.x,this.y-=t.y,this},divideBy:function(t){return this.clone()._divideBy(t)},_divideBy:function(t){return this.x/=t,this.y/=t,this},multiplyBy:function(t){return this.clone()._multiplyBy(t)},_multiplyBy:function(t){return this.x*=t,this.y*=t,this},scaleBy:function(t){return new p(this.x*t.x,this.y*t.y)},unscaleBy:function(t){return new p(this.x/t.x,this.y/t.y)},round:function(){return this.clone()._round()},_round:function(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this},floor:function(){return this.clone()._floor()},_floor:function(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this},ceil:function(){return this.clone()._ceil()},_ceil:function(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this},trunc:function(){return this.clone()._trunc()},_trunc:function(){return this.x=nt(this.x),this.y=nt(this.y),this},distanceTo:function(t){var e=(t=m(t)).x-this.x,t=t.y-this.y;return Math.sqrt(e*e+t*t)},equals:function(t){return(t=m(t)).x===this.x&&t.y===this.y},contains:function(t){return t=m(t),Math.abs(t.x)<=Math.abs(this.x)&&Math.abs(t.y)<=Math.abs(this.y)},toString:function(){return"Point("+i(this.x)+", "+i(this.y)+")"}},f.prototype={extend:function(t){var e,i;if(t){if(t instanceof p||"number"==typeof t[0]||"x"in t)e=i=m(t);else if(e=(t=_(t)).min,i=t.max,!e||!i)return this;this.min||this.max?(this.min.x=Math.min(e.x,this.min.x),this.max.x=Math.max(i.x,this.max.x),this.min.y=Math.min(e.y,this.min.y),this.max.y=Math.max(i.y,this.max.y)):(this.min=e.clone(),this.max=i.clone())}return this},getCenter:function(t){return m((this.min.x+this.max.x)/2,(this.min.y+this.max.y)/2,t)},getBottomLeft:function(){return m(this.min.x,this.max.y)},getTopRight:function(){return m(this.max.x,this.min.y)},getTopLeft:function(){return this.min},getBottomRight:function(){return this.max},getSize:function(){return this.max.subtract(this.min)},contains:function(t){var e,i;return(t=("number"==typeof t[0]||t instanceof p?m:_)(t))instanceof f?(e=t.min,i=t.max):e=i=t,e.x>=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.x<i.x,t=t.y>e.y&&n.y<i.y;return o&&t},isValid:function(){return!(!this.min||!this.max)},pad:function(t){var e=this.min,i=this.max,n=Math.abs(e.x-i.x)*t,t=Math.abs(e.y-i.y)*t;return _(m(e.x-n,e.y-t),m(i.x+n,i.y+t))},equals:function(t){return!!t&&(t=_(t),this.min.equals(t.getTopLeft())&&this.max.equals(t.getBottomRight()))}},s.prototype={extend:function(t){var e,i,n=this._southWest,o=this._northEast;if(t instanceof v)i=e=t;else{if(!(t instanceof s))return t?this.extend(w(t)||g(t)):this;if(e=t._southWest,i=t._northEast,!e||!i)return this}return n||o?(n.lat=Math.min(e.lat,n.lat),n.lng=Math.min(e.lng,n.lng),o.lat=Math.max(i.lat,o.lat),o.lng=Math.max(i.lng,o.lng)):(this._southWest=new v(e.lat,e.lng),this._northEast=new v(i.lat,i.lng)),this},pad:function(t){var e=this._southWest,i=this._northEast,n=Math.abs(e.lat-i.lat)*t,t=Math.abs(e.lng-i.lng)*t;return new s(new v(e.lat-n,e.lng-t),new v(i.lat+n,i.lng+t))},getCenter:function(){return new v((this._southWest.lat+this._northEast.lat)/2,(this._southWest.lng+this._northEast.lng)/2)},getSouthWest:function(){return this._southWest},getNorthEast:function(){return this._northEast},getNorthWest:function(){return new v(this.getNorth(),this.getWest())},getSouthEast:function(){return new v(this.getSouth(),this.getEast())},getWest:function(){return this._southWest.lng},getSouth:function(){return this._southWest.lat},getEast:function(){return this._northEast.lng},getNorth:function(){return this._northEast.lat},contains:function(t){t=("number"==typeof t[0]||t instanceof v||"lat"in t?w:g)(t);var e,i,n=this._southWest,o=this._northEast;return t instanceof s?(e=t.getSouthWest(),i=t.getNorthEast()):e=i=t,e.lat>=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.lat<i.lat,t=t.lng>e.lng&&n.lng<i.lng;return o&&t},toBBoxString:function(){return[this.getWest(),this.getSouth(),this.getEast(),this.getNorth()].join(",")},equals:function(t,e){return!!t&&(t=g(t),this._southWest.equals(t.getSouthWest(),e)&&this._northEast.equals(t.getNorthEast(),e))},isValid:function(){return!(!this._southWest||!this._northEast)}};var ot={latLngToPoint:function(t,e){t=this.projection.project(t),e=this.scale(e);return this.transformation._transform(t,e)},pointToLatLng:function(t,e){e=this.scale(e),t=this.transformation.untransform(t,e);return this.projection.unproject(t)},project:function(t){return this.projection.project(t)},unproject:function(t){return this.projection.unproject(t)},scale:function(t){return 256*Math.pow(2,t)},zoom:function(t){return Math.log(t/256)/Math.LN2},getProjectedBounds:function(t){var e;return this.infinite?null:(e=this.projection.bounds,t=this.scale(t),new f(this.transformation.transform(e.min,t),this.transformation.transform(e.max,t)))},infinite:!(v.prototype={equals:function(t,e){return!!t&&(t=w(t),Math.max(Math.abs(this.lat-t.lat),Math.abs(this.lng-t.lng))<=(void 0===e?1e-9:e))},toString:function(t){return"LatLng("+i(this.lat,t)+", "+i(this.lng,t)+")"},distanceTo:function(t){return st.distance(this,w(t))},wrap:function(){return st.wrapLatLng(this)},toBounds:function(t){var t=180*t/40075017,e=t/Math.cos(Math.PI/180*this.lat);return g([this.lat-t,this.lng-e],[this.lat+t,this.lng+e])},clone:function(){return new v(this.lat,this.lng,this.alt)}}),wrapLatLng:function(t){var e=this.wrapLng?H(t.lng,this.wrapLng,!0):t.lng;return new v(this.wrapLat?H(t.lat,this.wrapLat,!0):t.lat,e,t.alt)},wrapLatLngBounds:function(t){var e=t.getCenter(),i=this.wrapLatLng(e),n=e.lat-i.lat,e=e.lng-i.lng;return 0==n&&0==e?t:(i=t.getSouthWest(),t=t.getNorthEast(),new s(new v(i.lat-n,i.lng-e),new v(t.lat-n,t.lng-e)))}},st=l({},ot,{wrapLng:[-180,180],R:6371e3,distance:function(t,e){var i=Math.PI/180,n=t.lat*i,o=e.lat*i,s=Math.sin((e.lat-t.lat)*i/2),e=Math.sin((e.lng-t.lng)*i/2),t=s*s+Math.cos(n)*Math.cos(o)*e*e,i=2*Math.atan2(Math.sqrt(t),Math.sqrt(1-t));return this.R*i}}),rt=6378137,rt={R:rt,MAX_LATITUDE:85.0511287798,project:function(t){var e=Math.PI/180,i=this.MAX_LATITUDE,i=Math.max(Math.min(i,t.lat),-i),i=Math.sin(i*e);return new p(this.R*t.lng*e,this.R*Math.log((1+i)/(1-i))/2)},unproject:function(t){var e=180/Math.PI;return new v((2*Math.atan(Math.exp(t.y/this.R))-Math.PI/2)*e,t.x*e/this.R)},bounds:new f([-(rt=rt*Math.PI),-rt],[rt,rt])};function at(t,e,i,n){d(t)?(this._a=t[0],this._b=t[1],this._c=t[2],this._d=t[3]):(this._a=t,this._b=e,this._c=i,this._d=n)}function ht(t,e,i,n){return new at(t,e,i,n)}at.prototype={transform:function(t,e){return this._transform(t.clone(),e)},_transform:function(t,e){return t.x=(e=e||1)*(this._a*t.x+this._b),t.y=e*(this._c*t.y+this._d),t},untransform:function(t,e){return new p((t.x/(e=e||1)-this._b)/this._a,(t.y/e-this._d)/this._c)}};var lt=l({},st,{code:"EPSG:3857",projection:rt,transformation:ht(lt=.5/(Math.PI*rt.R),.5,-lt,.5)}),ut=l({},lt,{code:"EPSG:900913"});function ct(t){return document.createElementNS("http://www.w3.org/2000/svg",t)}function dt(t,e){for(var i,n,o,s,r="",a=0,h=t.length;a<h;a++){for(i=0,n=(o=t[a]).length;i<n;i++)r+=(i?"L":"M")+(s=o[i]).x+" "+s.y;r+=e?b.svg?"z":"x":""}return r||"M0 0"}var _t=document.documentElement.style,pt="ActiveXObject"in window,mt=pt&&!document.addEventListener,n="msLaunchUri"in navigator&&!("documentMode"in document),ft=y("webkit"),gt=y("android"),vt=y("android 2")||y("android 3"),yt=parseInt(/WebKit\/([0-9]+)|$/.exec(navigator.userAgent)[1],10),yt=gt&&y("Google")&&yt<537&&!("AudioNode"in window),xt=!!window.opera,wt=!n&&y("chrome"),bt=y("gecko")&&!ft&&!xt&&!pt,Pt=!wt&&y("safari"),Lt=y("phantom"),o="OTransition"in _t,Tt=0===navigator.platform.indexOf("Win"),Mt=pt&&"transition"in _t,zt="WebKitCSSMatrix"in window&&"m11"in new window.WebKitCSSMatrix&&!vt,_t="MozPerspective"in _t,Ct=!window.L_DISABLE_3D&&(Mt||zt||_t)&&!o&&!Lt,Zt="undefined"!=typeof orientation||y("mobile"),St=Zt&&ft,Et=Zt&&zt,kt=!window.PointerEvent&&window.MSPointerEvent,Ot=!(!window.PointerEvent&&!kt),At="ontouchstart"in window||!!window.TouchEvent,Bt=!window.L_NO_TOUCH&&(At||Ot),It=Zt&&xt,Rt=Zt&&bt,Nt=1<(window.devicePixelRatio||window.screen.deviceXDPI/window.screen.logicalXDPI),Dt=function(){var t=!1;try{var e=Object.defineProperty({},"passive",{get:function(){t=!0}});window.addEventListener("testPassiveEventSupport",u,e),window.removeEventListener("testPassiveEventSupport",u,e)}catch(t){}return t}(),jt=!!document.createElement("canvas").getContext,Ht=!(!document.createElementNS||!ct("svg").createSVGRect),Wt=!!Ht&&((Wt=document.createElement("div")).innerHTML="<svg/>","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML='<v:shape adj="1"/>',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;o<s;o++)t.classList.add(n[o]);else ve(t,e)||ye(t,((i=xe(t))?i+" ":"")+e)}function z(t,e){void 0!==t.classList?t.classList.remove(e):ye(t,W((" "+xe(t)+" ").replace(" "+e+" "," ")))}function ye(t,e){void 0===t.className.baseVal?t.className=e:t.className.baseVal=e}function xe(t){return void 0===(t=t.correspondingElement?t.correspondingElement:t).className.baseVal?t.className:t.className.baseVal}function C(t,e){if("opacity"in t.style)t.style.opacity=e;else if("filter"in t.style){var i=!1,n="DXImageTransform.Microsoft.Alpha";try{i=t.filters.item(n)}catch(t){if(1===e)return}e=Math.round(100*e),i?(i.Enabled=100!==e,i.Opacity=e):t.style.filter+=" progid:"+n+"(opacity="+e+")"}}function we(t){for(var e=document.documentElement.style,i=0;i<t.length;i++)if(t[i]in e)return t[i];return!1}function be(t,e,i){e=e||new p(0,0);t.style[ue]=(b.ie3d?"translate("+e.x+"px,"+e.y+"px)":"translate3d("+e.x+"px,"+e.y+"px,0)")+(i?" scale("+i+")":"")}function Z(t,e){t._leaflet_pos=e,b.any3d?be(t,e):(t.style.left=e.x+"px",t.style.top=e.y+"px")}function Pe(t){return t._leaflet_pos||new p(0,0)}function Le(){S(window,"dragstart",O)}function Te(){k(window,"dragstart",O)}function Me(t){for(;-1===t.tabIndex;)t=t.parentNode;t.style&&(ze(),le=(he=t).style.outlineStyle,t.style.outlineStyle="none",S(window,"keydown",ze))}function ze(){he&&(he.style.outlineStyle=le,le=he=void 0,k(window,"keydown",ze))}function Ce(t){for(;!((t=t.parentNode).offsetWidth&&t.offsetHeight||t===document.body););return t}function Ze(t){var e=t.getBoundingClientRect();return{x:e.width/t.offsetWidth||1,y:e.height/t.offsetHeight||1,boundingClientRect:e}}ae="onselectstart"in document?(re=function(){S(window,"selectstart",O)},function(){k(window,"selectstart",O)}):(se=we(["userSelect","WebkitUserSelect","OUserSelect","MozUserSelect","msUserSelect"]),re=function(){var t;se&&(t=document.documentElement.style,oe=t[se],t[se]="none")},function(){se&&(document.documentElement.style[se]=oe,oe=void 0)});pt={__proto__:null,TRANSFORM:ue,TRANSITION:ce,TRANSITION_END:de,get:_e,getStyle:pe,create:P,remove:T,empty:me,toFront:fe,toBack:ge,hasClass:ve,addClass:M,removeClass:z,setClass:ye,getClass:xe,setOpacity:C,testProp:we,setTransform:be,setPosition:Z,getPosition:Pe,get disableTextSelection(){return re},get enableTextSelection(){return ae},disableImageDrag:Le,enableImageDrag:Te,preventOutline:Me,restoreOutline:ze,getSizedParentNode:Ce,getScale:Ze};function S(t,e,i,n){if(e&&"object"==typeof e)for(var o in e)ke(t,o,e[o],i);else for(var s=0,r=(e=F(e)).length;s<r;s++)ke(t,e[s],i,n);return this}var E="_leaflet_events";function k(t,e,i,n){if(1===arguments.length)Se(t),delete t[E];else if(e&&"object"==typeof e)for(var o in e)Oe(t,o,e[o],i);else if(e=F(e),2===arguments.length)Se(t,function(t){return-1!==G(e,t)});else for(var s=0,r=e.length;s<r;s++)Oe(t,e[s],i,n);return this}function Se(t,e){for(var i in t[E]){var n=i.split(/\d/)[0];e&&!e(n)||Oe(t,n,null,null,i)}}var Ee={mouseenter:"mouseover",mouseleave:"mouseout",wheel:!("onwheel"in window)&&"mousewheel"};function ke(e,t,i,n){var o,s,r=t+h(i)+(n?"_"+h(n):"");e[E]&&e[E][r]||(s=o=function(t){return i.call(n||e,t||window.event)},!b.touchNative&&b.pointer&&0===t.indexOf("touch")?o=Jt(e,t,o):b.touch&&"dblclick"===t?o=ne(e,o):"addEventListener"in e?"touchstart"===t||"touchmove"===t||"wheel"===t||"mousewheel"===t?e.addEventListener(Ee[t]||t,o,!!b.passiveEvents&&{passive:!1}):"mouseenter"===t||"mouseleave"===t?e.addEventListener(Ee[t],o=function(t){t=t||window.event,We(e,t)&&s(t)},!1):e.addEventListener(t,s,!1):e.attachEvent("on"+t,o),e[E]=e[E]||{},e[E][r]=o)}function Oe(t,e,i,n,o){o=o||e+h(i)+(n?"_"+h(n):"");var s,r,i=t[E]&&t[E][o];i&&(!b.touchNative&&b.pointer&&0===e.indexOf("touch")?(n=t,r=i,Gt[s=e]?n.removeEventListener(Gt[s],r,!1):console.warn("wrong event specified:",s)):b.touch&&"dblclick"===e?(n=i,(r=t).removeEventListener("dblclick",n.dblclick),r.removeEventListener("click",n.simDblclick)):"removeEventListener"in t?t.removeEventListener(Ee[e]||e,i,!1):t.detachEvent("on"+e,i),t[E][o]=null)}function Ae(t){return t.stopPropagation?t.stopPropagation():t.originalEvent?t.originalEvent._stopped=!0:t.cancelBubble=!0,this}function Be(t){return ke(t,"wheel",Ae),this}function Ie(t){return S(t,"mousedown touchstart dblclick contextmenu",Ae),t._leaflet_disable_click=!0,this}function O(t){return t.preventDefault?t.preventDefault():t.returnValue=!1,this}function Re(t){return O(t),Ae(t),this}function Ne(t){if(t.composedPath)return t.composedPath();for(var e=[],i=t.target;i;)e.push(i),i=i.parentNode;return e}function De(t,e){var i,n;return e?(n=(i=Ze(e)).boundingClientRect,new p((t.clientX-n.left)/i.x-e.clientLeft,(t.clientY-n.top)/i.y-e.clientTop)):new p(t.clientX,t.clientY)}var je=b.linux&&b.chrome?window.devicePixelRatio:b.mac?3*window.devicePixelRatio:0<window.devicePixelRatio?2*window.devicePixelRatio:1;function He(t){return b.edge?t.wheelDeltaY/2:t.deltaY&&0===t.deltaMode?-t.deltaY/je:t.deltaY&&1===t.deltaMode?20*-t.deltaY:t.deltaY&&2===t.deltaMode?60*-t.deltaY:t.deltaX||t.deltaZ?0:t.wheelDelta?(t.wheelDeltaY||t.wheelDelta)/2:t.detail&&Math.abs(t.detail)<32765?20*-t.detail:t.detail?t.detail/-32765*60:0}function We(t,e){var i=e.relatedTarget;if(!i)return!0;try{for(;i&&i!==t;)i=i.parentNode}catch(t){return!1}return i!==t}var mt={__proto__:null,on:S,off:k,stopPropagation:Ae,disableScrollPropagation:Be,disableClickPropagation:Ie,preventDefault:O,stop:Re,getPropagationPath:Ne,getMousePosition:De,getWheelDelta:He,isExternalTarget:We,addListener:S,removeListener:k},Fe=it.extend({run:function(t,e,i,n){this.stop(),this._el=t,this._inProgress=!0,this._duration=i||.25,this._easeOutPower=1/Math.max(n||.5,.2),this._startPos=Pe(t),this._offset=e.subtract(this._startPos),this._startTime=+new Date,this.fire("start"),this._animate()},stop:function(){this._inProgress&&(this._step(!0),this._complete())},_animate:function(){this._animId=x(this._animate,this),this._step()},_step:function(t){var e=+new Date-this._startTime,i=1e3*this._duration;e<i?this._runFrame(this._easeOut(e/i),t):(this._runFrame(1),this._complete())},_runFrame:function(t,e){t=this._startPos.add(this._offset.multiplyBy(t));e&&t._round(),Z(this._el,t),this.fire("step")},_complete:function(){r(this._animId),this._inProgress=!1,this.fire("end")},_easeOut:function(t){return 1-Math.pow(1-t,this._easeOutPower)}}),A=it.extend({options:{crs:lt,center:void 0,zoom:void 0,minZoom:void 0,maxZoom:void 0,layers:[],maxBounds:void 0,renderer:void 0,zoomAnimation:!0,zoomAnimationThreshold:4,fadeAnimation:!0,markerZoomAnimation:!0,transform3DLimit:8388608,zoomSnap:1,zoomDelta:1,trackResize:!0},initialize:function(t,e){e=c(this,e),this._handlers=[],this._layers={},this._zoomBoundLayers={},this._sizeChanged=!0,this._initContainer(t),this._initLayout(),this._onResize=a(this._onResize,this),this._initEvents(),e.maxBounds&&this.setMaxBounds(e.maxBounds),void 0!==e.zoom&&(this._zoom=this._limitZoom(e.zoom)),e.center&&void 0!==e.zoom&&this.setView(w(e.center),e.zoom,{reset:!0}),this.callInitHooks(),this._zoomAnimated=ce&&b.any3d&&!b.mobileOpera&&this.options.zoomAnimation,this._zoomAnimated&&(this._createAnimProxy(),S(this._proxy,de,this._catchTransitionEnd,this)),this._addLayers(this.options.layers)},setView:function(t,e,i){if((e=void 0===e?this._zoom:this._limitZoom(e),t=this._limitCenter(w(t),e,this.options.maxBounds),i=i||{},this._stop(),this._loaded&&!i.reset&&!0!==i)&&(void 0!==i.animate&&(i.zoom=l({animate:i.animate},i.zoom),i.pan=l({animate:i.animate,duration:i.duration},i.pan)),this._zoom!==e?this._tryAnimatedZoom&&this._tryAnimatedZoom(t,e,i.zoom):this._tryAnimatedPan(t,i.pan)))return clearTimeout(this._sizeTimer),this;return this._resetView(t,e,i.pan&&i.pan.noMoveStart),this},setZoom:function(t,e){return this._loaded?this.setView(this.getCenter(),t,{zoom:e}):(this._zoom=t,this)},zoomIn:function(t,e){return t=t||(b.any3d?this.options.zoomDelta:1),this.setZoom(this._zoom+t,e)},zoomOut:function(t,e){return t=t||(b.any3d?this.options.zoomDelta:1),this.setZoom(this._zoom-t,e)},setZoomAround:function(t,e,i){var n=this.getZoomScale(e),o=this.getSize().divideBy(2),t=(t instanceof p?t:this.latLngToContainerPoint(t)).subtract(o).multiplyBy(1-1/n),n=this.containerPointToLatLng(o.add(t));return this.setView(n,e,{zoom:i})},_getBoundsCenterZoom:function(t,e){e=e||{},t=t.getBounds?t.getBounds():g(t);var i=m(e.paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.getBoundsZoom(t,!1,i.add(n));return(o="number"==typeof e.maxZoom?Math.min(e.maxZoom,o):o)===1/0?{center:t.getCenter(),zoom:o}:(e=n.subtract(i).divideBy(2),n=this.project(t.getSouthWest(),o),i=this.project(t.getNorthEast(),o),{center:this.unproject(n.add(i).divideBy(2).add(e),o),zoom:o})},fitBounds:function(t,e){if((t=g(t)).isValid())return t=this._getBoundsCenterZoom(t,e),this.setView(t.center,t.zoom,e);throw new Error("Bounds are not valid.")},fitWorld:function(t){return this.fitBounds([[-90,-180],[90,180]],t)},panTo:function(t,e){return this.setView(t,this._zoom,{pan:e})},panBy:function(t,e){var i;return e=e||{},(t=m(t).round()).x||t.y?(!0===e.animate||this.getSize().contains(t)?(this._panAnim||(this._panAnim=new Fe,this._panAnim.on({step:this._onPanTransitionStep,end:this._onPanTransitionEnd},this)),e.noMoveStart||this.fire("movestart"),!1!==e.animate?(M(this._mapPane,"leaflet-pan-anim"),i=this._getMapPanePos().subtract(t).round(),this._panAnim.run(this._mapPane,i,e.duration||.25,e.easeLinearity)):(this._rawPanBy(t),this.fire("move").fire("moveend"))):this._resetView(this.unproject(this.project(this.getCenter()).add(t)),this.getZoom()),this):this.fire("moveend")},flyTo:function(n,o,t){if(!1===(t=t||{}).animate||!b.any3d)return this.setView(n,o,t);this._stop();var s=this.project(this.getCenter()),r=this.project(n),e=this.getSize(),a=this._zoom,h=(n=w(n),o=void 0===o?a:o,Math.max(e.x,e.y)),i=h*this.getZoomScale(a,o),l=r.distanceTo(s)||1,u=1.42,c=u*u;function d(t){t=(i*i-h*h+(t?-1:1)*c*c*l*l)/(2*(t?i:h)*c*l),t=Math.sqrt(t*t+1)-t;return t<1e-9?-18:Math.log(t)}function _(t){return(Math.exp(t)-Math.exp(-t))/2}function p(t){return(Math.exp(t)+Math.exp(-t))/2}var m=d(0);function f(t){return h*(p(m)*(_(t=m+u*t)/p(t))-_(m))/c}var g=Date.now(),v=(d(1)-m)/u,y=t.duration?1e3*t.duration:1e3*v*.8;return this._moveStart(!0,t.noMoveStart),function t(){var e=(Date.now()-g)/y,i=(1-Math.pow(1-e,1.5))*v;e<=1?(this._flyToFrame=x(t,this),this._move(this.unproject(s.add(r.subtract(s).multiplyBy(f(i)/l)),a),this.getScaleZoom(h/(e=i,h*(p(m)/p(m+u*e))),a),{flyTo:!0})):this._move(n,o)._moveEnd(!0)}.call(this),this},flyToBounds:function(t,e){t=this._getBoundsCenterZoom(t,e);return this.flyTo(t.center,t.zoom,e)},setMaxBounds:function(t){return t=g(t),this.listens("moveend",this._panInsideMaxBounds)&&this.off("moveend",this._panInsideMaxBounds),t.isValid()?(this.options.maxBounds=t,this._loaded&&this._panInsideMaxBounds(),this.on("moveend",this._panInsideMaxBounds)):(this.options.maxBounds=null,this)},setMinZoom:function(t){var e=this.options.minZoom;return this.options.minZoom=t,this._loaded&&e!==t&&(this.fire("zoomlevelschange"),this.getZoom()<this.options.minZoom)?this.setZoom(t):this},setMaxZoom:function(t){var e=this.options.maxZoom;return this.options.maxZoom=t,this._loaded&&e!==t&&(this.fire("zoomlevelschange"),this.getZoom()>this.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;s<i.length;s++)i[s].listens(e,!0)&&o.push(i[s]);n=o.concat(n)}if(n.length){"contextmenu"===e&&O(t);var r,a=n[0],h={originalEvent:t};for("keypress"!==t.type&&"keydown"!==t.type&&"keyup"!==t.type&&(r=a.getLatLng&&(!a._radius||a._radius<=10),h.containerPoint=r?this.latLngToContainerPoint(a.getLatLng()):this.mouseEventToContainerPoint(t),h.layerPoint=this.containerPointToLayerPoint(h.containerPoint),h.latlng=r?a.getLatLng():this.layerPointToLatLng(h.layerPoint)),s=0;s<n.length;s++)if(n[s].fire(e,h,!0),h.originalEvent._stopped||!1===n[s].options.bubblingMouseEvents&&-1!==G(this._mouseEvents,e))return}},_draggableMoved:function(t){return(t=t.dragging&&t.dragging.enabled()?t:this).dragging&&t.dragging.moved()||this.boxZoom&&this.boxZoom.moved()},_clearHandlers:function(){for(var t=0,e=this._handlers.length;t<e;t++)this._handlers[t].disable()},whenReady:function(t,e){return this._loaded?t.call(e||this,{target:this}):this.on("load",t,e),this},_getMapPanePos:function(){return Pe(this._mapPane)||new p(0,0)},_moved:function(){var t=this._getMapPanePos();return t&&!t.equals([0,0])},_getTopLeftPoint:function(t,e){return(t&&void 0!==e?this._getNewPixelOrigin(t,e):this.getPixelOrigin()).subtract(this._getMapPanePos())},_getNewPixelOrigin:function(t,e){var i=this.getSize()._divideBy(2);return this.project(t,e)._subtract(i)._add(this._getMapPanePos())._round()},_latLngToNewLayerPoint:function(t,e,i){i=this._getNewPixelOrigin(i,e);return this.project(t,e)._subtract(i)},_latLngBoundsToNewLayerBounds:function(t,e,i){i=this._getNewPixelOrigin(i,e);return _([this.project(t.getSouthWest(),e)._subtract(i),this.project(t.getNorthWest(),e)._subtract(i),this.project(t.getSouthEast(),e)._subtract(i),this.project(t.getNorthEast(),e)._subtract(i)])},_getCenterLayerPoint:function(){return this.containerPointToLayerPoint(this.getSize()._divideBy(2))},_getCenterOffset:function(t){return this.latLngToLayerPoint(t).subtract(this._getCenterLayerPoint())},_limitCenter:function(t,e,i){var n,o;return!i||(n=this.project(t,e),o=this.getSize().divideBy(2),o=new f(n.subtract(o),n.add(o)),o=this._getBoundsOffset(o,i,e),Math.abs(o.x)<=1&&Math.abs(o.y)<=1)?t:this.unproject(n.add(o),e)},_limitOffset:function(t,e){var i;return e?(i=new f((i=this.getPixelBounds()).min.add(t),i.max.add(t)),t.add(this._getBoundsOffset(i,e))):t},_getBoundsOffset:function(t,e,i){e=_(this.project(e.getNorthEast(),i),this.project(e.getSouthWest(),i)),i=e.min.subtract(t.min),e=e.max.subtract(t.max);return new p(this._rebound(i.x,-e.x),this._rebound(i.y,-e.y))},_rebound:function(t,e){return 0<t+e?Math.round(t-e)/2:Math.max(0,Math.ceil(t))-Math.max(0,Math.floor(e))},_limitZoom:function(t){var e=this.getMinZoom(),i=this.getMaxZoom(),n=b.any3d?this.options.zoomSnap:1;return n&&(t=Math.round(t/n)*n),Math.max(e,Math.min(i,t))},_onPanTransitionStep:function(){this.fire("move")},_onPanTransitionEnd:function(){z(this._mapPane,"leaflet-pan-anim"),this.fire("moveend")},_tryAnimatedPan:function(t,e){t=this._getCenterOffset(t)._trunc();return!(!0!==(e&&e.animate)&&!this.getSize().contains(t))&&(this.panBy(t,e),!0)},_createAnimProxy:function(){var t=this._proxy=P("div","leaflet-proxy leaflet-zoom-animated");this._panes.mapPane.appendChild(t),this.on("zoomanim",function(t){var e=ue,i=this._proxy.style[e];be(this._proxy,this.project(t.center,t.zoom),this.getZoomScale(t.zoom,1)),i===this._proxy.style[e]&&this._animatingZoom&&this._onZoomTransitionEnd()},this),this.on("load moveend",this._animMoveEnd,this),this._on("unload",this._destroyAnimProxy,this)},_destroyAnimProxy:function(){T(this._proxy),this.off("load moveend",this._animMoveEnd,this),delete this._proxy},_animMoveEnd:function(){var t=this.getCenter(),e=this.getZoom();be(this._proxy,this.project(t,e),this.getZoomScale(e,1))},_catchTransitionEnd:function(t){this._animatingZoom&&0<=t.propertyName.indexOf("transform")&&this._onZoomTransitionEnd()},_nothingToAnimate:function(){return!this._container.getElementsByClassName("leaflet-zoom-animated").length},_tryAnimatedZoom:function(t,e,i){if(!this._animatingZoom){if(i=i||{},!this._zoomAnimated||!1===i.animate||this._nothingToAnimate()||Math.abs(e-this._zoom)>this.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0<t.screenX&&0<t.screenY&&this._map.getContainer().focus()}}),Ve=(A.include({addControl:function(t){return t.addTo(this),this},removeControl:function(t){return t.remove(),this},_initControlPos:function(){var i=this._controlCorners={},n="leaflet-",o=this._controlContainer=P("div",n+"control-container",this._container);function t(t,e){i[t+e]=P("div",n+t+" "+n+e,o)}t("top","left"),t("top","right"),t("bottom","left"),t("bottom","right")},_clearControlPos:function(){for(var t in this._controlCorners)T(this._controlCorners[t]);T(this._controlContainer),delete this._controlCorners,delete this._controlContainer}}),B.extend({options:{collapsed:!0,position:"topright",autoZIndex:!0,hideSingleBase:!1,sortLayers:!1,sortFunction:function(t,e,i,n){return i<n?-1:n<i?1:0}},initialize:function(t,e,i){for(var n in c(this,i),this._layerControlInputs=[],this._layers=[],this._lastZIndex=0,this._handlingClick=!1,this._preventClick=!1,t)this._addLayer(t[n],n);for(n in e)this._addLayer(e[n],n,!0)},onAdd:function(t){this._initLayout(),this._update(),(this._map=t).on("zoomend",this._checkDisabledLayers,this);for(var e=0;e<this._layers.length;e++)this._layers[e].layer.on("add remove",this._onLayerChange,this);return this._container},addTo:function(t){return B.prototype.addTo.call(this,t),this._expandIfNotCollapsed()},onRemove:function(){this._map.off("zoomend",this._checkDisabledLayers,this);for(var t=0;t<this._layers.length;t++)this._layers[t].layer.off("add remove",this._onLayerChange,this)},addBaseLayer:function(t,e){return this._addLayer(t,e),this._map?this._update():this},addOverlay:function(t,e){return this._addLayer(t,e,!0),this._map?this._update():this},removeLayer:function(t){t.off("add remove",this._onLayerChange,this);t=this._getLayer(h(t));return t&&this._layers.splice(this._layers.indexOf(t),1),this._map?this._update():this},expand:function(){M(this._container,"leaflet-control-layers-expanded"),this._section.style.height=null;var t=this._map.getSize().y-(this._container.offsetTop+50);return t<this._section.clientHeight?(M(this._section,"leaflet-control-layers-scrollbar"),this._section.style.height=t+"px"):z(this._section,"leaflet-control-layers-scrollbar"),this._checkDisabledLayers(),this},collapse:function(){return z(this._container,"leaflet-control-layers-expanded"),this},_initLayout:function(){var t="leaflet-control-layers",e=this._container=P("div",t),i=this.options.collapsed,n=(e.setAttribute("aria-haspopup",!0),Ie(e),Be(e),this._section=P("section",t+"-list")),o=(i&&(this._map.on("click",this.collapse,this),S(e,{mouseenter:this._expandSafely,mouseleave:this.collapse},this)),this._layersLink=P("a",t+"-toggle",e));o.href="#",o.title="Layers",o.setAttribute("role","button"),S(o,{keydown:function(t){13===t.keyCode&&this._expandSafely()},click:function(t){O(t),this._expandSafely()}},this),i||this.expand(),this._baseLayersList=P("div",t+"-base",n),this._separator=P("div",t+"-separator",n),this._overlaysList=P("div",t+"-overlays",n),e.appendChild(n)},_getLayer:function(t){for(var e=0;e<this._layers.length;e++)if(this._layers[e]&&h(this._layers[e].layer)===t)return this._layers[e]},_addLayer:function(t,e,i){this._map&&t.on("add remove",this._onLayerChange,this),this._layers.push({layer:t,name:e,overlay:i}),this.options.sortLayers&&this._layers.sort(a(function(t,e){return this.options.sortFunction(t.layer,e.layer,t.name,e.name)},this)),this.options.autoZIndex&&t.setZIndex&&(this._lastZIndex++,t.setZIndex(this._lastZIndex)),this._expandIfNotCollapsed()},_update:function(){if(this._container){me(this._baseLayersList),me(this._overlaysList),this._layerControlInputs=[];for(var t,e,i,n=0,o=0;o<this._layers.length;o++)i=this._layers[o],this._addItem(i),e=e||i.overlay,t=t||!i.overlay,n+=i.overlay?0:1;this.options.hideSingleBase&&(this._baseLayersList.style.display=(t=t&&1<n)?"":"none"),this._separator.style.display=e&&t?"":"none"}return this},_onLayerChange:function(t){this._handlingClick||this._update();var e=this._getLayer(h(t.target)),t=e.overlay?"add"===t.type?"overlayadd":"overlayremove":"add"===t.type?"baselayerchange":null;t&&this._map.fire(t,e)},_createRadioElement:function(t,e){t='<input type="radio" class="leaflet-control-layers-selector" name="'+t+'"'+(e?' checked="checked"':"")+"/>",e=document.createElement("div");return e.innerHTML=t,e.firstChild},_addItem:function(t){var e,i=document.createElement("label"),n=this._map.hasLayer(t.layer),n=(t.overlay?((e=document.createElement("input")).type="checkbox",e.className="leaflet-control-layers-selector",e.defaultChecked=n):e=this._createRadioElement("leaflet-base-layers_"+h(this),n),this._layerControlInputs.push(e),e.layerId=h(t.layer),S(e,"click",this._onInputClick,this),document.createElement("span")),o=(n.innerHTML=" "+t.name,document.createElement("span"));return i.appendChild(o),o.appendChild(e),o.appendChild(n),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t,e,i=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=i.length-1;0<=s;s--)t=i[s],e=this._getLayer(t.layerId).layer,t.checked?n.push(e):t.checked||o.push(e);for(s=0;s<o.length;s++)this._map.hasLayer(o[s])&&this._map.removeLayer(o[s]);for(s=0;s<n.length;s++)this._map.hasLayer(n[s])||this._map.addLayer(n[s]);this._handlingClick=!1,this._refocusOnMap()}},_checkDisabledLayers:function(){for(var t,e,i=this._layerControlInputs,n=this._map.getZoom(),o=i.length-1;0<=o;o--)t=i[o],e=this._getLayer(t.layerId).layer,t.disabled=void 0!==e.options.minZoom&&n<e.options.minZoom||void 0!==e.options.maxZoom&&n>e.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section,e=(this._preventClick=!0,S(t,"click",O),this.expand(),this);setTimeout(function(){k(t,"click",O),e._preventClick=!1})}})),qe=B.extend({options:{position:"topleft",zoomInText:'<span aria-hidden="true">+</span>',zoomInTitle:"Zoom in",zoomOutText:'<span aria-hidden="true">−</span>',zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=P("div",e+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoom<this._map.getMaxZoom()&&this._map.zoomIn(this._map.options.zoomDelta*(t.shiftKey?3:1))},_zoomOut:function(t){!this._disabled&&this._map._zoom>this._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,n,o){i=P("a",i,n);return i.innerHTML=t,i.href="#",i.title=e,i.setAttribute("role","button"),i.setAttribute("aria-label",e),Ie(i),S(i,"click",Re),S(i,"click",o,this),S(i,"click",this._refocusOnMap,this),i},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";z(this._zoomInButton,e),z(this._zoomOutButton,e),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),!this._disabled&&t._zoom!==t.getMinZoom()||(M(this._zoomOutButton,e),this._zoomOutButton.setAttribute("aria-disabled","true")),!this._disabled&&t._zoom!==t.getMaxZoom()||(M(this._zoomInButton,e),this._zoomInButton.setAttribute("aria-disabled","true"))}}),Ge=(A.mergeOptions({zoomControl:!0}),A.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new qe,this.addControl(this.zoomControl))}),B.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=P("div",e),n=this.options;return this._addScales(n,e+"-line",i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=P("div",e,i)),t.imperial&&(this._iScale=P("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,t=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(t)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t);this._updateScale(this._mScale,e<1e3?e+" m":e/1e3+" km",e/t)},_updateImperial:function(t){var e,i,t=3.2808399*t;5280<t?(i=this._getRoundNum(e=t/5280),this._updateScale(this._iScale,i+" mi",i/e)):(i=this._getRoundNum(t),this._updateScale(this._iScale,i+" ft",i/t))},_updateScale:function(t,e,i){t.style.width=Math.round(this.options.maxWidth*i)+"px",t.innerHTML=e},_getRoundNum:function(t){var e=Math.pow(10,(Math.floor(t)+"").length-1),t=t/e;return e*(t=10<=t?10:5<=t?5:3<=t?3:2<=t?2:1)}})),Ke=B.extend({options:{position:"bottomright",prefix:'<a href="https://leafletjs.com" title="A JavaScript library for interactive maps">'+(b.inlineSvg?'<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="8" viewBox="0 0 12 8" class="leaflet-attribution-flag"><path fill="#4C7BE1" d="M0 0h12v4H0z"/><path fill="#FFD500" d="M0 4h12v3H0z"/><path fill="#E0BC00" d="M0 7h12v1H0z"/></svg> ':"")+"Leaflet</a>"},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var e in(t.attributionControl=this)._container=P("div","leaflet-control-attribution"),Ie(this._container),t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t,e=[];for(t in this._attributions)this._attributions[t]&&e.push(t);var i=[];this.options.prefix&&i.push(this.options.prefix),e.length&&i.push(e.join(", ")),this._container.innerHTML=i.join(' <span aria-hidden="true">|</span> ')}}}),n=(A.mergeOptions({attributionControl:!0}),A.addInitHook(function(){this.options.attributionControl&&(new Ke).addTo(this)}),B.Layers=Ve,B.Zoom=qe,B.Scale=Ge,B.Attribution=Ke,Ue.layers=function(t,e,i){return new Ve(t,e,i)},Ue.zoom=function(t){return new qe(t)},Ue.scale=function(t){return new Ge(t)},Ue.attribution=function(t){return new Ke(t)},et.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}})),ft=(n.addTo=function(t,e){return t.addHandler(e,this),this},{Events:e}),Ye=b.touch?"touchstart mousedown":"mousedown",Xe=it.extend({options:{clickTolerance:3},initialize:function(t,e,i,n){c(this,n),this._element=t,this._dragStartTarget=e||t,this._preventOutline=i},enable:function(){this._enabled||(S(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(Xe._dragging===this&&this.finishDrag(!0),k(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var e,i;this._enabled&&(this._moved=!1,ve(this._element,"leaflet-zoom-anim")||(t.touches&&1!==t.touches.length?Xe._dragging===this&&this.finishDrag():Xe._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((Xe._dragging=this)._preventOutline&&Me(this._element),Le(),re(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=Ce(this._element),this._startPoint=new p(i.clientX,i.clientY),this._startPos=Pe(this._element),this._parentScale=Ze(e),i="mousedown"===t.type,S(document,i?"mousemove":"touchmove",this._onMove,this),S(document,i?"mouseup":"touchend touchcancel",this._onUp,this)))))},_onMove:function(t){var e;this._enabled&&(t.touches&&1<t.touches.length?this._moved=!0:!(e=new p((e=t.touches&&1===t.touches.length?t.touches[0]:t).clientX,e.clientY)._subtract(this._startPoint)).x&&!e.y||Math.abs(e.x)+Math.abs(e.y)<this.options.clickTolerance||(e.x/=this._parentScale.x,e.y/=this._parentScale.y,O(t),this._moved||(this.fire("dragstart"),this._moved=!0,M(document.body,"leaflet-dragging"),this._lastTarget=t.target||t.srcElement,window.SVGElementInstance&&this._lastTarget instanceof window.SVGElementInstance&&(this._lastTarget=this._lastTarget.correspondingUseElement),M(this._lastTarget,"leaflet-drag-target")),this._newPos=this._startPos.add(e),this._moving=!0,this._lastEvent=t,this._updatePosition()))},_updatePosition:function(){var t={originalEvent:this._lastEvent};this.fire("predrag",t),Z(this._element,this._newPos),this.fire("drag",t)},_onUp:function(){this._enabled&&this.finishDrag()},finishDrag:function(t){z(document.body,"leaflet-dragging"),this._lastTarget&&(z(this._lastTarget,"leaflet-drag-target"),this._lastTarget=null),k(document,"mousemove touchmove",this._onMove,this),k(document,"mouseup touchend touchcancel",this._onUp,this),Te(),ae();var e=this._moved&&this._moving;this._moving=!1,Xe._dragging=!1,e&&this.fire("dragend",{noInertia:t,distance:this._newPos.distanceTo(this._startPos)})}});function Je(t,e,i){for(var n,o,s,r,a,h,l,u=[1,4,2,8],c=0,d=t.length;c<d;c++)t[c]._code=si(t[c],e);for(s=0;s<4;s++){for(h=u[s],n=[],c=0,o=(d=t.length)-1;c<d;o=c++)r=t[c],a=t[o],r._code&h?a._code&h||((l=oi(a,r,h,e,i))._code=si(l,e),n.push(l)):(a._code&h&&((l=oi(a,r,h,e,i))._code=si(l,e),n.push(l)),n.push(r));t=n}return t}function $e(t,e){var i,n,o,s,r,a,h;if(!t||0===t.length)throw new Error("latlngs not passed");I(t)||(console.warn("latlngs are not flat! Only the first ring will be used"),t=t[0]);for(var l=w([0,0]),u=g(t),c=(u.getNorthWest().distanceTo(u.getSouthWest())*u.getNorthEast().distanceTo(u.getNorthWest())<1700&&(l=Qe(t)),t.length),d=[],_=0;_<c;_++){var p=w(t[_]);d.push(e.project(w([p.lat-l.lat,p.lng-l.lng])))}for(_=r=a=h=0,i=c-1;_<c;i=_++)n=d[_],o=d[i],s=n.y*o.x-o.y*n.x,a+=(n.x+o.x)*s,h+=(n.y+o.y)*s,r+=3*s;u=0===r?d[0]:[a/r,h/r],u=e.unproject(m(u));return w([u.lat+l.lat,u.lng+l.lng])}function Qe(t){for(var e=0,i=0,n=0,o=0;o<t.length;o++){var s=w(t[o]);e+=s.lat,i+=s.lng,n++}return w([e/n,i/n])}var ti,gt={__proto__:null,clipPolygon:Je,polygonCenter:$e,centroid:Qe};function ei(t,e){if(e&&t.length){var i=t=function(t,e){for(var i=[t[0]],n=1,o=0,s=t.length;n<s;n++)(function(t,e){var i=e.x-t.x,e=e.y-t.y;return i*i+e*e})(t[n],t[o])>e&&(i.push(t[n]),o=n);o<s-1&&i.push(t[s-1]);return i}(t,e=e*e),n=i.length,o=new(typeof Uint8Array!=void 0+""?Uint8Array:Array)(n);o[0]=o[n-1]=1,function t(e,i,n,o,s){var r,a,h,l=0;for(a=o+1;a<=s-1;a++)h=ri(e[a],e[o],e[s],!0),l<h&&(r=a,l=h);n<l&&(i[r]=1,t(e,i,n,o,r),t(e,i,n,r,s))}(i,o,e,0,n-1);var s,r=[];for(s=0;s<n;s++)o[s]&&r.push(i[s]);return r}return t.slice()}function ii(t,e,i){return Math.sqrt(ri(t,e,i,!0))}function ni(t,e,i,n,o){var s,r,a,h=n?ti:si(t,i),l=si(e,i);for(ti=l;;){if(!(h|l))return[t,e];if(h&l)return!1;a=si(r=oi(t,e,s=h||l,i,o),i),s===h?(t=r,h=a):(e=r,l=a)}}function oi(t,e,i,n,o){var s,r,a=e.x-t.x,e=e.y-t.y,h=n.min,n=n.max;return 8&i?(s=t.x+a*(n.y-t.y)/e,r=n.y):4&i?(s=t.x+a*(h.y-t.y)/e,r=h.y):2&i?(s=n.x,r=t.y+e*(n.x-t.x)/a):1&i&&(s=h.x,r=t.y+e*(h.x-t.x)/a),new p(s,r,o)}function si(t,e){var i=0;return t.x<e.min.x?i|=1:t.x>e.max.x&&(i|=2),t.y<e.min.y?i|=4:t.y>e.max.y&&(i|=8),i}function ri(t,e,i,n){var o=e.x,e=e.y,s=i.x-o,r=i.y-e,a=s*s+r*r;return 0<a&&(1<(a=((t.x-o)*s+(t.y-e)*r)/a)?(o=i.x,e=i.y):0<a&&(o+=s*a,e+=r*a)),s=t.x-o,r=t.y-e,n?s*s+r*r:new p(o,e)}function I(t){return!d(t[0])||"object"!=typeof t[0][0]&&void 0!==t[0][0]}function ai(t){return console.warn("Deprecated use of _flat, please use L.LineUtil.isFlat instead."),I(t)}function hi(t,e){var i,n,o,s,r,a;if(!t||0===t.length)throw new Error("latlngs not passed");I(t)||(console.warn("latlngs are not flat! Only the first ring will be used"),t=t[0]);for(var h=w([0,0]),l=g(t),u=(l.getNorthWest().distanceTo(l.getSouthWest())*l.getNorthEast().distanceTo(l.getNorthWest())<1700&&(h=Qe(t)),t.length),c=[],d=0;d<u;d++){var _=w(t[d]);c.push(e.project(w([_.lat-h.lat,_.lng-h.lng])))}for(i=d=0;d<u-1;d++)i+=c[d].distanceTo(c[d+1])/2;if(0===i)a=c[0];else for(n=d=0;d<u-1;d++)if(o=c[d],s=c[d+1],i<(n+=r=o.distanceTo(s))){a=[s.x-(r=(n-i)/r)*(s.x-o.x),s.y-r*(s.y-o.y)];break}l=e.unproject(m(a));return w([l.lat+h.lat,l.lng+h.lng])}var vt={__proto__:null,simplify:ei,pointToSegmentDistance:ii,closestPointOnSegment:function(t,e,i){return ri(t,e,i)},clipSegment:ni,_getEdgeIntersection:oi,_getBitCode:si,_sqClosestPointOnSegment:ri,isFlat:I,_flat:ai,polylineCenter:hi},yt={project:function(t){return new p(t.lng,t.lat)},unproject:function(t){return new v(t.y,t.x)},bounds:new f([-180,-90],[180,90])},xt={R:6378137,R_MINOR:6356752.314245179,bounds:new f([-20037508.34279,-15496570.73972],[20037508.34279,18764656.23138]),project:function(t){var e=Math.PI/180,i=this.R,n=t.lat*e,o=this.R_MINOR/i,o=Math.sqrt(1-o*o),s=o*Math.sin(n),s=Math.tan(Math.PI/4-n/2)/Math.pow((1-s)/(1+s),o/2),n=-i*Math.log(Math.max(s,1e-10));return new p(t.lng*e*i,n)},unproject:function(t){for(var e,i=180/Math.PI,n=this.R,o=this.R_MINOR/n,s=Math.sqrt(1-o*o),r=Math.exp(-t.y/n),a=Math.PI/2-2*Math.atan(r),h=0,l=.1;h<15&&1e-7<Math.abs(l);h++)e=s*Math.sin(a),e=Math.pow((1-e)/(1+e),s/2),a+=l=Math.PI/2-2*Math.atan(r*e)-a;return new v(a*i,t.x*i/n)}},wt={__proto__:null,LonLat:yt,Mercator:xt,SphericalMercator:rt},Pt=l({},st,{code:"EPSG:3395",projection:xt,transformation:ht(bt=.5/(Math.PI*xt.R),.5,-bt,.5)}),li=l({},st,{code:"EPSG:4326",projection:yt,transformation:ht(1/180,1,-1/180,.5)}),Lt=l({},ot,{projection:yt,transformation:ht(1,0,-1,0),scale:function(t){return Math.pow(2,t)},zoom:function(t){return Math.log(t)/Math.LN2},distance:function(t,e){var i=e.lng-t.lng,e=e.lat-t.lat;return Math.sqrt(i*i+e*e)},infinite:!0}),o=(ot.Earth=st,ot.EPSG3395=Pt,ot.EPSG3857=lt,ot.EPSG900913=ut,ot.EPSG4326=li,ot.Simple=Lt,it.extend({options:{pane:"overlayPane",attribution:null,bubblingMouseEvents:!0},addTo:function(t){return t.addLayer(this),this},remove:function(){return this.removeFrom(this._map||this._mapToAdd)},removeFrom:function(t){return t&&t.removeLayer(this),this},getPane:function(t){return this._map.getPane(t?this.options[t]||t:this.options.pane)},addInteractiveTarget:function(t){return this._map._targets[h(t)]=this},removeInteractiveTarget:function(t){return delete this._map._targets[h(t)],this},getAttribution:function(){return this.options.attribution},_layerAdd:function(t){var e,i=t.target;i.hasLayer(this)&&(this._map=i,this._zoomAnimated=i._zoomAnimated,this.getEvents&&(e=this.getEvents(),i.on(e,this),this.once("remove",function(){i.off(e,this)},this)),this.onAdd(i),this.fire("add"),i.fire("layeradd",{layer:this}))}})),ui=(A.include({addLayer:function(t){var e;if(t._layerAdd)return e=h(t),this._layers[e]||((this._layers[e]=t)._mapToAdd=this,t.beforeAdd&&t.beforeAdd(this),this.whenReady(t._layerAdd,t)),this;throw new Error("The provided object is not a Layer.")},removeLayer:function(t){var e=h(t);return this._layers[e]&&(this._loaded&&t.onRemove(this),delete this._layers[e],this._loaded&&(this.fire("layerremove",{layer:t}),t.fire("remove")),t._map=t._mapToAdd=null),this},hasLayer:function(t){return h(t)in this._layers},eachLayer:function(t,e){for(var i in this._layers)t.call(e,this._layers[i]);return this},_addLayers:function(t){for(var e=0,i=(t=t?d(t)?t:[t]:[]).length;e<i;e++)this.addLayer(t[e])},_addZoomLimit:function(t){isNaN(t.options.maxZoom)&&isNaN(t.options.minZoom)||(this._zoomBoundLayers[h(t)]=t,this._updateZoomLevels())},_removeZoomLimit:function(t){t=h(t);this._zoomBoundLayers[t]&&(delete this._zoomBoundLayers[t],this._updateZoomLevels())},_updateZoomLevels:function(){var t,e=1/0,i=-1/0,n=this._getZoomSpan();for(t in this._zoomBoundLayers)var o=this._zoomBoundLayers[t].options,e=void 0===o.minZoom?e:Math.min(e,o.minZoom),i=void 0===o.maxZoom?i:Math.max(i,o.maxZoom);this._layersMaxZoom=i===-1/0?void 0:i,this._layersMinZoom=e===1/0?void 0:e,n!==this._getZoomSpan()&&this.fire("zoomlevelschange"),void 0===this.options.maxZoom&&this._layersMaxZoom&&this.getZoom()>this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()<this._layersMinZoom&&this.setZoom(this._layersMinZoom)}}),o.extend({initialize:function(t,e){var i,n;if(c(this,e),this._layers={},t)for(i=0,n=t.length;i<n;i++)this.addLayer(t[i])},addLayer:function(t){var e=this.getLayerId(t);return this._layers[e]=t,this._map&&this._map.addLayer(t),this},removeLayer:function(t){t=t in this._layers?t:this.getLayerId(t);return this._map&&this._layers[t]&&this._map.removeLayer(this._layers[t]),delete this._layers[t],this},hasLayer:function(t){return("number"==typeof t?t:this.getLayerId(t))in this._layers},clearLayers:function(){return this.eachLayer(this.removeLayer,this)},invoke:function(t){var e,i,n=Array.prototype.slice.call(arguments,1);for(e in this._layers)(i=this._layers[e])[t]&&i[t].apply(i,n);return this},onAdd:function(t){this.eachLayer(t.addLayer,t)},onRemove:function(t){this.eachLayer(t.removeLayer,t)},eachLayer:function(t,e){for(var i in this._layers)t.call(e,this._layers[i]);return this},getLayer:function(t){return this._layers[t]},getLayers:function(){var t=[];return this.eachLayer(t.push,t),t},setZIndex:function(t){return this.invoke("setZIndex",t)},getLayerId:h})),ci=ui.extend({addLayer:function(t){return this.hasLayer(t)?this:(t.addEventParent(this),ui.prototype.addLayer.call(this,t),this.fire("layeradd",{layer:t}))},removeLayer:function(t){return this.hasLayer(t)?((t=t in this._layers?this._layers[t]:t).removeEventParent(this),ui.prototype.removeLayer.call(this,t),this.fire("layerremove",{layer:t})):this},setStyle:function(t){return this.invoke("setStyle",t)},bringToFront:function(){return this.invoke("bringToFront")},bringToBack:function(){return this.invoke("bringToBack")},getBounds:function(){var t,e=new s;for(t in this._layers){var i=this._layers[t];e.extend(i.getBounds?i.getBounds():i.getLatLng())}return e}}),di=et.extend({options:{popupAnchor:[0,0],tooltipAnchor:[0,0],crossOrigin:!1},initialize:function(t){c(this,t)},createIcon:function(t){return this._createIcon("icon",t)},createShadow:function(t){return this._createIcon("shadow",t)},_createIcon:function(t,e){var i=this._getIconUrl(t);if(i)return i=this._createImg(i,e&&"IMG"===e.tagName?e:null),this._setIconStyles(i,t),!this.options.crossOrigin&&""!==this.options.crossOrigin||(i.crossOrigin=!0===this.options.crossOrigin?"":this.options.crossOrigin),i;if("icon"===t)throw new Error("iconUrl not set in Icon options (see the docs).");return null},_setIconStyles:function(t,e){var i=this.options,n=i[e+"Size"],n=m(n="number"==typeof n?[n,n]:n),o=m("shadow"===e&&i.shadowAnchor||i.iconAnchor||n&&n.divideBy(2,!0));t.className="leaflet-marker-"+e+" "+(i.className||""),o&&(t.style.marginLeft=-o.x+"px",t.style.marginTop=-o.y+"px"),n&&(t.style.width=n.x+"px",t.style.height=n.y+"px")},_createImg:function(t,e){return(e=e||document.createElement("img")).src=t,e},_getIconUrl:function(t){return b.retina&&this.options[t+"RetinaUrl"]||this.options[t+"Url"]}});var _i=di.extend({options:{iconUrl:"marker-icon.png",iconRetinaUrl:"marker-icon-2x.png",shadowUrl:"marker-shadow.png",iconSize:[25,41],iconAnchor:[12,41],popupAnchor:[1,-34],tooltipAnchor:[16,-28],shadowSize:[41,41]},_getIconUrl:function(t){return"string"!=typeof _i.imagePath&&(_i.imagePath=this._detectIconPath()),(this.options.imagePath||_i.imagePath)+di.prototype._getIconUrl.call(this,t)},_stripUrl:function(t){function e(t,e,i){return(e=e.exec(t))&&e[i]}return(t=e(t,/^url\((['"])?(.+)\1\)$/,2))&&e(t,/^(.*)marker-icon\.png$/,1)},_detectIconPath:function(){var t=P("div","leaflet-default-icon-path",document.body),e=pe(t,"background-image")||pe(t,"backgroundImage");return document.body.removeChild(t),(e=this._stripUrl(e))?e:(t=document.querySelector('link[href$="leaflet.css"]'))?t.href.substring(0,t.href.length-"leaflet.css".length-1):""}}),pi=n.extend({initialize:function(t){this._marker=t},addHooks:function(){var t=this._marker._icon;this._draggable||(this._draggable=new Xe(t,t,!0)),this._draggable.on({dragstart:this._onDragStart,predrag:this._onPreDrag,drag:this._onDrag,dragend:this._onDragEnd},this).enable(),M(t,"leaflet-marker-draggable")},removeHooks:function(){this._draggable.off({dragstart:this._onDragStart,predrag:this._onPreDrag,drag:this._onDrag,dragend:this._onDragEnd},this).disable(),this._marker._icon&&z(this._marker._icon,"leaflet-marker-draggable")},moved:function(){return this._draggable&&this._draggable._moved},_adjustPan:function(t){var e=this._marker,i=e._map,n=this._marker.options.autoPanSpeed,o=this._marker.options.autoPanPadding,s=Pe(e._icon),r=i.getPixelBounds(),a=i.getPixelOrigin(),a=_(r.min._subtract(a).add(o),r.max._subtract(a).subtract(o));a.contains(s)||(o=m((Math.max(a.max.x,s.x)-a.max.x)/(r.max.x-a.max.x)-(Math.min(a.min.x,s.x)-a.min.x)/(r.min.x-a.min.x),(Math.max(a.max.y,s.y)-a.max.y)/(r.max.y-a.max.y)-(Math.min(a.min.y,s.y)-a.min.y)/(r.min.y-a.min.y)).multiplyBy(n),i.panBy(o,{animate:!1}),this._draggable._newPos._add(o),this._draggable._startPos._add(o),Z(e._icon,this._draggable._newPos),this._onDrag(t),this._panRequest=x(this._adjustPan.bind(this,t)))},_onDragStart:function(){this._oldLatLng=this._marker.getLatLng(),this._marker.closePopup&&this._marker.closePopup(),this._marker.fire("movestart").fire("dragstart")},_onPreDrag:function(t){this._marker.options.autoPan&&(r(this._panRequest),this._panRequest=x(this._adjustPan.bind(this,t)))},_onDrag:function(t){var e=this._marker,i=e._shadow,n=Pe(e._icon),o=e._map.layerPointToLatLng(n);i&&Z(i,n),e._latlng=o,t.latlng=o,t.oldLatLng=this._oldLatLng,e.fire("move",t).fire("drag",t)},_onDragEnd:function(t){r(this._panRequest),delete this._oldLatLng,this._marker.fire("moveend").fire("dragend",t)}}),mi=o.extend({options:{icon:new _i,interactive:!0,keyboard:!0,title:"",alt:"Marker",zIndexOffset:0,opacity:1,riseOnHover:!1,riseOffset:250,pane:"markerPane",shadowPane:"shadowPane",bubblingMouseEvents:!1,autoPanOnFocus:!0,draggable:!1,autoPan:!1,autoPanPadding:[50,50],autoPanSpeed:10},initialize:function(t,e){c(this,e),this._latlng=w(t)},onAdd:function(t){this._zoomAnimated=this._zoomAnimated&&t.options.markerZoomAnimation,this._zoomAnimated&&t.on("zoomanim",this._animateZoom,this),this._initIcon(),this.update()},onRemove:function(t){this.dragging&&this.dragging.enabled()&&(this.options.draggable=!0,this.dragging.removeHooks()),delete this.dragging,this._zoomAnimated&&t.off("zoomanim",this._animateZoom,this),this._removeIcon(),this._removeShadow()},getEvents:function(){return{zoom:this.update,viewreset:this.update}},getLatLng:function(){return this._latlng},setLatLng:function(t){var e=this._latlng;return this._latlng=w(t),this.update(),this.fire("move",{oldLatLng:e,latlng:this._latlng})},setZIndexOffset:function(t){return this.options.zIndexOffset=t,this.update()},getIcon:function(){return this.options.icon},setIcon:function(t){return this.options.icon=t,this._map&&(this._initIcon(),this.update()),this._popup&&this.bindPopup(this._popup,this._popup.options),this},getElement:function(){return this._icon},update:function(){var t;return this._icon&&this._map&&(t=this._map.latLngToLayerPoint(this._latlng).round(),this._setPos(t)),this},_initIcon:function(){var t=this.options,e="leaflet-zoom-"+(this._zoomAnimated?"animated":"hide"),i=t.icon.createIcon(this._icon),n=!1,i=(i!==this._icon&&(this._icon&&this._removeIcon(),n=!0,t.title&&(i.title=t.title),"IMG"===i.tagName&&(i.alt=t.alt||"")),M(i,e),t.keyboard&&(i.tabIndex="0",i.setAttribute("role","button")),this._icon=i,t.riseOnHover&&this.on({mouseover:this._bringToFront,mouseout:this._resetZIndex}),this.options.autoPanOnFocus&&S(i,"focus",this._panOnFocus,this),t.icon.createShadow(this._shadow)),o=!1;i!==this._shadow&&(this._removeShadow(),o=!0),i&&(M(i,e),i.alt=""),this._shadow=i,t.opacity<1&&this._updateOpacity(),n&&this.getPane().appendChild(this._icon),this._initInteraction(),i&&o&&this.getPane(t.shadowPane).appendChild(this._shadow)},_removeIcon:function(){this.options.riseOnHover&&this.off({mouseover:this._bringToFront,mouseout:this._resetZIndex}),this.options.autoPanOnFocus&&k(this._icon,"focus",this._panOnFocus,this),T(this._icon),this.removeInteractiveTarget(this._icon),this._icon=null},_removeShadow:function(){this._shadow&&T(this._shadow),this._shadow=null},_setPos:function(t){this._icon&&Z(this._icon,t),this._shadow&&Z(this._shadow,t),this._zIndex=t.y+this.options.zIndexOffset,this._resetZIndex()},_updateZIndex:function(t){this._icon&&(this._icon.style.zIndex=this._zIndex+t)},_animateZoom:function(t){t=this._map._latLngToNewLayerPoint(this._latlng,t.zoom,t.center).round();this._setPos(t)},_initInteraction:function(){var t;this.options.interactive&&(M(this._icon,"leaflet-interactive"),this.addInteractiveTarget(this._icon),pi&&(t=this.options.draggable,this.dragging&&(t=this.dragging.enabled(),this.dragging.disable()),this.dragging=new pi(this),t&&this.dragging.enable()))},setOpacity:function(t){return this.options.opacity=t,this._map&&this._updateOpacity(),this},_updateOpacity:function(){var t=this.options.opacity;this._icon&&C(this._icon,t),this._shadow&&C(this._shadow,t)},_bringToFront:function(){this._updateZIndex(this.options.riseOffset)},_resetZIndex:function(){this._updateZIndex(0)},_panOnFocus:function(){var t,e,i=this._map;i&&(t=(e=this.options.icon.options).iconSize?m(e.iconSize):m(0,0),e=e.iconAnchor?m(e.iconAnchor):m(0,0),i.panInside(this._latlng,{paddingTopLeft:e,paddingBottomRight:t.subtract(e)}))},_getPopupAnchor:function(){return this.options.icon.options.popupAnchor},_getTooltipAnchor:function(){return this.options.icon.options.tooltipAnchor}});var fi=o.extend({options:{stroke:!0,color:"#3388ff",weight:3,opacity:1,lineCap:"round",lineJoin:"round",dashArray:null,dashOffset:null,fill:!1,fillColor:null,fillOpacity:.2,fillRule:"evenodd",interactive:!0,bubblingMouseEvents:!0},beforeAdd:function(t){this._renderer=t.getRenderer(this)},onAdd:function(){this._renderer._initPath(this),this._reset(),this._renderer._addPath(this)},onRemove:function(){this._renderer._removePath(this)},redraw:function(){return this._map&&this._renderer._updatePath(this),this},setStyle:function(t){return c(this,t),this._renderer&&(this._renderer._updateStyle(this),this.options.stroke&&t&&Object.prototype.hasOwnProperty.call(t,"weight")&&this._updateBounds()),this},bringToFront:function(){return this._renderer&&this._renderer._bringToFront(this),this},bringToBack:function(){return this._renderer&&this._renderer._bringToBack(this),this},getElement:function(){return this._path},_reset:function(){this._project(),this._update()},_clickTolerance:function(){return(this.options.stroke?this.options.weight/2:0)+(this._renderer.options.tolerance||0)}}),gi=fi.extend({options:{fill:!0,radius:10},initialize:function(t,e){c(this,e),this._latlng=w(t),this._radius=this.options.radius},setLatLng:function(t){var e=this._latlng;return this._latlng=w(t),this.redraw(),this.fire("move",{oldLatLng:e,latlng:this._latlng})},getLatLng:function(){return this._latlng},setRadius:function(t){return this.options.radius=this._radius=t,this.redraw()},getRadius:function(){return this._radius},setStyle:function(t){var e=t&&t.radius||this._radius;return fi.prototype.setStyle.call(this,t),this.setRadius(e),this},_project:function(){this._point=this._map.latLngToLayerPoint(this._latlng),this._updateBounds()},_updateBounds:function(){var t=this._radius,e=this._radiusY||t,i=this._clickTolerance(),t=[t+i,e+i];this._pxBounds=new f(this._point.subtract(t),this._point.add(t))},_update:function(){this._map&&this._updatePath()},_updatePath:function(){this._renderer._updateCircle(this)},_empty:function(){return this._radius&&!this._renderer._bounds.intersects(this._pxBounds)},_containsPoint:function(t){return t.distanceTo(this._point)<=this._radius+this._clickTolerance()}});var vi=gi.extend({initialize:function(t,e,i){if(c(this,e="number"==typeof e?l({},i,{radius:e}):e),this._latlng=w(t),isNaN(this.options.radius))throw new Error("Circle radius cannot be NaN");this._mRadius=this.options.radius},setRadius:function(t){return this._mRadius=t,this.redraw()},getRadius:function(){return this._mRadius},getBounds:function(){var t=[this._radius,this._radiusY||this._radius];return new s(this._map.layerPointToLatLng(this._point.subtract(t)),this._map.layerPointToLatLng(this._point.add(t)))},setStyle:fi.prototype.setStyle,_project:function(){var t,e,i,n,o,s=this._latlng.lng,r=this._latlng.lat,a=this._map,h=a.options.crs;h.distance===st.distance?(n=Math.PI/180,o=this._mRadius/st.R/n,t=a.project([r+o,s]),e=a.project([r-o,s]),e=t.add(e).divideBy(2),i=a.unproject(e).lat,n=Math.acos((Math.cos(o*n)-Math.sin(r*n)*Math.sin(i*n))/(Math.cos(r*n)*Math.cos(i*n)))/n,!isNaN(n)&&0!==n||(n=o/Math.cos(Math.PI/180*r)),this._point=e.subtract(a.getPixelOrigin()),this._radius=isNaN(n)?0:e.x-a.project([i,s-n]).x,this._radiusY=e.y-t.y):(o=h.unproject(h.project(this._latlng).subtract([this._mRadius,0])),this._point=a.latLngToLayerPoint(this._latlng),this._radius=this._point.x-a.latLngToLayerPoint(o).x),this._updateBounds()}});var yi=fi.extend({options:{smoothFactor:1,noClip:!1},initialize:function(t,e){c(this,e),this._setLatLngs(t)},getLatLngs:function(){return this._latlngs},setLatLngs:function(t){return this._setLatLngs(t),this.redraw()},isEmpty:function(){return!this._latlngs.length},closestLayerPoint:function(t){for(var e=1/0,i=null,n=ri,o=0,s=this._parts.length;o<s;o++)for(var r=this._parts[o],a=1,h=r.length;a<h;a++){var l,u,c=n(t,l=r[a-1],u=r[a],!0);c<e&&(e=c,i=n(t,l,u))}return i&&(i.distance=Math.sqrt(e)),i},getCenter:function(){if(this._map)return hi(this._defaultShape(),this._map.options.crs);throw new Error("Must add layer to map before using getCenter()")},getBounds:function(){return this._bounds},addLatLng:function(t,e){return e=e||this._defaultShape(),t=w(t),e.push(t),this._bounds.extend(t),this.redraw()},_setLatLngs:function(t){this._bounds=new s,this._latlngs=this._convertLatLngs(t)},_defaultShape:function(){return I(this._latlngs)?this._latlngs:this._latlngs[0]},_convertLatLngs:function(t){for(var e=[],i=I(t),n=0,o=t.length;n<o;n++)i?(e[n]=w(t[n]),this._bounds.extend(e[n])):e[n]=this._convertLatLngs(t[n]);return e},_project:function(){var t=new f;this._rings=[],this._projectLatlngs(this._latlngs,this._rings,t),this._bounds.isValid()&&t.isValid()&&(this._rawPxBounds=t,this._updateBounds())},_updateBounds:function(){var t=this._clickTolerance(),t=new p(t,t);this._rawPxBounds&&(this._pxBounds=new f([this._rawPxBounds.min.subtract(t),this._rawPxBounds.max.add(t)]))},_projectLatlngs:function(t,e,i){var n,o,s=t[0]instanceof v,r=t.length;if(s){for(o=[],n=0;n<r;n++)o[n]=this._map.latLngToLayerPoint(t[n]),i.extend(o[n]);e.push(o)}else for(n=0;n<r;n++)this._projectLatlngs(t[n],e,i)},_clipPoints:function(){var t=this._renderer._bounds;if(this._parts=[],this._pxBounds&&this._pxBounds.intersects(t))if(this.options.noClip)this._parts=this._rings;else for(var e,i,n,o,s=this._parts,r=0,a=0,h=this._rings.length;r<h;r++)for(e=0,i=(o=this._rings[r]).length;e<i-1;e++)(n=ni(o[e],o[e+1],t,e,!0))&&(s[a]=s[a]||[],s[a].push(n[0]),n[1]===o[e+1]&&e!==i-2||(s[a].push(n[1]),a++))},_simplifyPoints:function(){for(var t=this._parts,e=this.options.smoothFactor,i=0,n=t.length;i<n;i++)t[i]=ei(t[i],e)},_update:function(){this._map&&(this._clipPoints(),this._simplifyPoints(),this._updatePath())},_updatePath:function(){this._renderer._updatePoly(this)},_containsPoint:function(t,e){var i,n,o,s,r,a,h=this._clickTolerance();if(this._pxBounds&&this._pxBounds.contains(t))for(i=0,s=this._parts.length;i<s;i++)for(n=0,o=(r=(a=this._parts[i]).length)-1;n<r;o=n++)if((e||0!==n)&&ii(t,a[o],a[n])<=h)return!0;return!1}});yi._flat=ai;var xi=yi.extend({options:{fill:!0},isEmpty:function(){return!this._latlngs.length||!this._latlngs[0].length},getCenter:function(){if(this._map)return $e(this._defaultShape(),this._map.options.crs);throw new Error("Must add layer to map before using getCenter()")},_convertLatLngs:function(t){var t=yi.prototype._convertLatLngs.call(this,t),e=t.length;return 2<=e&&t[0]instanceof v&&t[0].equals(t[e-1])&&t.pop(),t},_setLatLngs:function(t){yi.prototype._setLatLngs.call(this,t),I(this._latlngs)&&(this._latlngs=[this._latlngs])},_defaultShape:function(){return(I(this._latlngs[0])?this._latlngs:this._latlngs[0])[0]},_clipPoints:function(){var t=this._renderer._bounds,e=this.options.weight,e=new p(e,e),t=new f(t.min.subtract(e),t.max.add(e));if(this._parts=[],this._pxBounds&&this._pxBounds.intersects(t))if(this.options.noClip)this._parts=this._rings;else for(var i,n=0,o=this._rings.length;n<o;n++)(i=Je(this._rings[n],t,!0)).length&&this._parts.push(i)},_updatePath:function(){this._renderer._updatePoly(this,!0)},_containsPoint:function(t){var e,i,n,o,s,r,a,h,l=!1;if(!this._pxBounds||!this._pxBounds.contains(t))return!1;for(o=0,a=this._parts.length;o<a;o++)for(s=0,r=(h=(e=this._parts[o]).length)-1;s<h;r=s++)i=e[s],n=e[r],i.y>t.y!=n.y>t.y&&t.x<(n.x-i.x)*(t.y-i.y)/(n.y-i.y)+i.x&&(l=!l);return l||yi.prototype._containsPoint.call(this,t,!0)}});var wi=ci.extend({initialize:function(t,e){c(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,n,o=d(t)?t:t.features;if(o){for(e=0,i=o.length;e<i;e++)((n=o[e]).geometries||n.geometry||n.features||n.coordinates)&&this.addData(n);return this}var s,r=this.options;return(!r.filter||r.filter(t))&&(s=bi(t,r))?(s.feature=Zi(t),s.defaultOptions=s.options,this.resetStyle(s),r.onEachFeature&&r.onEachFeature(t,s),this.addLayer(s)):this},resetStyle:function(t){return void 0===t?this.eachLayer(this.resetStyle,this):(t.options=l({},t.defaultOptions),this._setLayerStyle(t,this.options.style),this)},setStyle:function(e){return this.eachLayer(function(t){this._setLayerStyle(t,e)},this)},_setLayerStyle:function(t,e){t.setStyle&&("function"==typeof e&&(e=e(t.feature)),t.setStyle(e))}});function bi(t,e){var i,n,o,s,r="Feature"===t.type?t.geometry:t,a=r?r.coordinates:null,h=[],l=e&&e.pointToLayer,u=e&&e.coordsToLatLng||Li;if(!a&&!r)return null;switch(r.type){case"Point":return Pi(l,t,i=u(a),e);case"MultiPoint":for(o=0,s=a.length;o<s;o++)i=u(a[o]),h.push(Pi(l,t,i,e));return new ci(h);case"LineString":case"MultiLineString":return n=Ti(a,"LineString"===r.type?0:1,u),new yi(n,e);case"Polygon":case"MultiPolygon":return n=Ti(a,"Polygon"===r.type?1:2,u),new xi(n,e);case"GeometryCollection":for(o=0,s=r.geometries.length;o<s;o++){var c=bi({geometry:r.geometries[o],type:"Feature",properties:t.properties},e);c&&h.push(c)}return new ci(h);case"FeatureCollection":for(o=0,s=r.features.length;o<s;o++){var d=bi(r.features[o],e);d&&h.push(d)}return new ci(h);default:throw new Error("Invalid GeoJSON object.")}}function Pi(t,e,i,n){return t?t(e,i):new mi(i,n&&n.markersInheritOptions&&n)}function Li(t){return new v(t[1],t[0],t[2])}function Ti(t,e,i){for(var n,o=[],s=0,r=t.length;s<r;s++)n=e?Ti(t[s],e-1,i):(i||Li)(t[s]),o.push(n);return o}function Mi(t,e){return void 0!==(t=w(t)).alt?[i(t.lng,e),i(t.lat,e),i(t.alt,e)]:[i(t.lng,e),i(t.lat,e)]}function zi(t,e,i,n){for(var o=[],s=0,r=t.length;s<r;s++)o.push(e?zi(t[s],I(t[s])?0:e-1,i,n):Mi(t[s],n));return!e&&i&&0<o.length&&o.push(o[0].slice()),o}function Ci(t,e){return t.feature?l({},t.feature,{geometry:e}):Zi(e)}function Zi(t){return"Feature"===t.type||"FeatureCollection"===t.type?t:{type:"Feature",properties:{},geometry:t}}Tt={toGeoJSON:function(t){return Ci(this,{type:"Point",coordinates:Mi(this.getLatLng(),t)})}};function Si(t,e){return new wi(t,e)}mi.include(Tt),vi.include(Tt),gi.include(Tt),yi.include({toGeoJSON:function(t){var e=!I(this._latlngs);return Ci(this,{type:(e?"Multi":"")+"LineString",coordinates:zi(this._latlngs,e?1:0,!1,t)})}}),xi.include({toGeoJSON:function(t){var e=!I(this._latlngs),i=e&&!I(this._latlngs[0]),t=zi(this._latlngs,i?2:e?1:0,!0,t);return Ci(this,{type:(i?"Multi":"")+"Polygon",coordinates:t=e?t:[t]})}}),ui.include({toMultiPoint:function(e){var i=[];return this.eachLayer(function(t){i.push(t.toGeoJSON(e).geometry.coordinates)}),Ci(this,{type:"MultiPoint",coordinates:i})},toGeoJSON:function(e){var i,n,t=this.feature&&this.feature.geometry&&this.feature.geometry.type;return"MultiPoint"===t?this.toMultiPoint(e):(i="GeometryCollection"===t,n=[],this.eachLayer(function(t){t.toGeoJSON&&(t=t.toGeoJSON(e),i?n.push(t.geometry):"FeatureCollection"===(t=Zi(t)).type?n.push.apply(n,t.features):n.push(t))}),i?Ci(this,{geometries:n,type:"GeometryCollection"}):{type:"FeatureCollection",features:n})}});var Mt=Si,Ei=o.extend({options:{opacity:1,alt:"",interactive:!1,crossOrigin:!1,errorOverlayUrl:"",zIndex:1,className:""},initialize:function(t,e,i){this._url=t,this._bounds=g(e),c(this,i)},onAdd:function(){this._image||(this._initImage(),this.options.opacity<1&&this._updateOpacity()),this.options.interactive&&(M(this._image,"leaflet-interactive"),this.addInteractiveTarget(this._image)),this.getPane().appendChild(this._image),this._reset()},onRemove:function(){T(this._image),this.options.interactive&&this.removeInteractiveTarget(this._image)},setOpacity:function(t){return this.options.opacity=t,this._image&&this._updateOpacity(),this},setStyle:function(t){return t.opacity&&this.setOpacity(t.opacity),this},bringToFront:function(){return this._map&&fe(this._image),this},bringToBack:function(){return this._map&&ge(this._image),this},setUrl:function(t){return this._url=t,this._image&&(this._image.src=t),this},setBounds:function(t){return this._bounds=g(t),this._map&&this._reset(),this},getEvents:function(){var t={zoom:this._reset,viewreset:this._reset};return this._zoomAnimated&&(t.zoomanim=this._animateZoom),t},setZIndex:function(t){return this.options.zIndex=t,this._updateZIndex(),this},getBounds:function(){return this._bounds},getElement:function(){return this._image},_initImage:function(){var t="IMG"===this._url.tagName,e=this._image=t?this._url:P("img");M(e,"leaflet-image-layer"),this._zoomAnimated&&M(e,"leaflet-zoom-animated"),this.options.className&&M(e,this.options.className),e.onselectstart=u,e.onmousemove=u,e.onload=a(this.fire,this,"load"),e.onerror=a(this._overlayOnError,this,"error"),!this.options.crossOrigin&&""!==this.options.crossOrigin||(e.crossOrigin=!0===this.options.crossOrigin?"":this.options.crossOrigin),this.options.zIndex&&this._updateZIndex(),t?this._url=e.src:(e.src=this._url,e.alt=this.options.alt)},_animateZoom:function(t){var e=this._map.getZoomScale(t.zoom),t=this._map._latLngBoundsToNewLayerBounds(this._bounds,t.zoom,t.center).min;be(this._image,t,e)},_reset:function(){var t=this._image,e=new f(this._map.latLngToLayerPoint(this._bounds.getNorthWest()),this._map.latLngToLayerPoint(this._bounds.getSouthEast())),i=e.getSize();Z(t,e.min),t.style.width=i.x+"px",t.style.height=i.y+"px"},_updateOpacity:function(){C(this._image,this.options.opacity)},_updateZIndex:function(){this._image&&void 0!==this.options.zIndex&&null!==this.options.zIndex&&(this._image.style.zIndex=this.options.zIndex)},_overlayOnError:function(){this.fire("error");var t=this.options.errorOverlayUrl;t&&this._url!==t&&(this._url=t,this._image.src=t)},getCenter:function(){return this._bounds.getCenter()}}),ki=Ei.extend({options:{autoplay:!0,loop:!0,keepAspectRatio:!0,muted:!1,playsInline:!0},_initImage:function(){var t="VIDEO"===this._url.tagName,e=this._image=t?this._url:P("video");if(M(e,"leaflet-image-layer"),this._zoomAnimated&&M(e,"leaflet-zoom-animated"),this.options.className&&M(e,this.options.className),e.onselectstart=u,e.onmousemove=u,e.onloadeddata=a(this.fire,this,"load"),t){for(var i=e.getElementsByTagName("source"),n=[],o=0;o<i.length;o++)n.push(i[o].src);this._url=0<i.length?n:[e.src]}else{d(this._url)||(this._url=[this._url]),!this.options.keepAspectRatio&&Object.prototype.hasOwnProperty.call(e.style,"objectFit")&&(e.style.objectFit="fill"),e.autoplay=!!this.options.autoplay,e.loop=!!this.options.loop,e.muted=!!this.options.muted,e.playsInline=!!this.options.playsInline;for(var s=0;s<this._url.length;s++){var r=P("source");r.src=this._url[s],e.appendChild(r)}}}});var Oi=Ei.extend({_initImage:function(){var t=this._image=this._url;M(t,"leaflet-image-layer"),this._zoomAnimated&&M(t,"leaflet-zoom-animated"),this.options.className&&M(t,this.options.className),t.onselectstart=u,t.onmousemove=u}});var Ai=o.extend({options:{interactive:!1,offset:[0,0],className:"",pane:void 0,content:""},initialize:function(t,e){t&&(t instanceof v||d(t))?(this._latlng=w(t),c(this,e)):(c(this,t),this._source=e),this.options.content&&(this._content=this.options.content)},openOn:function(t){return(t=arguments.length?t:this._source._map).hasLayer(this)||t.addLayer(this),this},close:function(){return this._map&&this._map.removeLayer(this),this},toggle:function(t){return this._map?this.close():(arguments.length?this._source=t:t=this._source,this._prepareOpen(),this.openOn(t._map)),this},onAdd:function(t){this._zoomAnimated=t._zoomAnimated,this._container||this._initLayout(),t._fadeAnimated&&C(this._container,0),clearTimeout(this._removeTimeout),this.getPane().appendChild(this._container),this.update(),t._fadeAnimated&&C(this._container,1),this.bringToFront(),this.options.interactive&&(M(this._container,"leaflet-interactive"),this.addInteractiveTarget(this._container))},onRemove:function(t){t._fadeAnimated?(C(this._container,0),this._removeTimeout=setTimeout(a(T,void 0,this._container),200)):T(this._container),this.options.interactive&&(z(this._container,"leaflet-interactive"),this.removeInteractiveTarget(this._container))},getLatLng:function(){return this._latlng},setLatLng:function(t){return this._latlng=w(t),this._map&&(this._updatePosition(),this._adjustPan()),this},getContent:function(){return this._content},setContent:function(t){return this._content=t,this.update(),this},getElement:function(){return this._container},update:function(){this._map&&(this._container.style.visibility="hidden",this._updateContent(),this._updateLayout(),this._updatePosition(),this._container.style.visibility="",this._adjustPan())},getEvents:function(){var t={zoom:this._updatePosition,viewreset:this._updatePosition};return this._zoomAnimated&&(t.zoomanim=this._animateZoom),t},isOpen:function(){return!!this._map&&this._map.hasLayer(this)},bringToFront:function(){return this._map&&fe(this._container),this},bringToBack:function(){return this._map&&ge(this._container),this},_prepareOpen:function(t){if(!(i=this._source)._map)return!1;if(i instanceof ci){var e,i=null,n=this._source._layers;for(e in n)if(n[e]._map){i=n[e];break}if(!i)return!1;this._source=i}if(!t)if(i.getCenter)t=i.getCenter();else if(i.getLatLng)t=i.getLatLng();else{if(!i.getBounds)throw new Error("Unable to get source layer LatLng.");t=i.getBounds().getCenter()}return this.setLatLng(t),this._map&&this.update(),!0},_updateContent:function(){if(this._content){var t=this._contentNode,e="function"==typeof this._content?this._content(this._source||this):this._content;if("string"==typeof e)t.innerHTML=e;else{for(;t.hasChildNodes();)t.removeChild(t.firstChild);t.appendChild(e)}this.fire("contentupdate")}},_updatePosition:function(){var t,e,i;this._map&&(e=this._map.latLngToLayerPoint(this._latlng),t=m(this.options.offset),i=this._getAnchor(),this._zoomAnimated?Z(this._container,e.add(i)):t=t.add(e).add(i),e=this._containerBottom=-t.y,i=this._containerLeft=-Math.round(this._containerWidth/2)+t.x,this._container.style.bottom=e+"px",this._container.style.left=i+"px")},_getAnchor:function(){return[0,0]}}),Bi=(A.include({_initOverlay:function(t,e,i,n){var o=e;return o instanceof t||(o=new t(n).setContent(e)),i&&o.setLatLng(i),o}}),o.include({_initOverlay:function(t,e,i,n){var o=i;return o instanceof t?(c(o,n),o._source=this):(o=e&&!n?e:new t(n,this)).setContent(i),o}}),Ai.extend({options:{pane:"popupPane",offset:[0,7],maxWidth:300,minWidth:50,maxHeight:null,autoPan:!0,autoPanPaddingTopLeft:null,autoPanPaddingBottomRight:null,autoPanPadding:[5,5],keepInView:!1,closeButton:!0,autoClose:!0,closeOnEscapeKey:!0,className:""},openOn:function(t){return!(t=arguments.length?t:this._source._map).hasLayer(this)&&t._popup&&t._popup.options.autoClose&&t.removeLayer(t._popup),t._popup=this,Ai.prototype.openOn.call(this,t)},onAdd:function(t){Ai.prototype.onAdd.call(this,t),t.fire("popupopen",{popup:this}),this._source&&(this._source.fire("popupopen",{popup:this},!0),this._source instanceof fi||this._source.on("preclick",Ae))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("popupclose",{popup:this}),this._source&&(this._source.fire("popupclose",{popup:this},!0),this._source instanceof fi||this._source.off("preclick",Ae))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return(void 0!==this.options.closeOnClick?this.options.closeOnClick:this._map.options.closePopupOnClick)&&(t.preclick=this.close),this.options.keepInView&&(t.moveend=this._adjustPan),t},_initLayout:function(){var t="leaflet-popup",e=this._container=P("div",t+" "+(this.options.className||"")+" leaflet-zoom-animated"),i=this._wrapper=P("div",t+"-content-wrapper",e);this._contentNode=P("div",t+"-content",i),Ie(e),Be(this._contentNode),S(e,"contextmenu",Ae),this._tipContainer=P("div",t+"-tip-container",e),this._tip=P("div",t+"-tip",this._tipContainer),this.options.closeButton&&((i=this._closeButton=P("a",t+"-close-button",e)).setAttribute("role","button"),i.setAttribute("aria-label","Close popup"),i.href="#close",i.innerHTML='<span aria-hidden="true">×</span>',S(i,"click",function(t){O(t),this.close()},this))},_updateLayout:function(){var t=this._contentNode,e=t.style,i=(e.width="",e.whiteSpace="nowrap",t.offsetWidth),i=Math.min(i,this.options.maxWidth),i=(i=Math.max(i,this.options.minWidth),e.width=i+1+"px",e.whiteSpace="",e.height="",t.offsetHeight),n=this.options.maxHeight,o="leaflet-popup-scrolled";(n&&n<i?(e.height=n+"px",M):z)(t,o),this._containerWidth=this._container.offsetWidth},_animateZoom:function(t){var t=this._map._latLngToNewLayerPoint(this._latlng,t.zoom,t.center),e=this._getAnchor();Z(this._container,t.add(e))},_adjustPan:function(){var t,e,i,n,o,s,r,a;this.options.autoPan&&(this._map._panAnim&&this._map._panAnim.stop(),this._autopanning?this._autopanning=!1:(t=this._map,e=parseInt(pe(this._container,"marginBottom"),10)||0,e=this._container.offsetHeight+e,a=this._containerWidth,(i=new p(this._containerLeft,-e-this._containerBottom))._add(Pe(this._container)),i=t.layerPointToContainerPoint(i),o=m(this.options.autoPanPadding),n=m(this.options.autoPanPaddingTopLeft||o),o=m(this.options.autoPanPaddingBottomRight||o),s=t.getSize(),r=0,i.x+a+o.x>s.x&&(r=i.x+a-s.x+o.x),i.x-r-n.x<(a=0)&&(r=i.x-n.x),i.y+e+o.y>s.y&&(a=i.y+e-s.y+o.y),i.y-a-n.y<0&&(a=i.y-n.y),(r||a)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([r,a]))))},_getAnchor:function(){return m(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}})),Ii=(A.mergeOptions({closePopupOnClick:!0}),A.include({openPopup:function(t,e,i){return this._initOverlay(Bi,t,e,i).openOn(this),this},closePopup:function(t){return(t=arguments.length?t:this._popup)&&t.close(),this}}),o.include({bindPopup:function(t,e){return this._popup=this._initOverlay(Bi,this._popup,t,e),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ci||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e;this._popup&&this._map&&(Re(t),e=t.layer||t.target,this._popup._source!==e||e instanceof fi?(this._popup._source=e,this.openPopup(t.latlng)):this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}}),Ai.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Ai.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=P("div",t),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+h(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e,i=this._map,n=this._container,o=i.latLngToContainerPoint(i.getCenter()),i=i.layerPointToContainerPoint(t),s=this.options.direction,r=n.offsetWidth,a=n.offsetHeight,h=m(this.options.offset),l=this._getAnchor(),i="top"===s?(e=r/2,a):"bottom"===s?(e=r/2,0):(e="center"===s?r/2:"right"===s?0:"left"===s?r:i.x<o.x?(s="right",0):(s="left",r+2*(h.x+l.x)),a/2);t=t.subtract(m(e,i,!0)).add(h).add(l),z(n,"leaflet-tooltip-right"),z(n,"leaflet-tooltip-left"),z(n,"leaflet-tooltip-top"),z(n,"leaflet-tooltip-bottom"),M(n,"leaflet-tooltip-"+s),Z(n,t)},_updatePosition:function(){var t=this._map.latLngToLayerPoint(this._latlng);this._setPosition(t)},setOpacity:function(t){this.options.opacity=t,this._container&&C(this._container,t)},_animateZoom:function(t){t=this._map._latLngToNewLayerPoint(this._latlng,t.zoom,t.center);this._setPosition(t)},_getAnchor:function(){return m(this._source&&this._source._getTooltipAnchor&&!this.options.sticky?this._source._getTooltipAnchor():[0,0])}})),Ri=(A.include({openTooltip:function(t,e,i){return this._initOverlay(Ii,t,e,i).openOn(this),this},closeTooltip:function(t){return t.close(),this}}),o.include({bindTooltip:function(t,e){return this._tooltip&&this.isTooltipOpen()&&this.unbindTooltip(),this._tooltip=this._initOverlay(Ii,this._tooltip,t,e),this._initTooltipInteractions(),this._tooltip.options.permanent&&this._map&&this._map.hasLayer(this)&&this.openTooltip(),this},unbindTooltip:function(){return this._tooltip&&(this._initTooltipInteractions(!0),this.closeTooltip(),this._tooltip=null),this},_initTooltipInteractions:function(t){var e,i;!t&&this._tooltipHandlersAdded||(e=t?"off":"on",i={remove:this.closeTooltip,move:this._moveTooltip},this._tooltip.options.permanent?i.add=this._openTooltip:(i.mouseover=this._openTooltip,i.mouseout=this.closeTooltip,i.click=this._openTooltip,this._map?this._addFocusListeners():i.add=this._addFocusListeners),this._tooltip.options.sticky&&(i.mousemove=this._moveTooltip),this[e](i),this._tooltipHandlersAdded=!t)},openTooltip:function(t){return this._tooltip&&(this instanceof ci||(this._tooltip._source=this),this._tooltip._prepareOpen(t)&&(this._tooltip.openOn(this._map),this.getElement?this._setAriaDescribedByOnLayer(this):this.eachLayer&&this.eachLayer(this._setAriaDescribedByOnLayer,this))),this},closeTooltip:function(){if(this._tooltip)return this._tooltip.close()},toggleTooltip:function(){return this._tooltip&&this._tooltip.toggle(this),this},isTooltipOpen:function(){return this._tooltip.isOpen()},setTooltipContent:function(t){return this._tooltip&&this._tooltip.setContent(t),this},getTooltip:function(){return this._tooltip},_addFocusListeners:function(){this.getElement?this._addFocusListenersOnLayer(this):this.eachLayer&&this.eachLayer(this._addFocusListenersOnLayer,this)},_addFocusListenersOnLayer:function(t){var e="function"==typeof t.getElement&&t.getElement();e&&(S(e,"focus",function(){this._tooltip._source=t,this.openTooltip()},this),S(e,"blur",this.closeTooltip,this))},_setAriaDescribedByOnLayer:function(t){t="function"==typeof t.getElement&&t.getElement();t&&t.setAttribute("aria-describedby",this._tooltip._container.id)},_openTooltip:function(t){var e;this._tooltip&&this._map&&(this._map.dragging&&this._map.dragging.moving()&&!this._openOnceFlag?(this._openOnceFlag=!0,(e=this)._map.once("moveend",function(){e._openOnceFlag=!1,e._openTooltip(t)})):(this._tooltip._source=t.layer||t.target,this.openTooltip(this._tooltip.options.sticky?t.latlng:void 0)))},_moveTooltip:function(t){var e=t.latlng;this._tooltip.options.sticky&&t.originalEvent&&(t=this._map.mouseEventToContainerPoint(t.originalEvent),t=this._map.containerPointToLayerPoint(t),e=this._map.layerPointToLatLng(t)),this._tooltip.setLatLng(e)}}),di.extend({options:{iconSize:[12,12],html:!1,bgPos:null,className:"leaflet-div-icon"},createIcon:function(t){var t=t&&"DIV"===t.tagName?t:document.createElement("div"),e=this.options;return e.html instanceof Element?(me(t),t.appendChild(e.html)):t.innerHTML=!1!==e.html?e.html:"",e.bgPos&&(e=m(e.bgPos),t.style.backgroundPosition=-e.x+"px "+-e.y+"px"),this._setIconStyles(t,"icon"),t},createShadow:function(){return null}}));di.Default=_i;var Ni=o.extend({options:{tileSize:256,opacity:1,updateWhenIdle:b.mobile,updateWhenZooming:!0,updateInterval:200,zIndex:1,bounds:null,minZoom:0,maxZoom:void 0,maxNativeZoom:void 0,minNativeZoom:void 0,noWrap:!1,pane:"tilePane",className:"",keepBuffer:2},initialize:function(t){c(this,t)},onAdd:function(){this._initContainer(),this._levels={},this._tiles={},this._resetView()},beforeAdd:function(t){t._addZoomLimit(this)},onRemove:function(t){this._removeAllTiles(),T(this._container),t._removeZoomLimit(this),this._container=null,this._tileZoom=void 0},bringToFront:function(){return this._map&&(fe(this._container),this._setAutoZIndex(Math.max)),this},bringToBack:function(){return this._map&&(ge(this._container),this._setAutoZIndex(Math.min)),this},getContainer:function(){return this._container},setOpacity:function(t){return this.options.opacity=t,this._updateOpacity(),this},setZIndex:function(t){return this.options.zIndex=t,this._updateZIndex(),this},isLoading:function(){return this._loading},redraw:function(){var t;return this._map&&(this._removeAllTiles(),(t=this._clampZoom(this._map.getZoom()))!==this._tileZoom&&(this._tileZoom=t,this._updateLevels()),this._update()),this},getEvents:function(){var t={viewprereset:this._invalidateAll,viewreset:this._resetView,zoom:this._resetView,moveend:this._onMoveEnd};return this.options.updateWhenIdle||(this._onMove||(this._onMove=j(this._onMoveEnd,this.options.updateInterval,this)),t.move=this._onMove),this._zoomAnimated&&(t.zoomanim=this._animateZoom),t},createTile:function(){return document.createElement("div")},getTileSize:function(){var t=this.options.tileSize;return t instanceof p?t:new p(t,t)},_updateZIndex:function(){this._container&&void 0!==this.options.zIndex&&null!==this.options.zIndex&&(this._container.style.zIndex=this.options.zIndex)},_setAutoZIndex:function(t){for(var e,i=this.getPane().children,n=-t(-1/0,1/0),o=0,s=i.length;o<s;o++)e=i[o].style.zIndex,i[o]!==this._container&&e&&(n=t(n,+e));isFinite(n)&&(this.options.zIndex=n+t(-1,1),this._updateZIndex())},_updateOpacity:function(){if(this._map&&!b.ielt9){C(this._container,this.options.opacity);var t,e=+new Date,i=!1,n=!1;for(t in this._tiles){var o,s=this._tiles[t];s.current&&s.loaded&&(o=Math.min(1,(e-s.loaded)/200),C(s.el,o),o<1?i=!0:(s.active?n=!0:this._onOpaqueTile(s),s.active=!0))}n&&!this._noPrune&&this._pruneTiles(),i&&(r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this))}},_onOpaqueTile:u,_initContainer:function(){this._container||(this._container=P("div","leaflet-layer "+(this.options.className||"")),this._updateZIndex(),this.options.opacity<1&&this._updateOpacity(),this.getPane().appendChild(this._container))},_updateLevels:function(){var t=this._tileZoom,e=this.options.maxZoom;if(void 0!==t){for(var i in this._levels)i=Number(i),this._levels[i].el.children.length||i===t?(this._levels[i].el.style.zIndex=e-Math.abs(t-i),this._onUpdateLevel(i)):(T(this._levels[i].el),this._removeTilesAtZoom(i),this._onRemoveLevel(i),delete this._levels[i]);var n=this._levels[t],o=this._map;return n||((n=this._levels[t]={}).el=P("div","leaflet-tile-container leaflet-zoom-animated",this._container),n.el.style.zIndex=e,n.origin=o.project(o.unproject(o.getPixelOrigin()),t).round(),n.zoom=t,this._setZoomTransform(n,o.getCenter(),o.getZoom()),u(n.el.offsetWidth),this._onCreateLevel(n)),this._level=n}},_onUpdateLevel:u,_onRemoveLevel:u,_onCreateLevel:u,_pruneTiles:function(){if(this._map){var t,e,i,n=this._map.getZoom();if(n>this.options.maxZoom||n<this.options.minZoom)this._removeAllTiles();else{for(t in this._tiles)(i=this._tiles[t]).retain=i.current;for(t in this._tiles)(i=this._tiles[t]).current&&!i.active&&(e=i.coords,this._retainParent(e.x,e.y,e.z,e.z-5)||this._retainChildren(e.x,e.y,e.z,e.z+2));for(t in this._tiles)this._tiles[t].retain||this._removeTile(t)}}},_removeTilesAtZoom:function(t){for(var e in this._tiles)this._tiles[e].coords.z===t&&this._removeTile(e)},_removeAllTiles:function(){for(var t in this._tiles)this._removeTile(t)},_invalidateAll:function(){for(var t in this._levels)T(this._levels[t].el),this._onRemoveLevel(Number(t)),delete this._levels[t];this._removeAllTiles(),this._tileZoom=void 0},_retainParent:function(t,e,i,n){var t=Math.floor(t/2),e=Math.floor(e/2),i=i-1,o=new p(+t,+e),o=(o.z=i,this._tileCoordsToKey(o)),o=this._tiles[o];return o&&o.active?o.retain=!0:(o&&o.loaded&&(o.retain=!0),n<i&&this._retainParent(t,e,i,n))},_retainChildren:function(t,e,i,n){for(var o=2*t;o<2*t+2;o++)for(var s=2*e;s<2*e+2;s++){var r=new p(o,s),r=(r.z=i+1,this._tileCoordsToKey(r)),r=this._tiles[r];r&&r.active?r.retain=!0:(r&&r.loaded&&(r.retain=!0),i+1<n&&this._retainChildren(o,s,i+1,n))}},_resetView:function(t){t=t&&(t.pinch||t.flyTo);this._setView(this._map.getCenter(),this._map.getZoom(),t,t)},_animateZoom:function(t){this._setView(t.center,t.zoom,!0,t.noUpdate)},_clampZoom:function(t){var e=this.options;return void 0!==e.minNativeZoom&&t<e.minNativeZoom?e.minNativeZoom:void 0!==e.maxNativeZoom&&e.maxNativeZoom<t?e.maxNativeZoom:t},_setView:function(t,e,i,n){var o=Math.round(e),o=void 0!==this.options.maxZoom&&o>this.options.maxZoom||void 0!==this.options.minZoom&&o<this.options.minZoom?void 0:this._clampZoom(o),s=this.options.updateWhenZooming&&o!==this._tileZoom;n&&!s||(this._tileZoom=o,this._abortLoading&&this._abortLoading(),this._updateLevels(),this._resetGrid(),void 0!==o&&this._update(t),i||this._pruneTiles(),this._noPrune=!!i),this._setZoomTransforms(t,e)},_setZoomTransforms:function(t,e){for(var i in this._levels)this._setZoomTransform(this._levels[i],t,e)},_setZoomTransform:function(t,e,i){var n=this._map.getZoomScale(i,t.zoom),e=t.origin.multiplyBy(n).subtract(this._map._getNewPixelOrigin(e,i)).round();b.any3d?be(t.el,e,n):Z(t.el,e)},_resetGrid:function(){var t=this._map,e=t.options.crs,i=this._tileSize=this.getTileSize(),n=this._tileZoom,o=this._map.getPixelWorldBounds(this._tileZoom);o&&(this._globalTileRange=this._pxBoundsToTileRange(o)),this._wrapX=e.wrapLng&&!this.options.noWrap&&[Math.floor(t.project([0,e.wrapLng[0]],n).x/i.x),Math.ceil(t.project([0,e.wrapLng[1]],n).x/i.y)],this._wrapY=e.wrapLat&&!this.options.noWrap&&[Math.floor(t.project([e.wrapLat[0],0],n).y/i.x),Math.ceil(t.project([e.wrapLat[1],0],n).y/i.y)]},_onMoveEnd:function(){this._map&&!this._map._animatingZoom&&this._update()},_getTiledPixelBounds:function(t){var e=this._map,i=e._animatingZoom?Math.max(e._animateToZoom,e.getZoom()):e.getZoom(),i=e.getZoomScale(i,this._tileZoom),t=e.project(t,this._tileZoom).floor(),e=e.getSize().divideBy(2*i);return new f(t.subtract(e),t.add(e))},_update:function(t){var e=this._map;if(e){var i=this._clampZoom(e.getZoom());if(void 0===t&&(t=e.getCenter()),void 0!==this._tileZoom){var n,e=this._getTiledPixelBounds(t),o=this._pxBoundsToTileRange(e),s=o.getCenter(),r=[],e=this.options.keepBuffer,a=new f(o.getBottomLeft().subtract([e,-e]),o.getTopRight().add([e,-e]));if(!(isFinite(o.min.x)&&isFinite(o.min.y)&&isFinite(o.max.x)&&isFinite(o.max.y)))throw new Error("Attempted to load an infinite number of tiles");for(n in this._tiles){var h=this._tiles[n].coords;h.z===this._tileZoom&&a.contains(new p(h.x,h.y))||(this._tiles[n].current=!1)}if(1<Math.abs(i-this._tileZoom))this._setView(t,i);else{for(var l=o.min.y;l<=o.max.y;l++)for(var u=o.min.x;u<=o.max.x;u++){var c,d=new p(u,l);d.z=this._tileZoom,this._isValidTile(d)&&((c=this._tiles[this._tileCoordsToKey(d)])?c.current=!0:r.push(d))}if(r.sort(function(t,e){return t.distanceTo(s)-e.distanceTo(s)}),0!==r.length){this._loading||(this._loading=!0,this.fire("loading"));for(var _=document.createDocumentFragment(),u=0;u<r.length;u++)this._addTile(r[u],_);this._level.el.appendChild(_)}}}}},_isValidTile:function(t){var e=this._map.options.crs;if(!e.infinite){var i=this._globalTileRange;if(!e.wrapLng&&(t.x<i.min.x||t.x>i.max.x)||!e.wrapLat&&(t.y<i.min.y||t.y>i.max.y))return!1}return!this.options.bounds||(e=this._tileCoordsToBounds(t),g(this.options.bounds).overlaps(e))},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var e=this._map,i=this.getTileSize(),n=t.scaleBy(i),i=n.add(i);return[e.unproject(n,t.z),e.unproject(i,t.z)]},_tileCoordsToBounds:function(t){t=this._tileCoordsToNwSe(t),t=new s(t[0],t[1]);return t=this.options.noWrap?t:this._map.wrapLatLngBounds(t)},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var t=t.split(":"),e=new p(+t[0],+t[1]);return e.z=+t[2],e},_removeTile:function(t){var e=this._tiles[t];e&&(T(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){M(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=u,t.onmousemove=u,b.ielt9&&this.options.opacity<1&&C(t,this.options.opacity)},_addTile:function(t,e){var i=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&x(a(this._tileReady,this,t,null,o)),Z(o,i),this._tiles[n]={el:o,coords:t,current:!0},e.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,e,i){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var n=this._tileCoordsToKey(t);(i=this._tiles[n])&&(i.loaded=+new Date,this._map._fadeAnimated?(C(i.el,0),r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(M(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?x(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new p(this._wrapX?H(t.x,this._wrapX):t.x,this._wrapY?H(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new f(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var Di=Ni.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,e){this._url=t,(e=c(this,e)).detectRetina&&b.retina&&0<e.maxZoom?(e.tileSize=Math.floor(e.tileSize/2),e.zoomReverse?(e.zoomOffset--,e.minZoom=Math.min(e.maxZoom,e.minZoom+1)):(e.zoomOffset++,e.maxZoom=Math.max(e.minZoom,e.maxZoom-1)),e.minZoom=Math.max(0,e.minZoom)):e.zoomReverse?e.minZoom=Math.min(e.maxZoom,e.minZoom):e.maxZoom=Math.max(e.minZoom,e.maxZoom),"string"==typeof e.subdomains&&(e.subdomains=e.subdomains.split("")),this.on("tileunload",this._onTileRemove)},setUrl:function(t,e){return this._url===t&&void 0===e&&(e=!0),this._url=t,e||this.redraw(),this},createTile:function(t,e){var i=document.createElement("img");return S(i,"load",a(this._tileOnLoad,this,e,i)),S(i,"error",a(this._tileOnError,this,e,i)),!this.options.crossOrigin&&""!==this.options.crossOrigin||(i.crossOrigin=!0===this.options.crossOrigin?"":this.options.crossOrigin),"string"==typeof this.options.referrerPolicy&&(i.referrerPolicy=this.options.referrerPolicy),i.alt="",i.src=this.getTileUrl(t),i},getTileUrl:function(t){var e={r:b.retina?"@2x":"",s:this._getSubdomain(t),x:t.x,y:t.y,z:this._getZoomForUrl()};return this._map&&!this._map.options.crs.infinite&&(t=this._globalTileRange.max.y-t.y,this.options.tms&&(e.y=t),e["-y"]=t),q(this._url,l(e,this.options))},_tileOnLoad:function(t,e){b.ielt9?setTimeout(a(t,this,null,e),0):t(null,e)},_tileOnError:function(t,e,i){var n=this.options.errorTileUrl;n&&e.getAttribute("src")!==n&&(e.src=n),t(i,e)},_onTileRemove:function(t){t.tile.onload=null},_getZoomForUrl:function(){var t=this._tileZoom,e=this.options.maxZoom;return(t=this.options.zoomReverse?e-t:t)+this.options.zoomOffset},_getSubdomain:function(t){t=Math.abs(t.x+t.y)%this.options.subdomains.length;return this.options.subdomains[t]},_abortLoading:function(){var t,e,i;for(t in this._tiles)this._tiles[t].coords.z!==this._tileZoom&&((i=this._tiles[t].el).onload=u,i.onerror=u,i.complete||(i.src=K,e=this._tiles[t].coords,T(i),delete this._tiles[t],this.fire("tileabort",{tile:i,coords:e})))},_removeTile:function(t){var e=this._tiles[t];if(e)return e.el.setAttribute("src",K),Ni.prototype._removeTile.call(this,t)},_tileReady:function(t,e,i){if(this._map&&(!i||i.getAttribute("src")!==K))return Ni.prototype._tileReady.call(this,t,e,i)}});function ji(t,e){return new Di(t,e)}var Hi=Di.extend({defaultWmsParams:{service:"WMS",request:"GetMap",layers:"",styles:"",format:"image/jpeg",transparent:!1,version:"1.1.1"},options:{crs:null,uppercase:!1},initialize:function(t,e){this._url=t;var i,n=l({},this.defaultWmsParams);for(i in e)i in this.options||(n[i]=e[i]);var t=(e=c(this,e)).detectRetina&&b.retina?2:1,o=this.getTileSize();n.width=o.x*t,n.height=o.y*t,this.wmsParams=n},onAdd:function(t){this._crs=this.options.crs||t.options.crs,this._wmsVersion=parseFloat(this.wmsParams.version);var e=1.3<=this._wmsVersion?"crs":"srs";this.wmsParams[e]=this._crs.code,Di.prototype.onAdd.call(this,t)},getTileUrl:function(t){var e=this._tileCoordsToNwSe(t),i=this._crs,i=_(i.project(e[0]),i.project(e[1])),e=i.min,i=i.max,e=(1.3<=this._wmsVersion&&this._crs===li?[e.y,e.x,i.y,i.x]:[e.x,e.y,i.x,i.y]).join(","),i=Di.prototype.getTileUrl.call(this,t);return i+U(this.wmsParams,i,this.options.uppercase)+(this.options.uppercase?"&BBOX=":"&bbox=")+e},setParams:function(t,e){return l(this.wmsParams,t),e||this.redraw(),this}});Di.WMS=Hi,ji.wms=function(t,e){return new Hi(t,e)};var Wi=o.extend({options:{padding:.1},initialize:function(t){c(this,t),h(this),this._layers=this._layers||{}},onAdd:function(){this._container||(this._initContainer(),M(this._container,"leaflet-zoom-animated")),this.getPane().appendChild(this._container),this._update(),this.on("update",this._updatePaths,this)},onRemove:function(){this.off("update",this._updatePaths,this),this._destroyContainer()},getEvents:function(){var t={viewreset:this._reset,zoom:this._onZoom,moveend:this._update,zoomend:this._onZoomEnd};return this._zoomAnimated&&(t.zoomanim=this._onAnimZoom),t},_onAnimZoom:function(t){this._updateTransform(t.center,t.zoom)},_onZoom:function(){this._updateTransform(this._map.getCenter(),this._map.getZoom())},_updateTransform:function(t,e){var i=this._map.getZoomScale(e,this._zoom),n=this._map.getSize().multiplyBy(.5+this.options.padding),o=this._map.project(this._center,e),n=n.multiplyBy(-i).add(o).subtract(this._map._getNewPixelOrigin(t,e));b.any3d?be(this._container,n,i):Z(this._container,n)},_reset:function(){for(var t in this._update(),this._updateTransform(this._center,this._zoom),this._layers)this._layers[t]._reset()},_onZoomEnd:function(){for(var t in this._layers)this._layers[t]._project()},_updatePaths:function(){for(var t in this._layers)this._layers[t]._update()},_update:function(){var t=this.options.padding,e=this._map.getSize(),i=this._map.containerPointToLayerPoint(e.multiplyBy(-t)).round();this._bounds=new f(i,i.add(e.multiplyBy(1+2*t)).round()),this._center=this._map.getCenter(),this._zoom=this._map.getZoom()}}),Fi=Wi.extend({options:{tolerance:0},getEvents:function(){var t=Wi.prototype.getEvents.call(this);return t.viewprereset=this._onViewPreReset,t},_onViewPreReset:function(){this._postponeUpdatePaths=!0},onAdd:function(){Wi.prototype.onAdd.call(this),this._draw()},_initContainer:function(){var t=this._container=document.createElement("canvas");S(t,"mousemove",this._onMouseMove,this),S(t,"click dblclick mousedown mouseup contextmenu",this._onClick,this),S(t,"mouseout",this._handleMouseOut,this),t._leaflet_disable_events=!0,this._ctx=t.getContext("2d")},_destroyContainer:function(){r(this._redrawRequest),delete this._ctx,T(this._container),k(this._container),delete this._container},_updatePaths:function(){if(!this._postponeUpdatePaths){for(var t in this._redrawBounds=null,this._layers)this._layers[t]._update();this._redraw()}},_update:function(){var t,e,i,n;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),t=this._bounds,e=this._container,i=t.getSize(),n=b.retina?2:1,Z(e,t.min),e.width=n*i.x,e.height=n*i.y,e.style.width=i.x+"px",e.style.height=i.y+"px",b.retina&&this._ctx.scale(2,2),this._ctx.translate(-t.min.x,-t.min.y),this.fire("update"))},_reset:function(){Wi.prototype._reset.call(this),this._postponeUpdatePaths&&(this._postponeUpdatePaths=!1,this._updatePaths())},_initPath:function(t){this._updateDashArray(t);t=(this._layers[h(t)]=t)._order={layer:t,prev:this._drawLast,next:null};this._drawLast&&(this._drawLast.next=t),this._drawLast=t,this._drawFirst=this._drawFirst||this._drawLast},_addPath:function(t){this._requestRedraw(t)},_removePath:function(t){var e=t._order,i=e.next,e=e.prev;i?i.prev=e:this._drawLast=e,e?e.next=i:this._drawFirst=i,delete t._order,delete this._layers[h(t)],this._requestRedraw(t)},_updatePath:function(t){this._extendRedrawBounds(t),t._project(),t._update(),this._requestRedraw(t)},_updateStyle:function(t){this._updateDashArray(t),this._requestRedraw(t)},_updateDashArray:function(t){if("string"==typeof t.options.dashArray){for(var e,i=t.options.dashArray.split(/[, ]+/),n=[],o=0;o<i.length;o++){if(e=Number(i[o]),isNaN(e))return;n.push(e)}t.options._dashArray=n}else t.options._dashArray=t.options.dashArray},_requestRedraw:function(t){this._map&&(this._extendRedrawBounds(t),this._redrawRequest=this._redrawRequest||x(this._redraw,this))},_extendRedrawBounds:function(t){var e;t._pxBounds&&(e=(t.options.weight||0)+1,this._redrawBounds=this._redrawBounds||new f,this._redrawBounds.extend(t._pxBounds.min.subtract([e,e])),this._redrawBounds.extend(t._pxBounds.max.add([e,e])))},_redraw:function(){this._redrawRequest=null,this._redrawBounds&&(this._redrawBounds.min._floor(),this._redrawBounds.max._ceil()),this._clear(),this._draw(),this._redrawBounds=null},_clear:function(){var t,e=this._redrawBounds;e?(t=e.getSize(),this._ctx.clearRect(e.min.x,e.min.y,t.x,t.y)):(this._ctx.save(),this._ctx.setTransform(1,0,0,1,0,0),this._ctx.clearRect(0,0,this._container.width,this._container.height),this._ctx.restore())},_draw:function(){var t,e,i=this._redrawBounds;this._ctx.save(),i&&(e=i.getSize(),this._ctx.beginPath(),this._ctx.rect(i.min.x,i.min.y,e.x,e.y),this._ctx.clip()),this._drawing=!0;for(var n=this._drawFirst;n;n=n.next)t=n.layer,(!i||t._pxBounds&&t._pxBounds.intersects(i))&&t._updatePath();this._drawing=!1,this._ctx.restore()},_updatePoly:function(t,e){if(this._drawing){var i,n,o,s,r=t._parts,a=r.length,h=this._ctx;if(a){for(h.beginPath(),i=0;i<a;i++){for(n=0,o=r[i].length;n<o;n++)s=r[i][n],h[n?"lineTo":"moveTo"](s.x,s.y);e&&h.closePath()}this._fillStroke(h,t)}}},_updateCircle:function(t){var e,i,n,o;this._drawing&&!t._empty()&&(e=t._point,i=this._ctx,n=Math.max(Math.round(t._radius),1),1!=(o=(Math.max(Math.round(t._radiusY),1)||n)/n)&&(i.save(),i.scale(1,o)),i.beginPath(),i.arc(e.x,e.y/o,n,0,2*Math.PI,!1),1!=o&&i.restore(),this._fillStroke(i,t))},_fillStroke:function(t,e){var i=e.options;i.fill&&(t.globalAlpha=i.fillOpacity,t.fillStyle=i.fillColor||i.color,t.fill(i.fillRule||"evenodd")),i.stroke&&0!==i.weight&&(t.setLineDash&&t.setLineDash(e.options&&e.options._dashArray||[]),t.globalAlpha=i.opacity,t.lineWidth=i.weight,t.strokeStyle=i.color,t.lineCap=i.lineCap,t.lineJoin=i.lineJoin,t.stroke())},_onClick:function(t){for(var e,i,n=this._map.mouseEventToLayerPoint(t),o=this._drawFirst;o;o=o.next)(e=o.layer).options.interactive&&e._containsPoint(n)&&(("click"===t.type||"preclick"===t.type)&&this._map._draggableMoved(e)||(i=e));this._fireEvent(!!i&&[i],t)},_onMouseMove:function(t){var e;!this._map||this._map.dragging.moving()||this._map._animatingZoom||(e=this._map.mouseEventToLayerPoint(t),this._handleMouseHover(t,e))},_handleMouseOut:function(t){var e=this._hoveredLayer;e&&(z(this._container,"leaflet-interactive"),this._fireEvent([e],t,"mouseout"),this._hoveredLayer=null,this._mouseHoverThrottled=!1)},_handleMouseHover:function(t,e){if(!this._mouseHoverThrottled){for(var i,n,o=this._drawFirst;o;o=o.next)(i=o.layer).options.interactive&&i._containsPoint(e)&&(n=i);n!==this._hoveredLayer&&(this._handleMouseOut(t),n&&(M(this._container,"leaflet-interactive"),this._fireEvent([n],t,"mouseover"),this._hoveredLayer=n)),this._fireEvent(!!this._hoveredLayer&&[this._hoveredLayer],t),this._mouseHoverThrottled=!0,setTimeout(a(function(){this._mouseHoverThrottled=!1},this),32)}},_fireEvent:function(t,e,i){this._map._fireDOMEvent(e,i||e.type,t)},_bringToFront:function(t){var e,i,n=t._order;n&&(e=n.next,i=n.prev,e&&((e.prev=i)?i.next=e:e&&(this._drawFirst=e),n.prev=this._drawLast,(this._drawLast.next=n).next=null,this._drawLast=n,this._requestRedraw(t)))},_bringToBack:function(t){var e,i,n=t._order;n&&(e=n.next,(i=n.prev)&&((i.next=e)?e.prev=i:i&&(this._drawLast=i),n.prev=null,n.next=this._drawFirst,this._drawFirst.prev=n,this._drawFirst=n,this._requestRedraw(t)))}});function Ui(t){return b.canvas?new Fi(t):null}var Vi=function(){try{return document.namespaces.add("lvml","urn:schemas-microsoft-com:vml"),function(t){return document.createElement("<lvml:"+t+' class="lvml">')}}catch(t){}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),zt={_initContainer:function(){this._container=P("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Wi.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=Vi("shape");M(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=Vi("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;T(e),t.removeInteractiveTarget(e),delete this._layers[h(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(e=e||(t._stroke=Vi("stroke")),o.appendChild(e),e.weight=n.weight+"px",e.color=n.color,e.opacity=n.opacity,n.dashArray?e.dashStyle=d(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=n.lineCap.replace("butt","flat"),e.joinstyle=n.lineJoin):e&&(o.removeChild(e),t._stroke=null),n.fill?(i=i||(t._fill=Vi("fill")),o.appendChild(i),i.color=n.fillColor||n.color,i.opacity=n.fillOpacity):i&&(o.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){fe(t._container)},_bringToBack:function(t){ge(t._container)}},qi=b.vml?Vi:ct,Gi=Wi.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){T(this._container),k(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){var t,e,i;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),e=(t=this._bounds).getSize(),i=this._container,this._svgSize&&this._svgSize.equals(e)||(this._svgSize=e,i.setAttribute("width",e.x),i.setAttribute("height",e.y)),Z(i,t.min),i.setAttribute("viewBox",[t.min.x,t.min.y,e.x,e.y].join(" ")),this.fire("update"))},_initPath:function(t){var e=t._path=qi("path");t.options.className&&M(e,t.options.className),t.options.interactive&&M(e,"leaflet-interactive"),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){T(t._path),t.removeInteractiveTarget(t._path),delete this._layers[h(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var e=t._path,t=t.options;e&&(t.stroke?(e.setAttribute("stroke",t.color),e.setAttribute("stroke-opacity",t.opacity),e.setAttribute("stroke-width",t.weight),e.setAttribute("stroke-linecap",t.lineCap),e.setAttribute("stroke-linejoin",t.lineJoin),t.dashArray?e.setAttribute("stroke-dasharray",t.dashArray):e.removeAttribute("stroke-dasharray"),t.dashOffset?e.setAttribute("stroke-dashoffset",t.dashOffset):e.removeAttribute("stroke-dashoffset")):e.setAttribute("stroke","none"),t.fill?(e.setAttribute("fill",t.fillColor||t.color),e.setAttribute("fill-opacity",t.fillOpacity),e.setAttribute("fill-rule",t.fillRule||"evenodd")):e.setAttribute("fill","none"))},_updatePoly:function(t,e){this._setPath(t,dt(t._parts,e))},_updateCircle:function(t){var e=t._point,i=Math.max(Math.round(t._radius),1),n="a"+i+","+(Math.max(Math.round(t._radiusY),1)||i)+" 0 1,0 ",e=t._empty()?"M0 0":"M"+(e.x-i)+","+e.y+n+2*i+",0 "+n+2*-i+",0 ";this._setPath(t,e)},_setPath:function(t,e){t._path.setAttribute("d",e)},_bringToFront:function(t){fe(t._path)},_bringToBack:function(t){ge(t._path)}});function Ki(t){return b.svg||b.vml?new Gi(t):null}b.vml&&Gi.include(zt),A.include({getRenderer:function(t){t=(t=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(t)||this.addLayer(t),t},_getPaneRenderer:function(t){var e;return"overlayPane"!==t&&void 0!==t&&(void 0===(e=this._paneRenderers[t])&&(e=this._createRenderer({pane:t}),this._paneRenderers[t]=e),e)},_createRenderer:function(t){return this.options.preferCanvas&&Ui(t)||Ki(t)}});var Yi=xi.extend({initialize:function(t,e){xi.prototype.initialize.call(this,this._boundsToLatLngs(t),e)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=g(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Gi.create=qi,Gi.pointsToPath=dt,wi.geometryToLayer=bi,wi.coordsToLatLng=Li,wi.coordsToLatLngs=Ti,wi.latLngToCoords=Mi,wi.latLngsToCoords=zi,wi.getFeature=Ci,wi.asFeature=Zi,A.mergeOptions({boxZoom:!0});var _t=n.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){S(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){k(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){T(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),re(),Le(),this._startPoint=this._map.mouseEventToContainerPoint(t),S(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=P("div","leaflet-zoom-box",this._container),M(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var t=new f(this._point,this._startPoint),e=t.getSize();Z(this._box,t.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(T(this._box),z(this._container,"leaflet-crosshair")),ae(),Te(),k(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0),t=new s(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(t).fire("boxzoomend",{boxZoomBounds:t})))},_onKeyDown:function(t){27===t.keyCode&&(this._finish(),this._clearDeferredResetState(),this._resetState())}}),Ct=(A.addInitHook("addHandler","boxZoom",_t),A.mergeOptions({doubleClickZoom:!0}),n.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var e=this._map,i=e.getZoom(),n=e.options.zoomDelta,i=t.originalEvent.shiftKey?i-n:i+n;"center"===e.options.doubleClickZoom?e.setZoom(i):e.setZoomAround(t.containerPoint,i)}})),Zt=(A.addInitHook("addHandler","doubleClickZoom",Ct),A.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0}),n.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new Xe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),M(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){z(this._map._container,"leaflet-grab"),z(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,e=this._map;e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=g(this._map.options.maxBounds),this._offsetLimit=_(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var e,i;this._map.options.inertia&&(e=this._lastTime=+new Date,i=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(i),this._times.push(e),this._prunePositions(e)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1<this._positions.length&&50<t-this._times[0];)this._positions.shift(),this._times.shift()},_onZoomEnd:function(){var t=this._map.getSize().divideBy(2),e=this._map.latLngToLayerPoint([0,0]);this._initialWorldOffset=e.subtract(t).x,this._worldWidth=this._map.getPixelWorldBounds().getSize().x},_viscousLimit:function(t,e){return t-(t-e)*this._viscosity},_onPreDragLimit:function(){var t,e;this._viscosity&&this._offsetLimit&&(t=this._draggable._newPos.subtract(this._draggable._startPos),e=this._offsetLimit,t.x<e.min.x&&(t.x=this._viscousLimit(t.x,e.min.x)),t.y<e.min.y&&(t.y=this._viscousLimit(t.y,e.min.y)),t.x>e.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,n=(n+e+i)%t-e-i,t=Math.abs(o+i)<Math.abs(n+i)?o:n;this._draggable._absPos=this._draggable._newPos.clone(),this._draggable._newPos.x=t},_onDragEnd:function(t){var e,i,n,o,s=this._map,r=s.options,a=!r.inertia||t.noInertia||this._times.length<2;s.fire("dragend",t),!a&&(this._prunePositions(+new Date),t=this._lastPos.subtract(this._positions[0]),a=(this._lastTime-this._times[0])/1e3,e=r.easeLinearity,a=(t=t.multiplyBy(e/a)).distanceTo([0,0]),i=Math.min(r.inertiaMaxSpeed,a),t=t.multiplyBy(i/a),n=i/(r.inertiaDeceleration*e),(o=t.multiplyBy(-n/2).round()).x||o.y)?(o=s._limitOffset(o,s.options.maxBounds),x(function(){s.panBy(o,{duration:n,easeLinearity:e,noMoveStart:!0,animate:!0})})):s.fire("moveend")}})),St=(A.addInitHook("addHandler","dragging",Zt),A.mergeOptions({keyboard:!0,keyboardPanDelta:80}),n.extend({keyCodes:{left:[37],right:[39],down:[40],up:[38],zoomIn:[187,107,61,171],zoomOut:[189,109,54,173]},initialize:function(t){this._map=t,this._setPanDelta(t.options.keyboardPanDelta),this._setZoomDelta(t.options.zoomDelta)},addHooks:function(){var t=this._map._container;t.tabIndex<=0&&(t.tabIndex="0"),S(t,{focus:this._onFocus,blur:this._onBlur,mousedown:this._onMouseDown},this),this._map.on({focus:this._addHooks,blur:this._removeHooks},this)},removeHooks:function(){this._removeHooks(),k(this._map._container,{focus:this._onFocus,blur:this._onBlur,mousedown:this._onMouseDown},this),this._map.off({focus:this._addHooks,blur:this._removeHooks},this)},_onMouseDown:function(){var t,e,i;this._focused||(i=document.body,t=document.documentElement,e=i.scrollTop||t.scrollTop,i=i.scrollLeft||t.scrollLeft,this._map._container.focus(),window.scrollTo(i,e))},_onFocus:function(){this._focused=!0,this._map.fire("focus")},_onBlur:function(){this._focused=!1,this._map.fire("blur")},_setPanDelta:function(t){for(var e=this._panKeys={},i=this.keyCodes,n=0,o=i.left.length;n<o;n++)e[i.left[n]]=[-1*t,0];for(n=0,o=i.right.length;n<o;n++)e[i.right[n]]=[t,0];for(n=0,o=i.down.length;n<o;n++)e[i.down[n]]=[0,t];for(n=0,o=i.up.length;n<o;n++)e[i.up[n]]=[0,-1*t]},_setZoomDelta:function(t){for(var e=this._zoomKeys={},i=this.keyCodes,n=0,o=i.zoomIn.length;n<o;n++)e[i.zoomIn[n]]=t;for(n=0,o=i.zoomOut.length;n<o;n++)e[i.zoomOut[n]]=-t},_addHooks:function(){S(document,"keydown",this._onKeyDown,this)},_removeHooks:function(){k(document,"keydown",this._onKeyDown,this)},_onKeyDown:function(t){if(!(t.altKey||t.ctrlKey||t.metaKey)){var e,i,n=t.keyCode,o=this._map;if(n in this._panKeys)o._panAnim&&o._panAnim._inProgress||(i=this._panKeys[n],t.shiftKey&&(i=m(i).multiplyBy(3)),o.options.maxBounds&&(i=o._limitOffset(m(i),o.options.maxBounds)),o.options.worldCopyJump?(e=o.wrapLatLng(o.unproject(o.project(o.getCenter()).add(i))),o.panTo(e)):o.panBy(i));else if(n in this._zoomKeys)o.setZoom(o.getZoom()+(t.shiftKey?3:1)*this._zoomKeys[n]);else{if(27!==n||!o._popup||!o._popup.options.closeOnEscapeKey)return;o.closePopup()}Re(t)}}})),Et=(A.addInitHook("addHandler","keyboard",St),A.mergeOptions({scrollWheelZoom:!0,wheelDebounceTime:40,wheelPxPerZoomLevel:60}),n.extend({addHooks:function(){S(this._map._container,"wheel",this._onWheelScroll,this),this._delta=0},removeHooks:function(){k(this._map._container,"wheel",this._onWheelScroll,this)},_onWheelScroll:function(t){var e=He(t),i=this._map.options.wheelDebounceTime,e=(this._delta+=e,this._lastMousePos=this._map.mouseEventToContainerPoint(t),this._startTime||(this._startTime=+new Date),Math.max(i-(+new Date-this._startTime),0));clearTimeout(this._timer),this._timer=setTimeout(a(this._performZoom,this),e),Re(t)},_performZoom:function(){var t=this._map,e=t.getZoom(),i=this._map.options.zoomSnap||0,n=(t._stop(),this._delta/(4*this._map.options.wheelPxPerZoomLevel)),n=4*Math.log(2/(1+Math.exp(-Math.abs(n))))/Math.LN2,i=i?Math.ceil(n/i)*i:n,n=t._limitZoom(e+(0<this._delta?i:-i))-e;this._delta=0,this._startTime=null,n&&("center"===t.options.scrollWheelZoom?t.setZoom(e+n):t.setZoomAround(this._lastMousePos,e+n))}})),kt=(A.addInitHook("addHandler","scrollWheelZoom",Et),A.mergeOptions({tapHold:b.touchNative&&b.safari&&b.mobile,tapTolerance:15}),n.extend({addHooks:function(){S(this._map._container,"touchstart",this._onDown,this)},removeHooks:function(){k(this._map._container,"touchstart",this._onDown,this)},_onDown:function(t){var e;clearTimeout(this._holdTimeout),1===t.touches.length&&(e=t.touches[0],this._startPos=this._newPos=new p(e.clientX,e.clientY),this._holdTimeout=setTimeout(a(function(){this._cancel(),this._isTapValid()&&(S(document,"touchend",O),S(document,"touchend touchcancel",this._cancelClickPrevent),this._simulateEvent("contextmenu",e))},this),600),S(document,"touchend touchcancel contextmenu",this._cancel,this),S(document,"touchmove",this._onMove,this))},_cancelClickPrevent:function t(){k(document,"touchend",O),k(document,"touchend touchcancel",t)},_cancel:function(){clearTimeout(this._holdTimeout),k(document,"touchend touchcancel contextmenu",this._cancel,this),k(document,"touchmove",this._onMove,this)},_onMove:function(t){t=t.touches[0];this._newPos=new p(t.clientX,t.clientY)},_isTapValid:function(){return this._newPos.distanceTo(this._startPos)<=this._map.options.tapTolerance},_simulateEvent:function(t,e){t=new MouseEvent(t,{bubbles:!0,cancelable:!0,view:window,screenX:e.screenX,screenY:e.screenY,clientX:e.clientX,clientY:e.clientY});t._simulated=!0,e.target.dispatchEvent(t)}})),Ot=(A.addInitHook("addHandler","tapHold",kt),A.mergeOptions({touchZoom:b.touch,bounceAtZoomLimits:!0}),n.extend({addHooks:function(){M(this._map._container,"leaflet-touch-zoom"),S(this._map._container,"touchstart",this._onTouchStart,this)},removeHooks:function(){z(this._map._container,"leaflet-touch-zoom"),k(this._map._container,"touchstart",this._onTouchStart,this)},_onTouchStart:function(t){var e,i,n=this._map;!t.touches||2!==t.touches.length||n._animatingZoom||this._zooming||(e=n.mouseEventToContainerPoint(t.touches[0]),i=n.mouseEventToContainerPoint(t.touches[1]),this._centerPoint=n.getSize()._divideBy(2),this._startLatLng=n.containerPointToLatLng(this._centerPoint),"center"!==n.options.touchZoom&&(this._pinchStartLatLng=n.containerPointToLatLng(e.add(i)._divideBy(2))),this._startDist=e.distanceTo(i),this._startZoom=n.getZoom(),this._moved=!1,this._zooming=!0,n._stop(),S(document,"touchmove",this._onTouchMove,this),S(document,"touchend touchcancel",this._onTouchEnd,this),O(t))},_onTouchMove:function(t){if(t.touches&&2===t.touches.length&&this._zooming){var e=this._map,i=e.mouseEventToContainerPoint(t.touches[0]),n=e.mouseEventToContainerPoint(t.touches[1]),o=i.distanceTo(n)/this._startDist;if(this._zoom=e.getScaleZoom(o,this._startZoom),!e.options.bounceAtZoomLimits&&(this._zoom<e.getMinZoom()&&o<1||this._zoom>e.getMaxZoom()&&1<o)&&(this._zoom=e._limitZoom(this._zoom)),"center"===e.options.touchZoom){if(this._center=this._startLatLng,1==o)return}else{i=i._add(n)._divideBy(2)._subtract(this._centerPoint);if(1==o&&0===i.x&&0===i.y)return;this._center=e.unproject(e.project(this._pinchStartLatLng,this._zoom).subtract(i),this._zoom)}this._moved||(e._moveStart(!0,!1),this._moved=!0),r(this._animRequest);n=a(e._move,e,this._center,this._zoom,{pinch:!0,round:!1},void 0);this._animRequest=x(n,this,!0),O(t)}},_onTouchEnd:function(){this._moved&&this._zooming?(this._zooming=!1,r(this._animRequest),k(document,"touchmove",this._onTouchMove,this),k(document,"touchend touchcancel",this._onTouchEnd,this),this._map.options.zoomAnimation?this._map._animateZoom(this._center,this._map._limitZoom(this._zoom),!0,this._map.options.zoomSnap):this._map._resetView(this._center,this._map._limitZoom(this._zoom))):this._zooming=!1}})),Xi=(A.addInitHook("addHandler","touchZoom",Ot),A.BoxZoom=_t,A.DoubleClickZoom=Ct,A.Drag=Zt,A.Keyboard=St,A.ScrollWheelZoom=Et,A.TapHold=kt,A.TouchZoom=Ot,t.Bounds=f,t.Browser=b,t.CRS=ot,t.Canvas=Fi,t.Circle=vi,t.CircleMarker=gi,t.Class=et,t.Control=B,t.DivIcon=Ri,t.DivOverlay=Ai,t.DomEvent=mt,t.DomUtil=pt,t.Draggable=Xe,t.Evented=it,t.FeatureGroup=ci,t.GeoJSON=wi,t.GridLayer=Ni,t.Handler=n,t.Icon=di,t.ImageOverlay=Ei,t.LatLng=v,t.LatLngBounds=s,t.Layer=o,t.LayerGroup=ui,t.LineUtil=vt,t.Map=A,t.Marker=mi,t.Mixin=ft,t.Path=fi,t.Point=p,t.PolyUtil=gt,t.Polygon=xi,t.Polyline=yi,t.Popup=Bi,t.PosAnimation=Fe,t.Projection=wt,t.Rectangle=Yi,t.Renderer=Wi,t.SVG=Gi,t.SVGOverlay=Oi,t.TileLayer=Di,t.Tooltip=Ii,t.Transformation=at,t.Util=tt,t.VideoOverlay=ki,t.bind=a,t.bounds=_,t.canvas=Ui,t.circle=function(t,e,i){return new vi(t,e,i)},t.circleMarker=function(t,e){return new gi(t,e)},t.control=Ue,t.divIcon=function(t){return new Ri(t)},t.extend=l,t.featureGroup=function(t,e){return new ci(t,e)},t.geoJSON=Si,t.geoJson=Mt,t.gridLayer=function(t){return new Ni(t)},t.icon=function(t){return new di(t)},t.imageOverlay=function(t,e,i){return new Ei(t,e,i)},t.latLng=w,t.latLngBounds=g,t.layerGroup=function(t,e){return new ui(t,e)},t.map=function(t,e){return new A(t,e)},t.marker=function(t,e){return new mi(t,e)},t.point=m,t.polygon=function(t,e){return new xi(t,e)},t.polyline=function(t,e){return new yi(t,e)},t.popup=function(t,e){return new Bi(t,e)},t.rectangle=function(t,e){return new Yi(t,e)},t.setOptions=c,t.stamp=h,t.svg=Ki,t.svgOverlay=function(t,e,i){return new Oi(t,e,i)},t.tileLayer=ji,t.tooltip=function(t,e){return new Ii(t,e)},t.transformation=ht,t.version="1.9.4",t.videoOverlay=function(t,e,i){return new ki(t,e,i)},window.L);t.noConflict=function(){return window.L=Xi,this},window.L=t});
//# sourceMappingURL=leaflet.js.map
Dweb/vite.config.ts-17
@@ -1,17 +0,0 @@
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
// Vite rather than `bun build`: Solid's JSX is a compile-time transform
// (babel-plugin-jsx-dom-expressions), not the automatic JSX runtime bun
// implements. bun is still the package manager and the runtime — `bun run dev`.
//
// The dev server proxies /api to the Rust binary so cookies stay same-origin.
// Start the backend with `--dev`, which drops the cookie's Secure requirement.
export default defineConfig({
plugins: [solid()],
server: {
port: 5173,
proxy: { "/api": "http://127.0.0.1:7372" },
},
build: { outDir: "dist", emptyOutDir: true },
});