Compare commits

...

12 commits
v0.0.1 ... main

Author SHA1 Message Date
9962a6932d added export gpx 2026-08-03 20:14:22 +01:00
fff8a8382b extend more info timeout to 30s 2026-08-03 14:47:33 +01:00
195c84106e fix more info 2026-08-03 14:36:08 +01:00
bd87587391 readme 2026-08-03 14:29:14 +01:00
b8ee4b4cc0 add gpx editing 2026-08-03 14:21:40 +01:00
da5a068224 add gpx snapping 2026-08-03 14:13:58 +01:00
c2400f4b29 add gpx creation 2026-08-03 13:41:35 +01:00
dae64cc434 add more info 2026-08-03 13:13:47 +01:00
7a1aafaa84 add search pin, multi result and click a place 2026-08-03 11:58:00 +01:00
0191dd42ed fix account menu stacking, rename to rs_maps 2026-08-03 11:15:54 +01:00
0afcf34858 fixed name 2026-08-03 11:02:29 +01:00
88d2f30ad3 serve index at nested root 2026-08-03 10:53:21 +01:00
10 changed files with 1582 additions and 123 deletions

303
Cargo.lock generated
View file

@ -220,6 +220,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "const-oid"
version = "0.9.6"
@ -464,6 +481,12 @@ dependencies = [
"spin",
]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
@ -593,8 +616,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@ -604,8 +629,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"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]]
@ -737,6 +784,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@ -745,6 +793,23 @@ dependencies = [
"pin-project-lite",
"smallvec",
"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]]
@ -753,13 +818,21 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"tokio",
"tower-service",
"tracing",
]
[[package]]
@ -875,6 +948,12 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "ipnet"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
[[package]]
name = "itoa"
version = "1.0.18"
@ -984,6 +1063,12 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "matchers"
version = "0.2.0"
@ -1088,7 +1173,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
"rand",
"rand 0.8.7",
"smallvec",
"zeroize",
]
@ -1170,7 +1255,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core",
"rand_core 0.6.4",
"subtle",
]
@ -1261,6 +1346,62 @@ dependencies = [
"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]]
name = "quote"
version = "1.0.47"
@ -1290,7 +1431,18 @@ checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [
"libc",
"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]]
@ -1300,7 +1452,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@ -1312,6 +1464,21 @@ dependencies = [
"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]]
name = "redox_syscall"
version = "0.5.18"
@ -1347,6 +1514,47 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "ring"
version = "0.17.14"
@ -1382,7 +1590,7 @@ dependencies = [
[[package]]
name = "rs_maps"
version = "0.0.1"
version = "0.3.4"
dependencies = [
"anyhow",
"argon2",
@ -1392,7 +1600,8 @@ dependencies = [
"lettre",
"mime_guess",
"password-hash",
"rand",
"rand 0.8.7",
"reqwest",
"rust-embed",
"serde",
"serde_json",
@ -1423,7 +1632,7 @@ dependencies = [
"num-traits",
"pkcs1",
"pkcs8",
"rand_core",
"rand_core 0.6.4",
"signature",
"spki",
"subtle",
@ -1465,6 +1674,12 @@ dependencies = [
"walkdir",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustls"
version = "0.23.43"
@ -1486,6 +1701,7 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"web-time",
"zeroize",
]
@ -1658,7 +1874,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest 0.10.7",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@ -1823,7 +2039,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rand",
"rand 0.8.7",
"rsa",
"serde",
"sha1",
@ -1863,7 +2079,7 @@ dependencies = [
"md-5",
"memchr",
"once_cell",
"rand",
"rand 0.8.7",
"serde",
"serde_json",
"sha2 0.10.9",
@ -1953,6 +2169,9 @@ name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
@ -2118,6 +2337,20 @@ dependencies = [
"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]]
name = "tower"
version = "0.5.3"
@ -2158,12 +2391,15 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
"futures-util",
"http",
"http-body",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
"tracing",
"url",
]
[[package]]
@ -2208,7 +2444,7 @@ dependencies = [
"futures",
"http",
"parking_lot",
"rand",
"rand 0.8.7",
"serde",
"serde_json",
"thiserror 2.0.19",
@ -2305,6 +2541,12 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.20.1"
@ -2408,6 +2650,15 @@ dependencies = [
"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]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@ -2433,6 +2684,16 @@ dependencies = [
"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]]
name = "wasm-bindgen-macro"
version = "0.2.126"
@ -2465,6 +2726,26 @@ dependencies = [
"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]]
name = "webpki-roots"
version = "0.26.11"

View file

@ -1,7 +1,7 @@
# Cargo.toml
[package]
name = "rs_maps"
version = "0.0.1"
version = "0.3.4"
edition = "2021"
[dependencies]
@ -23,6 +23,13 @@ tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
argon2 = "0.5"
password-hash = { version = "0.5", features = ["std"] }
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"
subtle = "2"
hex = "0.4"

190
README.md
View file

@ -1,114 +1,132 @@
<!-- README.md -->
# Waymark — build and deploy
# rs_maps
Single Rust binary + one rootless Postgres container. See `DESIGN.md` for the reasoning.
A self-hosted replacement for the parts of Google Maps most people actually use:
saving places you care about, and planning walking routes. It runs as a single
Rust binary with one Postgres container behind it, and it's built for a handful
of trusted users rather than the public.
> **This code has not been compiled.** It was written without a Rust toolchain available,
> so treat the first `cargo build` as the real review. Expect to fix a handful of import
> paths and trait-bound details, not the structure.
It is not a Google Maps clone. There are no reviews, no photos, no live traffic
and no turn-by-turn navigation, and there is no plan to add them.
## 1. Refresh dependency versions
## What it does
`Cargo.toml` has plausible versions, but pin them properly:
**Places.** Click the map to drop a tagged marker with a name, kind, colour and
description. Markers are private by default; flip one to shared and everyone
else with an account sees it too.
**Search.** Type a place or address and get up to eight matches with their type
and full address, so two branches of the same chain are distinguishable. Picking
one drops a pin and shows what was found. "More details" pulls opening hours,
phone, website and similar from OpenStreetMap where they exist — and says so
plainly where they don't. Any hit can be saved as a place in one click.
**Routes.** Draw a walking route by clicking waypoints on the map. The line
snaps to real paths and tracks, updating as you drag points around, with the
routed distance and total ascent shown. Save it and the GPX lands in a shared
folder both users can see. Saved routes can be reopened and edited later.
**GPX files.** Open a `.gpx` from your device to view it without uploading
anything, or save it to the shared folder if you want it on the server. Files in
the shared folder can be rendered on the map by anyone logged in.
**Live location.** Toggle it on to see where you are while the map is open. It's
a dot on a map, not navigation guidance.
## What it's built on
- **Rust binary** (`axum`) serving the API and the whole frontend, which is
embedded into the executable at compile time. One file to deploy.
- **Postgres** in a rootless podman container, holding users, sessions and
markers.
- **A plain directory** for GPX files. No database involvement, no metadata.
- **Nothing else self-hosted.** Map tiles come from OpenStreetMap, search from
Nominatim, route snapping from brouter.de, and place details from Overpass —
called directly from the browser, or proxied through one endpoint. Hosting any
of them would mean gigabytes of data for a tool used a few times a week.
## Requirements
- A Linux server with systemd and podman
- nginx (or equivalent) terminating TLS — **not optional**: browser geolocation
refuses to run without HTTPS, and session cookies are `Secure`
- A Rust toolchain to build, or a release binary
- A domain
## Quick start
```bash
cargo add axum -F multipart,macros
cargo add tokio -F full
cargo add sqlx --no-default-features -F runtime-tokio,tls-rustls,postgres,uuid,time,macros,migrate
cargo add tower-sessions
cargo add tower-sessions-sqlx-store -F postgres
cargo add lettre --no-default-features -F tokio1-rustls,smtp-transport,builder,pool
cargo add rust-embed -F debug-embed
cargo build
git clone <this repo> && cd rs_maps
cargo build --release
```
Two version-sensitive spots to check first if it doesn't compile:
- **`tower-sessions` / `tower-sessions-sqlx-store` must be a matching pair.** The store crate
tracks the main crate's version and they break in lockstep. Check the store's own README for
which `tower-sessions` it expects.
- **axum 0.8 changed path params from `:id` to `{id}`.** This code uses `{id}`. If you end up on
0.7 for some reason, they all need changing back.
## 2. Database
Database and directories:
```bash
podman secret create mapserver-db-password - # type the password, then Ctrl-D
mkdir -p ~/.config/containers/systemd
mkdir -p ~/.config/containers/systemd ~/gpx
cp deploy/postgres.container ~/.config/containers/systemd/
# Hex, not base64: / and + in a password break DATABASE_URL parsing.
openssl rand -hex 32 | tr -d '\n' | podman secret create rs_maps-db-password -
systemctl --user daemon-reload
systemctl --user start postgres
```
Migrations run automatically on startup — both `./migrations` and the session store's own.
Copy `.env.example` to `.env` and fill in at least these:
## 3. Config
| Key | Notes |
|---|---|
| `DATABASE_URL` | `postgres://rs_maps:PASSWORD@127.0.0.1:5432/rs_maps` |
| `REG_TOKEN` | `openssl rand -hex 32`. Anyone with this can register — it is the only access control |
| `PUBLIC_URL` | Origin only: `https://example.com`. No trailing slash, no subpath |
| `BASE_PATH` | `/maps` if served under a subpath, empty for the domain root |
| `GPX_DIR` | Absolute path to a directory the service can write to |
| `BIND_ADDR` | `127.0.0.1:8080` — loopback only, nginx is the only client |
`PUBLIC_URL` must match the browser's `Origin` header exactly. A mismatch
(`www.` versus bare, or a stray trailing slash) means every save fails with a
403 while pages load normally — a confusing thing to debug.
Run it:
```bash
mkdir -p ~/.config/mapserver ~/gpx
cp .env.example ~/.config/mapserver/.env
chmod 600 ~/.config/mapserver/.env
$EDITOR ~/.config/mapserver/.env
./target/release/rs_maps
```
Must change: `DATABASE_URL`, `REG_TOKEN`, `PUBLIC_URL`, `BASE_PATH`, `GPX_DIR`.
Migrations apply on startup. Point nginx at it using `deploy/nginx-snippet.conf`
— note the `client_max_body_size 25m` on the upload path and the redirect for
the bare subpath. Then open the site, register with your `REG_TOKEN`, and you're
in. The first account isn't special: there are no roles and no admin UI.
Leave `SMTP_HOST` empty for now — reset links get logged instead of emailed, and the whole flow
is testable without a provider.
For the service unit, systemd hardening and the release/upgrade scripts, see
`deploy/`. `DESIGN.md` covers why the architecture is the way it is.
## 4. Run
## Passwords and email
Email is optional and used only for password resets. Give a bad address and the
only thing you lose is your own ability to reset — nothing else depends on it.
With `SMTP_HOST` empty, reset links are written to the log rather than sent,
which is enough to test the flow without an email provider.
Changing a password affects future logins only. Sessions already active on other
devices stay valid; clearing those means deleting the session rows directly.
That's a deliberate trade for a two-person deployment.
## Being a good citizen
Tiles, search, routing and place details all come from volunteer-run
infrastructure. The app is built to stay inside their usage policies: search
runs on submit rather than as you type, route previews are debounced, and place
details are fetched only when asked for. If you fork this and put it in front of
a lot of users, read those policies before scaling up.
## Tests
```bash
cargo build --release
mkdir -p ~/.local/bin && cp target/release/mapserver ~/.local/bin/
cp deploy/mapserver.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now mapserver
loginctl enable-linger "$USER" # or nothing starts at boot
journalctl --user -u mapserver -f
cargo test
```
## 5. nginx
Merge `deploy/nginx-snippet.conf` into your existing TLS server block, then
`nginx -t && systemctl reload nginx`.
## 6. First account
Visit `https://your-domain/maps/`, choose "Create an account", and enter the `REG_TOKEN` from
`.env`.
---
## Testing the reset flow with real mail
`mailpit` gives you a local SMTP server and a web inbox with no signup:
```
SMTP_HOST=127.0.0.1
SMTP_PORT=1025
SMTP_TLS=none
SMTP_USERNAME=
SMTP_PASSWORD=
```
When you move to a real provider: use an app-specific password (any account with 2FA will reject
the normal one over SMTP), check SMTP isn't disabled by default on the account, and make sure
`SMTP_FROM` is an address that account may send as.
## Things worth checking by hand after the first deploy
- `curl -i https://your-domain/maps/api/gpx/file/../../etc/passwd` → 400 or 404, never a file
- Live location works (needs HTTPS; it silently fails on plain HTTP)
- Session survives `systemctl --user restart mapserver`
- Upload a >2 MB GPX — catches both the axum and nginx body limits at once
- Upload the same filename twice — second one should come back as `name-1.gpx`
- Reboot the server, confirm both units come back (this is what `enable-linger` is for)
## Unit tests
cargo test
No database needed — every test is pure: base-path normalisation, bbox parsing,
GPX filename safety, and the rate-limiter window.
No database needed — the tests cover base-path handling, bbox parsing, GPX
filename safety, waypoint embedding, coordinate ordering and the rate limiter.

View file

@ -1,5 +1,5 @@
// frontend/app.js
/* Waymark all URLs relative, resolved against the injected <base href>.
/* rs_maps all URLs relative, resolved against the injected <base href>.
Never use a leading slash: it would escape BASE_PATH. */
'use strict';
@ -23,9 +23,29 @@ async function loadClientConfig() {
}
}
const NOMINATIM = 'https://nominatim.openstreetmap.org/search';
// Nominatim's search endpoint doesn't return object tags, so opening hours,
// phone and website need a second lookup by OSM id. Overpass is donated
// infrastructure like Nominatim: fine on an explicit click, not per result.
//
// Tried in order. The main overpass-api.de instance is by far the busiest and
// returns 504 under load even for a trivial single-object query, so the mirrors
// are a practical necessity rather than belt and braces.
const OVERPASS_ENDPOINTS = [
'https://overpass.kumi.systems/api/interpreter',
'https://overpass-api.de/api/interpreter',
'https://overpass.private.coffee/api/interpreter',
];
// Overpass queues requests when busy — a trivial single-object lookup has been
// observed taking over a minute before returning a perfectly good 200. 30s is
// the most that's reasonable to make someone wait; past that, give up quietly.
const OVERPASS_TIMEOUT_MS = 30000;
const COLOURS = ['#4E9C6B', '#D2467F', '#E2A93C', '#4E8FC9', '#B07BD4', '#D3574B'];
const DEFAULT_COLOUR = COLOURS[0];
// Deliberately not one of the saved-place colours: a search hit is transient
// and shouldn't be mistaken for something already saved.
const SEARCH_COLOUR = '#E8B23A';
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
@ -42,6 +62,9 @@ const state = {
editing: null, // marker being edited, or null for a new one
draftLatLng: null,
colour: DEFAULT_COLOUR,
searchPin: null, // transient pin for the current search hit
draw: { active: false, points: [], markers: [], line: null, guide: null,
timer: null, seq: 0, snapped: null, editing: null },
};
/* ───────────────────────────── api ───────────────────────────── */
@ -221,7 +244,10 @@ function initMap() {
state.map = L.map('map', { zoomControl: true }).setView([54.5, -3.0], 6);
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);
updateReadout();
}
@ -286,20 +312,39 @@ function renderMarkers() {
li.appendChild(badge);
}
const go = document.createElement('button');
go.className = 'link';
go.textContent = mine ? 'Edit' : 'Show';
go.addEventListener('click', () => {
state.map.setView([m.lat, m.lon], Math.max(state.map.getZoom(), 14));
state.layers.get(m.id).openPopup();
if (mine) openMarkerSheet(m);
// The whole row jumps to the place and opens its popup. Previously only the
// button did anything, and for your own places it opened the edit sheet on
// top of the popup — so "edit then cancel" was the only way to just look.
li.tabIndex = 0;
li.addEventListener('click', () => focusMarker(m));
li.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); focusMarker(m); }
});
li.appendChild(go);
if (mine) {
const go = document.createElement('button');
go.className = 'link';
go.textContent = 'Edit';
// Without this the row handler also fires and the popup opens behind the sheet.
go.addEventListener('click', (e) => {
e.stopPropagation();
focusMarker(m);
openMarkerSheet(m);
});
li.appendChild(go);
}
list.appendChild(li);
});
}
/* Centre the map on a marker and open its popup. */
function focusMarker(m) {
state.map.setView([m.lat, m.lon], Math.max(state.map.getZoom(), 14));
const layer = state.layers.get(m.id);
if (layer) layer.openPopup();
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s == null ? '' : s;
@ -427,29 +472,507 @@ $('#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';
/* brouter.de is donated infrastructure, so previews are debounced rather than
fired on every click and every pixel of a drag. */
const PREVIEW_DEBOUNCE_MS = 1000;
function drawReset() {
if (state.draw.timer) clearTimeout(state.draw.timer);
state.draw.markers.forEach((m) => state.map.removeLayer(m));
if (state.draw.line) state.map.removeLayer(state.draw.line);
if (state.draw.guide) state.map.removeLayer(state.draw.guide);
state.draw = { active: false, points: [], markers: [], line: null, guide: null,
timer: null, seq: 0, snapped: null, editing: 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;
if (!on) $('#draw-hint').textContent = 'Snapped to paths for walking';
state.map.getContainer().style.cursor = on ? 'crosshair' : '';
if (on) drawUpdate();
}
function fmtDistance(metres) {
if (!metres) return '—';
return metres < 1000
? `${Math.round(metres)} m`
: `${(metres / 1000).toFixed(1)} km`;
}
/* The dashed guide shows the order of waypoints. It's replaced visually by the
snapped line once brouter answers, but kept underneath so there's always
something on screen while a preview is in flight. */
function drawGuide() {
const latlngs = state.draw.points.map((p) => [p.lat, p.lng]);
if (state.draw.guide) {
state.draw.guide.setLatLngs(latlngs);
} else {
state.draw.guide = L.polyline(latlngs, {
color: DRAW_COLOUR, weight: 1, opacity: .35, dashArray: '3 6', interactive: false,
}).addTo(state.map);
}
}
function drawUpdate() {
const points = state.draw.points;
$('#draw-n').textContent = points.length;
$('#draw-save').disabled = points.length < 2;
$('#draw-undo').disabled = points.length === 0;
drawGuide();
schedulePreview();
}
function setPreviewStatus(text) {
$('#draw-dist').textContent = text;
}
function schedulePreview() {
if (state.draw.timer) clearTimeout(state.draw.timer);
if (state.draw.points.length < 2) {
if (state.draw.line) { state.map.removeLayer(state.draw.line); state.draw.line = null; }
state.draw.snapped = null;
setPreviewStatus('—');
return;
}
setPreviewStatus('snapping…');
state.draw.timer = setTimeout(runPreview, PREVIEW_DEBOUNCE_MS);
}
async function runPreview() {
const points = state.draw.points.slice();
// Responses can arrive out of order after a fast edit; only the newest counts.
const seq = (state.draw.seq += 1);
try {
const body = { name: 'preview.gpx', waypoints: points.map((p) => ({ lat: p.lat, lon: p.lng })) };
const result = await api('api/routes/preview', { method: 'POST', ...json(body) });
if (seq !== state.draw.seq || !state.draw.active) return;
state.draw.snapped = result;
const latlngs = result.points.map((p) => [p[0], p[1]]);
if (state.draw.line) {
state.draw.line.setLatLngs(latlngs);
} else {
state.draw.line = L.polyline(latlngs, {
color: DRAW_COLOUR, weight: 4, opacity: .9,
}).addTo(state.map);
}
const ascend = result.ascend_m ? ` · ${Math.round(result.ascend_m)} m ascent` : '';
setPreviewStatus(fmtDistance(result.length_m) + ascend);
markUnsnappable(false);
} catch (err) {
if (seq !== state.draw.seq || !state.draw.active) return;
// Keep the last good line rather than clearing the map — usually only the
// most recent point is the problem, and it's about to be moved or undone.
setPreviewStatus('no route');
markUnsnappable(true);
toast(err.message || 'Could not snap that route', true);
}
}
/* Flags the most recently added waypoint, which is nearly always the one that
can't be reached — brouter doesn't say which point failed. */
function markUnsnappable(bad) {
const last = state.draw.markers[state.draw.markers.length - 1];
if (!last) return;
const el = last.getElement();
if (el) el.classList.toggle('wp-bad', bad);
}
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);
// Guide follows the drag live; the snapped preview waits for the debounce.
marker.on('drag', (e) => {
const i = state.draw.markers.indexOf(marker);
if (i !== -1) { state.draw.points[i] = e.target.getLatLng(); drawGuide(); }
});
marker.on('dragend', 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', () => {
if (state.draw.editing && !confirm('Discard changes to this route?')) return;
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 })),
// The backend replaces in place only when this matches the new name.
// Rename the file and the original is left alone, as a copy.
replace: state.draw.editing || null,
};
const result = await api('api/routes/calculate', { method: 'POST', ...json(body) });
const renamed = state.draw.editing && result.filename !== state.draw.editing;
toast(
!state.draw.editing ? `Saved as ${result.filename}`
: renamed ? `Saved as ${result.filename}${state.draw.editing} kept`
: `${result.filename} updated`
);
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) ─────────────────────── */
/* Tags worth showing, in display order. `keys` is tried in order OSM has both
bare and contact:-prefixed forms for most contact details. */
const DETAIL_ROWS = [
{ keys: ['opening_hours'], label: 'Hours', kind: 'hours' },
{ keys: ['phone', 'contact:phone'], label: 'Phone', kind: 'tel' },
{ keys: ['website', 'contact:website', 'url'],label: 'Website', kind: 'link' },
{ keys: ['email', 'contact:email'], label: 'Email', kind: 'email' },
{ keys: ['operator'], label: 'Operator', kind: 'text' },
{ keys: ['brand'], label: 'Brand', kind: 'text' },
{ keys: ['cuisine'], label: 'Cuisine', kind: 'text' },
{ keys: ['wheelchair'], label: 'Step-free',kind: 'text' },
{ keys: ['description'], label: 'Notes', kind: 'text' },
];
const OSM_SHORT = { node: 'node', way: 'way', relation: 'rel' };
/* One Overpass call for a single object's tags. */
async function fetchOsmTags(osmType, osmId) {
const short = OSM_SHORT[osmType];
if (!short) return null;
// Server-side budget matched to ours, so it sheds the request rather than
// holding it in a queue we've already stopped waiting on.
const query = `[out:json][timeout:30];${short}(${osmId});out tags;`;
let lastError = null;
for (const endpoint of OVERPASS_ENDPOINTS) {
// AbortController rather than relying on the server: a hung connection
// would otherwise block the fallback for as long as the browser allows.
const abort = new AbortController();
const timer = setTimeout(() => abort.abort(), OVERPASS_TIMEOUT_MS);
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'data=' + encodeURIComponent(query),
signal: abort.signal,
});
// 429 and 504 mean this mirror is busy, not that the object is missing —
// worth asking someone else.
if (!res.ok) throw new Error('overpass ' + res.status);
const body = await res.json();
return (body.elements && body.elements[0] && body.elements[0].tags) || {};
} catch (err) {
lastError = err;
console.warn('overpass mirror failed:', endpoint, err.message);
} finally {
clearTimeout(timer);
}
}
throw lastError || new Error('no overpass endpoint responded');
}
/* Renders whatever came back. Deliberately raw values no "open now", no
parsing of the opening_hours grammar beyond splitting it onto its own lines. */
function renderDetails(tags) {
const wrap = document.createElement('div');
wrap.className = 'details';
let shown = 0;
DETAIL_ROWS.forEach((row) => {
const key = row.keys.find((k) => tags[k]);
if (!key) return;
const value = tags[key];
shown += 1;
const dt = document.createElement('span');
dt.className = 'd-label';
dt.textContent = row.label;
const dd = document.createElement('span');
dd.className = 'd-value';
if (row.kind === 'hours') {
// "Mo-Fr 08:00-18:00; Sa 09:00-17:00" is far more readable one clause
// per line, and that's as far as this goes — no interpretation.
dd.classList.add('d-hours');
value.split(';').forEach((clause) => {
const line = document.createElement('span');
line.textContent = clause.trim();
dd.appendChild(line);
});
} else if (row.kind === 'link' || row.kind === 'tel' || row.kind === 'email') {
const a = document.createElement('a');
a.textContent = value;
a.href = row.kind === 'tel' ? 'tel:' + value.replace(/\s+/g, '')
: row.kind === 'email' ? 'mailto:' + value
: /^https?:/i.test(value) ? value : 'https://' + value;
if (row.kind === 'link') { a.target = '_blank'; a.rel = 'noopener noreferrer'; }
dd.appendChild(a);
} else {
dd.textContent = value.replace(/_/g, ' ');
}
wrap.append(dt, dd);
});
if (!shown) {
const empty = document.createElement('p');
empty.className = 'd-empty';
empty.textContent = 'No extra details recorded in OpenStreetMap.';
return { node: empty, shown };
}
return { node: wrap, shown };
}
/* ─────────────────────────── search ─────────────────────────── */
// Submit-on-enter only. Nominatim's usage policy prohibits autocomplete-style
// search-as-you-type, so there is deliberately no keystroke handler here.
const SEARCH_LIMIT = 8;
function clearSearchPin() {
if (state.searchPin) {
state.map.removeLayer(state.searchPin);
state.searchPin = null;
}
}
function hideResults() {
const box = $('#search-results');
box.hidden = true;
box.innerHTML = '';
}
/* Nominatim's display_name is a long comma-separated string. The first part is
the place itself; the rest is the address, which is what distinguishes one
Tesco from the next. */
function splitName(hit) {
const parts = (hit.display_name || '').split(',').map((p) => p.trim());
return { head: parts[0] || hit.name || 'Unnamed', rest: parts.slice(1).join(', ') };
}
function renderResults(hits) {
const box = $('#search-results');
box.innerHTML = '';
hits.forEach((hit) => {
const { head, rest } = splitName(hit);
const li = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
const name = document.createElement('span');
name.className = 'r-name';
name.textContent = head;
const where = document.createElement('span');
where.className = 'r-where';
where.textContent = rest;
button.append(name, where);
// e.g. "supermarket", "cafe" — helps tell near-identical entries apart.
const kind = hit.type || hit.category;
if (kind) {
const tag = document.createElement('span');
tag.className = 'r-kind';
tag.textContent = String(kind).replace(/_/g, ' ');
button.appendChild(tag);
}
button.addEventListener('click', () => { showSearchHit(hit); hideResults(); });
li.appendChild(button);
box.appendChild(li);
});
box.hidden = hits.length === 0;
}
/* Drop a pin at the hit, centre on it, and show what was actually found
previously the map jumped with nothing to indicate where or what. */
function showSearchHit(hit) {
clearSearchPin();
const lat = parseFloat(hit.lat);
const lon = parseFloat(hit.lon);
const { head, rest } = splitName(hit);
const kind = (hit.type || hit.category || '').replace(/_/g, ' ');
const osmLink = hit.osm_type && hit.osm_id
? `https://www.openstreetmap.org/${hit.osm_type}/${hit.osm_id}`
: null;
state.searchPin = L.marker([lat, lon], { icon: pinIcon(SEARCH_COLOUR) }).addTo(state.map);
state.searchPin.bindPopup(
`<b>${escapeHtml(head)}</b>` +
(kind ? `<span>${escapeHtml(kind)}</span><br>` : '') +
(rest ? `${escapeHtml(rest)}<br>` : '') +
`<span class="mono">${fmtCoord(lat, lon)}</span>` +
`<div class="detail-slot"></div>` +
`<div class="popup-actions">` +
`<button type="button" class="link" data-save-hit>Save as place</button>` +
(osmLink ? `<button type="button" class="link" data-more>More details</button>` : '') +
(osmLink ? `<a class="link" href="${osmLink}" target="_blank" rel="noopener noreferrer">View on OSM</a>` : '') +
`</div>`,
{ minWidth: 210, maxWidth: 300 }
);
// The popup is rebuilt each time it opens, so the handler is attached here
// rather than once at bind time.
state.searchPin.on('popupopen', (e) => {
const popup = e.popup;
const root = popup.getElement();
// Details are fetched on demand, once per popup opening. Overpass is
// donated infrastructure — one call per deliberate click, never per result.
const more = root.querySelector('[data-more]');
const slot = root.querySelector('.detail-slot');
if (more && slot) {
more.addEventListener('click', async () => {
more.disabled = true;
more.textContent = 'Loading…';
try {
const tags = await fetchOsmTags(hit.osm_type, hit.osm_id);
const { node } = renderDetails(tags || {});
slot.replaceChildren(node);
more.remove();
} catch {
// Overpass being busy is routine and not worth interrupting anyone
// over. The button simply comes back so it can be pressed again;
// the reason stays in the console.
more.disabled = false;
more.textContent = 'More details';
}
popup.update(); // re-measure after the content grew
});
}
const save = root.querySelector('[data-save-hit]');
if (!save) return;
save.addEventListener('click', () => {
state.searchPin.closePopup();
openMarkerSheet(null, { lat, lng: lon });
const form = $('#marker-form');
form.name.value = head;
// category is a fixed <select>; Nominatim's type rarely matches an option,
// so only preselect on an exact hit and otherwise leave it unclassified.
const match = [...form.category.options].some((o) => o.value === kind);
if (match) form.category.value = kind;
});
});
state.map.setView([lat, lon], Math.max(state.map.getZoom(), 16));
state.searchPin.openPopup();
}
$('#search').addEventListener('submit', async (e) => {
e.preventDefault();
const q = e.target.q.value.trim();
if (!q) return;
hideResults();
try {
const url = `${NOMINATIM}?format=jsonv2&limit=1&q=${encodeURIComponent(q)}`;
const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
// Ask for several: a chain name in a city has many equally valid matches,
// and limit=1 picked one arbitrarily with no way to see the others.
const url = `${NOMINATIM}?format=jsonv2&addressdetails=1&limit=${SEARCH_LIMIT}` +
`&q=${encodeURIComponent(q)}`;
const res = await fetch(url, { headers: { Accept: 'application/json' } });
const hits = await res.json();
if (!hits.length) { toast('Nothing found for that', true); return; }
const hit = hits[0];
state.map.setView([parseFloat(hit.lat), parseFloat(hit.lon)], 14);
toast(hit.display_name.split(',').slice(0, 2).join(','));
if (hits.length === 1) {
showSearchHit(hits[0]);
} else {
renderResults(hits);
toast(`${hits.length} matches — pick one`);
}
} catch {
toast('Search is unavailable right now', true);
}
});
// Dismiss the results list on outside click or Escape.
document.addEventListener('click', (e) => {
if (!e.target.closest('#search')) hideResults();
});
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideResults(); });
/* ────────────────────── live location ────────────────────── */
$('#locate').addEventListener('click', () => {
@ -580,10 +1103,68 @@ async function loadGpxList() {
}
});
li.appendChild(show);
const edit = document.createElement('button');
edit.className = 'link';
edit.textContent = 'Edit';
edit.addEventListener('click', () => editRoute(f.name));
li.appendChild(edit);
// A plain anchor rather than a fetch-and-blob: same-origin navigation
// carries the session cookie, and the browser handles the save dialog,
// resumability and large files without any of it passing through JS.
const download = document.createElement('a');
download.className = 'link';
download.textContent = 'Download';
download.href = 'api/gpx/file/' + encodeURIComponent(f.name);
// Without an explicit filename the browser would name it after the URL's
// last segment, which is percent-encoded.
download.download = f.name;
li.appendChild(download);
list.appendChild(li);
});
}
/* Reopens a saved route for editing. Only routes created here can be edited:
they carry their original <wpt> waypoints alongside the dense snapped track.
A GPX recorded on a device has no such waypoints, and a few thousand track
points can't be reduced back to the handful someone actually clicked. */
async function editRoute(filename) {
try {
const res = await fetch('api/gpx/file/' + encodeURIComponent(filename), {
credentials: 'same-origin',
});
if (!res.ok) throw new Error('Could not read that file.');
const doc = new DOMParser().parseFromString(await res.text(), 'application/xml');
const wpts = [...doc.getElementsByTagName('wpt')]
.map((w) => ({
lat: parseFloat(w.getAttribute('lat')),
lng: parseFloat(w.getAttribute('lon')),
}))
.filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lng));
if (wpts.length < 2) {
toast('That file has no editable waypoints — only routes drawn here can be edited', true);
return;
}
drawSetActive(true);
state.draw.editing = filename;
// Strip the extension: it's re-added on save, and showing it invites
// someone to delete it by accident.
$('#draw-name').value = filename.replace(/\.gpx$/i, '');
$('#draw-hint').textContent = `Editing ${filename}`;
wpts.forEach((p) => addWaypoint(L.latLng(p.lat, p.lng)));
state.map.fitBounds(L.latLngBounds(wpts.map((p) => [p.lat, p.lng])), { padding: [40, 40] });
toast('Drag points to adjust, then save');
} catch (err) {
toast(err.message || 'Could not open that route', true);
}
}
$('#gpx-local').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;

View file

@ -7,7 +7,7 @@
and nothing in this app may use a leading-slash URL. -->
<base href="__BASE_HREF__">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Waymark</title>
<title>rs_maps</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="style.css">
</head>
@ -18,7 +18,7 @@
<div class="auth-card">
<header class="auth-head">
<span class="trig" aria-hidden="true"></span>
<h1>Waymark</h1>
<h1>rs_maps</h1>
<p class="auth-sub" id="auth-sub">Places worth going back to.</p>
</header>
@ -72,6 +72,7 @@
<form id="search" class="search" role="search">
<input name="q" type="search" placeholder="Search a place or address" aria-label="Search places">
<button type="submit" aria-label="Search">Find</button>
<ol id="search-results" class="results" hidden aria-label="Search results"></ol>
</form>
<button id="locate" class="ghost" aria-pressed="false">Live location</button>
@ -131,6 +132,23 @@
<button class="primary small" id="gpx-save">Save to shared folder</button>
</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> routed</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 &amp; 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>
<ul class="list" id="gpx-list"></ul>
<p class="empty" id="gpx-empty" hidden>No routes on the server yet.</p>

View file

@ -1,6 +1,6 @@
/* frontend/style.css */
/*
Waymark chrome for a map, not a dashboard.
rs_maps chrome for a map, not a dashboard.
Palette taken from Ordnance Survey sheet conventions:
rights-of-way green, road-fill magenta, contour bistre.
*/
@ -14,7 +14,7 @@
--paper-dim: #9DB0A8;
--moss: #4E9C6B; /* rights of way — primary action */
--moss-lift: #5FB47D;
--waymark: #D2467F; /* road fill — shared / accent */
--accent: #D2467F; /* road fill — shared / accent */
--sun: #E2A93C; /* live location, warnings */
--alarm: #D3574B;
@ -201,7 +201,10 @@ button.link:hover { color: var(--paper); background: none; }
.bar {
position: absolute;
top: 0; left: 0; right: 0;
z-index: 500;
/* Above .rail (500) so the account menu isn't painted behind the
Places/Routes tabs. .bar sets a stacking context, so raising .menu alone
would have no effect the parent is what has to come up. */
z-index: 700;
display: flex;
align-items: center;
gap: 10px;
@ -210,7 +213,46 @@ button.link:hover { color: var(--paper); background: none; }
padding-bottom: 22px;
}
.search { display: flex; gap: 6px; flex: 1 1 auto; max-width: 420px; }
/* The bar's gradient extends 22px past its contents. Now that it sits above
the rail, that transparent strip would intercept clicks meant for the tabs
beneath it, so the bar ignores pointer events and its children take them back. */
.bar { pointer-events: none; }
.bar > * { pointer-events: auto; }
.search { display: flex; gap: 6px; flex: 1 1 auto; max-width: 420px; position: relative; }
/* Search results. Nominatim frequently returns many equally-valid matches for
a chain name, so the list is shown rather than silently taking the first. */
.results {
position: absolute;
top: calc(100% + 6px); left: 0; right: 0;
max-height: min(46vh, 380px);
overflow-y: auto;
margin: 0; padding: 5px;
list-style: none;
background: var(--panel);
border: 1px solid var(--hair);
border-radius: var(--r);
box-shadow: var(--shadow);
}
.results li { margin: 0; }
.results button {
display: block; width: 100%;
background: none; border: none; text-align: left;
padding: 8px 10px; border-radius: calc(var(--r) - 2px);
font-size: 13px; line-height: 1.35;
}
.results button:hover, .results button:focus-visible { background: var(--panel-2); }
.results .r-name { display: block; color: var(--paper); }
.results .r-where { display: block; color: var(--paper-dim); font-size: 11.5px; }
.results .r-kind {
display: inline-block; margin-top: 3px;
font-family: var(--mono); font-size: 10px; letter-spacing: .1em;
text-transform: uppercase; color: var(--moss-lift);
}
/* Marker list rows are clickable as a whole; the Edit button sits on top. */
#marker-list li { cursor: pointer; }
.search input { margin-top: 0; background: var(--panel); }
.search button { flex: none; background: var(--panel); border-color: var(--hair); }
@ -329,7 +371,7 @@ button.link:hover { color: var(--paper); background: none; }
.badge {
font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
color: var(--waymark); border: 1px solid var(--waymark);
color: var(--accent); border: 1px solid var(--accent);
border-radius: var(--r); padding: 1px 5px; flex: none;
}
@ -461,3 +503,77 @@ button.link:hover { color: var(--paper); background: none; }
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}
/* ───────────────────── place details in popups ───────────────────── */
.popup-actions {
display: flex; flex-wrap: wrap; gap: 4px 12px;
margin-top: 8px; padding-top: 7px;
border-top: 1px solid var(--hair);
}
.popup-actions .link { padding: 0; font-size: 12px; }
/* Two-column label/value grid. Values are shown as OSM records them no
interpretation, so what you see is what's actually in the data. */
.details {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 10px;
margin-top: 8px; padding-top: 7px;
border-top: 1px solid var(--hair);
font-size: 12px;
line-height: 1.4;
}
.d-label {
font-family: var(--mono); font-size: 10px; letter-spacing: .1em;
text-transform: uppercase; color: var(--paper-dim);
padding-top: 2px; white-space: nowrap;
}
.d-value { color: var(--paper); overflow-wrap: anywhere; }
.d-value a { color: var(--moss-lift); }
/* opening_hours clauses, one per line. */
.d-hours { display: flex; flex-direction: column; gap: 1px; font-family: var(--mono); font-size: 11px; }
.d-empty {
margin: 8px 0 0; padding-top: 7px;
border-top: 1px solid var(--hair);
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; }
/* Waypoint brouter couldn't reach usually too far from any path. The line
keeps its last good shape, so this is the only cue that something's wrong. */
.wp-bad .wp {
background: #C4443A;
color: #fff;
border-color: rgba(255,255,255,.9);
}

View file

@ -21,6 +21,12 @@ pub enum AppError {
#[error("payload too large")]
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)]
Database(#[from] sqlx::Error),
@ -42,6 +48,7 @@ impl IntoResponse for AppError {
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
AppError::Conflict(m) => (StatusCode::CONFLICT, m.clone()),
AppError::TooLarge => (StatusCode::PAYLOAD_TOO_LARGE, self.to_string()),
AppError::BadGateway(m) => (StatusCode::BAD_GATEWAY, m.clone()),
other => {
// Internal detail stays in the log, never in the response body.
tracing::error!(error = %other, "internal error");

View file

@ -34,7 +34,7 @@ pub struct GpxFile {
// ---------------------------------------------------------------- name safety
/// 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()));
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,
/// 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);
for n in 0..MAX_COLLISION_ATTEMPTS {
@ -107,6 +107,39 @@ fn reserve(dir: &StdPath, name: &str) -> AppResult<(std::fs::File, String)> {
))
}
/// Replaces an existing file's contents atomically: write a sibling temp file,
/// then rename over the target. A reader that opens the file mid-save sees
/// either the old contents or the new, never a partial write.
pub async fn overwrite(dir: &StdPath, name: &str, bytes: &[u8]) -> AppResult<()> {
validate_filename(name)?;
// Must exist already — this path is for editing, not creating.
let target = resolve_existing(dir, name)?;
// Same directory, so the rename stays on one filesystem and is atomic.
let temp = dir.join(format!(".{name}.tmp"));
let write = async {
let mut file = tokio::fs::File::create(&temp).await?;
file.write_all(bytes).await?;
file.flush().await?;
// Durable before the rename, so a crash can't leave an empty file in place.
file.sync_all().await?;
Ok::<_, std::io::Error>(())
};
if let Err(e) = write.await {
let _ = tokio::fs::remove_file(&temp).await;
return Err(e.into());
}
if let Err(e) = tokio::fs::rename(&temp, &target).await {
let _ = tokio::fs::remove_file(&temp).await;
return Err(e.into());
}
Ok(())
}
// ---------------------------------------------------------------- handlers
async fn list(

View file

@ -6,6 +6,7 @@ mod gpx;
mod mail;
mod markers;
mod ratelimit;
mod routes;
mod web;
use std::net::SocketAddr;
@ -98,10 +99,16 @@ async fn main() -> Result<()> {
.merge(auth_routes)
.route("/config", get(web::client_config))
.nest("/markers", markers::routes())
.nest("/gpx", gpx::routes());
.nest("/gpx", gpx::routes())
.nest("/routes", routes::routes());
let app = Router::new()
.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)
.layer(axum::middleware::from_fn_with_state(
state.clone(),

391
src/routes.rs Normal file
View file

@ -0,0 +1,391 @@
// 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,
/// When editing: the file this route came from. If it matches the target
/// name the file is replaced in place; if the user renamed it, a new file is
/// written and the original left alone.
#[serde(default)]
pub replace: Option<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("|")
}
/// The stored GPX carries the clicked waypoints as `<wpt>` elements alongside
/// the dense snapped `<trk>`. Without them a saved route can't be reopened for
/// editing — several thousand track points can't be reduced back to the handful
/// of points the user actually placed. `<wpt>` is standard GPX, so other tools
/// simply show them as markers.
fn embed_waypoints(gpx: &str, waypoints: &[Waypoint]) -> String {
let block: String = waypoints
.iter()
.map(|p| {
format!(
" <wpt lat=\"{:.6}\" lon=\"{:.6}\"><type>rs_maps:via</type></wpt>\n",
p.lat, p.lon
)
})
.collect();
// Insert immediately after the opening <gpx …> tag; the schema requires
// wpt elements to precede trk.
match gpx.find("<gpx").and_then(|start| gpx[start..].find('>').map(|o| start + o + 1)) {
Some(after_open) => {
let mut out = String::with_capacity(gpx.len() + block.len() + 1);
out.push_str(&gpx[..after_open]);
out.push('\n');
out.push_str(&block);
out.push_str(&gpx[after_open..]);
out
}
// Shouldn't happen — looks_like_gpx already ran — but never lose the route.
None => gpx.to_string(),
}
}
/// 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")
}
/// Calls brouter and returns the raw body. `format` is brouter's own parameter:
/// "gpx" for the file we store, "geojson" for the preview (smaller, and trivial
/// to parse for coordinates without pulling in an XML dependency).
async fn brouter_fetch(waypoints: &[Waypoint], format: &str) -> AppResult<String> {
let url = format!(
"{BROUTER_URL}?lonlats={}&profile={PROFILE}&alternativeidx=0&format={format}",
lonlats(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 handle.".into()));
}
Ok(body)
}
/// BRouter reports routing failures as HTTP 200 with a plain-text body, so the
/// status code alone never tells you whether it worked.
fn routing_failure(body: &str) -> AppError {
let detail = body.lines().next().unwrap_or("").trim();
tracing::info!(detail, "brouter could not route");
AppError::BadRequest(if detail.is_empty() {
"No route could be found between those points.".into()
} else {
format!("No route found: {detail}")
})
}
#[derive(Serialize)]
pub struct PreviewResponse {
/// [[lat, lon], …] — flipped from brouter's lon-first order, ready for Leaflet.
pub points: Vec<[f64; 2]>,
/// Routed distance in metres, as reported by brouter.
pub length_m: Option<f64>,
/// Cumulative ascent in metres, where brouter provides it.
pub ascend_m: Option<f64>,
}
/// brouter's geojson has one LineString feature; distance and ascent arrive as
/// strings in its properties.
fn parse_geojson(body: &str) -> AppResult<PreviewResponse> {
let parsed: serde_json::Value =
serde_json::from_str(body).map_err(|_| routing_failure(body))?;
let feature = parsed
.get("features")
.and_then(|f| f.get(0))
.ok_or_else(|| routing_failure(body))?;
let coords = feature
.get("geometry")
.and_then(|g| g.get("coordinates"))
.and_then(|c| c.as_array())
.ok_or_else(|| routing_failure(body))?;
let points = coords
.iter()
.filter_map(|pair| {
let pair = pair.as_array()?;
// Longitude first in geojson, latitude first for Leaflet.
Some([pair.get(1)?.as_f64()?, pair.first()?.as_f64()?])
})
.collect::<Vec<_>>();
if points.len() < 2 {
return Err(routing_failure(body));
}
let number = |key: &str| {
feature
.get("properties")
.and_then(|p| p.get(key))
.and_then(|v| v.as_str())
.and_then(|v| v.parse::<f64>().ok())
};
Ok(PreviewResponse {
points,
length_m: number("track-length"),
ascend_m: number("filtered ascend"),
})
}
/// Snapped geometry without writing anything. Called repeatedly while drawing,
/// so it does no disk I/O at all.
async fn preview(
State(_): State<AppState>,
CurrentUser(_): CurrentUser,
Json(request): Json<CalculateRequest>,
) -> AppResult<Json<PreviewResponse>> {
validate(&request)?;
let body = brouter_fetch(&request.waypoints, "geojson").await?;
Ok(Json(parse_geojson(&body)?))
}
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 body = brouter_fetch(&request.waypoints, "gpx").await?;
if !looks_like_gpx(&body) {
return Err(routing_failure(&body));
}
let body = embed_waypoints(&body, &request.waypoints);
// Replacing in place only when the user kept the name. A rename leaves the
// original untouched and takes the normal collision-avoiding path.
let replacing = request
.replace
.as_deref()
.map(str::trim)
.filter(|original| !original.is_empty())
.filter(|original| original.eq_ignore_ascii_case(&filename));
let written_name = if let Some(original) = replacing {
gpx::validate_filename(original)?;
gpx::overwrite(&state.config.gpx_dir, original, body.as_bytes()).await?;
original.to_string()
} else {
// 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?;
written_name
};
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()
// Preview writes nothing; calculate writes into GPX_DIR.
.route("/preview", post(preview))
.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 waypoints_are_embedded_before_the_track() {
let gpx = r#"<?xml version="1.0"?><gpx version="1.1"><trk><name>x</name></trk></gpx>"#;
let out = embed_waypoints(gpx, &[wp(53.5, -2.2), wp(53.6, -2.3)]);
assert_eq!(out.matches("<wpt").count(), 2);
// Schema requires wpt before trk.
assert!(out.find("<wpt").unwrap() < out.find("<trk").unwrap());
assert!(out.contains(r#"lat="53.500000""#));
}
#[test]
fn parses_geojson_and_flips_coordinate_order() {
let body = r#"{"features":[{"geometry":{"coordinates":
[[-2.24,53.48,50],[-2.25,53.49,60]]},
"properties":{"track-length":"1234","filtered ascend":"56"}}]}"#;
let parsed = parse_geojson(body).expect("valid geojson");
// geojson is lon,lat; the response must be lat,lon for Leaflet.
assert_eq!(parsed.points[0], [53.48, -2.24]);
assert_eq!(parsed.length_m, Some(1234.0));
assert_eq!(parsed.ascend_m, Some(56.0));
}
#[test]
fn plain_text_failure_is_not_geojson() {
assert!(parse_geojson("position not mapped in existing datafile").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(""));
}
}