Compare commits
4 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9962a6932d | |||
| fff8a8382b | |||
| 195c84106e | |||
| bd87587391 |
4 changed files with 168 additions and 99 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1590,7 +1590,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "rs_maps"
|
||||
version = "0.3.0"
|
||||
version = "0.3.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Cargo.toml
|
||||
[package]
|
||||
name = "rs_maps"
|
||||
version = "0.3.0"
|
||||
version = "0.3.4"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
|
|
|||
190
README.md
190
README.md
|
|
@ -1,114 +1,132 @@
|
|||
<!-- 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,
|
||||
> 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.
|
||||
|
|
|
|||
|
|
@ -26,7 +26,20 @@ 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.
|
||||
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 DEFAULT_COLOUR = COLOURS[0];
|
||||
|
|
@ -693,16 +706,40 @@ async function fetchOsmTags(osmType, osmId) {
|
|||
const short = OSM_SHORT[osmType];
|
||||
if (!short) return null;
|
||||
|
||||
const query = `[out:json][timeout:20];${short}(${osmId});out tags;`;
|
||||
const res = await fetch(OVERPASS, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'data=' + encodeURIComponent(query),
|
||||
});
|
||||
if (!res.ok) throw new Error('overpass ' + res.status);
|
||||
// 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;
|
||||
|
||||
const body = await res.json();
|
||||
return (body.elements && body.elements[0] && body.elements[0].tags) || {};
|
||||
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
|
||||
|
|
@ -873,9 +910,11 @@ function showSearchHit(hit) {
|
|||
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';
|
||||
toast('Could not load details right now', true);
|
||||
}
|
||||
popup.update(); // re-measure after the content grew
|
||||
});
|
||||
|
|
@ -1071,6 +1110,18 @@ async function loadGpxList() {
|
|||
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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue