diff --git a/.gitignore b/.gitignore
index 2a43c834..be63b047 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,13 @@ static/shopping/cumulus.svg
static/hikes/
hikes-assets/
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).
# Sources are private + large, so they're ignored too — only the README is kept.
private-assets/
diff --git a/package.json b/package.json
index bed5121c..4f4eb276 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "homepage",
- "version": "1.93.0",
+ "version": "1.94.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/src/lib/data/mapTiles.ts b/src/lib/data/mapTiles.ts
index 8d4569ca..2c237192 100644
--- a/src/lib/data/mapTiles.ts
+++ b/src/lib/data/mapTiles.ts
@@ -21,10 +21,14 @@ export const TILE_URL = {
} as const;
/** 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
- * on-map control is disabled). */
+ * region in view, so all are credited. Shown in the page footer (the on-map
+ * 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 =
'© swisstopo · ' +
- '© OpenStreetMap, ' +
+ 'Maps © Thunderforest, ' +
+ 'Data © OpenStreetMap · ' +
'OpenTopoMap · ' +
'© Esri';
diff --git a/tile-proxy/.gitignore b/tile-proxy/.gitignore
deleted file mode 100644
index 0c50b62f..00000000
--- a/tile-proxy/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-/target
-/tile-proxy
diff --git a/tile-proxy/README.md b/tile-proxy/README.md
index 80fa32fa..4a9a9fb5 100644
--- a/tile-proxy/README.md
+++ b/tile-proxy/README.md
@@ -27,11 +27,25 @@ is stateless.
## Layers & providers
-| `{layer}` | swisstopo region (CH + LI) | elsewhere |
-|------------|----------------------------------|-----------------------|
-| `karte` | `ch.swisstopo.pixelkarte-farbe` | OpenTopoMap |
-| `luftbild` | `ch.swisstopo.swissimage` | Esri World Imagery |
-| `dufour` | `ch.swisstopo.hiks-dufour` | — (CH/LI-only) |
+| `{layer}` | swisstopo region (CH + LI) | elsewhere |
+|------------|----------------------------------|----------------------------------------|
+| `karte` | `ch.swisstopo.pixelkarte-farbe` | Thunderforest Outdoors (or OpenTopoMap)|
+| `luftbild` | `ch.swisstopo.swissimage` | Esri World Imagery |
+| `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
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
```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
TILE_PROXY_ADDR=127.0.0.1:8765 ./target/release/tile-proxy
# smoke test
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
http://127.0.0.1:8765/karte/9/266/180 # Bern → swisstopo (jpeg)
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.
@@ -78,11 +95,12 @@ cache so old tiles aren't served by the previous provider.
## Attribution (required)
-All three providers require credit — keep these on the page (the hikes pages
-show them in the footer):
+All providers require credit — keep these on the page (the hikes pages show
+them in the footer):
- **© 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.
## Notes
diff --git a/tile-proxy/build.rs b/tile-proxy/build.rs
new file mode 100644
index 00000000..5f5347dd
--- /dev/null
+++ b/tile-proxy/build.rs
@@ -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}");
+ }
+}
diff --git a/tile-proxy/deploy/tile-proxy.service b/tile-proxy/deploy/tile-proxy.service
index 26fd6ef0..8b8d16e3 100644
--- a/tile-proxy/deploy/tile-proxy.service
+++ b/tile-proxy/deploy/tile-proxy.service
@@ -1,6 +1,9 @@
# systemd unit for the tile proxy.
# 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
[Unit]
diff --git a/tile-proxy/src/main.rs b/tile-proxy/src/main.rs
index f724d0c4..a6cfe615 100644
--- a/tile-proxy/src/main.rs
+++ b/tile-proxy/src/main.rs
@@ -5,11 +5,18 @@
//! the tile overlaps a swisstopo-covered region (Switzerland or Liechtenstein,
//! each buffered ~2 km) and a global provider otherwise:
//!
-//! | layer | swisstopo region (CH + LI) | elsewhere |
-//! |----------|------------------------------|----------------------------|
-//! | karte | ch.swisstopo.pixelkarte-farbe| OpenTopoMap |
-//! | luftbild | ch.swisstopo.swissimage | Esri World Imagery |
-//! | dufour | ch.swisstopo.hiks-dufour | — (CH/LI-only historical) |
+//! | layer | swisstopo region (CH + LI) | elsewhere |
+//! |----------|------------------------------|--------------------------------------|
+//! | karte | ch.swisstopo.pixelkarte-farbe| Thunderforest Outdoors (or OpenTopo) |
+//! | luftbild | ch.swisstopo.swissimage | Esri World Imagery |
+//! | 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
//! (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 format!("https://tile.thunderforest.com/outdoors/{z}/{x}/{y}.png?apikey={key}"),
+ None => opentopo(),
+ };
// NB: Esri uses {z}/{y}/{x} (row before column).
let esri = || {
format!("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}")
};
match layer {
- "karte" if swiss => Some((swisstopo("ch.swisstopo.pixelkarte-farbe", "jpeg", z, x, y), Some(opentopo()))),
- "karte" => Some((opentopo(), None)),
+ "karte" if swiss => Some((swisstopo("ch.swisstopo.pixelkarte-farbe", "jpeg", z, x, y), Some(outdoors_abroad()))),
+ "karte" => Some((outdoors_abroad(), None)),
"luftbild" if swiss => Some((swisstopo("ch.swisstopo.swissimage", "jpeg", z, x, y), Some(esri()))),
"luftbild" => Some((esri(), None)),
// Historical Dufour map only exists for Switzerland.
@@ -148,6 +163,12 @@ fn upstreams(layer: &str, z: u32, x: u32, y: u32) -> Option<(String, Option = option_env!("THUNDERFOREST_API_KEY");
+
async fn fetch(url: &str) -> Option {
let r = client().get(url).send().await.ok()?;
if !r.status().is_success() {