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