add gpx creation
This commit is contained in:
parent
dae64cc434
commit
c2400f4b29
9 changed files with 726 additions and 16 deletions
303
Cargo.lock
generated
303
Cargo.lock
generated
|
|
@ -220,6 +220,23 @@ version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.0",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.9.6"
|
version = "0.9.6"
|
||||||
|
|
@ -464,6 +481,12 @@ dependencies = [
|
||||||
"spin",
|
"spin",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fnv"
|
||||||
|
version = "1.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
|
|
@ -593,8 +616,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -604,8 +629,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http",
|
||||||
|
"indexmap",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -737,6 +784,7 @@ dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
|
|
@ -745,6 +793,23 @@ dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"want",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-rustls"
|
||||||
|
version = "0.27.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower-service",
|
||||||
|
"webpki-roots 1.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -753,13 +818,21 @@ version = "0.1.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"ipnet",
|
||||||
|
"libc",
|
||||||
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"socket2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -875,6 +948,12 @@ dependencies = [
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ipnet"
|
||||||
|
version = "2.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
|
|
@ -984,6 +1063,12 @@ version = "0.4.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -1088,7 +1173,7 @@ dependencies = [
|
||||||
"num-integer",
|
"num-integer",
|
||||||
"num-iter",
|
"num-iter",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rand",
|
"rand 0.8.7",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
@ -1170,7 +1255,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64ct",
|
"base64ct",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1261,6 +1346,62 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn"
|
||||||
|
version = "0.11.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn-proto",
|
||||||
|
"quinn-udp",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"socket2",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-proto"
|
||||||
|
version = "0.11.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"lru-slab",
|
||||||
|
"rand 0.10.2",
|
||||||
|
"rand_pcg",
|
||||||
|
"ring",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"slab",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"tinyvec",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-udp"
|
||||||
|
version = "0.5.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"socket2",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
|
|
@ -1290,7 +1431,18 @@ checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha",
|
"rand_chacha",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||||
|
dependencies = [
|
||||||
|
"chacha20",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1300,7 +1452,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"ppv-lite86",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1312,6 +1464,21 @@ dependencies = [
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_pcg"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
|
|
@ -1347,6 +1514,47 @@ version = "0.8.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reqwest"
|
||||||
|
version = "0.12.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
|
"futures-core",
|
||||||
|
"h2",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
|
"hyper-util",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"mime",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"webpki-roots 1.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
|
|
@ -1382,7 +1590,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rs_maps"
|
name = "rs_maps"
|
||||||
version = "0.0.6"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|
@ -1392,7 +1600,8 @@ dependencies = [
|
||||||
"lettre",
|
"lettre",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"password-hash",
|
"password-hash",
|
||||||
"rand",
|
"rand 0.8.7",
|
||||||
|
"reqwest",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
@ -1423,7 +1632,7 @@ dependencies = [
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"pkcs1",
|
"pkcs1",
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"signature",
|
"signature",
|
||||||
"spki",
|
"spki",
|
||||||
"subtle",
|
"subtle",
|
||||||
|
|
@ -1465,6 +1674,12 @@ dependencies = [
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.43"
|
version = "0.23.43"
|
||||||
|
|
@ -1486,6 +1701,7 @@ version = "1.15.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1658,7 +1874,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1823,7 +2039,7 @@ dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand",
|
"rand 0.8.7",
|
||||||
"rsa",
|
"rsa",
|
||||||
"serde",
|
"serde",
|
||||||
"sha1",
|
"sha1",
|
||||||
|
|
@ -1863,7 +2079,7 @@ dependencies = [
|
||||||
"md-5",
|
"md-5",
|
||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rand",
|
"rand 0.8.7",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
|
|
@ -1953,6 +2169,9 @@ name = "sync_wrapper"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "synstructure"
|
name = "synstructure"
|
||||||
|
|
@ -2118,6 +2337,20 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-util"
|
||||||
|
version = "0.7.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"libc",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
|
|
@ -2158,12 +2391,15 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2208,7 +2444,7 @@ dependencies = [
|
||||||
"futures",
|
"futures",
|
||||||
"http",
|
"http",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rand",
|
"rand 0.8.7",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
|
|
@ -2305,6 +2541,12 @@ dependencies = [
|
||||||
"tracing-log",
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "try-lock"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.1"
|
version = "1.20.1"
|
||||||
|
|
@ -2408,6 +2650,15 @@ dependencies = [
|
||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "want"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
|
||||||
|
dependencies = [
|
||||||
|
"try-lock",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
|
@ -2433,6 +2684,16 @@ dependencies = [
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-futures"
|
||||||
|
version = "0.4.76"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.126"
|
version = "0.2.126"
|
||||||
|
|
@ -2465,6 +2726,26 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-sys"
|
||||||
|
version = "0.3.103"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-time"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "0.26.11"
|
version = "0.26.11"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Cargo.toml
|
# Cargo.toml
|
||||||
[package]
|
[package]
|
||||||
name = "rs_maps"
|
name = "rs_maps"
|
||||||
version = "0.0.6"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
@ -23,6 +23,13 @@ tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
password-hash = { version = "0.5", features = ["std"] }
|
password-hash = { version = "0.5", features = ["std"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
# HTTP client for proxying route requests to brouter.de. rustls-tls-manual-roots
|
||||||
|
# is avoided; ring + webpki roots keeps one crypto provider across the binary,
|
||||||
|
# matching the sqlx and lettre feature choices above.
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = [
|
||||||
|
"rustls-tls-webpki-roots", "json", "charset", "http2",
|
||||||
|
] }
|
||||||
|
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
subtle = "2"
|
subtle = "2"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
|
|
||||||
139
frontend/app.js
139
frontend/app.js
|
|
@ -50,6 +50,7 @@ const state = {
|
||||||
draftLatLng: null,
|
draftLatLng: null,
|
||||||
colour: DEFAULT_COLOUR,
|
colour: DEFAULT_COLOUR,
|
||||||
searchPin: null, // transient pin for the current search hit
|
searchPin: null, // transient pin for the current search hit
|
||||||
|
draw: { active: false, points: [], markers: [], line: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ───────────────────────────── api ───────────────────────────── */
|
/* ───────────────────────────── api ───────────────────────────── */
|
||||||
|
|
@ -229,7 +230,10 @@ function initMap() {
|
||||||
state.map = L.map('map', { zoomControl: true }).setView([54.5, -3.0], 6);
|
state.map = L.map('map', { zoomControl: true }).setView([54.5, -3.0], 6);
|
||||||
L.tileLayer(tileUrl, { maxZoom: 19, attribution: TILE_ATTRIB }).addTo(state.map);
|
L.tileLayer(tileUrl, { maxZoom: 19, attribution: TILE_ATTRIB }).addTo(state.map);
|
||||||
|
|
||||||
state.map.on('click', (e) => openMarkerSheet(null, e.latlng));
|
state.map.on('click', (e) => {
|
||||||
|
if (state.draw.active) { addWaypoint(e.latlng); return; }
|
||||||
|
openMarkerSheet(null, e.latlng);
|
||||||
|
});
|
||||||
state.map.on('move zoom', updateReadout);
|
state.map.on('move zoom', updateReadout);
|
||||||
updateReadout();
|
updateReadout();
|
||||||
}
|
}
|
||||||
|
|
@ -455,6 +459,139 @@ $('#marker-delete').addEventListener('click', async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* ─────────────────────── route drawing (snapped) ─────────────────────── */
|
||||||
|
|
||||||
|
/* Waypoints are snapped server-side by brouter.de via POST /api/routes/calculate.
|
||||||
|
Nothing is snapped locally: the straight guide line below is only a preview of
|
||||||
|
the order of points, not the route that gets saved. */
|
||||||
|
|
||||||
|
const DRAW_COLOUR = '#4FA3D1';
|
||||||
|
|
||||||
|
function drawReset() {
|
||||||
|
state.draw.markers.forEach((m) => state.map.removeLayer(m));
|
||||||
|
if (state.draw.line) state.map.removeLayer(state.draw.line);
|
||||||
|
state.draw = { active: false, points: [], markers: [], line: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSetActive(on) {
|
||||||
|
if (!on) { drawReset(); }
|
||||||
|
state.draw.active = on;
|
||||||
|
|
||||||
|
$('#draw-panel').hidden = !on;
|
||||||
|
$('#draw-start').textContent = on ? 'Drawing…' : 'New route';
|
||||||
|
$('#draw-start').disabled = on;
|
||||||
|
// Crosshair makes the mode obvious; without it the map looks unchanged.
|
||||||
|
state.map.getContainer().style.cursor = on ? 'crosshair' : '';
|
||||||
|
if (on) drawUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Straight-line distance through the waypoints. Not the routed distance — the
|
||||||
|
real figure is only known once brouter has answered, so this is labelled as
|
||||||
|
a direct measurement rather than presented as the route length. */
|
||||||
|
function directDistance(points) {
|
||||||
|
let metres = 0;
|
||||||
|
for (let i = 1; i < points.length; i += 1) {
|
||||||
|
metres += state.map.distance(points[i - 1], points[i]);
|
||||||
|
}
|
||||||
|
return metres;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDistance(metres) {
|
||||||
|
if (!metres) return '—';
|
||||||
|
return metres < 1000
|
||||||
|
? `${Math.round(metres)} m direct`
|
||||||
|
: `${(metres / 1000).toFixed(1)} km direct`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawUpdate() {
|
||||||
|
const points = state.draw.points;
|
||||||
|
$('#draw-n').textContent = points.length;
|
||||||
|
$('#draw-dist').textContent = fmtDistance(directDistance(points));
|
||||||
|
$('#draw-save').disabled = points.length < 2;
|
||||||
|
$('#draw-undo').disabled = points.length === 0;
|
||||||
|
|
||||||
|
const latlngs = points.map((p) => [p.lat, p.lng]);
|
||||||
|
if (state.draw.line) {
|
||||||
|
state.draw.line.setLatLngs(latlngs);
|
||||||
|
} else {
|
||||||
|
state.draw.line = L.polyline(latlngs, {
|
||||||
|
color: DRAW_COLOUR, weight: 2, dashArray: '4 5', interactive: false,
|
||||||
|
}).addTo(state.map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWaypoint(latlng) {
|
||||||
|
state.draw.points.push(latlng);
|
||||||
|
|
||||||
|
const index = state.draw.points.length - 1;
|
||||||
|
const marker = L.marker(latlng, {
|
||||||
|
draggable: true,
|
||||||
|
icon: L.divIcon({
|
||||||
|
className: 'wp-icon',
|
||||||
|
html: `<div class="wp">${index + 1}</div>`,
|
||||||
|
iconSize: [22, 22], iconAnchor: [11, 11],
|
||||||
|
}),
|
||||||
|
}).addTo(state.map);
|
||||||
|
|
||||||
|
// Dragging rewrites the point in place; numbering is unaffected because the
|
||||||
|
// marker's position in the array doesn't change.
|
||||||
|
marker.on('drag', (e) => {
|
||||||
|
const i = state.draw.markers.indexOf(marker);
|
||||||
|
if (i !== -1) { state.draw.points[i] = e.target.getLatLng(); drawUpdate(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
state.draw.markers.push(marker);
|
||||||
|
drawUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function undoWaypoint() {
|
||||||
|
const marker = state.draw.markers.pop();
|
||||||
|
if (marker) state.map.removeLayer(marker);
|
||||||
|
state.draw.points.pop();
|
||||||
|
drawUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#draw-start').addEventListener('click', () => {
|
||||||
|
$('#draw-name').value = '';
|
||||||
|
drawSetActive(true);
|
||||||
|
toast('Click the map to add waypoints');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#draw-cancel').addEventListener('click', () => drawSetActive(false));
|
||||||
|
$('#draw-undo').addEventListener('click', undoWaypoint);
|
||||||
|
|
||||||
|
$('#draw-save').addEventListener('click', async () => {
|
||||||
|
const points = state.draw.points;
|
||||||
|
if (points.length < 2) { toast('Add at least two points', true); return; }
|
||||||
|
|
||||||
|
const name = $('#draw-name').value.trim();
|
||||||
|
if (!name) { toast('Give the route a name', true); return; }
|
||||||
|
|
||||||
|
const button = $('#draw-save');
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = 'Calculating…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
name,
|
||||||
|
waypoints: points.map((p) => ({ lat: p.lat, lon: p.lng })),
|
||||||
|
};
|
||||||
|
const result = await api('api/routes/calculate', { method: 'POST', ...json(body) });
|
||||||
|
|
||||||
|
toast(`Saved as ${result.filename}`);
|
||||||
|
drawSetActive(false);
|
||||||
|
await loadGpxList();
|
||||||
|
} catch (err) {
|
||||||
|
// The backend forwards brouter's own explanation when a point can't be
|
||||||
|
// snapped to a path, which is more useful than a generic failure.
|
||||||
|
toast(err.message || 'Could not calculate that route', true);
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = 'Calculate & save';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/* ─────────────────────── place details (Overpass) ─────────────────────── */
|
/* ─────────────────────── place details (Overpass) ─────────────────────── */
|
||||||
|
|
||||||
/* Tags worth showing, in display order. `keys` is tried in order — OSM has both
|
/* Tags worth showing, in display order. `keys` is tried in order — OSM has both
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,23 @@
|
||||||
<button class="primary small" id="gpx-save">Save to shared folder</button>
|
<button class="primary small" id="gpx-save">Save to shared folder</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 class="sub">Draw a route</h3>
|
||||||
|
<div class="row">
|
||||||
|
<button class="ghost" id="draw-start">New route</button>
|
||||||
|
<span class="hint" id="draw-hint">Snapped to paths for walking</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="draw-panel" hidden>
|
||||||
|
<p class="draw-count"><span id="draw-n">0</span> waypoints · <span id="draw-dist">—</span></p>
|
||||||
|
<div class="row">
|
||||||
|
<button class="ghost small" id="draw-undo">Undo point</button>
|
||||||
|
<button class="ghost small" id="draw-cancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<label>Save as<input id="draw-name" maxlength="110" placeholder="moel-famau-circuit"></label>
|
||||||
|
<button class="primary small" id="draw-save">Calculate & save</button>
|
||||||
|
<p class="hint">Click the map to add points. Drag any point to move it.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3 class="sub">Shared folder</h3>
|
<h3 class="sub">Shared folder</h3>
|
||||||
<ul class="list" id="gpx-list"></ul>
|
<ul class="list" id="gpx-list"></ul>
|
||||||
<p class="empty" id="gpx-empty" hidden>No routes on the server yet.</p>
|
<p class="empty" id="gpx-empty" hidden>No routes on the server yet.</p>
|
||||||
|
|
|
||||||
|
|
@ -540,3 +540,32 @@ button.link:hover { color: var(--paper); background: none; }
|
||||||
border-top: 1px solid var(--hair);
|
border-top: 1px solid var(--hair);
|
||||||
font-size: 12px; color: var(--paper-dim);
|
font-size: 12px; color: var(--paper-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ───────────────────────── route drawing ───────────────────────── */
|
||||||
|
|
||||||
|
#draw-panel { margin-top: 8px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
#draw-panel label { display: block; }
|
||||||
|
|
||||||
|
.draw-count {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--mono); font-size: 11px;
|
||||||
|
letter-spacing: .06em; color: var(--paper-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint { font-size: 11.5px; color: var(--paper-dim); }
|
||||||
|
|
||||||
|
/* Numbered waypoint handles. Distinct from place pins and from the amber search
|
||||||
|
pin so the three never read as the same kind of thing. */
|
||||||
|
.wp-icon { background: none; border: none; }
|
||||||
|
.wp {
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4FA3D1;
|
||||||
|
color: #08131b;
|
||||||
|
font-family: var(--mono); font-size: 11px; font-weight: 600;
|
||||||
|
border: 2px solid rgba(255,255,255,.85);
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,.45);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.wp:active { cursor: grabbing; }
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,12 @@ pub enum AppError {
|
||||||
#[error("payload too large")]
|
#[error("payload too large")]
|
||||||
TooLarge,
|
TooLarge,
|
||||||
|
|
||||||
|
/// An upstream service we proxy to (currently brouter.de) failed or was
|
||||||
|
/// unreachable. Distinct from Internal so the user is told it's not their
|
||||||
|
/// request that's at fault.
|
||||||
|
#[error("{0}")]
|
||||||
|
BadGateway(String),
|
||||||
|
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Database(#[from] sqlx::Error),
|
Database(#[from] sqlx::Error),
|
||||||
|
|
||||||
|
|
@ -42,6 +48,7 @@ impl IntoResponse for AppError {
|
||||||
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
|
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
|
||||||
AppError::Conflict(m) => (StatusCode::CONFLICT, m.clone()),
|
AppError::Conflict(m) => (StatusCode::CONFLICT, m.clone()),
|
||||||
AppError::TooLarge => (StatusCode::PAYLOAD_TOO_LARGE, self.to_string()),
|
AppError::TooLarge => (StatusCode::PAYLOAD_TOO_LARGE, self.to_string()),
|
||||||
|
AppError::BadGateway(m) => (StatusCode::BAD_GATEWAY, m.clone()),
|
||||||
other => {
|
other => {
|
||||||
// Internal detail stays in the log, never in the response body.
|
// Internal detail stays in the log, never in the response body.
|
||||||
tracing::error!(error = %other, "internal error");
|
tracing::error!(error = %other, "internal error");
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ pub struct GpxFile {
|
||||||
// ---------------------------------------------------------------- name safety
|
// ---------------------------------------------------------------- name safety
|
||||||
|
|
||||||
/// Accepts only a plain filename: no separators, no traversal, must be a .gpx.
|
/// Accepts only a plain filename: no separators, no traversal, must be a .gpx.
|
||||||
fn validate_filename(name: &str) -> AppResult<()> {
|
pub fn validate_filename(name: &str) -> AppResult<()> {
|
||||||
let bad = |m: &str| Err(AppError::BadRequest(m.to_string()));
|
let bad = |m: &str| Err(AppError::BadRequest(m.to_string()));
|
||||||
|
|
||||||
if name.is_empty() || name.len() > 120 {
|
if name.is_empty() || name.len() > 120 {
|
||||||
|
|
@ -81,7 +81,7 @@ fn split_name(name: &str) -> (String, String) {
|
||||||
|
|
||||||
/// Reserves a free filename atomically. `create_new` fails if the path exists,
|
/// Reserves a free filename atomically. `create_new` fails if the path exists,
|
||||||
/// so there's no check-then-write race: track.gpx → track-1.gpx → track-2.gpx.
|
/// so there's no check-then-write race: track.gpx → track-1.gpx → track-2.gpx.
|
||||||
fn reserve(dir: &StdPath, name: &str) -> AppResult<(std::fs::File, String)> {
|
pub fn reserve(dir: &StdPath, name: &str) -> AppResult<(std::fs::File, String)> {
|
||||||
let (stem, ext) = split_name(name);
|
let (stem, ext) = split_name(name);
|
||||||
|
|
||||||
for n in 0..MAX_COLLISION_ATTEMPTS {
|
for n in 0..MAX_COLLISION_ATTEMPTS {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ mod gpx;
|
||||||
mod mail;
|
mod mail;
|
||||||
mod markers;
|
mod markers;
|
||||||
mod ratelimit;
|
mod ratelimit;
|
||||||
|
mod routes;
|
||||||
mod web;
|
mod web;
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|
@ -98,10 +99,16 @@ async fn main() -> Result<()> {
|
||||||
.merge(auth_routes)
|
.merge(auth_routes)
|
||||||
.route("/config", get(web::client_config))
|
.route("/config", get(web::client_config))
|
||||||
.nest("/markers", markers::routes())
|
.nest("/markers", markers::routes())
|
||||||
.nest("/gpx", gpx::routes());
|
.nest("/gpx", gpx::routes())
|
||||||
|
.nest("/routes", routes::routes());
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.nest("/api", api)
|
.nest("/api", api)
|
||||||
|
// Explicit root route. Inside a nest, "/maps/" reduces to "/" once the
|
||||||
|
// prefix is stripped, and that empty path is the one case the fallback
|
||||||
|
// below does not catch — which happens to be the exact URL a browser
|
||||||
|
// requests. "/maps" and "/maps/anything" work via the fallback.
|
||||||
|
.route("/", get(web::serve_asset))
|
||||||
.fallback(web::serve_asset)
|
.fallback(web::serve_asset)
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
|
|
|
||||||
225
src/routes.rs
Normal file
225
src/routes.rs
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
// src/routes.rs
|
||||||
|
//
|
||||||
|
// Snapped route creation. Waypoints in, a GPX file in GPX_DIR out.
|
||||||
|
//
|
||||||
|
// The calculation itself is delegated to the public BRouter instance at
|
||||||
|
// brouter.de. Self-hosting BRouter was considered and rejected: it needs a Java
|
||||||
|
// process plus ~1GB of pre-generated .rd5 segment files for Britain alone, which
|
||||||
|
// is poor value for a feature used a handful of times a year.
|
||||||
|
//
|
||||||
|
// The call is proxied rather than made from the browser so that calculating and
|
||||||
|
// saving are one action: brouter returns GPX bytes and we write them straight
|
||||||
|
// into the shared folder, instead of round-tripping them back through
|
||||||
|
// /api/gpx/upload.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::routing::post;
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
use crate::auth::CurrentUser;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::gpx;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
const BROUTER_URL: &str = "https://brouter.de/brouter";
|
||||||
|
|
||||||
|
/// Hiking only, per the build brief. BRouter's own profile name.
|
||||||
|
const PROFILE: &str = "hiking-beta";
|
||||||
|
|
||||||
|
/// Two is a line; beyond this the URL gets unwieldy and the request slow.
|
||||||
|
const MAX_WAYPOINTS: usize = 50;
|
||||||
|
|
||||||
|
/// brouter.de is donated infrastructure and can be slow under load. Long enough
|
||||||
|
/// to be useful, short enough that a hung request doesn't tie up a connection.
|
||||||
|
const TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// A generous ceiling for a returned track — a long multi-day route with
|
||||||
|
/// elevation runs to a few MB at most.
|
||||||
|
const MAX_RESPONSE_BYTES: usize = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Waypoint {
|
||||||
|
pub lat: f64,
|
||||||
|
pub lon: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CalculateRequest {
|
||||||
|
pub waypoints: Vec<Waypoint>,
|
||||||
|
/// Desired filename. ".gpx" is appended if absent.
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct CalculateResponse {
|
||||||
|
/// The name actually written, which may differ if it collided.
|
||||||
|
pub filename: String,
|
||||||
|
pub bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(request: &CalculateRequest) -> AppResult<()> {
|
||||||
|
if request.waypoints.len() < 2 {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"A route needs at least a start and an end.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if request.waypoints.len() > MAX_WAYPOINTS {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"A route can have at most {MAX_WAYPOINTS} waypoints."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
for point in &request.waypoints {
|
||||||
|
if !(-90.0..=90.0).contains(&point.lat) || !(-180.0..=180.0).contains(&point.lon) {
|
||||||
|
return Err(AppError::BadRequest("Waypoint out of range.".into()));
|
||||||
|
}
|
||||||
|
if !point.lat.is_finite() || !point.lon.is_finite() {
|
||||||
|
return Err(AppError::BadRequest("Waypoint is not a number.".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BRouter wants `lon,lat|lon,lat|…` — longitude first, which is the opposite
|
||||||
|
/// order to Leaflet and to everything else in this codebase.
|
||||||
|
fn lonlats(waypoints: &[Waypoint]) -> String {
|
||||||
|
waypoints
|
||||||
|
.iter()
|
||||||
|
.map(|p| format!("{:.6},{:.6}", p.lon, p.lat))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("|")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cheap sanity check on the response body. BRouter reports routing failures
|
||||||
|
/// ("operation not supported", "position not mapped in existing datafile") as
|
||||||
|
/// 200 with a plain-text body, so status alone isn't enough.
|
||||||
|
fn looks_like_gpx(body: &str) -> bool {
|
||||||
|
let head = &body[..body.len().min(512)];
|
||||||
|
head.contains("<gpx")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn calculate(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(_): CurrentUser,
|
||||||
|
Json(request): Json<CalculateRequest>,
|
||||||
|
) -> AppResult<Json<CalculateResponse>> {
|
||||||
|
validate(&request)?;
|
||||||
|
|
||||||
|
let mut filename = request.name.trim().to_string();
|
||||||
|
if !filename.to_ascii_lowercase().ends_with(".gpx") {
|
||||||
|
filename.push_str(".gpx");
|
||||||
|
}
|
||||||
|
// Rejects traversal and anything that isn't a plain .gpx name.
|
||||||
|
gpx::validate_filename(&filename)?;
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{BROUTER_URL}?lonlats={}&profile={PROFILE}&alternativeidx=0&format=gpx",
|
||||||
|
lonlats(&request.waypoints)
|
||||||
|
);
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(TIMEOUT)
|
||||||
|
.user_agent(concat!("rs_maps/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| AppError::Other(e.into()))?;
|
||||||
|
|
||||||
|
let response = client.get(&url).send().await.map_err(|e| {
|
||||||
|
tracing::warn!(error = %e, "brouter request failed");
|
||||||
|
AppError::BadGateway("The routing service didn't respond.".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
tracing::warn!(status = %response.status(), "brouter returned an error status");
|
||||||
|
return Err(AppError::BadGateway(
|
||||||
|
"The routing service rejected the request.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Other(e.into()))?;
|
||||||
|
|
||||||
|
if body.len() > MAX_RESPONSE_BYTES {
|
||||||
|
return Err(AppError::BadGateway("Route too large to store.".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !looks_like_gpx(&body) {
|
||||||
|
// BRouter's plain-text failures are usually "couldn't snap that point to
|
||||||
|
// a path" — surface a trimmed version, it's genuinely useful to the user.
|
||||||
|
let detail = body.lines().next().unwrap_or("").trim();
|
||||||
|
tracing::info!(detail, "brouter could not route");
|
||||||
|
return Err(AppError::BadRequest(if detail.is_empty() {
|
||||||
|
"No route could be found between those points.".into()
|
||||||
|
} else {
|
||||||
|
format!("No route found: {detail}")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same atomic create_new reservation the upload path uses, so a name clash
|
||||||
|
// becomes route-1.gpx rather than silently overwriting.
|
||||||
|
let (file, written_name) = gpx::reserve(&state.config.gpx_dir, &filename)?;
|
||||||
|
let mut file = tokio::fs::File::from_std(file);
|
||||||
|
|
||||||
|
if let Err(e) = file.write_all(body.as_bytes()).await {
|
||||||
|
// Don't leave a zero-byte stub behind on a failed write.
|
||||||
|
let _ = tokio::fs::remove_file(state.config.gpx_dir.join(&written_name)).await;
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
file.flush().await?;
|
||||||
|
|
||||||
|
tracing::info!(file = %written_name, points = request.waypoints.len(), "route saved");
|
||||||
|
|
||||||
|
Ok(Json(CalculateResponse {
|
||||||
|
filename: written_name,
|
||||||
|
bytes: body.len(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new().route("/calculate", post(calculate))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn wp(lat: f64, lon: f64) -> Waypoint {
|
||||||
|
Waypoint { lat, lon }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn brouter_wants_lon_before_lat() {
|
||||||
|
let points = vec![wp(53.4808, -2.2426), wp(53.4, -2.1)];
|
||||||
|
assert_eq!(lonlats(&points), "-2.242600,53.480800|-2.100000,53.400000");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_too_few_points() {
|
||||||
|
let request = CalculateRequest {
|
||||||
|
waypoints: vec![wp(53.0, -2.0)],
|
||||||
|
name: "x.gpx".into(),
|
||||||
|
};
|
||||||
|
assert!(validate(&request).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_out_of_range() {
|
||||||
|
let request = CalculateRequest {
|
||||||
|
waypoints: vec![wp(91.0, -2.0), wp(53.0, -2.0)],
|
||||||
|
name: "x.gpx".into(),
|
||||||
|
};
|
||||||
|
assert!(validate(&request).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_non_gpx_body() {
|
||||||
|
assert!(looks_like_gpx("<?xml version=\"1.0\"?><gpx version=\"1.1\">"));
|
||||||
|
assert!(!looks_like_gpx("position not mapped in existing datafile"));
|
||||||
|
assert!(!looks_like_gpx(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue