Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9962a6932d | |||
| fff8a8382b | |||
| 195c84106e | |||
| bd87587391 | |||
| b8ee4b4cc0 | |||
| da5a068224 |
8 changed files with 566 additions and 166 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1590,7 +1590,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rs_maps"
|
name = "rs_maps"
|
||||||
version = "0.1.0"
|
version = "0.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Cargo.toml
|
# Cargo.toml
|
||||||
[package]
|
[package]
|
||||||
name = "rs_maps"
|
name = "rs_maps"
|
||||||
version = "0.1.0"
|
version = "0.3.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
|
||||||
190
README.md
190
README.md
|
|
@ -1,114 +1,132 @@
|
||||||
<!-- README.md -->
|
<!-- README.md -->
|
||||||
|
|
||||||
# rs_maps — 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,
|
It is not a Google Maps clone. There are no reviews, no photos, no live traffic
|
||||||
> so treat the first `cargo build` as the real review. Expect to fix a handful of import
|
and no turn-by-turn navigation, and there is no plan to add them.
|
||||||
> paths and trait-bound details, not the structure.
|
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
cargo add axum -F multipart,macros
|
git clone <this repo> && cd rs_maps
|
||||||
cargo add tokio -F full
|
cargo build --release
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Two version-sensitive spots to check first if it doesn't compile:
|
Database and directories:
|
||||||
|
|
||||||
- **`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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
podman secret create mapserver-db-password - # type the password, then Ctrl-D
|
mkdir -p ~/.config/containers/systemd ~/gpx
|
||||||
mkdir -p ~/.config/containers/systemd
|
|
||||||
cp deploy/postgres.container ~/.config/containers/systemd/
|
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 daemon-reload
|
||||||
systemctl --user start postgres
|
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
|
```bash
|
||||||
mkdir -p ~/.config/mapserver ~/gpx
|
./target/release/rs_maps
|
||||||
cp .env.example ~/.config/mapserver/.env
|
|
||||||
chmod 600 ~/.config/mapserver/.env
|
|
||||||
$EDITOR ~/.config/mapserver/.env
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
For the service unit, systemd hardening and the release/upgrade scripts, see
|
||||||
is testable without a provider.
|
`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
|
```bash
|
||||||
cargo build --release
|
cargo test
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5. nginx
|
No database needed — the tests cover base-path handling, bbox parsing, GPX
|
||||||
|
filename safety, waypoint embedding, coordinate ordering and the rate limiter.
|
||||||
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.
|
|
||||||
|
|
|
||||||
255
frontend/app.js
255
frontend/app.js
|
|
@ -26,7 +26,20 @@ const NOMINATIM = 'https://nominatim.openstreetmap.org/search';
|
||||||
// Nominatim's search endpoint doesn't return object tags, so opening hours,
|
// 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
|
// phone and website need a second lookup by OSM id. Overpass is donated
|
||||||
// infrastructure like Nominatim: fine on an explicit click, not per result.
|
// infrastructure like Nominatim: fine on an explicit click, not per result.
|
||||||
const OVERPASS = 'https://overpass-api.de/api/interpreter';
|
//
|
||||||
|
// 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 COLOURS = ['#4E9C6B', '#D2467F', '#E2A93C', '#4E8FC9', '#B07BD4', '#D3574B'];
|
||||||
const DEFAULT_COLOUR = COLOURS[0];
|
const DEFAULT_COLOUR = COLOURS[0];
|
||||||
|
|
@ -50,7 +63,8 @@ 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 },
|
draw: { active: false, points: [], markers: [], line: null, guide: null,
|
||||||
|
timer: null, seq: 0, snapped: null, editing: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ───────────────────────────── api ───────────────────────────── */
|
/* ───────────────────────────── api ───────────────────────────── */
|
||||||
|
|
@ -468,10 +482,17 @@ $('#marker-delete').addEventListener('click', async () => {
|
||||||
|
|
||||||
const DRAW_COLOUR = '#4FA3D1';
|
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() {
|
function drawReset() {
|
||||||
|
if (state.draw.timer) clearTimeout(state.draw.timer);
|
||||||
state.draw.markers.forEach((m) => state.map.removeLayer(m));
|
state.draw.markers.forEach((m) => state.map.removeLayer(m));
|
||||||
if (state.draw.line) state.map.removeLayer(state.draw.line);
|
if (state.draw.line) state.map.removeLayer(state.draw.line);
|
||||||
state.draw = { active: false, points: [], markers: [], line: null };
|
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) {
|
function drawSetActive(on) {
|
||||||
|
|
@ -481,44 +502,103 @@ function drawSetActive(on) {
|
||||||
$('#draw-panel').hidden = !on;
|
$('#draw-panel').hidden = !on;
|
||||||
$('#draw-start').textContent = on ? 'Drawing…' : 'New route';
|
$('#draw-start').textContent = on ? 'Drawing…' : 'New route';
|
||||||
$('#draw-start').disabled = on;
|
$('#draw-start').disabled = on;
|
||||||
// Crosshair makes the mode obvious; without it the map looks unchanged.
|
if (!on) $('#draw-hint').textContent = 'Snapped to paths for walking';
|
||||||
state.map.getContainer().style.cursor = on ? 'crosshair' : '';
|
state.map.getContainer().style.cursor = on ? 'crosshair' : '';
|
||||||
if (on) drawUpdate();
|
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) {
|
function fmtDistance(metres) {
|
||||||
if (!metres) return '—';
|
if (!metres) return '—';
|
||||||
return metres < 1000
|
return metres < 1000
|
||||||
? `${Math.round(metres)} m direct`
|
? `${Math.round(metres)} m`
|
||||||
: `${(metres / 1000).toFixed(1)} km direct`;
|
: `${(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() {
|
function drawUpdate() {
|
||||||
const points = state.draw.points;
|
const points = state.draw.points;
|
||||||
$('#draw-n').textContent = points.length;
|
$('#draw-n').textContent = points.length;
|
||||||
$('#draw-dist').textContent = fmtDistance(directDistance(points));
|
|
||||||
$('#draw-save').disabled = points.length < 2;
|
$('#draw-save').disabled = points.length < 2;
|
||||||
$('#draw-undo').disabled = points.length === 0;
|
$('#draw-undo').disabled = points.length === 0;
|
||||||
|
|
||||||
const latlngs = points.map((p) => [p.lat, p.lng]);
|
drawGuide();
|
||||||
if (state.draw.line) {
|
schedulePreview();
|
||||||
state.draw.line.setLatLngs(latlngs);
|
}
|
||||||
} else {
|
|
||||||
state.draw.line = L.polyline(latlngs, {
|
function setPreviewStatus(text) {
|
||||||
color: DRAW_COLOUR, weight: 2, dashArray: '4 5', interactive: false,
|
$('#draw-dist').textContent = text;
|
||||||
}).addTo(state.map);
|
}
|
||||||
|
|
||||||
|
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) {
|
function addWaypoint(latlng) {
|
||||||
|
|
@ -534,12 +614,12 @@ function addWaypoint(latlng) {
|
||||||
}),
|
}),
|
||||||
}).addTo(state.map);
|
}).addTo(state.map);
|
||||||
|
|
||||||
// Dragging rewrites the point in place; numbering is unaffected because the
|
// Guide follows the drag live; the snapped preview waits for the debounce.
|
||||||
// marker's position in the array doesn't change.
|
|
||||||
marker.on('drag', (e) => {
|
marker.on('drag', (e) => {
|
||||||
const i = state.draw.markers.indexOf(marker);
|
const i = state.draw.markers.indexOf(marker);
|
||||||
if (i !== -1) { state.draw.points[i] = e.target.getLatLng(); drawUpdate(); }
|
if (i !== -1) { state.draw.points[i] = e.target.getLatLng(); drawGuide(); }
|
||||||
});
|
});
|
||||||
|
marker.on('dragend', drawUpdate);
|
||||||
|
|
||||||
state.draw.markers.push(marker);
|
state.draw.markers.push(marker);
|
||||||
drawUpdate();
|
drawUpdate();
|
||||||
|
|
@ -558,7 +638,10 @@ $('#draw-start').addEventListener('click', () => {
|
||||||
toast('Click the map to add waypoints');
|
toast('Click the map to add waypoints');
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#draw-cancel').addEventListener('click', () => drawSetActive(false));
|
$('#draw-cancel').addEventListener('click', () => {
|
||||||
|
if (state.draw.editing && !confirm('Discard changes to this route?')) return;
|
||||||
|
drawSetActive(false);
|
||||||
|
});
|
||||||
$('#draw-undo').addEventListener('click', undoWaypoint);
|
$('#draw-undo').addEventListener('click', undoWaypoint);
|
||||||
|
|
||||||
$('#draw-save').addEventListener('click', async () => {
|
$('#draw-save').addEventListener('click', async () => {
|
||||||
|
|
@ -576,10 +659,18 @@ $('#draw-save').addEventListener('click', async () => {
|
||||||
const body = {
|
const body = {
|
||||||
name,
|
name,
|
||||||
waypoints: points.map((p) => ({ lat: p.lat, lon: p.lng })),
|
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 result = await api('api/routes/calculate', { method: 'POST', ...json(body) });
|
||||||
|
|
||||||
toast(`Saved as ${result.filename}`);
|
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);
|
drawSetActive(false);
|
||||||
await loadGpxList();
|
await loadGpxList();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -615,16 +706,40 @@ async function fetchOsmTags(osmType, osmId) {
|
||||||
const short = OSM_SHORT[osmType];
|
const short = OSM_SHORT[osmType];
|
||||||
if (!short) return null;
|
if (!short) return null;
|
||||||
|
|
||||||
const query = `[out:json][timeout:20];${short}(${osmId});out tags;`;
|
// Server-side budget matched to ours, so it sheds the request rather than
|
||||||
const res = await fetch(OVERPASS, {
|
// holding it in a queue we've already stopped waiting on.
|
||||||
method: 'POST',
|
const query = `[out:json][timeout:30];${short}(${osmId});out tags;`;
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
let lastError = null;
|
||||||
body: 'data=' + encodeURIComponent(query),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error('overpass ' + res.status);
|
|
||||||
|
|
||||||
const body = await res.json();
|
for (const endpoint of OVERPASS_ENDPOINTS) {
|
||||||
return (body.elements && body.elements[0] && body.elements[0].tags) || {};
|
// 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
|
/* Renders whatever came back. Deliberately raw values — no "open now", no
|
||||||
|
|
@ -795,9 +910,11 @@ function showSearchHit(hit) {
|
||||||
slot.replaceChildren(node);
|
slot.replaceChildren(node);
|
||||||
more.remove();
|
more.remove();
|
||||||
} catch {
|
} 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.disabled = false;
|
||||||
more.textContent = 'More details';
|
more.textContent = 'More details';
|
||||||
toast('Could not load details right now', true);
|
|
||||||
}
|
}
|
||||||
popup.update(); // re-measure after the content grew
|
popup.update(); // re-measure after the content grew
|
||||||
});
|
});
|
||||||
|
|
@ -986,10 +1103,68 @@ async function loadGpxList() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
li.appendChild(show);
|
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);
|
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) => {
|
$('#gpx-local').addEventListener('change', async (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="draw-panel" hidden>
|
<div id="draw-panel" hidden>
|
||||||
<p class="draw-count"><span id="draw-n">0</span> waypoints · <span id="draw-dist">—</span></p>
|
<p class="draw-count"><span id="draw-n">0</span> waypoints · <span id="draw-dist">—</span> routed</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="ghost small" id="draw-undo">Undo point</button>
|
<button class="ghost small" id="draw-undo">Undo point</button>
|
||||||
<button class="ghost small" id="draw-cancel">Cancel</button>
|
<button class="ghost small" id="draw-cancel">Cancel</button>
|
||||||
|
|
|
||||||
|
|
@ -569,3 +569,11 @@ button.link:hover { color: var(--paper); background: none; }
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
.wp:active { cursor: grabbing; }
|
.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);
|
||||||
|
}
|
||||||
|
|
|
||||||
33
src/gpx.rs
33
src/gpx.rs
|
|
@ -107,6 +107,39 @@ pub 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
|
// ---------------------------------------------------------------- handlers
|
||||||
|
|
||||||
async fn list(
|
async fn list(
|
||||||
|
|
|
||||||
240
src/routes.rs
240
src/routes.rs
|
|
@ -52,6 +52,11 @@ pub struct CalculateRequest {
|
||||||
pub waypoints: Vec<Waypoint>,
|
pub waypoints: Vec<Waypoint>,
|
||||||
/// Desired filename. ".gpx" is appended if absent.
|
/// Desired filename. ".gpx" is appended if absent.
|
||||||
pub name: String,
|
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)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -94,6 +99,38 @@ fn lonlats(waypoints: &[Waypoint]) -> String {
|
||||||
.join("|")
|
.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
|
/// Cheap sanity check on the response body. BRouter reports routing failures
|
||||||
/// ("operation not supported", "position not mapped in existing datafile") as
|
/// ("operation not supported", "position not mapped in existing datafile") as
|
||||||
/// 200 with a plain-text body, so status alone isn't enough.
|
/// 200 with a plain-text body, so status alone isn't enough.
|
||||||
|
|
@ -102,23 +139,13 @@ fn looks_like_gpx(body: &str) -> bool {
|
||||||
head.contains("<gpx")
|
head.contains("<gpx")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn calculate(
|
/// Calls brouter and returns the raw body. `format` is brouter's own parameter:
|
||||||
State(state): State<AppState>,
|
/// "gpx" for the file we store, "geojson" for the preview (smaller, and trivial
|
||||||
CurrentUser(_): CurrentUser,
|
/// to parse for coordinates without pulling in an XML dependency).
|
||||||
Json(request): Json<CalculateRequest>,
|
async fn brouter_fetch(waypoints: &[Waypoint], format: &str) -> AppResult<String> {
|
||||||
) -> 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!(
|
let url = format!(
|
||||||
"{BROUTER_URL}?lonlats={}&profile={PROFILE}&alternativeidx=0&format=gpx",
|
"{BROUTER_URL}?lonlats={}&profile={PROFILE}&alternativeidx=0&format={format}",
|
||||||
lonlats(&request.waypoints)
|
lonlats(waypoints)
|
||||||
);
|
);
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
|
|
@ -145,32 +172,139 @@ async fn calculate(
|
||||||
.map_err(|e| AppError::Other(e.into()))?;
|
.map_err(|e| AppError::Other(e.into()))?;
|
||||||
|
|
||||||
if body.len() > MAX_RESPONSE_BYTES {
|
if body.len() > MAX_RESPONSE_BYTES {
|
||||||
return Err(AppError::BadGateway("Route too large to store.".into()));
|
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) {
|
if !looks_like_gpx(&body) {
|
||||||
// BRouter's plain-text failures are usually "couldn't snap that point to
|
return Err(routing_failure(&body));
|
||||||
// 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
|
let body = embed_waypoints(&body, &request.waypoints);
|
||||||
// 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 {
|
// Replacing in place only when the user kept the name. A rename leaves the
|
||||||
// Don't leave a zero-byte stub behind on a failed write.
|
// original untouched and takes the normal collision-avoiding path.
|
||||||
let _ = tokio::fs::remove_file(state.config.gpx_dir.join(&written_name)).await;
|
let replacing = request
|
||||||
return Err(e.into());
|
.replace
|
||||||
}
|
.as_deref()
|
||||||
file.flush().await?;
|
.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");
|
tracing::info!(file = %written_name, points = request.waypoints.len(), "route saved");
|
||||||
|
|
||||||
|
|
@ -181,7 +315,10 @@ async fn calculate(
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new().route("/calculate", post(calculate))
|
Router::new()
|
||||||
|
// Preview writes nothing; calculate writes into GPX_DIR.
|
||||||
|
.route("/preview", post(preview))
|
||||||
|
.route("/calculate", post(calculate))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -216,6 +353,35 @@ mod tests {
|
||||||
assert!(validate(&request).is_err());
|
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]
|
#[test]
|
||||||
fn detects_non_gpx_body() {
|
fn detects_non_gpx_body() {
|
||||||
assert!(looks_like_gpx("<?xml version=\"1.0\"?><gpx version=\"1.1\">"));
|
assert!(looks_like_gpx("<?xml version=\"1.0\"?><gpx version=\"1.1\">"));
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue