first build
This commit is contained in:
commit
9ed25e2d5f
23 changed files with 6948 additions and 0 deletions
38
.env.example
Normal file
38
.env.example
Normal 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
24
.gitignore
vendored
Normal 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
2772
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
56
Cargo.toml
Normal file
56
Cargo.toml
Normal 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
428
DESIGN.md
Normal 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
|
||||
100–200 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 5–15 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 §§3–11 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
114
README.md
Normal 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
777
frontend/app.js
Normal 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 = '© <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
215
frontend/index.html
Normal 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
463
frontend/style.css
Normal 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
39
mapserver.service
Normal 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
36
migrations/0001_init.sql
Normal 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
36
nginx-snippet.conf
Normal 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
38
postgres.container
Normal 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
275
rust_push.sh
Executable 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
428
src/auth.rs
Normal 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
137
src/config.rs
Normal 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
59
src/error.rs
Normal 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
265
src/gpx.rs
Normal 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
|
||||
/// 5–15 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
92
src/mail.rs
Normal 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
167
src/main.rs
Normal 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
235
src/markers.rs
Normal 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
144
src/ratelimit.rs
Normal 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
110
src/web.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue