diff --git a/TODO.adoc b/TODO.adoc index 37dca45..fb207ff 100644 --- a/TODO.adoc +++ b/TODO.adoc @@ -14,7 +14,7 @@ independence from connectivity. * [ ] Contour lines from OSM for hikes and mountain passes * [x] Overpass/OSM scripts to extract POIs (drinking water, dump stations, LPG, public toilets, laundromats, shops, springs) — `rake overpass:pois`, see docs/development.adoc; writes a `.geojson` to drop in `data/gpx/` or upload, not called by the running app itself * [ ] Import Park4Night / iOverlander / Freecampsites data -* [x] Kiwix/ZIM offline Wikipedia linked to POI GPS coordinates — a "Nearby on Wikipedia" popup link straight to kiwix-serve's own geo search (`GET /search?latitude=&longitude=&distance=`, confirmed from its actual source, `libkiwix/internalServer.cpp`), not proxied/embedded — it already has a full search UI. `KIWIX_URL` + `GET /api/kiwix` reachability check, same optional-integration pattern as offline.pmtiles/terrain.pmtiles; `docker-compose.yaml` now runs a `kiwix` service, ZIM not included (see docs/development.adoc for where to get one). Not live-tested against a real ZIM (none available here) — verified via a stand-in local HTTP server + real popup-link generation +* [x] Kiwix/ZIM offline Wikipedia linked to POI GPS coordinates — a "Nearby on Wikipedia" popup link to kiwix-serve's own geo search (`GET /kiwix/search?latitude=&longitude=&distance=`), proxied through this app (`GET /kiwix/*` in app.rb) rather than a direct link to kiwix-serve — production sits behind a reverse proxy that only forwards to this app, so no address exists for a browser to reach kiwix-serve at directly; kiwix-serve runs with `--urlRootLocation=/kiwix` so its own emitted links already match. `KIWIX_URL` + `GET /api/kiwix` reachability check, same optional-integration pattern as offline.pmtiles/terrain.pmtiles; `docker-compose.yaml` now runs a `kiwix` service, ZIM not included (see docs/development.adoc for where to get one) * [x] Cache weather forecast when temporarily connected — Open-Meteo 3-day forecast in the Radar flyout, last successful fetch cached in localStorage as a fallback when offline ** [x] Cache the radar tile *images* too — `public/sw.js`, network-falling-back-to-cache for RainViewer tile requests, registered from layout.erb diff --git a/app.rb b/app.rb index 7e91819..e4e48e7 100644 --- a/app.rb +++ b/app.rb @@ -73,21 +73,13 @@ module Maptool # An external kiwix-serve instance (see docs/development.adoc), not # something this app runs itself — blank disables the "Nearby on # Wikipedia" popup link the same way the other optional-integration - # blanks above do. This is reached server-side only (the reachability - # check below, same-Docker-network service name in production, e.g. - # "http://kiwix:8080") — it is NOT what gets handed to the browser, see - # KIWIX_PUBLIC_PORT. + # blanks above do. Only ever reached server-side (the Docker-network + # service name in production, e.g. "http://kiwix:8080") — production + # sits behind a reverse proxy (map.priesol.net) that only forwards to + # this app, so a browser can't reach kiwix-serve directly by any + # hostname/port of its own. GET /kiwix/* below proxies to it instead. KIWIX_URL = ENV.fetch("KIWIX_URL", "") - # The port kiwix-serve is published on for browsers to reach directly - # (docker-compose.yaml's kiwix service ports: mapping) — KIWIX_URL above - # is a Docker-network-internal hostname a real browser can't resolve at - # all, so the "Nearby on Wikipedia" link is instead built from whatever - # host the visitor already used to reach maptool itself - # (request.host) plus this port. Blank disables the link, same as a - # blank KIWIX_URL. - KIWIX_PUBLIC_PORT = ENV.fetch("KIWIX_PUBLIC_PORT", "") - # Login delegates to the Gitea instance the user already has an account # on, rather than maintaining a local username/password — see # docs/architecture.adoc. Only users listed in ALLOWED_GITEA_USERS may @@ -218,16 +210,29 @@ module Maptool send_file TERRAIN_PMTILES_PATH, type: "application/octet-stream" end - # Pinged once per page load (public/js/map.js) — url is handed back so - # the frontend can build the geo-search link itself; this server never - # proxies to kiwix-serve, just says whether it's there. url is built - # from request.host (whatever hostname/IP the visitor already used to - # reach maptool itself) + KIWIX_PUBLIC_PORT, not KIWIX_URL — that's a - # Docker-network-internal address only this server can resolve. + # Pinged once per page load (public/js/map.js) — url is a same-origin + # path (not kiwix-serve's own address, which the browser can't reach + # directly behind the reverse proxy in front of this app), proxied by + # GET /kiwix/* below. get "/api/kiwix" do reachable = Kiwix.reachable?(KIWIX_URL) - url = KIWIX_PUBLIC_PORT.empty? ? "" : "http://#{request.host}:#{KIWIX_PUBLIC_PORT}" - json available: reachable && !url.empty?, url: url + json available: reachable, url: (reachable ? "/kiwix" : "") + end + + # Proxies kiwix-serve's own search UI and content pages through this + # app instead of linking the browser to it directly (see KIWIX_URL) — + # kiwix-serve is started with --urlRootLocation=/kiwix + # (docker-compose.yaml), so every link/asset it emits already carries + # this same /kiwix prefix and Just Works through this passthrough. GET + # only: browsing a read-only ZIM archive never needs to write anything. + get "/kiwix/*" do + halt 404 if KIWIX_URL.empty? + response = Kiwix.proxy(KIWIX_URL, "/kiwix/#{params[:splat].first}", request.query_string) + halt 502 unless response + + content_type response["content-type"] || "application/octet-stream" + status response.code.to_i + body response.body end get "/api/gpx/tree" do diff --git a/docker-compose.yaml b/docker-compose.yaml index 0718206..2002eed 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -37,14 +37,10 @@ services: # actually being present: GET /api/kiwix pings it and just reports # unavailable if the container isn't up/reachable, same story as # every other optional-local-file feature (offline.pmtiles, etc). - # Server-side reachability check only — a browser on the LAN can't - # resolve this hostname, see KIWIX_PUBLIC_PORT below. + # Reached server-side only — production sits behind a reverse proxy + # that only forwards to maptool itself, so app.rb's GET /kiwix/* + # proxies to this instead of handing the browser this address. KIWIX_URL: "http://kiwix:8080" - # The host port kiwix's own ports: mapping below publishes it on — - # app.rb builds the browser-facing "Nearby on Wikipedia" link from - # whatever host the visitor used to reach maptool itself plus this - # port, since KIWIX_URL above is Docker-internal only. - KIWIX_PUBLIC_PORT: "8090" volumes: # Uploaded GPX files and the offline PMTiles extract — kept outside # the image so a rebuild/redeploy doesn't wipe them (see @@ -93,11 +89,14 @@ services: # --port=8080 (its default) before appending this command array, so # passing it again produced "Unexpected argument: --port=8080, # --port=8080, /data/wikipedia.zim" and kiwix-serve refused to start. - command: ["/data/wikipedia.zim"] - # Published so browsers on the LAN can reach kiwix-serve's own search - # UI directly (see maptool's KIWIX_PUBLIC_PORT above) — 8090, not 8080, - # since 8080 is already taken by another container on this host. - ports: - - "8090:8080" + # + # --urlRootLocation=/kiwix makes every link/asset kiwix-serve itself + # emits (search results, article pages, its CSS/JS) carry a /kiwix + # prefix, matching where app.rb's GET /kiwix/* proxies requests in + # from (confirmed against libkiwix's internalServer.cpp: it strips + # this same prefix off incoming request paths, so both ends agree on + # it) — no ports: mapping needed, nothing outside the compose network + # ever talks to this container directly. + command: ["--urlRootLocation=/kiwix", "/data/wikipedia.zim"] volumes: - ./data/kiwix/wikipedia.zim:/data/wikipedia.zim diff --git a/docs/development.adoc b/docs/development.adoc index d1d6375..b2e2e1f 100644 --- a/docs/development.adoc +++ b/docs/development.adoc @@ -80,12 +80,12 @@ Wikipedia https://library.kiwix.org[ZIM] loaded — set `KIWIX_URL` to wherever *the server* can reach it (`docker-compose.yaml` already runs a `kiwix` service pointed at `data/kiwix/wikipedia.zim` and wires `KIWIX_URL` to it via its compose service name; blank/unset disables -the link, same as every other optional integration here). This is -used only for the server-side reachability check — a browser can't -resolve a Docker-internal hostname like `kiwix`, so the link itself is -built from `KIWIX_PUBLIC_PORT` (the host port kiwix-serve is -`ports:`-published on, `8090` in `docker-compose.yaml`) plus whatever -host the visitor already used to reach maptool itself. Not part of the +the link, same as every other optional integration here). Used only +server-side: production sits behind a reverse proxy that forwards only +to this app, so there's no address a browser could reach kiwix-serve +at directly — `GET /kiwix/*` (`app.rb`) proxies to it instead, and the +`kiwix` service runs with `--urlRootLocation=/kiwix` so its own +emitted links/assets already carry that same prefix. Not part of the repo — a ZIM is a large, self-contained archive, obtain one locally: * https://library.kiwix.org/#lang=eng&category=wikipedia[Kiwix's own @@ -105,10 +105,11 @@ repo — a ZIM is a large, self-contained archive, obtain one locally: Once it's there, `docker compose up -d kiwix` (or a full restart) picks it up — `GET /api/kiwix` reports `available: true` and the -"Nearby on Wikipedia" link appears. kiwix-serve's own geo search -(`GET /search?pattern=&latitude=&longitude=&distance=`, -`lib/maptool/kiwix.rb`'s docstring) is linked to directly, not -proxied/embedded — it already has a full search UI of its own. +"Nearby on Wikipedia" link appears, pointed at kiwix-serve's own geo +search (`GET /kiwix/search?pattern=&latitude=&longitude=&distance=`) +through this app's own proxy rather than at kiwix-serve directly — it +already has a full search UI of its own, this just makes it reachable +through the same origin/reverse proxy as the rest of the app. == GNSS position (gpsd) diff --git a/lib/maptool/kiwix.rb b/lib/maptool/kiwix.rb index 0edb26a..faf6342 100644 --- a/lib/maptool/kiwix.rb +++ b/lib/maptool/kiwix.rb @@ -1,12 +1,14 @@ require "net/http" module Maptool - # A thin reachability check for an external kiwix-serve instance serving - # an offline Wikipedia ZIM (see docs/development.adoc for how to set one - # up) — the app itself never talks to it beyond this ping. Linking a POI - # popup to a geo search there (public/js/gpx.js) is a plain browser straight to kiwix-serve, not something this server proxies — - # kiwix-serve already has its own full search UI, no reason to rebuild it. + # A reachability check plus a passthrough proxy for an external + # kiwix-serve instance serving an offline Wikipedia ZIM (see + # docs/development.adoc for how to set one up). A POI popup's "Nearby on + # Wikipedia" link (public/js/gpx.js) used to point straight at + # kiwix-serve itself — fine on a LAN, broken once this app started + # running behind a reverse proxy (map.priesol.net) that only forwards to + # *this* app, leaving no address a browser could reach kiwix-serve at + # directly. GET /kiwix/* in app.rb calls .proxy below instead. module Kiwix def self.reachable?(url, open_timeout: 1, read_timeout: 2) return false if url.to_s.strip.empty? @@ -17,5 +19,20 @@ module Maptool rescue StandardError false end + + # path already carries the /kiwix prefix kiwix-serve itself expects + # (started with --urlRootLocation=/kiwix, see docker-compose.yaml) — + # this is a plain passthrough, not a rewrite, so its emitted links stay + # correct without this having to touch response bodies at all. Returns + # nil (halted as a 502 by the caller) rather than raising, same + # not-reachable-isn't-exceptional stance as .reachable? above. + def self.proxy(base_url, path, query_string, open_timeout: 2, read_timeout: 10) + uri = URI(base_url) + uri.path = path + uri.query = query_string unless query_string.to_s.empty? + Net::HTTP.start(uri.host, uri.port, open_timeout: open_timeout, read_timeout: read_timeout) { |http| http.request(Net::HTTP::Get.new(uri)) } + rescue StandardError + nil + end end end diff --git a/test/test_app.rb b/test/test_app.rb index 558b08b..eb0331e 100644 --- a/test/test_app.rb +++ b/test/test_app.rb @@ -28,6 +28,10 @@ class AppTest < Minitest::Test assert_equal({ "available" => false, "url" => "" }, json_body(response)) end + def test_kiwix_proxy_404s_when_no_url_configured + assert_equal 404, request.get("/kiwix/search").status + end + def test_gnss_position_unavailable_with_no_fix response = request.get("/api/gnss/position", **logged_in_env) assert_equal({ "available" => false }, json_body(response)) diff --git a/test/test_kiwix.rb b/test/test_kiwix.rb index bb5ea52..d233aa1 100644 --- a/test/test_kiwix.rb +++ b/test/test_kiwix.rb @@ -15,4 +15,34 @@ class KiwixTest < Minitest::Test def test_reachable_is_false_when_nothing_is_listening refute Maptool::Kiwix.reachable?("http://127.0.0.1:1", open_timeout: 1) end + + # A bare TCPServer stub (not WEBrick — not a project dependency, and this + # only needs to echo back what request line it received) confirms .proxy + # actually builds the target URL from path + query_string correctly, not + # just that it can complete *a* request (already covered by nothing here + # exercising a wrong path/query, unlike .reachable? which only ever hits + # "/"). + def test_proxy_forwards_path_and_query_and_returns_the_response + server = TCPServer.new("127.0.0.1", 0) + port = server.addr[1] + request_line = nil + thread = Thread.new do + client = server.accept + request_line = client.gets + client.print("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nhi") + client.close + end + + response = Maptool::Kiwix.proxy("http://127.0.0.1:#{port}", "/kiwix/search", "pattern=&latitude=1") + thread.join + server.close + + assert_equal "GET /kiwix/search?pattern=&latitude=1 HTTP/1.1\r\n", request_line + assert_equal "200", response.code + assert_equal "hi", response.body + end + + def test_proxy_returns_nil_when_nothing_is_listening + assert_nil Maptool::Kiwix.proxy("http://127.0.0.1:1", "/kiwix/search", "", open_timeout: 1) + end end