429 lines
23 KiB
Markdown
429 lines
23 KiB
Markdown
|
|
<!-- 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.
|