first build

This commit is contained in:
TheFozid 2026-08-03 09:42:13 +01:00
commit 9ed25e2d5f
23 changed files with 6948 additions and 0 deletions

38
.env.example Normal file
View file

@ -0,0 +1,38 @@
# .env.example
# ── core ──────────────────────────────────────────────────────────────
DATABASE_URL=postgres://mapserver:CHANGEME@127.0.0.1:5432/mapserver
BIND_ADDR=127.0.0.1:8080
# Subpath nginx serves this under. nginx does NOT strip it.
# Leave empty (or "/") to serve from the domain root.
BASE_PATH=/maps
# Origin only, no trailing slash. BASE_PATH is appended for reset links.
PUBLIC_URL=https://your-domain.example
# ── auth ──────────────────────────────────────────────────────────────
# Shared secret required at registration. There is no other admin lever.
REG_TOKEN=CHANGEME-long-random-string
SESSION_DURATION_DAYS=30
# ── map ───────────────────────────────────────────────────────────────
# Served to the browser via GET /api/config, so switching providers (or to
# OpenTopoMap for hiking) is an edit and a restart, not a recompile.
# Use the apex host: the {s}.tile.openstreetmap.org sharded form is deprecated.
TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
# ── storage ───────────────────────────────────────────────────────────
GPX_DIR=/home/mapserver/gpx
# ── smtp ──────────────────────────────────────────────────────────────
# Leave SMTP_HOST empty to use the stub transport: reset links get logged
# at info level instead of emailed. The whole flow is testable that way.
SMTP_HOST=
SMTP_PORT=587
# starttls (587) | implicit (465) | none (localhost/mailpit only)
SMTP_TLS=starttls
SMTP_USERNAME=
SMTP_PASSWORD=
# MUST be an address this SMTP account is allowed to send as, or the
# provider returns 550/553 at send time and it looks like an auth failure.
SMTP_FROM=mapserver@your-domain.example
RUST_LOG=rs_maps=info,tower_http=warn

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# .gitignore
# Build output. Several GB after a release build — never commit this.
/target/
# Secrets: REG_TOKEN, database password, SMTP credentials.
# .env.example is the committed template; .env itself never goes in.
.env
.env.local
.env.*
!.env.example
# Release artifacts produced by rust_push.sh
*.tar.gz
# Local GPX scratch, if you keep any in-tree while testing
/gpx/
# Editor / OS noise
*.swp
*~
.DS_Store
.idea/
.vscode/

2772
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

56
Cargo.toml Normal file
View file

@ -0,0 +1,56 @@
# Cargo.toml
[package]
name = "rs_maps"
version = "0.0.1"
edition = "2021"
[dependencies]
axum = { version = "0.8", features = ["multipart", "macros"] }
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["trace"] }
# "tls-rustls" is an alias that selects aws-lc-rs. Naming ring explicitly keeps
# sqlx and lettre on one crypto provider rather than compiling both into the
# binary — which also avoids the ambiguity that makes rustls' install_default()
# panic when two providers are present.
sqlx = { version = "0.8", default-features = false, features = [
"runtime-tokio", "tls-rustls-ring", "postgres", "uuid", "time", "macros", "migrate",
] }
tower-sessions = "0.14"
tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
argon2 = "0.5"
password-hash = { version = "0.5", features = ["std"] }
rand = "0.8"
sha2 = "0.10"
subtle = "2"
hex = "0.4"
# 0.11.22 split rustls support three ways — transport, crypto provider and
# certificate roots must each be named or the build stops at a compile_error!.
# ring: no cmake/NASM build dependency, unlike aws-lc-rs.
# webpki-roots: bundled Mozilla roots, so SMTP TLS doesn't depend on the host
# trust store being populated. Swap to rustls-native-certs if the SMTP provider
# uses a private CA.
lettre = { version = "0.11", default-features = false, features = [
"tokio1-rustls", "ring", "webpki-roots", "smtp-transport", "builder", "pool",
] }
rust-embed = { version = "8", features = ["debug-embed"] }
mime_guess = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
time = { version = "0.3", features = ["serde", "formatting"] }
dotenvy = "0.15"
anyhow = "1"
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[profile.release]
opt-level = 3
lto = true
strip = true

428
DESIGN.md Normal file
View file

@ -0,0 +1,428 @@
<!-- DESIGN.md -->
# Self-Hosted Mapping Server — Design Document
*Revision 2. Incorporates design-review decisions on TLS/subpath deployment, rootless systemd,
upload limits, auth surface, and GPX collision handling.*
## 1. Purpose
A self-hosted replacement for Google Maps' "save places / plan routes" functionality, built as a
single Rust binary. It shows an OpenStreetMap-based map in the browser, lets multiple logged-in
users tag locations (privately or shared), shows live GPS position, and lets users view/import GPX
routes from a shared folder on the server. Route *creation* with road/trail snapping is an
explicitly deferred, later addition.
Scale is two users (myself and my wife). Several decisions below are deliberately sized to that
fact and are called out where they'd need revisiting at larger scale.
## 2. Non-goals / explicitly deferred
- **GPX route creation with road/trail snapping.** Out of scope for v1. Will be added later as a
separate backend integration with a self-hosted router (BRouter is the leading candidate — light
weight, single Java process, good for hiking/cycling profiles). The API/DB design below should not
need rework to accommodate this later; it will likely add a `POST /api/routes/calculate` endpoint
that proxies to BRouter and writes the result into the shared GPX folder.
*Note:* BRouter is not storage-free — it requires pre-generated `.rd5` segment files at roughly
100200 MB per 5°×5° tile, so a country-sized area is a few GB. Small compared to the rejected
Nominatim/tile options, but budget for it.
- **Live turn-by-turn navigation.** Out of scope. The live-location feature is "show my current
position on the map," not routing guidance.
- **User/role administration.** No admin UI, no roles, no user deactivation flow. The only
"admin" lever is the registration token in `.env`.
- **Self-hosted tiles/geocoding.** Explicitly rejected earlier due to storage cost (full planet
Nominatim ≈1TB, tiles ≈120GB+). Tiles and search go live to OSM's own tile servers and Nominatim
from the *browser*, not proxied through the backend.
- **Session revocation on password change.** Decided against. Changing a password affects
subsequent logins only; existing sessions on other devices remain valid. See §8.
- **GPX file metadata (uploader, upload date).** Decided against. The folder is shared between two
trusted users; provenance has no value here. Filesystem only, no table.
## 3. Architecture overview
Three moving parts:
1. **Rust binary** (built with `axum`), running as a **rootless systemd user unit**. Serves:
- The API (auth, account management, markers, GPX folder listing/serving/upload)
- The frontend static assets, embedded into the binary at compile time via `rust-embed` (no
separate web server / no separate static file deployment step)
2. **PostgreSQL**, running as a **rootless podman quadlet** container under the same user. Stores
users, sessions, and markers.
3. **A shared GPX folder** on the host filesystem, owned by the same user. Plain files, no database
involved. Shared across all users — anyone logged in can list, read, and upload into it.
An **existing nginx reverse proxy** terminates TLS and forwards to the binary on
`127.0.0.1:8080`. The app is served under a **subpath** (e.g. `/maps`), configured via `BASE_PATH`.
See §9 for the prefix-handling contract, which is the fiddliest part of the deployment.
The **browser** talks to three different things directly:
- OSM tile servers (map imagery) — direct, no backend involvement
- Nominatim (`nominatim.openstreetmap.org`) (POI/address search) — direct, no backend involvement
- The Rust backend, via nginx (auth, markers, GPX) — the only thing that's actually "yours"
```
┌────────────────────────────────────────────────────────┐
│ OSM infrastructure (external) │
│ Tile servers Nominatim │
└───────────▲──────────────────▲───────────────────────────┘
│ tile requests │ search requests
│ (direct) │ (direct, submit-on-enter)
┌─────┴──────────────────┴─────┐
│ Browser │
│ Leaflet map · live-location │
│ toggle · marker/tag UI · │
│ GPX list & device file picker │
└───────────────┬─────────────────┘
│ HTTPS
┌────────────────▼───────────────────┐
│ nginx (existing, TLS terminated) │
│ location /maps → 127.0.0.1:8080 │
│ prefix NOT stripped │
└────────────────┬───────────────────┘
│ HTTP, loopback only
┌────────────────▼───────────────────┐
│ Rust binary (axum, systemd --user) │
│ - auth (register/login/logout/ │
│ reset) + account management │
│ - markers API │
│ - GPX folder list/read/upload │
│ - serves embedded frontend assets │
└───────┬───────────────────┬──────────┘
│ │
┌──────────▼─────────┐ ┌─────▼──────────────┐
│ Postgres (rootless │ │ Shared GPX folder │
│ podman quadlet) │ │ (plain filesystem, │
│ 127.0.0.1:5432 │ │ no DB, shared) │
│ users, sessions, │ │ │
│ markers │ │ │
└───────────────────────┘ └──────────────────────┘
```
## 4. Tech stack
| Concern | Choice | Notes |
|---|---|---|
| Web framework | `axum` | async, tokio/hyper-based, mature ecosystem |
| DB access | `sqlx` (postgres) | features are `runtime-tokio` + `tls-rustls` (two separate features in 0.8, not one combined one). rustls keeps a musl static build painless |
| Sessions | `tower-sessions` + Postgres store | opaque server-side session IDs; store crate owns its own migration |
| Password hashing | `argon2` | current best practice |
| Rate limiting | `tower-governor` | applied to auth + account endpoints (§8) |
| Email (password reset) | `lettre` (SMTP transport) | stub/logging transport acceptable for the build session |
| Frontend asset embedding | `rust-embed` | note: reads from disk in debug builds unless `debug-embed` is enabled |
| Frontend | Plain Leaflet + vanilla JS | talks to OSM tiles/Nominatim directly, and to the API for everything else |
| Config | `.env` via `dotenvy` | see §7 |
| Migrations | `sqlx migrate` (SQL files) | run against Postgres on startup or via CLI |
Pin actual crate versions at build time (`cargo add`, let it resolve) rather than trusting anything
written here.
## 5. Database schema
```sql
-- Required if the Postgres image predates 13; gen_random_uuid() is core from 13 onward.
-- CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- users
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL,
email TEXT, -- nullable: recovery channel only, see §8
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
reset_token_hash TEXT, -- SHA-256 of the emailed token, never the token itself
reset_token_expires TIMESTAMPTZ
);
-- Case-insensitive uniqueness; multiple NULL emails permitted.
CREATE UNIQUE INDEX users_username_key ON users (lower(username));
CREATE UNIQUE INDEX users_email_key ON users (lower(email)) WHERE email IS NOT NULL;
-- markers (the "tags/flags" overlay)
CREATE TABLE markers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
lat DOUBLE PRECISION NOT NULL,
lon DOUBLE PRECISION NOT NULL,
category TEXT, -- free text in DB; fixed picker + "other" in the UI
color TEXT, -- optional marker color/icon hint
is_shared BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX markers_owner_idx ON markers (owner_id);
CREATE INDEX markers_shared_idx ON markers (id) WHERE is_shared;
-- updated_at: DEFAULT now() fires on INSERT only, so it needs a trigger (or an explicit
-- assignment in every UPDATE statement — trigger is harder to forget).
CREATE FUNCTION set_updated_at() RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER markers_updated_at BEFORE UPDATE ON markers
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- sessions table: created/managed by the tower-sessions Postgres store crate.
-- Do not hand-roll; let the crate's migration handle it.
```
GPX files: **no table**, by decision. The backend lists/reads/writes files in `GPX_DIR`.
## 6. API surface
All paths below are relative to `BASE_PATH` (§9).
**Auth**
- `POST /api/register``{ username, email?, password, reg_token }`. `reg_token` compared against
`REG_TOKEN` in **constant time**. Password hashed with argon2. Email optional.
- `POST /api/login``{ username_or_email, password }`. Sets a long-lived session cookie. When the
user isn't found, still verify against a dummy argon2 hash so both paths take comparable time.
- `POST /api/logout` — destroys the session server-side, clears the cookie.
- `GET /api/me` — current user info if authenticated, 401 otherwise. Frontend uses this to decide
between login page and map.
- `POST /api/password-reset/request``{ email }`. Generates a high-entropy token, stores its
SHA-256 hash plus expiry, emails the link. Always returns success regardless of whether the
address exists.
- `POST /api/password-reset/confirm``{ token, new_password }`. Hashes the supplied token,
looks it up, checks expiry, updates the password hash, clears the token fields.
**Account management** (require auth; both require the current password so a borrowed unlocked
device can't silently take over the account)
- `POST /api/me/password``{ current_password, new_password }`. Verify, rehash, update. Then call
`session.cycle_id()` to rotate the current session ID. No other sessions are touched (§8).
- `POST /api/me/email``{ current_password, new_email }`. Verify, update. `409` on unique
violation.
**Config**
- `GET /api/config` — returns `{ tile_url, base_path }` so the frontend doesn't hardcode them.
May be folded into `GET /api/me` if preferred.
**Markers** (all require auth)
- `GET /api/markers` — the current user's own markers plus all `is_shared = true` markers from any
user. Accepts an optional `?bbox=minlon,minlat,maxlon,maxlat` filter — worth building now, since
adding it later is a breaking change.
- `POST /api/markers` — create.
- `PUT /api/markers/:id` — update (only if `owner_id` matches).
- `DELETE /api/markers/:id` — delete (only if `owner_id` matches).
**GPX** (all require auth)
- `GET /api/gpx` — list files in `GPX_DIR` (name, size, modified time).
- `GET /api/gpx/:filename` — serve raw file content. See §10 for mandatory filename validation.
- `POST /api/gpx/upload` — multipart. Writes into `GPX_DIR` with collision avoidance (§10).
Returns the final stored filename, which may differ from the one submitted.
The frontend also handles **client-side-only GPX viewing** — a file opened from the user's own
device via `<input type="file">` is parsed and rendered without touching the API, unless the user
chooses to save it to the shared folder.
## 7. Configuration (`.env`)
```
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/mapserver
REG_TOKEN=<shared secret required at registration time>
SESSION_SECRET=<random 32+ bytes> # VERIFY AT BUILD TIME — see note below
SESSION_DURATION_DAYS=30 # "remember me" is the default behaviour, no checkbox
GPX_DIR=/home/mapserver/data/gpx
BIND_ADDR=127.0.0.1:8080 # loopback only; nginx is the only client
BASE_PATH=/maps # subpath the app is served under
PUBLIC_URL=https://your-domain.example # scheme+host only; reset links are PUBLIC_URL + BASE_PATH
TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
UPLOAD_MAX_BYTES=26214400 # 25 MiB
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=...
SMTP_PASSWORD=...
SMTP_FROM=mapserver@your-domain.example
```
Notes:
- **`SESSION_SECRET` may be vestigial.** `tower-sessions` backed by a Postgres store issues a random
opaque session ID and keeps all state server-side — there may be nothing to sign. Confirm at build
time whether the chosen store consumes a key; if it doesn't, delete this line rather than shipping
config that implies a security property that isn't there.
- **`TILE_URL`** is config rather than hardcoded so a switch to a paid provider or a different layer
(e.g. OpenTopoMap for hiking) is an edit and a restart, not a recompile. Use
`tile.openstreetmap.org` directly — the old `{s}.tile.` subdomain-sharded form is deprecated.
- `.env` lives at `~/.config/mapserver/.env`, mode `0600`.
## 8. Auth and account behaviour
- **Registration**: username + password + registration token required; email optional. Token checked
against `REG_TOKEN` in constant time.
- **Email is a recovery channel only.** It has no other function. A user who supplies a bad address,
or none at all, is only removing their own ability to self-serve a password reset — no other
capability depends on it. Accordingly there is no verification flow and the column is nullable.
- **Login**: sets a session cookie. Sessions live in Postgres so restarts don't log anyone out.
Expiry per `SESSION_DURATION_DAYS`.
- **Cookie attributes**: `Secure` (TLS is terminated at nginx), `HttpOnly`, `SameSite=Strict`, and
`Path` set to `BASE_PATH` so the cookie doesn't leak to other apps on the same domain.
- **CSRF**: `SameSite=Strict` is the primary defence, sufficient for a single-origin app. Also check
the `Origin` header on mutating requests as a cheap second layer.
- **Logout**: destroys the session server-side and clears the cookie.
- **Password reset**: request → email with a time-limited, single-use token link → confirm screen.
Only the token's hash is stored. Requires working SMTP; `lettre`'s stub transport logging the URL
to stdout is fine for the build session, so provider signup doesn't block starting.
- **Password change does not revoke sessions.** *Deliberate.* Changing a password affects subsequent
logins only; sessions already established on other devices stay valid. The current session's ID is
rotated via `cycle_id()`, which invalidates a leaked ID for the device performing the change but
touches nothing else. The consequence, accepted knowingly: if a device with a live session is
lost, changing the password will not lock it out — that requires clearing session rows directly
(`DELETE FROM tower_sessions;` via psql). At two users this is an acceptable manual recovery path;
it would not be at larger scale.
- **Rate limiting** via `tower-governor` on `/api/login`, `/api/register`,
`/api/password-reset/request`, `/api/me/password`, and `/api/me/email`. A single shared
registration secret with unlimited attempts is guessable given enough time.
## 9. Deployment
### Subpath handling (the part most likely to go wrong)
The contract is: **nginx does not strip the prefix.** Paths are then identical on both sides of the
proxy and there is nothing to keep in sync.
```nginx
location /maps {
proxy_pass http://127.0.0.1:8080; # NO trailing slash, no rewrite
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /maps/api/gpx/upload {
client_max_body_size 25m; # nginx default is 1m and would reject first
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
On the axum side: `Router::new().nest(&base_path, app)`.
Four things must carry the prefix:
1. **Session cookie `Path`** = `BASE_PATH`.
2. **Frontend asset URLs** — inject `<base href="/maps/">` into `index.html` at serve time. Every
relative URL in the JS (`fetch("api/markers")`) then resolves correctly with no other changes.
Two gotchas: no leading slashes anywhere in the frontend, and the trailing slash on the `href` is
mandatory.
3. **Reset links** — derive as `PUBLIC_URL + BASE_PATH + "/reset?token=…"` rather than storing a
second full URL that can drift out of sync.
4. **SPA fallback** — must live *inside* the nest, so `/maps/reset?token=…` serves `index.html`
rather than 404ing.
### Upload size limits
Two limits sit in series and both must be raised, on the upload route only:
- **nginx**: `client_max_body_size 25m;` (default 1m).
- **axum**: `DefaultBodyLimit` defaults to **2 MB** and applies to the `Multipart` extractor,
producing a `413` before the handler runs. Apply
`.layer(DefaultBodyLimit::max(UPLOAD_MAX_BYTES))` to the upload route specifically — a 25 MB body
allowance on `/api/login` would be free memory exhaustion.
- In the handler, **stream** the field to disk (`while let Some(chunk) = field.chunk().await?`)
rather than `field.bytes().await`, which buffers the entire file in RAM.
25 MB is sized for a long GPS track with per-point elevation and timestamps (~20k points lands in
the 515 MB range).
### Rootless systemd
Both the binary and Postgres run as the same non-root user, as **user units** — which means
ordering between them works normally within the single user manager.
- Quadlet: `~/.config/containers/systemd/postgres.container`
- App unit: `~/.config/systemd/user/mapserver.service`
```ini
[Unit]
Description=mapserver
After=network-online.target postgres.service
[Service]
EnvironmentFile=%h/.config/mapserver/.env
ExecStart=%h/.local/bin/mapserver
Restart=on-failure
[Install]
WantedBy=default.target
```
- `WantedBy=default.target`, **not** `multi-user.target` (user units).
- **`loginctl enable-linger <user>`** — without it, user units don't start at boot and are killed on
logout. (Already in place on this server; noted for completeness.)
- Postgres quadlet: **named volume** for `PGDATA`, not a host bind mount — rootless podman maps
container UIDs into the subuid range and bind mounts hit permission errors that named volumes
avoid. Publish as `127.0.0.1:5432:5432`.
- `After=` does **not** guarantee Postgres is accepting connections. The binary must retry its
initial pool connection with backoff rather than exiting.
- `GPX_DIR` under a directory the same user owns outright.
## 10. GPX file handling
**Filename validation is mandatory** on both read and write, since filenames are user-supplied and
appear in a path parameter. Without it, `GET /api/gpx/../../etc/passwd` reads anything the service
user can read.
- Reject any name containing a path separator, or not matching `^[A-Za-z0-9 ._-]{1,120}$`.
- Join to `GPX_DIR`, then canonicalize and assert the result is still under `GPX_DIR`.
- Do not attempt to sanitize by string-replacing `..`.
**Collision avoidance** uses `create_new(true)`, which is atomic — it fails if the path exists, so
there's no check-then-write race:
```rust
fn reserve(dir: &Path, stem: &str, ext: &str) -> io::Result<(File, String)> {
for n in 0..1000 {
let name = if n == 0 { format!("{stem}.{ext}") } else { format!("{stem}-{n}.{ext}") };
match OpenOptions::new().write(true).create_new(true).open(dir.join(&name)) {
Ok(f) => return Ok((f, name)),
Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(io::Error::other("too many collisions"))
}
```
`track.gpx` becomes `track-1.gpx`, then `track-2.gpx`. The final name is returned in the upload
response so the UI can show what was actually saved. On a failed or aborted upload, delete the
reserved file rather than leaving a zero-byte stub.
## 11. Frontend feature list (v1)
- Login / register / forgot-password / reset-password pages
- Account page: change password, change email (both requiring current password)
- Map view: Leaflet, tile layer from `TILE_URL`, Nominatim-backed search box
- **Search must be submit-on-enter**, not search-as-you-type. Nominatim's usage policy prohibits
autocomplete-style querying. Send an identifiable `User-Agent`/`Referer`.
- Live location toggle: `watchPosition()` (not one-shot), on/off, shows a dot at current position
while enabled. Requires a secure context — works because nginx terminates TLS.
- Marker/tag management: click map (or use current location) to drop a marker, set
name/category/color/description, toggle "shared with others". Own markers plus all shared markers
are shown. Category is a fixed picker plus a free-text "other", stored as free text.
- GPX panel:
- List files from the shared server folder (`GET /api/gpx`), select one to render on the map
- "Open from this device" — local file picker, parses and renders client-side immediately
- Optional "save to server" on a device-opened file, uploading into the shared folder. Surface the
returned filename if it was renamed for collision avoidance.
## 12. Open items for the build session
Design-level review is complete; everything in §§311 is settled. What remains are things that can
only be resolved against a compiler:
- Pin actual crate versions (`cargo add`).
- Confirm whether the chosen `tower-sessions` Postgres store consumes `SESSION_SECRET`; delete it
from `.env` if not (§7).
- Confirm `sqlx` feature-flag names resolve as expected for the pinned version.
- Decide whether `rust-embed`'s `debug-embed` feature is wanted, so debug and release builds behave
identically.
- Obtain an SMTP provider, or ship with the stub transport and wire it later.
- Verify the Postgres image version supports `gen_random_uuid()` natively; add the `pgcrypto`
extension to the first migration if not.

114
README.md Normal file
View file

@ -0,0 +1,114 @@
<!-- README.md -->
# Waymark — build and deploy
Single Rust binary + one rootless Postgres container. See `DESIGN.md` for the reasoning.
> **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.
## 1. Refresh dependency versions
`Cargo.toml` has plausible versions, but pin them properly:
```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
```
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
```bash
podman secret create mapserver-db-password - # type the password, then Ctrl-D
mkdir -p ~/.config/containers/systemd
cp deploy/postgres.container ~/.config/containers/systemd/
systemctl --user daemon-reload
systemctl --user start postgres
```
Migrations run automatically on startup — both `./migrations` and the session store's own.
## 3. Config
```bash
mkdir -p ~/.config/mapserver ~/gpx
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`.
Leave `SMTP_HOST` empty for now — reset links get logged instead of emailed, and the whole flow
is testable without a provider.
## 4. Run
```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
```
## 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.

777
frontend/app.js Normal file
View file

@ -0,0 +1,777 @@
// frontend/app.js
/* Waymark all URLs relative, resolved against the injected <base href>.
Never use a leading slash: it would escape BASE_PATH. */
'use strict';
// Swap this for a paid provider without rebuilding the binary.
// Tile source comes from GET /api/config (TILE_URL in .env) so it can be
// changed without rebuilding. These are only the fallback if that call fails.
const TILE_FALLBACK = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
const TILE_ATTRIB = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
let tileUrl = TILE_FALLBACK;
async function loadClientConfig() {
try {
const res = await fetch('api/config', { headers: { Accept: 'application/json' } });
if (res.ok) {
const cfg = await res.json();
if (cfg.tile_url) tileUrl = cfg.tile_url;
}
} catch (err) {
console.warn('using fallback tile source', err);
}
}
const NOMINATIM = 'https://nominatim.openstreetmap.org/search';
const COLOURS = ['#4E9C6B', '#D2467F', '#E2A93C', '#4E8FC9', '#B07BD4', '#D3574B'];
const DEFAULT_COLOUR = COLOURS[0];
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const state = {
me: null,
markers: [],
layers: new Map(), // marker id -> leaflet layer
map: null,
watchId: null,
hereLayer: null,
gpxLayer: null,
localGpx: null, // { name, text } awaiting an optional upload
editing: null, // marker being edited, or null for a new one
draftLatLng: null,
colour: DEFAULT_COLOUR,
};
/* ───────────────────────────── api ───────────────────────────── */
async function api(path, options = {}) {
const res = await fetch(path, {
credentials: 'same-origin',
headers: options.body instanceof FormData
? {}
: { 'Content-Type': 'application/json' },
...options,
});
if (res.status === 204) return null;
let payload = null;
try { payload = await res.json(); } catch { /* empty body */ }
if (!res.ok) {
const err = new Error((payload && payload.error) || 'Something went wrong.');
err.status = res.status;
throw err;
}
return payload;
}
const json = (body) => ({ body: JSON.stringify(body) });
/* ───────────────────────── small helpers ───────────────────────── */
function note(form, message, kind) {
const el = $('[data-note]', form);
if (!el) return;
el.textContent = message || '';
el.className = 'note' + (kind ? ' ' + kind : '');
}
let toastTimer;
function toast(message, bad) {
const el = $('#toast');
el.textContent = message;
el.className = 'toast' + (bad ? ' bad' : '');
el.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { el.hidden = true; }, 3600);
}
async function submitting(form, fn) {
const button = $('button[type=submit]', form);
if (button) button.disabled = true;
try {
await fn();
} finally {
if (button) button.disabled = false;
}
}
const fmtCoord = (lat, lon) =>
`${lat.toFixed(5)}, ${lon.toFixed(5)}`;
function fmtSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
function fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString();
}
/* ───────────────────────────── auth ───────────────────────────── */
function showPane(name) {
$$('.pane').forEach((p) => { p.hidden = p.dataset.pane !== name; });
const subs = {
login: 'Places worth going back to.',
register: 'You will need the registration token.',
forgot: 'We will email a link if that address is registered.',
reset: 'Choose a new password.',
};
$('#auth-sub').textContent = subs[name] || '';
}
$$('[data-goto]').forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
showPane(link.dataset.goto);
});
});
$('#form-login').addEventListener('submit', (e) => {
e.preventDefault();
const form = e.target;
submitting(form, async () => {
const data = Object.fromEntries(new FormData(form));
try {
state.me = await api('api/login', { method: 'POST', ...json(data) });
form.reset();
note(form, '');
enterApp();
} catch (err) {
note(form, err.status === 401
? 'That username or password is not right.'
: err.message, 'bad');
}
});
});
$('#form-register').addEventListener('submit', (e) => {
e.preventDefault();
const form = e.target;
submitting(form, async () => {
const data = Object.fromEntries(new FormData(form));
if (!data.email) data.email = null;
try {
await api('api/register', { method: 'POST', ...json(data) });
form.reset();
showPane('login');
note($('#form-login'), 'Account created. Log in below.', 'good');
} catch (err) {
note(form, err.status === 401
? 'That registration token is not right.'
: err.message, 'bad');
}
});
});
$('#form-forgot').addEventListener('submit', (e) => {
e.preventDefault();
const form = e.target;
submitting(form, async () => {
const data = Object.fromEntries(new FormData(form));
await api('api/password-reset/request', { method: 'POST', ...json(data) })
.catch(() => {});
// Deliberately the same message either way — the server does not reveal
// whether the address is registered, and neither does this.
note(form, 'If that address is registered, a reset link is on its way.', 'good');
form.reset();
});
});
$('#form-reset').addEventListener('submit', (e) => {
e.preventDefault();
const form = e.target;
const data = Object.fromEntries(new FormData(form));
if (data.new_password !== data.confirm) {
note(form, 'Those two passwords do not match.', 'bad');
return;
}
submitting(form, async () => {
try {
await api('api/password-reset/confirm', {
method: 'POST',
...json({ token: resetToken(), new_password: data.new_password }),
});
form.reset();
history.replaceState(null, '', location.pathname);
showPane('login');
note($('#form-login'), 'Password updated. Log in with it now.', 'good');
} catch (err) {
note(form, err.message, 'bad');
}
});
});
const resetToken = () => new URLSearchParams(location.search).get('token');
/* ───────────────────────────── map ───────────────────────────── */
function initMap() {
if (state.map) return;
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('move zoom', updateReadout);
updateReadout();
}
function updateReadout() {
if (!state.map) return;
const c = state.map.getCenter();
$('#readout-latlon').textContent = fmtCoord(c.lat, c.lng);
$('#readout-zoom').textContent = String(state.map.getZoom());
}
function pinIcon(colour) {
return L.divIcon({
className: '',
html: `<div class="pin" style="background:${colour || DEFAULT_COLOUR}"></div>`,
iconSize: [15, 15],
iconAnchor: [7, 14],
});
}
/* ─────────────────────────── markers ─────────────────────────── */
async function loadMarkers() {
state.markers = await api('api/markers');
renderMarkers();
}
function renderMarkers() {
state.layers.forEach((layer) => state.map.removeLayer(layer));
state.layers.clear();
const list = $('#marker-list');
list.innerHTML = '';
$('#marker-empty').hidden = state.markers.length > 0;
state.markers.forEach((m) => {
const mine = m.owner_id === state.me.id;
const layer = L.marker([m.lat, m.lon], { icon: pinIcon(m.color) }).addTo(state.map);
layer.bindPopup(
`<b>${escapeHtml(m.name)}</b>` +
(m.category ? `<span>${escapeHtml(m.category)}</span><br>` : '') +
(m.description ? `${escapeHtml(m.description)}<br>` : '') +
`<span class="mono">${fmtCoord(m.lat, m.lon)}</span>` +
(mine ? '' : '<br><span class="mono">shared with you</span>')
);
if (mine) layer.on('dblclick', () => openMarkerSheet(m));
state.layers.set(m.id, layer);
const li = document.createElement('li');
li.innerHTML =
`<span class="dot" style="background:${m.color || DEFAULT_COLOUR}"></span>` +
`<span class="grow"><span class="name"></span>` +
`<span class="meta">${fmtCoord(m.lat, m.lon)}</span></span>`;
$('.name', li).textContent = m.name;
if (m.is_shared) {
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = mine ? 'shared' : 'theirs';
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);
});
li.appendChild(go);
list.appendChild(li);
});
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s == null ? '' : s;
return div.innerHTML;
}
function buildSwatches() {
const wrap = $('#swatches');
wrap.innerHTML = '';
COLOURS.forEach((c) => {
const b = document.createElement('button');
b.type = 'button';
b.style.background = c;
b.setAttribute('aria-label', 'Colour ' + c);
b.setAttribute('aria-pressed', String(c === state.colour));
b.addEventListener('click', () => {
state.colour = c;
$$('#swatches button').forEach((x) =>
x.setAttribute('aria-pressed', String(x.style.background === b.style.background)));
});
wrap.appendChild(b);
});
}
function openSheet(id) {
$('#scrim').hidden = false;
$(id).hidden = false;
}
function closeSheets() {
$('#scrim').hidden = true;
$('#marker-sheet').hidden = true;
$('#account-sheet').hidden = true;
state.editing = null;
state.draftLatLng = null;
}
$('#scrim').addEventListener('click', closeSheets);
$$('[data-close-sheet]').forEach((b) => b.addEventListener('click', closeSheets));
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeSheets(); });
function openMarkerSheet(marker, latlng) {
const form = $('#marker-form');
form.reset();
note(form, '');
state.editing = marker || null;
state.draftLatLng = marker ? { lat: marker.lat, lng: marker.lon } : latlng;
state.colour = (marker && marker.color) || DEFAULT_COLOUR;
$('#sheet-title').textContent = marker ? 'Edit place' : 'New place';
$('#marker-coords').textContent =
fmtCoord(state.draftLatLng.lat, state.draftLatLng.lng);
$('#marker-delete').hidden = !marker;
if (marker) {
form.name.value = marker.name;
form.description.value = marker.description || '';
form.is_shared.checked = marker.is_shared;
const known = Array.from(form.category.options).some((o) => o.value === marker.category);
if (marker.category && !known) {
form.category.value = '__other';
form.category_other.value = marker.category;
} else {
form.category.value = marker.category || '';
}
}
$('#category-other-wrap').hidden = form.category.value !== '__other';
buildSwatches();
openSheet('#marker-sheet');
form.name.focus();
}
$('#marker-form').category.addEventListener('change', (e) => {
$('#category-other-wrap').hidden = e.target.value !== '__other';
});
$('#marker-form').addEventListener('submit', (e) => {
e.preventDefault();
const form = e.target;
submitting(form, async () => {
const data = Object.fromEntries(new FormData(form));
const category = data.category === '__other'
? (data.category_other || '').trim()
: data.category;
const body = {
name: data.name,
description: data.description || null,
lat: state.draftLatLng.lat,
lon: state.draftLatLng.lng,
category: category || null,
color: state.colour,
is_shared: form.is_shared.checked,
};
try {
if (state.editing) {
await api('api/markers/' + state.editing.id, { method: 'PUT', ...json(body) });
} else {
await api('api/markers', { method: 'POST', ...json(body) });
}
closeSheets();
await loadMarkers();
toast('Place saved');
} catch (err) {
note(form, err.message, 'bad');
}
});
});
$('#marker-delete').addEventListener('click', async () => {
if (!state.editing) return;
if (!confirm(`Delete "${state.editing.name}"?`)) return;
try {
await api('api/markers/' + state.editing.id, { method: 'DELETE' });
closeSheets();
await loadMarkers();
toast('Place deleted');
} catch (err) {
toast(err.message, true);
}
});
/* ─────────────────────────── search ─────────────────────────── */
// Submit-on-enter only. Nominatim's usage policy prohibits autocomplete-style
// search-as-you-type, so there is deliberately no keystroke handler here.
$('#search').addEventListener('submit', async (e) => {
e.preventDefault();
const q = e.target.q.value.trim();
if (!q) return;
try {
const url = `${NOMINATIM}?format=jsonv2&limit=1&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(','));
} catch {
toast('Search is unavailable right now', true);
}
});
/* ────────────────────── live location ────────────────────── */
$('#locate').addEventListener('click', () => {
const button = $('#locate');
if (state.watchId !== null) {
navigator.geolocation.clearWatch(state.watchId);
state.watchId = null;
if (state.hereLayer) { state.map.removeLayer(state.hereLayer); state.hereLayer = null; }
button.setAttribute('aria-pressed', 'false');
return;
}
if (!navigator.geolocation) {
toast('This browser has no location support', true);
return;
}
// watchPosition, not getCurrentPosition — the dot should follow you.
// Requires a secure context, so it will not run over plain HTTP.
state.watchId = navigator.geolocation.watchPosition(
(pos) => {
const { latitude, longitude } = pos.coords;
if (!state.hereLayer) {
state.hereLayer = L.marker([latitude, longitude], {
icon: L.divIcon({ className: '', html: '<div class="here"></div>', iconSize: [15, 15] }),
interactive: false,
}).addTo(state.map);
state.map.setView([latitude, longitude], Math.max(state.map.getZoom(), 15));
} else {
state.hereLayer.setLatLng([latitude, longitude]);
}
},
(err) => {
toast(err.code === err.PERMISSION_DENIED
? 'Location permission was refused'
: 'Could not get a location fix', true);
button.setAttribute('aria-pressed', 'false');
state.watchId = null;
},
{ enableHighAccuracy: true, maximumAge: 5000, timeout: 20000 }
);
button.setAttribute('aria-pressed', 'true');
});
/* ─────────────────────────── gpx ─────────────────────────── */
function parseGpx(text) {
const doc = new DOMParser().parseFromString(text, 'application/xml');
if (doc.querySelector('parsererror')) throw new Error('That file is not valid GPX.');
const tracks = [];
doc.querySelectorAll('trkseg').forEach((seg) => {
const pts = Array.from(seg.querySelectorAll('trkpt'))
.map((p) => [parseFloat(p.getAttribute('lat')), parseFloat(p.getAttribute('lon'))])
.filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b));
if (pts.length) tracks.push(pts);
});
const routePts = Array.from(doc.querySelectorAll('rte > rtept'))
.map((p) => [parseFloat(p.getAttribute('lat')), parseFloat(p.getAttribute('lon'))])
.filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b));
if (routePts.length) tracks.push(routePts);
const waypoints = Array.from(doc.querySelectorAll('gpx > wpt')).map((p) => ({
lat: parseFloat(p.getAttribute('lat')),
lon: parseFloat(p.getAttribute('lon')),
name: (p.querySelector('name') || {}).textContent || '',
}));
if (!tracks.length && !waypoints.length) throw new Error('That file has no track in it.');
return { tracks, waypoints };
}
function drawGpx(text, label) {
const { tracks, waypoints } = parseGpx(text);
if (state.gpxLayer) state.map.removeLayer(state.gpxLayer);
const group = L.layerGroup();
tracks.forEach((pts) => {
L.polyline(pts, { color: '#D2467F', weight: 4, opacity: 0.9 }).addTo(group);
});
waypoints.forEach((w) => {
L.circleMarker([w.lat, w.lon], {
radius: 4, color: '#E2A93C', fillColor: '#E2A93C', fillOpacity: 1,
}).bindPopup(escapeHtml(w.name || 'Waypoint')).addTo(group);
});
group.addTo(state.map);
state.gpxLayer = group;
const bounds = L.latLngBounds([]);
tracks.forEach((pts) => pts.forEach((p) => bounds.extend(p)));
waypoints.forEach((w) => bounds.extend([w.lat, w.lon]));
if (bounds.isValid()) state.map.fitBounds(bounds, { padding: [40, 40] });
toast('Showing ' + label);
}
async function loadGpxList() {
const files = await api('api/gpx');
const list = $('#gpx-list');
list.innerHTML = '';
$('#gpx-empty').hidden = files.length > 0;
files.forEach((f) => {
const li = document.createElement('li');
li.innerHTML =
'<span class="grow"><span class="name"></span>' +
`<span class="meta">${fmtSize(f.size)} · ${fmtDate(f.modified)}</span></span>`;
$('.name', li).textContent = f.name;
const show = document.createElement('button');
show.className = 'link';
show.textContent = 'Show';
show.addEventListener('click', async () => {
try {
const res = await fetch('api/gpx/file/' + encodeURIComponent(f.name), {
credentials: 'same-origin',
});
if (!res.ok) throw new Error('Could not read that file.');
drawGpx(await res.text(), f.name);
} catch (err) {
toast(err.message, true);
}
});
li.appendChild(show);
list.appendChild(li);
});
}
$('#gpx-local').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
drawGpx(text, file.name);
state.localGpx = { name: file.name, text };
$('#local-name').textContent = file.name;
$('#local-actions').hidden = false;
} catch (err) {
toast(err.message, true);
state.localGpx = null;
$('#local-actions').hidden = true;
}
e.target.value = '';
});
$('#gpx-save').addEventListener('click', async () => {
if (!state.localGpx) return;
const button = $('#gpx-save');
button.disabled = true;
try {
const body = new FormData();
body.append('file', new Blob([state.localGpx.text], { type: 'application/gpx+xml' }),
state.localGpx.name);
const result = await api('api/gpx/upload', { method: 'POST', body });
// The server may have renamed it to avoid a collision, so report what it used.
toast(result.name === state.localGpx.name
? `Saved as ${result.name}`
: `Saved as ${result.name} — that name was taken`);
state.localGpx = null;
$('#local-actions').hidden = true;
await loadGpxList();
} catch (err) {
toast(err.status === 413 ? 'That file is too large to upload.' : err.message, true);
} finally {
button.disabled = false;
}
});
$('#gpx-clear').addEventListener('click', () => {
if (state.gpxLayer) { state.map.removeLayer(state.gpxLayer); state.gpxLayer = null; }
});
/* ─────────────────────── panels & account ─────────────────────── */
$$('.tab').forEach((tab) => {
tab.addEventListener('click', () => {
const target = tab.dataset.panel;
$$('.tab').forEach((t) => t.setAttribute('aria-pressed', String(t === tab)));
$('#panel-places').hidden = target !== 'places';
$('#panel-routes').hidden = target !== 'routes';
if (target === 'routes') loadGpxList().catch(() => {});
});
});
$('#close-places').addEventListener('click', () => {
$('#panel-places').hidden = true;
$$('.tab').forEach((t) => t.setAttribute('aria-pressed', 'false'));
});
$('#close-routes').addEventListener('click', () => {
$('#panel-routes').hidden = true;
$$('.tab').forEach((t) => t.setAttribute('aria-pressed', 'false'));
});
const accountBtn = $('#account-btn');
const accountMenu = $('#account-menu');
accountBtn.addEventListener('click', () => {
const open = accountMenu.hidden;
accountMenu.hidden = !open;
accountBtn.setAttribute('aria-expanded', String(open));
});
document.addEventListener('click', (e) => {
if (!accountMenu.hidden && !e.target.closest('.account')) {
accountMenu.hidden = true;
accountBtn.setAttribute('aria-expanded', 'false');
}
});
$$('#account-menu button').forEach((b) => {
b.addEventListener('click', async () => {
accountMenu.hidden = true;
accountBtn.setAttribute('aria-expanded', 'false');
if (b.dataset.action === 'logout') {
await api('api/logout', { method: 'POST' }).catch(() => {});
location.reload();
return;
}
$('#current-email').textContent = state.me.email || 'Not set';
$('#email-form').reset();
$('#password-form').reset();
note($('#email-form'), '');
note($('#password-form'), '');
$('#email-form').new_email.value = state.me.email || '';
openSheet('#account-sheet');
});
});
$('#email-form').addEventListener('submit', (e) => {
e.preventDefault();
const form = e.target;
submitting(form, async () => {
const data = Object.fromEntries(new FormData(form));
try {
await api('api/me/email', {
method: 'POST',
...json({
current_password: data.current_password,
new_email: data.new_email.trim() || null,
}),
});
state.me = await api('api/me');
$('#current-email').textContent = state.me.email || 'Not set';
form.current_password.value = '';
note(form, 'Email updated.', 'good');
} catch (err) {
note(form, err.status === 401 ? 'Incorrect password.'
: err.status === 409 ? 'That email is already in use.'
: err.message, 'bad');
}
});
});
$('#password-form').addEventListener('submit', (e) => {
e.preventDefault();
const form = e.target;
const data = Object.fromEntries(new FormData(form));
if (data.new_password !== data.confirm) {
note(form, 'Those two passwords do not match.', 'bad');
return;
}
submitting(form, async () => {
try {
await api('api/me/password', {
method: 'POST',
...json({
current_password: data.current_password,
new_password: data.new_password,
}),
});
form.reset();
// No redirect to login — existing sessions stay valid by design.
note(form, 'Password updated.', 'good');
} catch (err) {
note(form, err.status === 401 ? 'Incorrect password.' : err.message, 'bad');
}
});
});
/* ─────────────────────────── boot ─────────────────────────── */
async function enterApp() {
$('#auth').hidden = true;
$('#app').hidden = false;
$('#account-name').textContent = state.me.username;
await loadClientConfig();
initMap();
state.map.invalidateSize();
await loadMarkers();
await loadGpxList().catch(() => {});
}
async function boot() {
if (resetToken()) {
$('#auth').hidden = false;
showPane('reset');
return;
}
try {
state.me = await api('api/me');
await enterApp();
} catch {
$('#auth').hidden = false;
showPane('login');
}
}
boot();

215
frontend/index.html Normal file
View file

@ -0,0 +1,215 @@
<!-- frontend/index.html -->
<!DOCTYPE html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<!-- Injected by the backend from BASE_PATH. Trailing slash is mandatory,
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>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- ───────────────────────────────── auth ───────────────────────────────── -->
<section id="auth" class="auth" hidden>
<div class="auth-card">
<header class="auth-head">
<span class="trig" aria-hidden="true"></span>
<h1>Waymark</h1>
<p class="auth-sub" id="auth-sub">Places worth going back to.</p>
</header>
<form class="pane" id="form-login" data-pane="login">
<label>Username or email<input name="username_or_email" autocomplete="username" required></label>
<label>Password<input name="password" type="password" autocomplete="current-password" required></label>
<p class="note" data-note></p>
<button class="primary" type="submit">Log in</button>
<p class="switch">
<a href="#" data-goto="forgot">Forgotten password</a>
<a href="#" data-goto="register">Create an account</a>
</p>
</form>
<form class="pane" id="form-register" data-pane="register" hidden>
<label>Username<input name="username" autocomplete="username" required></label>
<label>Email <span class="opt">optional — only used to reset your password</span>
<input name="email" type="email" autocomplete="email"></label>
<label>Password <span class="opt">at least 8 characters</span>
<input name="password" type="password" autocomplete="new-password" required minlength="8"></label>
<label>Registration token<input name="reg_token" required></label>
<p class="note" data-note></p>
<button class="primary" type="submit">Create account</button>
<p class="switch"><a href="#" data-goto="login">Back to log in</a></p>
</form>
<form class="pane" id="form-forgot" data-pane="forgot" hidden>
<label>Email<input name="email" type="email" autocomplete="email" required></label>
<p class="note" data-note></p>
<button class="primary" type="submit">Send reset link</button>
<p class="switch"><a href="#" data-goto="login">Back to log in</a></p>
</form>
<form class="pane" id="form-reset" data-pane="reset" hidden>
<label>New password <span class="opt">at least 8 characters</span>
<input name="new_password" type="password" autocomplete="new-password" required minlength="8"></label>
<label>Confirm new password<input name="confirm" type="password" autocomplete="new-password" required></label>
<p class="note" data-note></p>
<button class="primary" type="submit">Set new password</button>
<p class="switch"><a href="#" data-goto="login">Back to log in</a></p>
</form>
</div>
</section>
<!-- ───────────────────────────────── app ───────────────────────────────── -->
<main id="app" class="app" hidden>
<div id="map" role="application" aria-label="Map"></div>
<header class="bar">
<span class="trig small" aria-hidden="true"></span>
<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>
</form>
<button id="locate" class="ghost" aria-pressed="false">Live location</button>
<div class="account">
<button id="account-btn" class="ghost" aria-expanded="false" aria-haspopup="menu">
<span id="account-name"></span>
</button>
<div id="account-menu" class="menu" role="menu" hidden>
<button role="menuitem" data-action="settings">Account settings</button>
<button role="menuitem" data-action="logout">Log out</button>
</div>
</div>
</header>
<!-- signature: the map-margin grid readout -->
<div class="readout" id="readout" aria-live="off">
<span class="readout-label">CENTRE</span>
<span class="readout-val" id="readout-latlon"></span>
<span class="readout-label">ZOOM</span>
<span class="readout-val" id="readout-zoom"></span>
</div>
<aside class="rail">
<button class="tab" data-panel="places" aria-pressed="true">Places</button>
<button class="tab" data-panel="routes" aria-pressed="false">Routes</button>
</aside>
<!-- places -->
<section class="drawer" id="panel-places">
<header class="drawer-head">
<h2>Places</h2>
<button class="link" id="close-places" aria-label="Hide places">Hide</button>
</header>
<p class="hint">Click the map to drop a new place.</p>
<ul class="list" id="marker-list"></ul>
<p class="empty" id="marker-empty" hidden>Nothing saved yet. Click anywhere on the map to start.</p>
</section>
<!-- routes -->
<section class="drawer" id="panel-routes" hidden>
<header class="drawer-head">
<h2>Routes</h2>
<button class="link" id="close-routes" aria-label="Hide routes">Hide</button>
</header>
<div class="row">
<label class="filebtn">
Open from this device
<input type="file" id="gpx-local" accept=".gpx,application/gpx+xml" hidden>
</label>
<button class="ghost" id="gpx-clear">Clear from map</button>
</div>
<div id="local-actions" class="local-actions" hidden>
<span id="local-name" class="mono"></span>
<button class="primary small" id="gpx-save">Save to shared folder</button>
</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>
<p class="note" id="gpx-note"></p>
</section>
<!-- marker editor -->
<div class="scrim" id="scrim" hidden></div>
<section class="sheet" id="marker-sheet" hidden role="dialog" aria-modal="true" aria-labelledby="sheet-title">
<header class="drawer-head">
<h2 id="sheet-title">New place</h2>
<button class="link" data-close-sheet>Close</button>
</header>
<form id="marker-form">
<p class="coords mono" id="marker-coords"></p>
<label>Name<input name="name" required maxlength="120"></label>
<label>Kind
<select name="category">
<option value="">Unclassified</option>
<option value="campsite">Campsite</option>
<option value="water">Water</option>
<option value="viewpoint">Viewpoint</option>
<option value="parking">Parking</option>
<option value="shelter">Shelter</option>
<option value="food">Food</option>
<option value="hazard">Hazard</option>
<option value="__other">Other…</option>
</select>
</label>
<label id="category-other-wrap" hidden>Kind name<input name="category_other" maxlength="40"></label>
<label>Colour
<span class="swatches" id="swatches"></span>
</label>
<label>Notes<textarea name="description" rows="3" maxlength="2000"></textarea></label>
<label class="check">
<input type="checkbox" name="is_shared">
<span>Share with everyone</span>
</label>
<p class="note" data-note></p>
<div class="actions">
<button type="submit" class="primary">Save place</button>
<button type="button" class="danger" id="marker-delete" hidden>Delete</button>
</div>
</form>
</section>
<!-- account settings -->
<section class="sheet" id="account-sheet" hidden role="dialog" aria-modal="true" aria-labelledby="account-title">
<header class="drawer-head">
<h2 id="account-title">Account settings</h2>
<button class="link" data-close-sheet>Close</button>
</header>
<form id="email-form" class="block">
<h3 class="sub">Change email</h3>
<p class="current">Current: <span id="current-email" class="mono">Not set</span></p>
<label>New email <span class="opt">leave empty to remove it and give up password recovery</span>
<input name="new_email" type="email"></label>
<label>Current password<input name="current_password" type="password" autocomplete="current-password" required></label>
<p class="note" data-note></p>
<button class="primary" type="submit">Save email</button>
</form>
<form id="password-form" class="block">
<h3 class="sub">Change password</h3>
<label>Current password<input name="current_password" type="password" autocomplete="current-password" required></label>
<label>New password <span class="opt">at least 8 characters</span>
<input name="new_password" type="password" autocomplete="new-password" required minlength="8"></label>
<label>Confirm new password<input name="confirm" type="password" autocomplete="new-password" required></label>
<p class="note" data-note></p>
<button class="primary" type="submit">Save password</button>
</form>
</section>
<div class="toast" id="toast" hidden role="status"></div>
</main>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="app.js"></script>
</body>
</html>

463
frontend/style.css Normal file
View file

@ -0,0 +1,463 @@
/* frontend/style.css */
/*
Waymark chrome for a map, not a dashboard.
Palette taken from Ordnance Survey sheet conventions:
rights-of-way green, road-fill magenta, contour bistre.
*/
:root {
--ink: #0F1A17; /* deep bottle-green black */
--panel: #16231F;
--panel-2: #1E2E29;
--hair: #2C433C; /* hairline rules */
--paper: #E7EDE7; /* cool pale sage */
--paper-dim: #9DB0A8;
--moss: #4E9C6B; /* rights of way — primary action */
--moss-lift: #5FB47D;
--waymark: #D2467F; /* road fill — shared / accent */
--sun: #E2A93C; /* live location, warnings */
--alarm: #D3574B;
--r: 3px; /* map furniture is squared off, not pill-shaped */
--shadow: 0 8px 28px rgba(0,0,0,.38);
--sans: ui-sans-serif, "Inter", "Segoe UI", Roboto, system-ui, sans-serif;
--mono: ui-monospace, "SFMono-Regular", "JetBrains Mono", "Consolas", monospace;
}
* { box-sizing: border-box; }
html, body {
margin: 0; height: 100%;
font-family: var(--sans);
background: var(--ink);
color: var(--paper);
-webkit-text-size-adjust: 100%;
}
[hidden] { display: none !important; }
:focus-visible {
outline: 2px solid var(--sun);
outline-offset: 2px;
}
/* ── trig point: a small pyramid glyph, the app's only ornament ── */
.trig {
display: block;
width: 34px; height: 30px;
background: var(--moss);
clip-path: polygon(50% 0, 100% 100%, 0 100%);
margin: 0 auto 14px;
}
.trig.small { width: 20px; height: 17px; margin: 0 4px 0 0; flex: none; }
/* ───────────────────────── auth ───────────────────────── */
.auth {
min-height: 100%;
display: grid;
place-items: center;
padding: 24px;
/* faint contour rings, drawn not imported */
background:
radial-gradient(circle at 22% 30%, transparent 0 78px, rgba(78,156,107,.10) 78px 79px, transparent 79px),
radial-gradient(circle at 22% 30%, transparent 0 128px, rgba(78,156,107,.08) 128px 129px, transparent 129px),
radial-gradient(circle at 22% 30%, transparent 0 186px, rgba(78,156,107,.06) 186px 187px, transparent 187px),
radial-gradient(circle at 80% 74%, transparent 0 96px, rgba(210,70,127,.09) 96px 97px, transparent 97px),
radial-gradient(circle at 80% 74%, transparent 0 154px, rgba(210,70,127,.06) 154px 155px, transparent 155px),
var(--ink);
}
.auth-card {
width: min(420px, 100%);
background: var(--panel);
border: 1px solid var(--hair);
border-radius: var(--r);
padding: 34px 30px 30px;
box-shadow: var(--shadow);
}
.auth-head { text-align: center; margin-bottom: 26px; }
.auth-head h1 {
margin: 0;
font-size: 30px;
font-weight: 600;
letter-spacing: .16em;
text-transform: uppercase;
}
.auth-sub {
margin: 8px 0 0;
color: var(--paper-dim);
font-size: 13px;
}
/* ───────────────────────── forms ───────────────────────── */
label {
display: block;
margin-bottom: 15px;
font-size: 12px;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--paper-dim);
}
label .opt {
text-transform: none;
letter-spacing: 0;
font-size: 11px;
color: var(--paper-dim);
opacity: .8;
}
input[type=text], input[type=password], input[type=email],
input[type=search], input:not([type]), select, textarea {
display: block;
width: 100%;
margin-top: 6px;
padding: 10px 11px;
background: var(--ink);
border: 1px solid var(--hair);
border-radius: var(--r);
color: var(--paper);
font: 15px/1.3 var(--sans);
text-transform: none;
letter-spacing: 0;
}
textarea { resize: vertical; }
input:focus, select:focus, textarea:focus {
border-color: var(--moss);
outline: none;
}
label.check {
display: flex;
align-items: center;
gap: 9px;
text-transform: none;
letter-spacing: 0;
font-size: 14px;
color: var(--paper);
}
label.check input { width: auto; margin: 0; }
button {
font: 500 14px var(--sans);
border-radius: var(--r);
border: 1px solid transparent;
padding: 10px 15px;
cursor: pointer;
color: var(--paper);
background: var(--panel-2);
transition: background .12s ease, border-color .12s ease;
}
button:hover { background: var(--hair); }
button[disabled] { opacity: .55; cursor: progress; }
button.primary { background: var(--moss); color: #07140D; font-weight: 600; }
button.primary:hover { background: var(--moss-lift); }
button.danger { background: transparent; border-color: var(--alarm); color: var(--alarm); }
button.danger:hover { background: rgba(211,87,75,.14); }
button.ghost { background: transparent; border-color: var(--hair); }
button.ghost:hover { background: var(--panel-2); }
button.ghost[aria-pressed=true] { border-color: var(--sun); color: var(--sun); }
button.small { padding: 7px 11px; font-size: 13px; }
button.link {
background: none; border: none; padding: 4px;
color: var(--paper-dim); font-size: 13px;
}
button.link:hover { color: var(--paper); background: none; }
.pane .primary { width: 100%; margin-top: 6px; }
.switch {
display: flex;
justify-content: space-between;
gap: 12px;
margin: 16px 0 0;
font-size: 13px;
}
.switch a { color: var(--paper-dim); text-decoration: none; }
.switch a:hover { color: var(--moss-lift); text-decoration: underline; }
.note { margin: 10px 0 0; font-size: 13px; min-height: 1em; color: var(--paper-dim); }
.note.bad { color: var(--alarm); }
.note.good { color: var(--moss-lift); }
/* ───────────────────────── app shell ───────────────────────── */
.app { position: fixed; inset: 0; }
#map { position: absolute; inset: 0; background: var(--ink); }
.bar {
position: absolute;
top: 0; left: 0; right: 0;
z-index: 500;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: linear-gradient(var(--ink) 55%, rgba(15,26,23,0));
padding-bottom: 22px;
}
.search { display: flex; gap: 6px; flex: 1 1 auto; max-width: 420px; }
.search input { margin-top: 0; background: var(--panel); }
.search button { flex: none; background: var(--panel); border-color: var(--hair); }
.account { position: relative; margin-left: auto; }
.menu {
position: absolute;
right: 0; top: calc(100% + 6px);
min-width: 180px;
background: var(--panel);
border: 1px solid var(--hair);
border-radius: var(--r);
box-shadow: var(--shadow);
padding: 5px;
display: flex;
flex-direction: column;
}
.menu button {
background: none; border: none; text-align: left;
padding: 9px 10px; font-size: 14px;
}
.menu button:hover { background: var(--panel-2); }
/* signature: map-margin grid readout */
.readout {
position: absolute;
left: 12px; bottom: 12px;
z-index: 500;
display: flex;
align-items: baseline;
gap: 8px;
padding: 6px 11px;
background: rgba(15,26,23,.86);
border: 1px solid var(--hair);
border-left: 3px solid var(--moss);
border-radius: var(--r);
font-family: var(--mono);
font-size: 12px;
backdrop-filter: blur(3px);
}
.readout-label { color: var(--paper-dim); font-size: 10px; letter-spacing: .14em; }
.readout-val { color: var(--paper); }
.rail {
position: absolute;
top: 62px; right: 12px;
z-index: 500;
display: flex;
gap: 6px;
}
.tab {
background: var(--panel);
border: 1px solid var(--hair);
font-size: 13px;
padding: 8px 13px;
}
.tab[aria-pressed=true] { border-color: var(--moss); color: var(--moss-lift); }
/* ───────────────────────── drawers & sheets ───────────────────────── */
.drawer {
position: absolute;
top: 104px; right: 12px; bottom: 12px;
width: min(330px, calc(100vw - 24px));
z-index: 500;
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
overflow-y: auto;
background: rgba(22,35,31,.96);
border: 1px solid var(--hair);
border-radius: var(--r);
box-shadow: var(--shadow);
backdrop-filter: blur(6px);
}
.drawer-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.drawer-head h2 {
margin: 0; font-size: 13px; font-weight: 600;
letter-spacing: .14em; text-transform: uppercase;
}
.sub {
margin: 6px 0 0; font-size: 11px; font-weight: 600;
letter-spacing: .14em; text-transform: uppercase; color: var(--paper-dim);
}
.hint, .empty { margin: 0; font-size: 13px; color: var(--paper-dim); }
.list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.list li {
display: flex;
align-items: center;
gap: 9px;
padding: 9px 4px;
border-bottom: 1px solid var(--hair);
font-size: 14px;
}
.list li:last-child { border-bottom: none; }
.list .dot {
width: 9px; height: 9px; border-radius: 50%;
background: var(--moss); flex: none;
}
.list .grow { flex: 1 1 auto; min-width: 0; }
.list .name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.list .meta { display: block; font-family: var(--mono); font-size: 11px; color: var(--paper-dim); }
.badge {
font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
color: var(--waymark); border: 1px solid var(--waymark);
border-radius: var(--r); padding: 1px 5px; flex: none;
}
.row { display: flex; gap: 8px; flex-wrap: wrap; }
.filebtn {
margin: 0; padding: 10px 15px;
background: var(--panel-2); border: 1px solid var(--hair);
border-radius: var(--r); cursor: pointer;
font-size: 13px; letter-spacing: 0; text-transform: none;
color: var(--paper);
}
.filebtn:hover { background: var(--hair); }
.local-actions {
display: flex; align-items: center; gap: 9px; flex-wrap: wrap;
padding: 9px; border: 1px dashed var(--hair); border-radius: var(--r);
}
.mono { font-family: var(--mono); font-size: 12px; }
.scrim {
position: absolute; inset: 0; z-index: 900;
background: rgba(8,14,12,.55);
}
.sheet {
position: absolute;
z-index: 1000;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: min(430px, calc(100vw - 24px));
max-height: calc(100vh - 48px);
overflow-y: auto;
padding: 18px;
background: var(--panel);
border: 1px solid var(--hair);
border-radius: var(--r);
box-shadow: var(--shadow);
}
.sheet form { margin-top: 14px; }
.block + .block { margin-top: 26px; padding-top: 22px; border-top: 1px solid var(--hair); }
.current { margin: 8px 0 14px; font-size: 13px; color: var(--paper-dim); }
.coords {
margin: 0 0 14px; padding: 7px 9px;
background: var(--ink); border-left: 3px solid var(--moss);
border-radius: var(--r); color: var(--paper-dim);
}
.actions { display: flex; gap: 9px; margin-top: 6px; }
.actions .primary { flex: 1 1 auto; }
.swatches { display: flex; gap: 7px; margin-top: 7px; }
.swatches button {
width: 26px; height: 26px; padding: 0;
border-radius: 50%; border: 2px solid transparent;
}
.swatches button[aria-pressed=true] { border-color: var(--paper); }
.toast {
position: absolute;
left: 50%; bottom: 22px;
transform: translateX(-50%);
z-index: 1100;
padding: 10px 16px;
background: var(--panel);
border: 1px solid var(--hair);
border-left: 3px solid var(--moss);
border-radius: var(--r);
font-size: 14px;
box-shadow: var(--shadow);
}
.toast.bad { border-left-color: var(--alarm); }
/* ───────────────────────── leaflet overrides ───────────────────────── */
.leaflet-container { background: var(--ink); font-family: var(--sans); }
.leaflet-control-attribution {
background: rgba(15,26,23,.82) !important;
color: var(--paper-dim) !important;
font-size: 10px !important;
}
.leaflet-control-attribution a { color: var(--paper-dim) !important; }
.leaflet-bar a {
background: var(--panel) !important;
color: var(--paper) !important;
border-bottom-color: var(--hair) !important;
}
.leaflet-bar a:hover { background: var(--panel-2) !important; }
.leaflet-popup-content-wrapper, .leaflet-popup-tip {
background: var(--panel);
color: var(--paper);
border-radius: var(--r);
}
.leaflet-popup-content { margin: 11px 13px; font-size: 14px; }
.leaflet-popup-content b { display: block; margin-bottom: 3px; }
.leaflet-popup-content .mono { color: var(--paper-dim); }
.pin {
width: 15px; height: 15px;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
border: 2px solid rgba(0,0,0,.42);
}
.here {
width: 15px; height: 15px; border-radius: 50%;
background: var(--sun);
box-shadow: 0 0 0 5px rgba(226,169,60,.26);
}
/* ───────────────────────── responsive ───────────────────────── */
@media (max-width: 640px) {
.bar { flex-wrap: wrap; }
.search { order: 3; flex-basis: 100%; max-width: none; }
.drawer { top: auto; left: 12px; right: 12px; bottom: 12px; width: auto; max-height: 52vh; }
.readout { display: none; }
.rail { top: 108px; }
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}

39
mapserver.service Normal file
View file

@ -0,0 +1,39 @@
# deploy/mapserver.service
# ~/.config/systemd/user/mapserver.service
#
# systemctl --user daemon-reload
# systemctl --user enable --now mapserver
#
# Requires: loginctl enable-linger <user>
# Without linger, user units do not start at boot and are killed on logout.
[Unit]
Description=Waymark mapping server
# Quadlet generates postgres.service from postgres.container.
# Both must be USER units for this ordering to work.
After=postgres.service
Wants=postgres.service
[Service]
Type=simple
EnvironmentFile=%h/.config/mapserver/.env
WorkingDirectory=%h
ExecStart=%h/.local/bin/rs_maps
Restart=on-failure
RestartSec=5
# After= does not mean Postgres is accepting connections; the binary retries
# its initial pool connection with backoff, so this only catches hard failures.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=%h/gpx
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
MemoryDenyWriteExecute=true
[Install]
WantedBy=default.target

36
migrations/0001_init.sql Normal file
View file

@ -0,0 +1,36 @@
-- migrations/0001_init.sql
-- gen_random_uuid() is core since PostgreSQL 13. Kept for older images.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL,
email TEXT,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
reset_token_hash TEXT,
reset_token_expires TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS users_username_key
ON users (lower(username));
CREATE UNIQUE INDEX IF NOT EXISTS users_email_key
ON users (lower(email)) WHERE email IS NOT NULL;
CREATE TABLE IF NOT EXISTS markers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
lat DOUBLE PRECISION NOT NULL,
lon DOUBLE PRECISION NOT NULL,
category TEXT,
color TEXT,
is_shared BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS markers_owner_idx ON markers (owner_id);
CREATE INDEX IF NOT EXISTS markers_shared_idx ON markers (id) WHERE is_shared;

36
nginx-snippet.conf Normal file
View file

@ -0,0 +1,36 @@
# deploy/nginx-snippet.conf
# Add inside your existing TLS server block.
#
# The prefix is passed through UNCHANGED: no trailing slash on proxy_pass,
# no rewrite. Paths are then identical on both sides, so there is nothing
# to keep in sync between nginx and BASE_PATH.
# Without this, https://host/maps (no trailing slash) never matches the block
# below. It would also break <base href="/maps/">, since relative URLs would
# resolve against / instead.
location = /maps {
return 301 /maps/;
}
location /maps/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# GPX uploads: nginx defaults to client_max_body_size 1m, which rejects a
# real track before the app ever sees it. Raised here only, not globally.
location /maps/api/gpx/upload {
client_max_body_size 25m;
proxy_request_buffering off;
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

38
postgres.container Normal file
View file

@ -0,0 +1,38 @@
# deploy/postgres.container
# ~/.config/containers/systemd/postgres.container
#
# Rootless. Generates postgres.service on daemon-reload.
#
# podman secret create mapserver-db-password -
# systemctl --user daemon-reload
# systemctl --user start postgres
[Unit]
Description=PostgreSQL for mapserver
[Container]
Image=docker.io/library/postgres:17-alpine
ContainerName=mapserver-db
# Named volume, NOT a host bind mount: rootless podman maps container UIDs
# into the subuid range, and bind mounts hit permission errors that named
# volumes avoid entirely.
Volume=mapserver-pgdata:/var/lib/postgresql/data
# Localhost only. The Rust binary is the sole client.
PublishPort=127.0.0.1:5432:5432
Environment=POSTGRES_DB=mapserver
Environment=POSTGRES_USER=mapserver
Secret=mapserver-db-password,type=env,target=POSTGRES_PASSWORD
HealthCmd=pg_isready -U mapserver -d mapserver
HealthInterval=10s
HealthRetries=5
[Service]
Restart=always
TimeoutStartSec=120
[Install]
WantedBy=default.target

275
rust_push.sh Executable file
View file

@ -0,0 +1,275 @@
#!/usr/bin/env bash
# rust_push.sh
#
# Usage:
# ./rust_push.sh "commit message" [patch|minor|major]
#
# Order of operations matters here: everything that can fail locally (validation,
# version bump, build) happens BEFORE anything is committed, tagged or pushed.
# A failed build therefore leaves the repository exactly as it found it.
set -euo pipefail
MSG="${1:-}"
BUMP="${2:-}"
export CROSS_CONTAINER_ENGINE=podman
TARGET="x86_64-unknown-linux-musl"
FORGE_HOST="radiantfig.fyi"
# ─────────────────────────────── pre-flight ────────────────────────────────
# All of it, before the first mutating command.
if [ -z "$MSG" ] || [ -z "$BUMP" ]; then
echo "Usage: ./rust_push.sh \"Commit message\" [patch|minor|major]"
exit 1
fi
case "$BUMP" in
major|minor|patch) ;;
*) echo "❌ Invalid bump: $BUMP (use patch|minor|major)"; exit 1 ;;
esac
[ -f "Cargo.toml" ] || { echo "❌ Cargo.toml not found."; exit 1; }
if [ -z "${FORGEJO_TOKEN:-}" ]; then
echo "❌ FORGEJO_TOKEN is not set."
echo " Generate one at ${FORGE_HOST}/forgejo -> Settings -> Applications"
exit 1
fi
command -v jq >/dev/null 2>&1 || { echo "❌ 'jq' is required."; exit 1; }
command -v curl >/dev/null 2>&1 || { echo "❌ 'curl' is required."; exit 1; }
CROSS_PATH="$HOME/.cargo/bin/cross"
[ -x "$CROSS_PATH" ] || { echo "❌ cross not found at $CROSS_PATH (cargo install cross)"; exit 1; }
# `git add .` is indiscriminate, so verify the ignore rules exist BEFORE relying
# on them. On a fresh repo nothing is tracked yet, which means a check for
# "already tracked" would pass while the very next `git add .` commits the lot.
[ -f ".gitignore" ] || { echo "❌ No .gitignore. Create one before the first release (target/ and .env must be excluded)."; exit 1; }
for secret in .env .env.local; do
[ -e "$secret" ] || continue
# Would `git add .` pick this up?
if ! git check-ignore -q "$secret"; then
echo "$secret is NOT ignored by git."
echo " It holds REG_TOKEN, the database password and SMTP credentials."
echo " Add it to .gitignore before releasing."
exit 1
fi
# Already committed in a previous run?
if git ls-files --error-unmatch "$secret" >/dev/null 2>&1; then
echo "$secret is tracked by git despite being ignored."
echo " git rm --cached $secret"
exit 1
fi
done
if ! git check-ignore -q target 2>/dev/null; then
echo "❌ target/ is not ignored — a release build is several GB."
exit 1
fi
# Compile-time embeds: sqlx::migrate! and rust-embed both read these at build
# time, and both fail with an unhelpful message if the directory is absent.
[ -d "migrations" ] || { echo "❌ migrations/ is missing (sqlx::migrate! needs it)"; exit 1; }
[ -d "frontend" ] || { echo "❌ frontend/ is missing (rust-embed needs it)"; exit 1; }
BINARY_NAME=$(grep -m 1 '^name[[:space:]]*=' Cargo.toml | sed -E 's/name[[:space:]]*=[[:space:]]*"(.*)"/\1/')
[ -n "$BINARY_NAME" ] || { echo "❌ Could not parse 'name' from Cargo.toml."; exit 1; }
# A bare `git remote get-url` on a fresh repo dies with a raw git error, so
# check first and say what to do about it.
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "❌ Not a git repository. Run: git init"
exit 1
fi
if ! git remote get-url origin >/dev/null 2>&1; then
echo "❌ No 'origin' remote configured."
echo " Create the repo on ${FORGE_HOST}/forgejo, then:"
echo " git remote add origin https://${FORGE_HOST}/forgejo/<owner>/<repo>.git"
exit 1
fi
# Handles both https://…/forgejo/owner/repo.git and git@host:owner/repo.git
ORIGIN_URL=$(git remote get-url origin)
REPO=$(echo "$ORIGIN_URL" | sed -E "
s|\.git$||;
s|^[^/]*//[^@]*@|https://|;
s|^https?://${FORGE_HOST}/forgejo/||;
s|^[^@]*@${FORGE_HOST}:||;
")
if echo "$REPO" | grep -q '[:/]\{1\}.*[:/]'; then
echo "❌ Could not parse owner/repo from remote: $ORIGIN_URL"
echo " Got: $REPO"
exit 1
fi
API_URL="https://${FORGE_HOST}/forgejo/api/v1/repos/${REPO}"
# Releasing a dirty tree means `git add .` sweeps unrelated work into the tag.
# Skipped on a repo with no commits yet, where everything is legitimately new.
if git rev-parse HEAD >/dev/null 2>&1 && [ -n "$(git status --porcelain --untracked-files=no)" ]; then
echo "⚠️ Working tree has uncommitted changes; they will be included."
read -r -p " Continue? [y/N] " reply
[ "$reply" = "y" ] || exit 1
fi
# ──────────────────────────── version bumping ──────────────────────────────
# Harmless on a fresh repo with an empty remote; don't let it abort the run.
git fetch --tags --force --quiet 2>/dev/null || true
LATEST=$(git tag --list 'v*' | sort -V | tail -n 1)
LATEST=${LATEST#v}
[ -n "$LATEST" ] || LATEST="0.0.0"
MAJOR=$(echo "$LATEST" | cut -d. -f1)
MINOR=$(echo "$LATEST" | cut -d. -f2)
PATCH=$(echo "$LATEST" | cut -d. -f3)
case "$BUMP" in
major) MAJOR=$((MAJOR+1)); MINOR=0; PATCH=0 ;;
minor) MINOR=$((MINOR+1)); PATCH=0 ;;
patch) PATCH=$((PATCH+1)) ;;
esac
VERSION="$MAJOR.$MINOR.$PATCH"
TAG="v$VERSION"
if git ls-remote --tags origin | grep -q "refs/tags/${TAG}$"; then
echo "❌ Tag $TAG already exists on remote."
exit 1
fi
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "❌ Tag $TAG already exists locally (left over from a failed run?)."
echo " Remove it with: git tag -d $TAG"
exit 1
fi
echo "📦 Project: $BINARY_NAME"
echo "🔗 Repo: $REPO"
echo "📌 Previous: $LATEST"
echo "✨ New: $TAG"
echo
# Write the version into Cargo.toml so CARGO_PKG_VERSION matches the tag and the
# artifact name. Only the [package] version — the first `version =` in the file.
sed -i "0,/^version[[:space:]]*=.*/s//version = \"$VERSION\"/" Cargo.toml
# Keeps Cargo.lock in step so it isn't left dirty after the build.
cargo update --workspace --quiet 2>/dev/null || true
restore_manifest() {
echo "↩️ Restoring Cargo.toml"
git checkout -- Cargo.toml Cargo.lock 2>/dev/null || true
}
# ──────────────────────────────── build ────────────────────────────────────
# Before any git mutation: a failure here leaves the repo untouched.
echo "🚀 Compiling static binary ($TARGET) via cross..."
trap restore_manifest ERR
"$CROSS_PATH" build --release --target "$TARGET"
trap - ERR
BIN_PATH="target/$TARGET/release/$BINARY_NAME"
[ -f "$BIN_PATH" ] || { echo "❌ Binary not found at $BIN_PATH"; restore_manifest; exit 1; }
# [profile.release] already sets strip = true, so this is belt and braces.
BINARY_INFO=$(file "$BIN_PATH")
if echo "$BINARY_INFO" | grep -qE "statically linked|static-pie linked"; then
echo "✅ Binary is fully static"
else
echo "⚠️ Binary may not be static: $BINARY_INFO"
fi
ARTIFACT_NAME="${BINARY_NAME}-${VERSION}-linux-amd64-musl.tar.gz"
echo "📦 Packaging: $ARTIFACT_NAME"
tar -czf "$ARTIFACT_NAME" -C "target/$TARGET/release" "$BINARY_NAME"
# ────────────────────────── git commit / tag / push ────────────────────────
# Only now, with a working artifact in hand.
echo "📦 Committing and tagging..."
git add .
git commit -m "$MSG" || echo " (nothing to commit)"
git tag "$TAG"
echo "🌐 Pushing..."
git push -u origin HEAD
git push origin "$TAG"
# ────────────────────────────── forgejo release ────────────────────────────
echo "🌐 Creating release..."
curl -fsS -X PATCH "$API_URL" \
-H "Authorization: token $FORGEJO_TOKEN" \
-H "Content-Type: application/json" \
-d '{"has_releases": true}' > /dev/null
RELEASE_ID=$(curl -fsS "${API_URL}/releases" \
-H "Authorization: token $FORGEJO_TOKEN" |
jq -r ".[] | select(.tag_name==\"${TAG}\") | .id")
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "null" ]; then
echo "⚠️ Release $TAG already exists (ID: $RELEASE_ID)"
else
HTTP_CODE=""
for attempt in 1 2 3; do
RESPONSE=$(curl -sS -w "\n%{http_code}" -X POST "${API_URL}/releases" \
-H "Authorization: token $FORGEJO_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":\"Release ${VERSION}\"}")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
[ "$HTTP_CODE" = "201" ] && { RELEASE_ID=$(echo "$BODY" | jq -r .id); break; }
[ "$attempt" -lt 3 ] && { echo "⚠️ Attempt $attempt failed (HTTP $HTTP_CODE), retrying..."; sleep 2; }
done
if [ "$HTTP_CODE" != "201" ]; then
echo "❌ Could not create release. Response: ${BODY:-<empty>}"
echo " Artifact kept at ./$ARTIFACT_NAME for manual upload."
exit 1
fi
fi
# An empty ID here would silently produce /releases//assets and a confusing 404.
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
echo "❌ No usable release ID. Artifact kept at ./$ARTIFACT_NAME"
exit 1
fi
echo "⬆️ Uploading artifact..."
UPLOAD_URL="${API_URL}/releases/${RELEASE_ID}/assets?name=${ARTIFACT_NAME}"
UPLOAD_HTTP=""
for attempt in 1 2 3; do
UPLOAD_RESPONSE=$(curl -sS -w "\n%{http_code}" -X POST "$UPLOAD_URL" \
-H "Authorization: token $FORGEJO_TOKEN" \
-F "attachment=@${ARTIFACT_NAME}")
UPLOAD_HTTP=$(echo "$UPLOAD_RESPONSE" | tail -n1)
[ "$UPLOAD_HTTP" = "201" ] && break
[ "$attempt" -lt 3 ] && { echo "⚠️ Upload attempt $attempt failed (HTTP $UPLOAD_HTTP), retrying..."; sleep 2; }
done
if [ "$UPLOAD_HTTP" != "201" ]; then
echo "❌ Upload failed (HTTP $UPLOAD_HTTP). Artifact kept at ./$ARTIFACT_NAME"
exit 1
fi
rm -f "$ARTIFACT_NAME"
echo
echo "✅ Release $TAG complete"
echo " https://${FORGE_HOST}/forgejo/${REPO}/releases/tag/${TAG}"

428
src/auth.rs Normal file
View file

@ -0,0 +1,428 @@
// src/auth.rs
use std::sync::OnceLock;
use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString};
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use axum::extract::{FromRequestParts, State};
use axum::http::request::Parts;
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Json, Router};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::FromRow;
use subtle::ConstantTimeEq;
use tower_sessions::Session;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::AppState;
const SESSION_USER_KEY: &str = "user_id";
const MIN_PASSWORD_LEN: usize = 8;
const RESET_TOKEN_TTL_MINUTES: i64 = 60;
// ---------------------------------------------------------------- model
#[derive(Debug, Clone, FromRow)]
pub struct User {
pub id: Uuid,
pub username: String,
pub email: Option<String>,
pub password_hash: String,
}
#[derive(Debug, Serialize)]
pub struct MeResponse {
pub id: Uuid,
pub username: String,
pub email: Option<String>,
}
/// Extractor that resolves the logged-in user, or fails with 401.
pub struct CurrentUser(pub User);
impl FromRequestParts<AppState> for CurrentUser {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let session = Session::from_request_parts(parts, state)
.await
.map_err(|_| AppError::Unauthorised)?;
let user_id: Uuid = session
.get(SESSION_USER_KEY)
.await?
.ok_or(AppError::Unauthorised)?;
let user = load_user_by_id(&state.db, user_id)
.await?
.ok_or(AppError::Unauthorised)?;
Ok(CurrentUser(user))
}
}
// ---------------------------------------------------------------- hashing
fn hash_password(password: &str) -> AppResult<String> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| AppError::Other(anyhow::anyhow!("hashing failed: {e}")))
}
fn verify_password(password: &str, phc: &str) -> bool {
match PasswordHash::new(phc) {
Ok(parsed) => Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok(),
Err(_) => false,
}
}
/// A real Argon2 hash of a throwaway value. Verifying against this when the account
/// does not exist keeps the login path's timing the same either way, so response
/// time doesn't reveal whether a username is registered.
fn dummy_hash() -> &'static str {
static DUMMY: OnceLock<String> = OnceLock::new();
DUMMY.get_or_init(|| {
hash_password("not-a-real-password-just-for-timing")
.expect("dummy hash must be constructible")
})
}
fn validate_password(password: &str) -> AppResult<()> {
if password.chars().count() < MIN_PASSWORD_LEN {
return Err(AppError::BadRequest(format!(
"Password must be at least {MIN_PASSWORD_LEN} characters."
)));
}
Ok(())
}
fn normalise_email(raw: Option<String>) -> Option<String> {
raw.map(|e| e.trim().to_string()).filter(|e| !e.is_empty())
}
// ---------------------------------------------------------------- queries
async fn load_user_by_id(db: &sqlx::PgPool, id: Uuid) -> AppResult<Option<User>> {
let user = sqlx::query_as::<_, User>(
"SELECT id, username, email, password_hash FROM users WHERE id = $1",
)
.bind(id)
.fetch_optional(db)
.await?;
Ok(user)
}
async fn load_user_by_login(db: &sqlx::PgPool, login: &str) -> AppResult<Option<User>> {
let user = sqlx::query_as::<_, User>(
"SELECT id, username, email, password_hash FROM users \
WHERE lower(username) = lower($1) OR lower(email) = lower($1)",
)
.bind(login)
.fetch_optional(db)
.await?;
Ok(user)
}
fn is_unique_violation(err: &sqlx::Error) -> bool {
matches!(err, sqlx::Error::Database(e) if e.code().as_deref() == Some("23505"))
}
// ---------------------------------------------------------------- register
#[derive(Deserialize)]
pub struct RegisterRequest {
pub username: String,
#[serde(default)]
pub email: Option<String>,
pub password: String,
pub reg_token: String,
}
async fn register(
State(state): State<AppState>,
Json(body): Json<RegisterRequest>,
) -> AppResult<StatusCode> {
// Constant-time so the shared secret can't be recovered by timing.
let matches: bool = body
.reg_token
.as_bytes()
.ct_eq(state.config.reg_token.as_bytes())
.into();
if !matches {
return Err(AppError::Unauthorised);
}
let username = body.username.trim().to_string();
if username.is_empty() {
return Err(AppError::BadRequest("Enter a username.".into()));
}
validate_password(&body.password)?;
let email = normalise_email(body.email);
let hash = hash_password(&body.password)?;
let result = sqlx::query(
"INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3)",
)
.bind(&username)
.bind(&email)
.bind(&hash)
.execute(&state.db)
.await;
match result {
Ok(_) => Ok(StatusCode::NO_CONTENT),
Err(e) if is_unique_violation(&e) => Err(AppError::Conflict(
"That username or email is already taken.".into(),
)),
Err(e) => Err(e.into()),
}
}
// ---------------------------------------------------------------- login / logout / me
#[derive(Deserialize)]
pub struct LoginRequest {
pub username_or_email: String,
pub password: String,
}
async fn login(
State(state): State<AppState>,
session: Session,
Json(body): Json<LoginRequest>,
) -> AppResult<Json<MeResponse>> {
let user = load_user_by_login(&state.db, body.username_or_email.trim()).await?;
let Some(user) = user else {
// Spend the same time as a real verification before failing.
verify_password(&body.password, dummy_hash());
return Err(AppError::Unauthorised);
};
if !verify_password(&body.password, &user.password_hash) {
return Err(AppError::Unauthorised);
}
// Fresh session ID on privilege change.
session.cycle_id().await?;
session.insert(SESSION_USER_KEY, user.id).await?;
Ok(Json(MeResponse {
id: user.id,
username: user.username,
email: user.email,
}))
}
async fn logout(session: Session) -> AppResult<StatusCode> {
session.flush().await?;
Ok(StatusCode::NO_CONTENT)
}
async fn me(CurrentUser(user): CurrentUser) -> Json<MeResponse> {
Json(MeResponse {
id: user.id,
username: user.username,
email: user.email,
})
}
// ---------------------------------------------------------------- account changes
#[derive(Deserialize)]
pub struct ChangePasswordRequest {
pub current_password: String,
pub new_password: String,
}
async fn change_password(
State(state): State<AppState>,
session: Session,
CurrentUser(user): CurrentUser,
Json(body): Json<ChangePasswordRequest>,
) -> AppResult<StatusCode> {
if !verify_password(&body.current_password, &user.password_hash) {
return Err(AppError::Unauthorised);
}
validate_password(&body.new_password)?;
let hash = hash_password(&body.new_password)?;
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
.bind(&hash)
.bind(user.id)
.execute(&state.db)
.await?;
// Rotate this session's ID (closes session fixation). Deliberately does NOT
// invalidate other sessions — a password change affects the next login only.
session.cycle_id().await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
pub struct ChangeEmailRequest {
pub current_password: String,
/// null or empty clears the address, giving up password recovery.
#[serde(default)]
pub new_email: Option<String>,
}
async fn change_email(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Json(body): Json<ChangeEmailRequest>,
) -> AppResult<StatusCode> {
if !verify_password(&body.current_password, &user.password_hash) {
return Err(AppError::Unauthorised);
}
let email = normalise_email(body.new_email);
let result = sqlx::query("UPDATE users SET email = $1 WHERE id = $2")
.bind(&email)
.bind(user.id)
.execute(&state.db)
.await;
match result {
Ok(_) => Ok(StatusCode::NO_CONTENT),
Err(e) if is_unique_violation(&e) => {
Err(AppError::Conflict("That email is already in use.".into()))
}
Err(e) => Err(e.into()),
}
}
// ---------------------------------------------------------------- password reset
fn generate_reset_token() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
fn hash_reset_token(token: &str) -> String {
hex::encode(Sha256::digest(token.as_bytes()))
}
#[derive(Deserialize)]
pub struct ResetRequestBody {
pub email: String,
}
async fn reset_request(
State(state): State<AppState>,
Json(body): Json<ResetRequestBody>,
) -> AppResult<StatusCode> {
let email = body.email.trim().to_string();
let user = sqlx::query_as::<_, User>(
"SELECT id, username, email, password_hash FROM users WHERE lower(email) = lower($1)",
)
.bind(&email)
.fetch_optional(&state.db)
.await?;
if let Some(user) = user {
let token = generate_reset_token();
let expires = time::OffsetDateTime::now_utc()
+ time::Duration::minutes(RESET_TOKEN_TTL_MINUTES);
sqlx::query(
"UPDATE users SET reset_token_hash = $1, reset_token_expires = $2 WHERE id = $3",
)
.bind(hash_reset_token(&token))
.bind(expires)
.bind(user.id)
.execute(&state.db)
.await?;
let link = state
.config
.public_link(&format!("reset?token={token}"));
// Spawned so the response never blocks on SMTP. The token is already
// persisted, so a send failure means "no email arrived", not bad state.
let mailer = state.mailer.clone();
let to = user.email.clone().unwrap_or_default();
tokio::spawn(async move {
if let Err(e) = mailer.send_password_reset(&to, &link).await {
tracing::error!(error = %e, "password reset email failed");
}
});
}
// Always the same response, so this can't be used to enumerate addresses.
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
pub struct ResetConfirmBody {
pub token: String,
pub new_password: String,
}
#[derive(FromRow)]
struct ResetCandidate {
id: Uuid,
}
async fn reset_confirm(
State(state): State<AppState>,
Json(body): Json<ResetConfirmBody>,
) -> AppResult<StatusCode> {
validate_password(&body.new_password)?;
let token_hash = hash_reset_token(body.token.trim());
let candidate = sqlx::query_as::<_, ResetCandidate>(
"SELECT id FROM users WHERE reset_token_hash = $1 AND reset_token_expires > now()",
)
.bind(&token_hash)
.fetch_optional(&state.db)
.await?;
let Some(candidate) = candidate else {
return Err(AppError::BadRequest(
"That reset link has expired or already been used.".into(),
));
};
let hash = hash_password(&body.new_password)?;
sqlx::query(
"UPDATE users SET password_hash = $1, reset_token_hash = NULL, \
reset_token_expires = NULL WHERE id = $2",
)
.bind(&hash)
.bind(candidate.id)
.execute(&state.db)
.await?;
Ok(StatusCode::NO_CONTENT)
}
// ---------------------------------------------------------------- routes
pub fn routes() -> Router<AppState> {
Router::new()
.route("/register", post(register))
.route("/login", post(login))
.route("/logout", post(logout))
.route("/me", get(me))
.route("/me/password", post(change_password))
.route("/me/email", post(change_email))
.route("/password-reset/request", post(reset_request))
.route("/password-reset/confirm", post(reset_confirm))
}

137
src/config.rs Normal file
View file

@ -0,0 +1,137 @@
// src/config.rs
use anyhow::{Context, Result};
use std::path::PathBuf;
#[derive(Clone, Debug)]
pub struct Config {
pub database_url: String,
pub bind_addr: String,
/// Normalised: either "" (root) or "/maps" (leading slash, no trailing slash).
pub base_path: String,
/// Origin only, no trailing slash, e.g. "https://example.com".
pub public_url: String,
pub reg_token: String,
/// Sent to the browser via GET /api/config so swapping tile providers is an
/// edit and a restart, not a recompile.
pub tile_url: String,
pub session_duration_days: i64,
pub gpx_dir: PathBuf,
pub smtp: SmtpConfig,
}
#[derive(Clone, Debug)]
pub struct SmtpConfig {
/// Empty means "use the stub transport and log reset links".
pub host: String,
pub port: u16,
pub tls: SmtpTls,
pub username: String,
pub password: String,
pub from: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SmtpTls {
StartTls,
Implicit,
None,
}
fn var(key: &str) -> Result<String> {
std::env::var(key).with_context(|| format!("{key} is not set"))
}
fn var_or(key: &str, default: &str) -> String {
std::env::var(key)
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| default.to_string())
}
impl Config {
pub fn from_env() -> Result<Self> {
let base_path = normalise_base_path(&var_or("BASE_PATH", ""));
let public_url = var_or("PUBLIC_URL", "http://localhost:8080")
.trim_end_matches('/')
.to_string();
let tls = match var_or("SMTP_TLS", "starttls").to_lowercase().as_str() {
"implicit" | "tls" | "smtps" => SmtpTls::Implicit,
"none" | "plain" | "" => SmtpTls::None,
_ => SmtpTls::StartTls,
};
Ok(Config {
database_url: var("DATABASE_URL")?,
bind_addr: var_or("BIND_ADDR", "127.0.0.1:8080"),
base_path,
public_url,
reg_token: var("REG_TOKEN")?,
tile_url: var_or(
"TILE_URL",
// Apex host: the {s}.tile.openstreetmap.org sharded form is deprecated.
"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
),
session_duration_days: var_or("SESSION_DURATION_DAYS", "30")
.parse()
.context("SESSION_DURATION_DAYS must be an integer")?,
gpx_dir: PathBuf::from(var("GPX_DIR")?),
smtp: SmtpConfig {
host: var_or("SMTP_HOST", ""),
port: var_or("SMTP_PORT", "587")
.parse()
.context("SMTP_PORT must be a number")?,
tls,
username: var_or("SMTP_USERNAME", ""),
password: var_or("SMTP_PASSWORD", ""),
from: var_or("SMTP_FROM", "mapserver@localhost"),
},
})
}
/// The href used in the injected <base> tag. Always ends with "/".
pub fn base_href(&self) -> String {
format!("{}/", self.base_path)
}
/// Absolute URL for a path relative to the app root, e.g. "reset?token=…".
pub fn public_link(&self, rel: &str) -> String {
format!("{}{}/{}", self.public_url, self.base_path, rel)
}
/// Cookie Path attribute. Cookies require a non-empty path.
pub fn cookie_path(&self) -> String {
if self.base_path.is_empty() {
"/".to_string()
} else {
self.base_path.clone()
}
}
}
/// "" and "/" both mean root. Anything else gets a leading slash and no trailing slash.
fn normalise_base_path(raw: &str) -> String {
let trimmed = raw.trim().trim_end_matches('/');
if trimmed.is_empty() {
String::new()
} else if trimmed.starts_with('/') {
trimmed.to_string()
} else {
format!("/{trimmed}")
}
}
#[cfg(test)]
mod tests {
use super::normalise_base_path;
#[test]
fn base_path_normalisation() {
assert_eq!(normalise_base_path(""), "");
assert_eq!(normalise_base_path("/"), "");
assert_eq!(normalise_base_path("maps"), "/maps");
assert_eq!(normalise_base_path("/maps/"), "/maps");
assert_eq!(normalise_base_path("/maps"), "/maps");
}
}

59
src/error.rs Normal file
View file

@ -0,0 +1,59 @@
// src/error.rs
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("unauthorised")]
Unauthorised,
#[error("not found")]
NotFound,
#[error("{0}")]
BadRequest(String),
#[error("{0}")]
Conflict(String),
#[error("payload too large")]
TooLarge,
#[error(transparent)]
Database(#[from] sqlx::Error),
#[error(transparent)]
Session(#[from] tower_sessions::session::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::Unauthorised => (StatusCode::UNAUTHORIZED, self.to_string()),
AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
AppError::Conflict(m) => (StatusCode::CONFLICT, m.clone()),
AppError::TooLarge => (StatusCode::PAYLOAD_TOO_LARGE, self.to_string()),
other => {
// Internal detail stays in the log, never in the response body.
tracing::error!(error = %other, "internal error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"something went wrong".to_string(),
)
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
pub type AppResult<T> = Result<T, AppError>;

265
src/gpx.rs Normal file
View file

@ -0,0 +1,265 @@
// src/gpx.rs
use std::fs::OpenOptions;
use std::io::ErrorKind;
use std::path::{Path as StdPath, PathBuf};
use axum::extract::{DefaultBodyLimit, Multipart, Path, State};
use axum::http::header;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::Serialize;
use tokio::io::AsyncWriteExt;
use crate::auth::CurrentUser;
use crate::error::{AppError, AppResult};
use crate::AppState;
/// A GPX with ~20k trackpoints carrying elevation and timestamps lands in the
/// 515 MB range. Axum's default is 2 MB, so this is raised on the upload route
/// only — a 25 MB limit on /api/login would be free memory exhaustion.
/// nginx needs a matching `client_max_body_size 25m;` on the same location.
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
const MAX_COLLISION_ATTEMPTS: u32 = 1000;
#[derive(Serialize)]
pub struct GpxFile {
name: String,
size: u64,
/// RFC 3339, UTC.
modified: Option<String>,
}
// ---------------------------------------------------------------- name safety
/// Accepts only a plain filename: no separators, no traversal, must be a .gpx.
fn validate_filename(name: &str) -> AppResult<()> {
let bad = |m: &str| Err(AppError::BadRequest(m.to_string()));
if name.is_empty() || name.len() > 120 {
return bad("File names must be between 1 and 120 characters.");
}
if name.starts_with('.') {
return bad("File names can't start with a dot.");
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '.' | '_' | '-'))
{
return bad("File names can use letters, numbers, spaces, dots, underscores and hyphens.");
}
if !name.to_ascii_lowercase().ends_with(".gpx") {
return bad("Only .gpx files are accepted.");
}
Ok(())
}
/// Joins under `dir` and confirms the canonical result is still inside it.
/// Belt and braces on top of `validate_filename`.
fn resolve_existing(dir: &StdPath, name: &str) -> AppResult<PathBuf> {
validate_filename(name)?;
let candidate = dir.join(name);
let canonical_dir = dir.canonicalize().map_err(|_| AppError::NotFound)?;
let canonical = candidate.canonicalize().map_err(|_| AppError::NotFound)?;
if !canonical.starts_with(&canonical_dir) || !canonical.is_file() {
return Err(AppError::NotFound);
}
Ok(canonical)
}
/// Splits "track.gpx" into ("track", "gpx"). The extension is always gpx by the
/// time this runs, but the original case is preserved.
fn split_name(name: &str) -> (String, String) {
match name.rsplit_once('.') {
Some((stem, ext)) => (stem.to_string(), ext.to_string()),
None => (name.to_string(), "gpx".to_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)> {
let (stem, ext) = split_name(name);
for n in 0..MAX_COLLISION_ATTEMPTS {
let candidate = if n == 0 {
format!("{stem}.{ext}")
} else {
format!("{stem}-{n}.{ext}")
};
match OpenOptions::new()
.write(true)
.create_new(true)
.open(dir.join(&candidate))
{
Ok(file) => return Ok((file, candidate)),
Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e.into()),
}
}
Err(AppError::Conflict(
"Too many files with that name already.".into(),
))
}
// ---------------------------------------------------------------- handlers
async fn list(
State(state): State<AppState>,
CurrentUser(_): CurrentUser,
) -> AppResult<Json<Vec<GpxFile>>> {
let dir = &state.config.gpx_dir;
let mut entries = tokio::fs::read_dir(dir).await?;
let mut files = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let name = entry.file_name().to_string_lossy().to_string();
if validate_filename(&name).is_err() {
continue;
}
let meta = match entry.metadata().await {
Ok(m) if m.is_file() => m,
_ => continue,
};
let modified = meta
.modified()
.ok()
.map(time::OffsetDateTime::from)
.and_then(|t| t.format(&time::format_description::well_known::Rfc3339).ok());
files.push(GpxFile {
name,
size: meta.len(),
modified,
});
}
files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
Ok(Json(files))
}
async fn fetch(
State(state): State<AppState>,
CurrentUser(_): CurrentUser,
Path(name): Path<String>,
) -> AppResult<Response> {
let path = resolve_existing(&state.config.gpx_dir, &name)?;
let body = tokio::fs::read(&path).await?;
Ok((
[
(header::CONTENT_TYPE, "application/gpx+xml"),
(header::CACHE_CONTROL, "no-store"),
],
body,
)
.into_response())
}
#[derive(Serialize)]
struct UploadResponse {
/// The name actually written, which may differ from the one submitted.
name: String,
}
async fn upload(
State(state): State<AppState>,
CurrentUser(_): CurrentUser,
mut multipart: Multipart,
) -> AppResult<Json<UploadResponse>> {
let dir = state.config.gpx_dir.clone();
while let Some(mut field) = multipart
.next_field()
.await
.map_err(|e| AppError::BadRequest(format!("Upload failed: {e}")))?
{
let Some(raw_name) = field.file_name().map(|s| s.to_string()) else {
continue;
};
// Browsers may send a path in some cases; keep only the final component.
let submitted = raw_name
.rsplit(['/', '\\'])
.next()
.unwrap_or(&raw_name)
.trim()
.to_string();
validate_filename(&submitted)?;
let (std_file, final_name) = reserve(&dir, &submitted)?;
let mut file = tokio::fs::File::from_std(std_file);
let written_path = dir.join(&final_name);
let mut total: usize = 0;
loop {
let chunk = match field.chunk().await {
Ok(Some(chunk)) => chunk,
Ok(None) => break,
Err(e) => {
let _ = tokio::fs::remove_file(&written_path).await;
return Err(AppError::BadRequest(format!("Upload failed: {e}")));
}
};
total += chunk.len();
if total > MAX_UPLOAD_BYTES {
let _ = tokio::fs::remove_file(&written_path).await;
return Err(AppError::TooLarge);
}
if let Err(e) = file.write_all(&chunk).await {
let _ = tokio::fs::remove_file(&written_path).await;
return Err(e.into());
}
}
if let Err(e) = file.flush().await {
let _ = tokio::fs::remove_file(&written_path).await;
return Err(e.into());
}
return Ok(Json(UploadResponse { name: final_name }));
}
Err(AppError::BadRequest("No file was included.".into()))
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/", get(list))
// Distinct prefix rather than /{filename} so nothing can shadow /upload.
.route("/file/{name}", get(fetch))
.route(
"/upload",
post(upload).layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES)),
)
}
#[cfg(test)]
mod tests {
use super::validate_filename;
#[test]
fn rejects_traversal_and_junk() {
assert!(validate_filename("../../etc/passwd").is_err());
assert!(validate_filename("a/b.gpx").is_err());
assert!(validate_filename("a\\b.gpx").is_err());
assert!(validate_filename(".hidden.gpx").is_err());
assert!(validate_filename("notes.txt").is_err());
assert!(validate_filename("").is_err());
}
#[test]
fn accepts_ordinary_names() {
assert!(validate_filename("Snowdon ridge.gpx").is_ok());
assert!(validate_filename("track_2026-01-02.GPX").is_ok());
}
}

92
src/mail.rs Normal file
View file

@ -0,0 +1,92 @@
// src/mail.rs
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use lettre::message::header::ContentType;
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use crate::config::{SmtpConfig, SmtpTls};
#[derive(Clone)]
pub struct Mailer(Arc<Inner>);
enum Inner {
Smtp {
transport: AsyncSmtpTransport<Tokio1Executor>,
from: String,
},
/// No SMTP_HOST configured: log the link instead of sending. The entire reset
/// flow is testable this way with no provider.
Stub,
}
impl Mailer {
pub fn from_config(cfg: &SmtpConfig) -> Result<Self> {
if cfg.host.trim().is_empty() {
tracing::warn!("SMTP_HOST is empty — reset links will be logged, not emailed");
return Ok(Mailer(Arc::new(Inner::Stub)));
}
// relay() is implicit TLS (usually 465); starttls_relay() upgrades a
// plaintext connection (usually 587). Both verify certificates.
// builder_dangerous() does not — localhost only.
let builder = match cfg.tls {
SmtpTls::Implicit => AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.host)
.context("building implicit-TLS SMTP transport")?,
SmtpTls::StartTls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.host)
.context("building STARTTLS SMTP transport")?,
SmtpTls::None => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.host),
};
let mut builder = builder
.port(cfg.port)
.timeout(Some(Duration::from_secs(10)));
if !cfg.username.is_empty() {
builder = builder.credentials(Credentials::new(
cfg.username.clone(),
cfg.password.clone(),
));
}
Ok(Mailer(Arc::new(Inner::Smtp {
transport: builder.build(),
from: cfg.from.clone(),
})))
}
pub async fn send_password_reset(&self, to: &str, link: &str) -> Result<()> {
let body = format!(
"Someone asked to reset the password for your mapserver account.\n\n\
Open this link within the next hour to choose a new one:\n\n{link}\n\n\
If this wasn't you, ignore this message. Your password stays as it is.\n"
);
match &*self.0 {
Inner::Stub => {
tracing::info!(recipient = %to, %link, "password reset (stub transport)");
Ok(())
}
Inner::Smtp { transport, from } => {
if to.trim().is_empty() {
return Ok(());
}
// Plain text scores better with spam filters than HTML, and the
// From address must be one this SMTP account may send as.
let message = Message::builder()
.from(from.parse().context("SMTP_FROM is not a valid address")?)
.to(to.parse().context("recipient is not a valid address")?)
.subject("Password reset for mapserver")
.header(ContentType::TEXT_PLAIN)
.body(body)
.context("building reset email")?;
transport.send(message).await.context("sending reset email")?;
Ok(())
}
}
}
}

167
src/main.rs Normal file
View file

@ -0,0 +1,167 @@
// src/main.rs
mod auth;
mod config;
mod error;
mod gpx;
mod mail;
mod markers;
mod ratelimit;
mod web;
use std::net::SocketAddr;
use std::time::Duration;
use anyhow::{Context, Result};
use axum::routing::get;
use axum::Router;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use tower_http::trace::TraceLayer;
use tower_sessions::cookie::SameSite;
use tower_sessions::{Expiry, SessionManagerLayer};
use tower_sessions_sqlx_store::PostgresStore;
use crate::config::Config;
use crate::mail::Mailer;
use crate::ratelimit::RateLimiter;
#[derive(Clone)]
pub struct AppState {
pub db: PgPool,
pub config: std::sync::Arc<Config>,
pub mailer: Mailer,
}
#[tokio::main]
async fn main() -> Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "rs_maps=info,tower_http=warn".into()),
)
.init();
let config = Config::from_env()?;
// GPX_DIR must exist and be writable by this user before anything else.
tokio::fs::create_dir_all(&config.gpx_dir)
.await
.with_context(|| format!("cannot create GPX_DIR at {}", config.gpx_dir.display()))?;
let db = connect_with_retry(&config.database_url).await?;
sqlx::migrate!("./migrations")
.run(&db)
.await
.context("running migrations")?;
// The session store owns its own table; let its migration create it.
let session_store = PostgresStore::new(db.clone());
session_store
.migrate()
.await
.context("running session store migration")?;
let mailer = Mailer::from_config(&config.smtp)?;
let session_layer = SessionManagerLayer::new(session_store)
.with_name("mapserver.sid")
.with_secure(true)
.with_http_only(true)
.with_same_site(SameSite::Strict)
.with_path(config.cookie_path())
.with_expiry(Expiry::OnInactivity(time::Duration::days(
config.session_duration_days,
)));
let bind_addr = config.bind_addr.clone();
let base_path = config.base_path.clone();
let state = AppState {
db,
config: std::sync::Arc::new(config),
mailer,
};
// 20 attempts per minute per IP across the auth surface: generous for two
// humans, useless for grinding REG_TOKEN or a password.
let limiter = RateLimiter::new(20, Duration::from_secs(60), true);
let auth_routes = auth::routes().layer(axum::middleware::from_fn_with_state(
limiter.clone(),
ratelimit::limit,
));
let api = Router::new()
.merge(auth_routes)
.route("/config", get(web::client_config))
.nest("/markers", markers::routes())
.nest("/gpx", gpx::routes());
let app = Router::new()
.nest("/api", api)
.fallback(web::serve_asset)
.layer(axum::middleware::from_fn_with_state(
state.clone(),
web::origin_guard,
))
.layer(session_layer)
.layer(TraceLayer::new_for_http())
.with_state(state);
// nest("/") panics, so root deployment uses the router as-is.
let app = if base_path.is_empty() {
app
} else {
Router::new().nest(&base_path, app)
};
let listener = tokio::net::TcpListener::bind(&bind_addr)
.await
.with_context(|| format!("cannot bind {bind_addr}"))?;
tracing::info!(
"mapserver listening on {bind_addr} under {}",
if base_path.is_empty() { "/" } else { &base_path }
);
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.context("server error")?;
Ok(())
}
/// systemd's After= doesn't mean Postgres is accepting connections yet, so retry
/// rather than exiting on the first refusal.
async fn connect_with_retry(url: &str) -> Result<PgPool> {
const ATTEMPTS: u32 = 10;
for attempt in 1..=ATTEMPTS {
match PgPoolOptions::new()
.max_connections(8)
.acquire_timeout(Duration::from_secs(5))
.connect(url)
.await
{
Ok(pool) => return Ok(pool),
Err(e) if attempt < ATTEMPTS => {
let wait = Duration::from_millis(500 * u64::from(attempt));
tracing::warn!(
attempt,
error = %e,
"database not ready, retrying in {:?}",
wait
);
tokio::time::sleep(wait).await;
}
Err(e) => return Err(e).context("database unreachable after retries"),
}
}
unreachable!()
}

235
src/markers.rs Normal file
View file

@ -0,0 +1,235 @@
// src/markers.rs
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::routing::{get, put};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
use crate::auth::CurrentUser;
use crate::error::{AppError, AppResult};
use crate::AppState;
#[derive(Debug, Serialize, FromRow)]
pub struct Marker {
pub id: Uuid,
pub owner_id: Uuid,
pub name: String,
pub description: Option<String>,
pub lat: f64,
pub lon: f64,
pub category: Option<String>,
pub color: Option<String>,
pub is_shared: bool,
#[serde(with = "time::serde::rfc3339")]
pub created_at: time::OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
}
#[derive(Debug, Deserialize)]
pub struct MarkerInput {
pub name: String,
#[serde(default)]
pub description: Option<String>,
pub lat: f64,
pub lon: f64,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub is_shared: bool,
}
const COLUMNS: &str = "id, owner_id, name, description, lat, lon, category, color, \
is_shared, created_at, updated_at";
fn validate(input: &MarkerInput) -> AppResult<()> {
if input.name.trim().is_empty() {
return Err(AppError::BadRequest("Give the place a name.".into()));
}
if !(-90.0..=90.0).contains(&input.lat) || !(-180.0..=180.0).contains(&input.lon) {
return Err(AppError::BadRequest("Those coordinates are off the map.".into()));
}
Ok(())
}
fn clean(value: &Option<String>) -> Option<String> {
value
.as_ref()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
/// "minlon,minlat,maxlon,maxlat". Optional; omitted means everything.
#[serde(default)]
pub bbox: Option<String>,
}
/// Parses the bbox parameter. Built in now because adding it later would be a
/// breaking API change; the frontend can start using it whenever it wants to.
fn parse_bbox(raw: &str) -> AppResult<(f64, f64, f64, f64)> {
let bad = || AppError::BadRequest("bbox must be minlon,minlat,maxlon,maxlat".into());
let parts: Vec<f64> = raw
.split(',')
.map(|p| p.trim().parse::<f64>().map_err(|_| bad()))
.collect::<Result<_, _>>()?;
let [min_lon, min_lat, max_lon, max_lat] = parts[..] else {
return Err(bad());
};
if min_lon > max_lon || min_lat > max_lat {
return Err(bad());
}
if !(-90.0..=90.0).contains(&min_lat) || !(-90.0..=90.0).contains(&max_lat) {
return Err(bad());
}
if !(-180.0..=180.0).contains(&min_lon) || !(-180.0..=180.0).contains(&max_lon) {
return Err(bad());
}
Ok((min_lon, min_lat, max_lon, max_lat))
}
/// Own markers plus every shared marker, whoever owns it.
async fn list(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Query(query): Query<ListQuery>,
) -> AppResult<Json<Vec<Marker>>> {
let rows = match query.bbox.as_deref().filter(|b| !b.trim().is_empty()) {
None => {
sqlx::query_as::<_, Marker>(&format!(
"SELECT {COLUMNS} FROM markers WHERE owner_id = $1 OR is_shared \
ORDER BY created_at DESC"
))
.bind(user.id)
.fetch_all(&state.db)
.await?
}
Some(raw) => {
let (min_lon, min_lat, max_lon, max_lat) = parse_bbox(raw)?;
sqlx::query_as::<_, Marker>(&format!(
"SELECT {COLUMNS} FROM markers \
WHERE (owner_id = $1 OR is_shared) \
AND lon BETWEEN $2 AND $3 AND lat BETWEEN $4 AND $5 \
ORDER BY created_at DESC"
))
.bind(user.id)
.bind(min_lon)
.bind(max_lon)
.bind(min_lat)
.bind(max_lat)
.fetch_all(&state.db)
.await?
}
};
Ok(Json(rows))
}
async fn create(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Json(input): Json<MarkerInput>,
) -> AppResult<(StatusCode, Json<Marker>)> {
validate(&input)?;
let marker = sqlx::query_as::<_, Marker>(&format!(
"INSERT INTO markers (owner_id, name, description, lat, lon, category, color, is_shared) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING {COLUMNS}"
))
.bind(user.id)
.bind(input.name.trim())
.bind(clean(&input.description))
.bind(input.lat)
.bind(input.lon)
.bind(clean(&input.category))
.bind(clean(&input.color))
.bind(input.is_shared)
.fetch_one(&state.db)
.await?;
Ok((StatusCode::CREATED, Json(marker)))
}
async fn update(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(id): Path<Uuid>,
Json(input): Json<MarkerInput>,
) -> AppResult<Json<Marker>> {
validate(&input)?;
// updated_at is set explicitly; DEFAULT now() only fires on INSERT.
let marker = sqlx::query_as::<_, Marker>(&format!(
"UPDATE markers SET name = $1, description = $2, lat = $3, lon = $4, \
category = $5, color = $6, is_shared = $7, updated_at = now() \
WHERE id = $8 AND owner_id = $9 RETURNING {COLUMNS}"
))
.bind(input.name.trim())
.bind(clean(&input.description))
.bind(input.lat)
.bind(input.lon)
.bind(clean(&input.category))
.bind(clean(&input.color))
.bind(input.is_shared)
.bind(id)
.bind(user.id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
Ok(Json(marker))
}
async fn delete(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(id): Path<Uuid>,
) -> AppResult<StatusCode> {
let result = sqlx::query("DELETE FROM markers WHERE id = $1 AND owner_id = $2")
.bind(id)
.bind(user.id)
.execute(&state.db)
.await?;
if result.rows_affected() == 0 {
// Same response whether it's someone else's marker or doesn't exist.
return Err(AppError::NotFound);
}
Ok(StatusCode::NO_CONTENT)
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/", get(list).post(create))
// axum 0.8 uses {id}, not :id
.route("/{id}", put(update).delete(delete))
}
#[cfg(test)]
mod tests {
use super::parse_bbox;
#[test]
fn accepts_a_sane_box() {
let (min_lon, min_lat, max_lon, max_lat) =
parse_bbox("-4.2,53.0,-3.9,53.2").expect("valid bbox");
assert!(min_lon < max_lon && min_lat < max_lat);
}
#[test]
fn rejects_junk() {
assert!(parse_bbox("1,2,3").is_err());
assert!(parse_bbox("a,b,c,d").is_err());
assert!(parse_bbox("10,0,-10,5").is_err());
assert!(parse_bbox("0,0,0,999").is_err());
}
}

144
src/ratelimit.rs Normal file
View file

@ -0,0 +1,144 @@
// src/ratelimit.rs
//
// A fixed-window rate limiter for the auth endpoints, keyed by client IP.
//
// This is deliberately a few dozen lines rather than a dependency. At two users
// the requirement is "a shared registration secret shouldn't be brute-forceable
// and login shouldn't be a password oracle", which a fixed window covers. It is
// per-process and resets on restart; that is fine here and would not be at scale.
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use axum::extract::{ConnectInfo, Request};
use axum::http::{header, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use std::net::SocketAddr;
#[derive(Clone)]
pub struct RateLimiter {
inner: Arc<Mutex<State>>,
limit: u32,
window: Duration,
/// True when the app sits behind a reverse proxy we control, in which case
/// X-Forwarded-For is trustworthy. Direct peer address is used otherwise.
trust_forwarded: bool,
}
struct State {
hits: HashMap<String, Window>,
last_sweep: Instant,
}
struct Window {
count: u32,
started: Instant,
}
impl RateLimiter {
pub fn new(limit: u32, window: Duration, trust_forwarded: bool) -> Self {
Self {
inner: Arc::new(Mutex::new(State {
hits: HashMap::new(),
last_sweep: Instant::now(),
})),
limit,
window,
trust_forwarded,
}
}
/// Records a hit. Returns false when the caller is over the limit.
fn allow(&self, key: String) -> bool {
let now = Instant::now();
let mut state = self.inner.lock().expect("rate limiter mutex poisoned");
// Drop stale windows occasionally so the map can't grow without bound.
if now.duration_since(state.last_sweep) > self.window {
let window = self.window;
state
.hits
.retain(|_, w| now.duration_since(w.started) < window);
state.last_sweep = now;
}
let entry = state.hits.entry(key).or_insert(Window {
count: 0,
started: now,
});
if now.duration_since(entry.started) >= self.window {
entry.count = 0;
entry.started = now;
}
entry.count += 1;
entry.count <= self.limit
}
fn client_key(&self, request: &Request) -> String {
if self.trust_forwarded {
if let Some(forwarded) = request
.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.map(str::trim)
.filter(|v| !v.is_empty() && v.parse::<IpAddr>().is_ok())
{
return forwarded.to_string();
}
}
request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ConnectInfo(addr)| addr.ip().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
}
/// Layer this onto the auth routes with `axum::middleware::from_fn_with_state`.
pub async fn limit(
axum::extract::State(limiter): axum::extract::State<RateLimiter>,
request: Request,
next: Next,
) -> Response {
let key = limiter.client_key(&request);
if !limiter.allow(key) {
let retry_after = limiter.window.as_secs().to_string();
return (
StatusCode::TOO_MANY_REQUESTS,
[(header::RETRY_AFTER, retry_after)],
"Too many attempts. Wait a minute and try again.",
)
.into_response();
}
next.run(request).await
}
#[cfg(test)]
mod tests {
use super::RateLimiter;
use std::time::Duration;
#[test]
fn blocks_after_limit_then_recovers() {
let limiter = RateLimiter::new(2, Duration::from_millis(50), false);
assert!(limiter.allow("a".into()));
assert!(limiter.allow("a".into()));
assert!(!limiter.allow("a".into()));
// A different client is unaffected.
assert!(limiter.allow("b".into()));
std::thread::sleep(Duration::from_millis(60));
assert!(limiter.allow("a".into()));
}
}

110
src/web.rs Normal file
View file

@ -0,0 +1,110 @@
// src/web.rs
use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::{header, HeaderValue, Method, StatusCode, Uri};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::Json;
use rust_embed::RustEmbed;
use serde::Serialize;
use crate::AppState;
#[derive(RustEmbed)]
#[folder = "frontend/"]
struct Assets;
const BASE_PLACEHOLDER: &str = "__BASE_HREF__";
/// Serves an embedded asset, falling back to index.html so client-side routes
/// like /maps/reset?token=… load the app instead of 404ing.
pub async fn serve_asset(State(state): State<AppState>, uri: Uri) -> Response {
// Inside a nest, uri.path() is already stripped of BASE_PATH.
let path = uri.path().trim_start_matches('/');
let path = if path.is_empty() { "index.html" } else { path };
if let Some(response) = asset_response(&state, path) {
return response;
}
// Unknown path: hand back the shell and let the frontend route it.
asset_response(&state, "index.html").unwrap_or_else(|| {
(StatusCode::INTERNAL_SERVER_ERROR, "frontend assets missing").into_response()
})
}
fn asset_response(state: &AppState, path: &str) -> Option<Response> {
let file = Assets::get(path)?;
let mime = mime_guess::from_path(path).first_or_octet_stream();
// index.html carries a <base> tag so every relative URL in the frontend
// resolves under BASE_PATH without the frontend knowing what it is.
let body: Body = if path.ends_with(".html") {
let text = String::from_utf8_lossy(&file.data)
.replace(BASE_PLACEHOLDER, &state.config.base_href());
Body::from(text)
} else {
Body::from(file.data.into_owned())
};
let cache = if path.ends_with(".html") {
"no-cache"
} else {
"public, max-age=3600"
};
Some(
(
[
(header::CONTENT_TYPE, mime.as_ref()),
(header::CACHE_CONTROL, cache),
],
body,
)
.into_response(),
)
}
/// Belt and braces alongside SameSite=Strict: reject mutating requests whose
/// Origin header doesn't match PUBLIC_URL. Absent Origin is allowed, since
/// same-origin GETs and some clients omit it.
pub async fn origin_guard(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Response {
let mutating = matches!(
*request.method(),
Method::POST | Method::PUT | Method::PATCH | Method::DELETE
);
if mutating {
if let Some(origin) = request.headers().get(header::ORIGIN) {
let expected = HeaderValue::from_str(&state.config.public_url).ok();
if Some(origin) != expected.as_ref() {
tracing::warn!(?origin, "rejected cross-origin request");
return (StatusCode::FORBIDDEN, "cross-origin request rejected")
.into_response();
}
}
}
next.run(request).await
}
#[derive(Serialize)]
pub struct ClientConfig {
tile_url: String,
base_path: String,
attribution: String,
}
/// Public on purpose: the login screen renders the map behind it, and none of
/// this is secret.
pub async fn client_config(State(state): State<AppState>) -> Json<ClientConfig> {
Json(ClientConfig {
tile_url: state.config.tile_url.clone(),
base_path: state.config.base_path.clone(),
attribution: "\u{00a9} OpenStreetMap contributors".to_string(),
})
}