feat(tile-proxy): Thunderforest Outdoors as foreign karte upstream
OpenTopoMap's hypsometric tint reads "red mountains / green flats" and looks nothing like the Swisstopo Pixelkarte the proxy hands out in-region — produced a jarring visual seam right at the CH/LI border. Thunderforest Outdoors has a muted topo palette + subtle hillshade that matches the swisstopo tile aesthetic much more closely, so use it as the abroad `karte` upstream when an API key is available. - `tile-proxy/build.rs`: reads `tile-proxy/.env` (gitignored) at build time and forwards each `KEY=VAL` line to rustc as `--env`, so the key is baked into the binary via `option_env!` and never touched at runtime. A shell env var of the same name wins over the .env entry (dotenv precedence). `cargo:rerun-if-changed=.env` + `cargo:rerun-if-env-changed` force a recompile whenever the value changes — no stale key cached in the binary. - `main.rs`: `THUNDERFOREST_API_KEY` read via `option_env!`; foreign `karte` is Thunderforest Outdoors when set, OpenTopoMap fallback when absent. Behaviour unchanged for keyless builds. - `mapTiles.ts`: page-footer attribution credits Thunderforest + OSM alongside the existing swisstopo / OpenTopoMap / Esri lines so the attribution stays correct regardless of which build is deployed. - `.gitignore`: tile-proxy build artefacts (binary, `target/`, `.env`) moved to the root gitignore with fully-qualified paths so the source tree isn't hidden by a nested gitignore quirk; the per-dir `tile-proxy/.gitignore` is removed. - README + systemd service: documentation refreshed for the new build-time key flow.
This commit is contained in:
@@ -19,6 +19,13 @@ static/shopping/cumulus.svg
|
|||||||
static/hikes/
|
static/hikes/
|
||||||
hikes-assets/
|
hikes-assets/
|
||||||
src/lib/data/hikes.generated.ts
|
src/lib/data/hikes.generated.ts
|
||||||
|
# Tile-proxy build artefacts + secrets (the source tree itself is tracked).
|
||||||
|
# The binary is dropped next to Cargo.toml by `make build`; its name happens
|
||||||
|
# to collide with the directory it lives in, so the path is fully qualified
|
||||||
|
# here to avoid the nested-gitignore quirk that previously hid the source.
|
||||||
|
/tile-proxy/tile-proxy
|
||||||
|
/tile-proxy/target/
|
||||||
|
/tile-proxy/.env
|
||||||
# Private image build outputs (regenerated by scripts/build-private-images.ts).
|
# Private image build outputs (regenerated by scripts/build-private-images.ts).
|
||||||
# Sources are private + large, so they're ignored too — only the README is kept.
|
# Sources are private + large, so they're ignored too — only the README is kept.
|
||||||
private-assets/
|
private-assets/
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.93.0",
|
"version": "1.94.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -21,10 +21,14 @@ export const TILE_URL = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Combined attribution — the proxy may serve any provider depending on the
|
/** Combined attribution — the proxy may serve any provider depending on the
|
||||||
* region in view, so all three are credited. Shown in the page footer (the
|
* region in view, so all are credited. Shown in the page footer (the on-map
|
||||||
* on-map control is disabled). */
|
* control is disabled). Thunderforest is the primary `karte` upstream abroad
|
||||||
|
* when the proxy was built with a `THUNDERFOREST_API_KEY`; OpenTopoMap is
|
||||||
|
* the no-key fallback. Both are credited so the attribution stays correct
|
||||||
|
* regardless of which build is deployed. */
|
||||||
export const TILE_ATTRIBUTION =
|
export const TILE_ATTRIBUTION =
|
||||||
'© <a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener">swisstopo</a> · ' +
|
'© <a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener">swisstopo</a> · ' +
|
||||||
'© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a>, ' +
|
'Maps © <a href="https://www.thunderforest.com/" target="_blank" rel="noopener">Thunderforest</a>, ' +
|
||||||
|
'Data © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> · ' +
|
||||||
'<a href="https://opentopomap.org/" target="_blank" rel="noopener">OpenTopoMap</a> · ' +
|
'<a href="https://opentopomap.org/" target="_blank" rel="noopener">OpenTopoMap</a> · ' +
|
||||||
'© <a href="https://www.esri.com/" target="_blank" rel="noopener">Esri</a>';
|
'© <a href="https://www.esri.com/" target="_blank" rel="noopener">Esri</a>';
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
/target
|
|
||||||
/tile-proxy
|
|
||||||
+27
-9
@@ -27,11 +27,25 @@ is stateless.
|
|||||||
|
|
||||||
## Layers & providers
|
## Layers & providers
|
||||||
|
|
||||||
| `{layer}` | swisstopo region (CH + LI) | elsewhere |
|
| `{layer}` | swisstopo region (CH + LI) | elsewhere |
|
||||||
|------------|----------------------------------|-----------------------|
|
|------------|----------------------------------|----------------------------------------|
|
||||||
| `karte` | `ch.swisstopo.pixelkarte-farbe` | OpenTopoMap |
|
| `karte` | `ch.swisstopo.pixelkarte-farbe` | Thunderforest Outdoors (or OpenTopoMap)|
|
||||||
| `luftbild` | `ch.swisstopo.swissimage` | Esri World Imagery |
|
| `luftbild` | `ch.swisstopo.swissimage` | Esri World Imagery |
|
||||||
| `dufour` | `ch.swisstopo.hiks-dufour` | — (CH/LI-only) |
|
| `dufour` | `ch.swisstopo.hiks-dufour` | — (CH/LI-only) |
|
||||||
|
|
||||||
|
The `karte` upstream abroad is **Thunderforest Outdoors** when a
|
||||||
|
`THUNDERFOREST_API_KEY` is available at **build** time — it's baked into the
|
||||||
|
binary via `option_env!`. The key is read from `tile-proxy/.env` (gitignored)
|
||||||
|
by `build.rs`, or from a shell env var of the same name; both are watched so
|
||||||
|
the binary recompiles whenever the value changes. Without a key, the build
|
||||||
|
falls back to **OpenTopoMap** (no key needed, but its hypsometric tint reads
|
||||||
|
as "red mountains / green flats", which is why Thunderforest is preferred
|
||||||
|
when available).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# tile-proxy/.env (gitignored)
|
||||||
|
THUNDERFOREST_API_KEY=your-key-here
|
||||||
|
```
|
||||||
|
|
||||||
The swisstopo region is **Switzerland + Liechtenstein** (swisstopo has
|
The swisstopo region is **Switzerland + Liechtenstein** (swisstopo has
|
||||||
high-quality data for both). A tile is served by swisstopo when it **overlaps**
|
high-quality data for both). A tile is served by swisstopo when it **overlaps**
|
||||||
@@ -45,13 +59,16 @@ that 404s near the edge falls back to the global provider automatically.
|
|||||||
## Build & run
|
## Build & run
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
# Optional: drop a Thunderforest key in tile-proxy/.env for nicer abroad
|
||||||
|
# `karte` tiles; the build falls back to OpenTopoMap when the file is absent.
|
||||||
|
echo 'THUNDERFOREST_API_KEY=your-key-here' > .env
|
||||||
cargo build --release
|
cargo build --release
|
||||||
TILE_PROXY_ADDR=127.0.0.1:8765 ./target/release/tile-proxy
|
TILE_PROXY_ADDR=127.0.0.1:8765 ./target/release/tile-proxy
|
||||||
# smoke test
|
# smoke test
|
||||||
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
|
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
|
||||||
http://127.0.0.1:8765/karte/9/266/180 # Bern → swisstopo (jpeg)
|
http://127.0.0.1:8765/karte/9/266/180 # Bern → swisstopo (jpeg)
|
||||||
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
|
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
|
||||||
http://127.0.0.1:8765/karte/9/255/171 # London → OpenTopoMap (png)
|
http://127.0.0.1:8765/karte/9/255/171 # London → Thunderforest / OpenTopoMap (png)
|
||||||
```
|
```
|
||||||
|
|
||||||
`TILE_PROXY_ADDR` defaults to `127.0.0.1:8765` but should be set explicitly.
|
`TILE_PROXY_ADDR` defaults to `127.0.0.1:8765` but should be set explicitly.
|
||||||
@@ -78,11 +95,12 @@ cache so old tiles aren't served by the previous provider.
|
|||||||
|
|
||||||
## Attribution (required)
|
## Attribution (required)
|
||||||
|
|
||||||
All three providers require credit — keep these on the page (the hikes pages
|
All providers require credit — keep these on the page (the hikes pages show
|
||||||
show them in the footer):
|
them in the footer):
|
||||||
|
|
||||||
- **© swisstopo** — Swiss tiles.
|
- **© swisstopo** — Swiss tiles.
|
||||||
- **© OpenStreetMap contributors, SRTM | © OpenTopoMap (CC-BY-SA)** — world schematic.
|
- **Maps © Thunderforest, Data © OpenStreetMap contributors** — world schematic (when the key is baked in).
|
||||||
|
- **© OpenStreetMap contributors, SRTM | © OpenTopoMap (CC-BY-SA)** — world schematic (fallback build).
|
||||||
- **© Esri, Maxar, Earthstar Geographics** — world satellite.
|
- **© Esri, Maxar, Earthstar Geographics** — world satellite.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Reads `tile-proxy/.env` (gitignored, KEY=VALUE per line, `#` comments) and
|
||||||
|
// forwards each entry to rustc as `--env KEY=VALUE`, so `option_env!("KEY")`
|
||||||
|
// in main.rs picks the value up at compile time. This keeps secrets like
|
||||||
|
// THUNDERFOREST_API_KEY out of the shell and out of git — the key is read
|
||||||
|
// once at build time and baked into the binary; the running process never
|
||||||
|
// touches the file.
|
||||||
|
//
|
||||||
|
// `cargo:rerun-if-changed=.env` forces a recompile whenever the file is
|
||||||
|
// edited (set, unset, rotated), so a stale key never lingers in the cached
|
||||||
|
// binary. We also watch the env var of the same name so explicit
|
||||||
|
// `THUNDERFOREST_API_KEY=... cargo build` still works (env var wins over
|
||||||
|
// .env, mirroring how dotenv-style tools behave).
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=.env");
|
||||||
|
println!("cargo:rerun-if-env-changed=THUNDERFOREST_API_KEY");
|
||||||
|
|
||||||
|
let Ok(contents) = fs::read_to_string(".env") else {
|
||||||
|
return; // .env is optional — the binary falls back to OpenTopoMap.
|
||||||
|
};
|
||||||
|
|
||||||
|
for raw in contents.lines() {
|
||||||
|
let line = raw.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((key, value)) = line.split_once('=') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let key = key.trim();
|
||||||
|
// Strip optional surrounding quotes around the value, then trim.
|
||||||
|
let value = value.trim().trim_matches('"').trim_matches('\'');
|
||||||
|
if key.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Explicit shell env var beats the .env entry — same precedence as
|
||||||
|
// standard dotenv tooling. std::env::var here reflects what was set
|
||||||
|
// on the cargo invocation.
|
||||||
|
if std::env::var_os(key).is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
println!("cargo:rustc-env={key}={value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
# systemd unit for the tile proxy.
|
# systemd unit for the tile proxy.
|
||||||
# install: cp deploy/tile-proxy.service /etc/systemd/system/
|
# install: cp deploy/tile-proxy.service /etc/systemd/system/
|
||||||
# (build first: cargo build --release; adjust paths/user below)
|
# (build first: drop the Thunderforest key into tile-proxy/.env
|
||||||
|
# and run `cargo build --release`; build.rs reads .env and bakes
|
||||||
|
# the key into the binary at compile time, nothing is read at
|
||||||
|
# runtime; adjust paths/user below)
|
||||||
# enable: systemctl daemon-reload && systemctl enable --now tile-proxy
|
# enable: systemctl daemon-reload && systemctl enable --now tile-proxy
|
||||||
|
|
||||||
[Unit]
|
[Unit]
|
||||||
|
|||||||
+28
-7
@@ -5,11 +5,18 @@
|
|||||||
//! the tile overlaps a swisstopo-covered region (Switzerland or Liechtenstein,
|
//! the tile overlaps a swisstopo-covered region (Switzerland or Liechtenstein,
|
||||||
//! each buffered ~2 km) and a global provider otherwise:
|
//! each buffered ~2 km) and a global provider otherwise:
|
||||||
//!
|
//!
|
||||||
//! | layer | swisstopo region (CH + LI) | elsewhere |
|
//! | layer | swisstopo region (CH + LI) | elsewhere |
|
||||||
//! |----------|------------------------------|----------------------------|
|
//! |----------|------------------------------|--------------------------------------|
|
||||||
//! | karte | ch.swisstopo.pixelkarte-farbe| OpenTopoMap |
|
//! | karte | ch.swisstopo.pixelkarte-farbe| Thunderforest Outdoors (or OpenTopo) |
|
||||||
//! | luftbild | ch.swisstopo.swissimage | Esri World Imagery |
|
//! | luftbild | ch.swisstopo.swissimage | Esri World Imagery |
|
||||||
//! | dufour | ch.swisstopo.hiks-dufour | — (CH/LI-only historical) |
|
//! | dufour | ch.swisstopo.hiks-dufour | — (CH/LI-only historical) |
|
||||||
|
//!
|
||||||
|
//! The `karte` upstream abroad is Thunderforest Outdoors when the
|
||||||
|
//! `THUNDERFOREST_API_KEY` env var is set at *build* time (it's baked into
|
||||||
|
//! the binary via `option_env!`), otherwise OpenTopoMap (whose hypsometric
|
||||||
|
//! tint reads "red mountains / green flats"). `build.rs` reads the key from
|
||||||
|
//! `tile-proxy/.env` (gitignored) or a shell env var, and requests a
|
||||||
|
//! recompile when either changes.
|
||||||
//!
|
//!
|
||||||
//! Caching, TLS and rate-limiting live in nginx in front of this service
|
//! Caching, TLS and rate-limiting live in nginx in front of this service
|
||||||
//! (see README.md) — this binary only does the routing + upstream fetch.
|
//! (see README.md) — this binary only does the routing + upstream fetch.
|
||||||
@@ -133,13 +140,21 @@ fn upstreams(layer: &str, z: u32, x: u32, y: u32) -> Option<(String, Option<Stri
|
|||||||
// covered ground).
|
// covered ground).
|
||||||
let swiss = tile_intersects_regions(z, x, y);
|
let swiss = tile_intersects_regions(z, x, y);
|
||||||
let opentopo = || format!("https://a.tile.opentopomap.org/{z}/{x}/{y}.png");
|
let opentopo = || format!("https://a.tile.opentopomap.org/{z}/{x}/{y}.png");
|
||||||
|
// Thunderforest Outdoors looks much closer to swisstopo (subtle hillshade,
|
||||||
|
// muted topo palette, hiking paths) than OpenTopoMap's hypsometric tint.
|
||||||
|
// Used as the foreign `karte` upstream when a build-time API key is
|
||||||
|
// baked in; otherwise we fall back to OpenTopoMap.
|
||||||
|
let outdoors_abroad = || match THUNDERFOREST_API_KEY {
|
||||||
|
Some(key) => format!("https://tile.thunderforest.com/outdoors/{z}/{x}/{y}.png?apikey={key}"),
|
||||||
|
None => opentopo(),
|
||||||
|
};
|
||||||
// NB: Esri uses {z}/{y}/{x} (row before column).
|
// NB: Esri uses {z}/{y}/{x} (row before column).
|
||||||
let esri = || {
|
let esri = || {
|
||||||
format!("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}")
|
format!("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}")
|
||||||
};
|
};
|
||||||
match layer {
|
match layer {
|
||||||
"karte" if swiss => Some((swisstopo("ch.swisstopo.pixelkarte-farbe", "jpeg", z, x, y), Some(opentopo()))),
|
"karte" if swiss => Some((swisstopo("ch.swisstopo.pixelkarte-farbe", "jpeg", z, x, y), Some(outdoors_abroad()))),
|
||||||
"karte" => Some((opentopo(), None)),
|
"karte" => Some((outdoors_abroad(), None)),
|
||||||
"luftbild" if swiss => Some((swisstopo("ch.swisstopo.swissimage", "jpeg", z, x, y), Some(esri()))),
|
"luftbild" if swiss => Some((swisstopo("ch.swisstopo.swissimage", "jpeg", z, x, y), Some(esri()))),
|
||||||
"luftbild" => Some((esri(), None)),
|
"luftbild" => Some((esri(), None)),
|
||||||
// Historical Dufour map only exists for Switzerland.
|
// Historical Dufour map only exists for Switzerland.
|
||||||
@@ -148,6 +163,12 @@ fn upstreams(layer: &str, z: u32, x: u32, y: u32) -> Option<(String, Option<Stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Thunderforest Outdoors API key, baked into the binary at build time from
|
||||||
|
/// the `THUNDERFOREST_API_KEY` env var. When absent the `karte` layer falls
|
||||||
|
/// back to OpenTopoMap abroad. `build.rs` requests a rebuild whenever the
|
||||||
|
/// env var changes.
|
||||||
|
const THUNDERFOREST_API_KEY: Option<&str> = option_env!("THUNDERFOREST_API_KEY");
|
||||||
|
|
||||||
async fn fetch(url: &str) -> Option<Response> {
|
async fn fetch(url: &str) -> Option<Response> {
|
||||||
let r = client().get(url).send().await.ok()?;
|
let r = client().get(url).send().await.ok()?;
|
||||||
if !r.status().is_success() {
|
if !r.status().is_success() {
|
||||||
|
|||||||
Reference in New Issue
Block a user