From 72ffd0ef18cf75f4e6c4055eb5a44a749ded13da Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Wed, 9 Sep 2026 16:23:31 +0200 Subject: [PATCH] Add an import plugin system, with the Overpass POI query as its first plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin is a directory under plugins/ with a plugin.json manifest ({"label": "...", "command": "./run.sh"}) plus a script that reads --south/--west/--north/--east and prints a GeoJSON FeatureCollection to stdout (lib/maptool/plugins.rb) — that's the entire interface a plugin needs to know about this app. The sidebar's new import icon (logged in only, GET /api/plugins + POST /api/plugins/:name/run) lists whatever's found and runs one for the current map viewport, saving its output as a new .geojson file the same way an upload would be. The point: a scraper for a site whose ToS forbids that (Park4Night, iOverlander, Freecampsites) doesn't need to live in this public repo to work here — its own directory just goes under plugins/ locally and is never git add'ed, no special-casing needed anywhere. plugins/overpass-pois wraps the existing rake overpass:pois query as the first, in-repo, reference example of the contract. Also fills in four modules (auth.js, locate.js, measure.js, notes.js) missing from layout.erb's cache-busting importmap alongside adding plugins.js's own entry — a pre-existing gap that meant they could keep serving a stale cached copy across a deploy despite the importmap existing specifically to prevent that. --- TODO.adoc | 5 +- app.rb | 37 +++++++ docs/development.adoc | 31 ++++-- lib/maptool/overpass_pois.rb | 13 +-- lib/maptool/plugins.rb | 65 +++++++++++++ plugins/overpass-pois/plugin.json | 4 + plugins/overpass-pois/run.rb | 16 ++++ public/css/style.css | 57 +++++++++++ public/js/icons.js | 9 ++ public/js/map.js | 4 + public/js/plugins.js | 96 +++++++++++++++++++ test/fixtures/plugins/test-plugin/plugin.json | 4 + test/fixtures/plugins/test-plugin/run.sh | 5 + test/test_app.rb | 37 +++++++ test/test_helper.rb | 5 + test/test_plugins.rb | 55 +++++++++++ views/layout.erb | 5 + 17 files changed, 434 insertions(+), 14 deletions(-) create mode 100644 lib/maptool/plugins.rb create mode 100644 plugins/overpass-pois/plugin.json create mode 100755 plugins/overpass-pois/run.rb create mode 100644 public/js/plugins.js create mode 100644 test/fixtures/plugins/test-plugin/plugin.json create mode 100755 test/fixtures/plugins/test-plugin/run.sh create mode 100644 test/test_plugins.rb diff --git a/TODO.adoc b/TODO.adoc index fb207ff..ceb6b6e 100644 --- a/TODO.adoc +++ b/TODO.adoc @@ -12,8 +12,9 @@ independence from connectivity. * [x] Shaded relief / DEM layer — hillshade toggle in the sidebar, sourced from public Terrarium tiles online; reads `data/terrain.pmtiles` for offline if one's been generated (see docs/development.adoc), otherwise the toggle is online-only for now ** [ ] Terrain slope readout for parking (numeric, not just visual shading) * [ ] 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] 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. Also runnable from inside the app itself now, see the import plugins entry below (`plugins/overpass-pois` wraps this same query) +* [x] Import plugins: a pluggable way to add more data sources (Park4Night, iOverlander, Freecampsites, ...) without their scrapers necessarily living in this (public) repo — sidebar icon (logged in only) lists whatever's under `plugins/`, "Run for this area" uses the current map viewport as the bbox, no separate area-picker needed. Contract: a `plugin.json` manifest + a script that reads `--south`/`--west`/`--north`/`--east` and prints a GeoJSON `FeatureCollection` to stdout (`lib/maptool/plugins.rb`, `GET /api/plugins`, `POST /api/plugins/:name/run`) — output saved the same way an upload would be. `plugins/overpass-pois` ships as the reference example; a private/ToS-sensitive scraper just drops its own directory in locally and is never `git add`ed +** [ ] Park4Night / iOverlander / Freecampsites data itself — no plugin written yet for any of these, the infra above just makes adding one (in a private repo if needed) straightforward whenever that happens * [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 e4e48e7..2a7a216 100644 --- a/app.rb +++ b/app.rb @@ -11,6 +11,7 @@ require_relative "lib/maptool/gpsd_client" require_relative "lib/maptool/livetrack_store" require_relative "lib/maptool/livetrack_client" require_relative "lib/maptool/kiwix" +require_relative "lib/maptool/plugins" module Maptool class App < Sinatra::Base @@ -63,6 +64,14 @@ module Maptool GPX_STORE = GpxStore.new(File.join(DATA_DIR, "gpx")) LIVETRACK_STORE = LiveTrackStore.new(File.join(DATA_DIR, "livetrack.json")) + # Not under DATA_DIR: unlike everything else there (uploads, PMTiles, + # the kiwix ZIM), a plugin is code, not local data — plugins/overpass-pois + # ships checked into this repo as the reference example of the + # contract. A private/scraper plugin just lives in its own directory + # here too, simply never `git add`ed, no special-casing needed. + # Overridable so tests can point it at a throwaway fixture directory. + PLUGINS_DIR = ENV.fetch("MAPTOOL_PLUGINS_DIR") { File.join(__dir__, "plugins") } + # Shared secret for the Garmin LiveTrack webhook (a Google Apps Script # watching Gmail for LiveTrack emails, see docs/deployment.adoc) — not a # Gitea-authenticated user, so it can't use the OAuth2 login flow above. @@ -329,6 +338,34 @@ module Maptool json ok: true end + # Lists whatever import plugins are on disk (see lib/maptool/plugins.rb + # for the contract) — read-only, doesn't need require_login! the way + # actually running one does below. + get "/api/plugins" do + json Plugins.list(PLUGINS_DIR) + end + + # Runs one plugin for the given bbox (the current map viewport, from + # the frontend's "Run for this area" button) and saves its GeoJSON + # output as a new file the same way an uploaded .geojson would be + # saved — reusing GpxStore#save rather than #append_feature since this + # writes a whole FeatureCollection at once, not one feature into an + # existing one. + post "/api/plugins/:name/run" do + require_login! + bbox = %w[south west north east].map { |key| params[key].to_s } + halt 400, json(error: "south, west, north, east are required") if bbox.any?(&:empty?) + + begin + geojson = Plugins.run(PLUGINS_DIR, params[:name], bbox.map(&:to_f)) + rescue Plugins::RunError => e + halt 502, json(error: e.message) + end + + path = GPX_STORE.save(folder: "", filename: "#{params[:name]}.geojson", io: StringIO.new(JSON.generate(geojson))) + json path: path, tree: GPX_STORE.tree + end + # ?download=geojson exports the file's GeoJSON as an attachment instead # of rendering it on the map — the only way to get a .gpx/.fit's # converted form out of the app, since GpxStore#geojson's cache is diff --git a/docs/development.adoc b/docs/development.adoc index b2e2e1f..0cce540 100644 --- a/docs/development.adoc +++ b/docs/development.adoc @@ -65,12 +65,31 @@ https://github.com/protomaps/go-pmtiles[go-pmtiles], same as https://overpass-api.de[Overpass] for drinking water, dump stations, LPG refill, public toilets, laundromats, supermarkets/convenience stores, and springs within a bbox, and writes the result as a -`.geojson` file (`lib/maptool/overpass_pois.rb`). A dev-time tool, not -something the running app calls itself — drop the output into -`data/gpx/` (or upload it through the Tracks panel) like any other -`.geojson` layer once it's generated. Nodes only (nothing that's -mapped as a way/relation, e.g. a supermarket traced as a building -outline rather than a single point). +`.geojson` file (`lib/maptool/overpass_pois.rb`). Nodes only (nothing +that's mapped as a way/relation, e.g. a supermarket traced as a +building outline rather than a single point). Also reachable from +inside the running app itself, without the command line — see Import +plugins below, which wraps this exact query as `plugins/overpass-pois`. + +== Import plugins + +The sidebar's import icon (logged in only) lists whatever's under +`plugins/` and runs one for the current map viewport with a click — no +area-picker to fill in, the visible area *is* the area. Each plugin is +a directory with a `plugin.json` manifest (`{"label": "...", "command": +"./run.sh"}`) plus whatever script that command names; the entire +interface a plugin needs to know about maptool is "read +`--south`/`--west`/`--north`/`--east`, print a GeoJSON +`FeatureCollection` to stdout" (`lib/maptool/plugins.rb`) — the result +is saved as a new `.geojson` file the same way an upload would be. + +`plugins/overpass-pois` ships as the reference example (a thin wrapper +around the `rake overpass:pois` logic above). A plugin that scrapes a +site whose terms of service forbid that — Park4Night, iOverlander, +Freecampsites — doesn't have to live in this (public) repo to work +here: drop its directory under `plugins/` locally and just don't +`git add` it, same as any other local-only file this app already +treats that way (`data/offline.pmtiles`, the kiwix ZIM, ...). == Offline Wikipedia (kiwix-serve) diff --git a/lib/maptool/overpass_pois.rb b/lib/maptool/overpass_pois.rb index 636d973..740096e 100644 --- a/lib/maptool/overpass_pois.rb +++ b/lib/maptool/overpass_pois.rb @@ -2,12 +2,13 @@ require "net/http" require "json" module Maptool - # A dev-time tool (see the "overpass:pois" Rake task), not something the - # deployed app calls itself — same "generate locally, drop the result in - # data/gpx/" story as offline.pmtiles/terrain.pmtiles (docs/development.adoc), - # kept off the running server so it never depends on Overpass's public - # instance being up, or eats into its (fairly strict, shared) rate limits - # on every page load. + # Originally a dev-time-only tool (the "overpass:pois" Rake task) kept + # off the running server so it never depended on Overpass's public + # instance being up, or ate into its (fairly strict, shared) rate limits, + # on every page load. plugins/overpass-pois now also calls this from the + # running app — but only ever on an explicit, login-gated "Run for this + # area" click (see lib/maptool/plugins.rb), never automatically, so that + # concern doesn't apply there the way it would if this ran on page load. module OverpassPois class FetchError < StandardError; end diff --git a/lib/maptool/plugins.rb b/lib/maptool/plugins.rb new file mode 100644 index 0000000..60662e2 --- /dev/null +++ b/lib/maptool/plugins.rb @@ -0,0 +1,65 @@ +require "json" +require "open3" + +module Maptool + # Runs an external "importer" script that turns a bbox into a GeoJSON + # FeatureCollection, and lists what's available. Each plugin is a + # directory under PLUGINS_DIR with a plugin.json manifest ({"label": + # "...", "command": "./run.rb"}) plus whatever script it names — the + # entire interface a plugin needs to know about maptool is "read + # --south/--west/--north/--east, print GeoJSON to stdout". Kept this thin + # on purpose: it's what lets an importer that scrapes a site whose ToS + # forbids that live in its own private repo, dropped into plugins/ + # locally, never touching this (public) one — see plugins/overpass-pois + # for a real, in-repo example of the contract itself. + module Plugins + class RunError < StandardError; end + + # A bare directory name, same rule Sinatra's own :name route param + # already enforces (no "/"), but ".." alone would still resolve one + # level up via File.join — reject that too before it ever reaches the + # filesystem, same spirit as GpxStore's own path-escape guard. + NAME_PATTERN = /\A[A-Za-z0-9_-]+\z/ + + def self.list(dir) + Dir.glob(File.join(dir, "*", "plugin.json")).filter_map do |manifest_path| + name = File.basename(File.dirname(manifest_path)) + manifest = JSON.parse(File.read(manifest_path)) + { "name" => name, "label" => manifest["label"] || name } + rescue JSON::ParserError + nil + end.sort_by { |p| p["label"] } + end + + # bbox: [south, west, north, east]. Raises RunError with a short + # message on anything that isn't a clean run — an unknown plugin, a + # non-zero exit, or output that isn't a GeoJSON FeatureCollection — so + # the route can turn it into a single error response without knowing + # any of these details itself. + def self.run(dir, name, bbox) + raise RunError, "unknown plugin" unless name =~ NAME_PATTERN + + plugin_dir = File.join(dir, name) + manifest_path = File.join(plugin_dir, "plugin.json") + raise RunError, "unknown plugin" unless File.file?(manifest_path) + + command = JSON.parse(File.read(manifest_path))["command"] + raise RunError, "plugin has no command" unless command + + south, west, north, east = bbox + args = ["--south=#{south}", "--west=#{west}", "--north=#{north}", "--east=#{east}"] + + stdout, _stderr, status = Open3.capture3(command, *args, chdir: plugin_dir) + raise RunError, "plugin exited with status #{status.exitstatus}" unless status.success? + + geojson = JSON.parse(stdout) + raise RunError, "plugin did not print a GeoJSON FeatureCollection" unless geojson.is_a?(Hash) && geojson["type"] == "FeatureCollection" + + geojson + rescue Errno::ENOENT, Errno::EACCES => e + raise RunError, e.message + rescue JSON::ParserError + raise RunError, "plugin did not print valid JSON" + end + end +end diff --git a/plugins/overpass-pois/plugin.json b/plugins/overpass-pois/plugin.json new file mode 100644 index 0000000..13e3440 --- /dev/null +++ b/plugins/overpass-pois/plugin.json @@ -0,0 +1,4 @@ +{ + "label": "Camper POIs (Overpass)", + "command": "./run.rb" +} diff --git a/plugins/overpass-pois/run.rb b/plugins/overpass-pois/run.rb new file mode 100755 index 0000000..1c846ee --- /dev/null +++ b/plugins/overpass-pois/run.rb @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# The reference example of the plugin contract (see lib/maptool/plugins.rb): +# read --south/--west/--north/--east, print a GeoJSON FeatureCollection to +# stdout. Thin wrapper around lib/maptool/overpass_pois.rb, which already +# did all the real work for the "rake overpass:pois" dev-time task this +# plugin now sits alongside as a second way to run the same query. + +require_relative "../../lib/maptool/overpass_pois" + +args = ARGV.to_h { |arg| arg.sub(/\A--/, "").split("=", 2) } +bbox = %w[south west north east].map { |key| Float(args.fetch(key)) } + +response = Maptool::OverpassPois.fetch(bbox) +puts JSON.generate(Maptool::OverpassPois.to_geojson(response)) diff --git a/public/css/style.css b/public/css/style.css index 4422f68..13246a3 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1439,3 +1439,60 @@ html, body { .profile-panel__tooltip-label { color: #c3c2b7; } + +.plugins-panel__hint { + margin: 0 0 6px; + max-width: 200px; + color: #888; + font-size: 11px; +} + +.plugins-panel__list { + list-style: none; + margin: 0; + padding: 0; +} + +.plugins-panel__item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + white-space: nowrap; +} + +.plugins-panel__label { + flex: 1; +} + +.plugins-panel__run { + /* See .notes-panel__actions button's own comment — without this some + browsers paint their own hover/active highlight over a custom + background. */ + appearance: none; + padding: 4px 10px; + border: none; + border-radius: 4px; + background: #1a5fb4; + color: #fff; + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.plugins-panel__run:disabled { + opacity: 0.6; + cursor: default; +} + +.plugins-panel__status { + min-width: 3.5em; + color: #888; + font-size: 11px; +} + +.plugins-panel__empty { + color: #888; + white-space: normal; + max-width: 200px; +} diff --git a/public/js/icons.js b/public/js/icons.js index d3d061d..1187ae6 100644 --- a/public/js/icons.js +++ b/public/js/icons.js @@ -56,6 +56,15 @@ export const UPLOAD_ICON = ` `; +// The inverse of UPLOAD_ICON (arrow into the tray, not out of it) — used +// for the import plugins panel (plugins.js), which pulls data in from +// somewhere else rather than sending a file out. +export const PLUGIN_ICON = ` + + + +`; + // Shown next to each file in the GPX tree, indicating whether it has // waypoints, a track, or both. export const WAYPOINT_ICON = ` diff --git a/public/js/map.js b/public/js/map.js index 2ac3239..341635b 100644 --- a/public/js/map.js +++ b/public/js/map.js @@ -11,6 +11,7 @@ import { LiveTrackPanelControl } from "/js/livetrack.js"; import { TrackProfilePanel } from "/js/track_profile.js"; import { MeasureControl } from "/js/measure.js"; import { NotesControl } from "/js/notes.js"; +import { PluginsControl } from "/js/plugins.js"; import { LocateControl } from "/js/locate.js"; import { LAYERS_ICON, HILLSHADE_ICON } from "/js/icons.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; @@ -557,6 +558,9 @@ sidebar.insertAdjacentHTML("beforeend", ``); layerToggle.mount(sidebar); diff --git a/public/js/plugins.js b/public/js/plugins.js new file mode 100644 index 0000000..a43ab74 --- /dev/null +++ b/public/js/plugins.js @@ -0,0 +1,96 @@ +import { PLUGIN_ICON } from "/js/icons.js"; +import { notifyGpxStoreChanged } from "/js/panels.js"; + +// A sidebar flyout listing whatever import plugins are on disk (see +// lib/maptool/plugins.rb for the contract) — each one just a "Run for +// this area" button using the current map viewport as the bbox, so there's +// no separate area-picker to build: pan/zoom to where you want, click Run. +// Only ever mounted when logged in (see map.js) — same "hide the +// write-only affordance" story as NotesControl, since the only thing this +// panel does is a login-gated write. +export class PluginsControl { + constructor(map) { + this._map = map; + } + + mount(sidebar) { + this._container = document.createElement("div"); + this._container.className = "sidebar-item"; + this._container.innerHTML = ` + + + `; + this._icon = this._container.querySelector(".sidebar-icon"); + this._flyout = this._container.querySelector(".sidebar-flyout"); + this._list = this._container.querySelector(".plugins-panel__list"); + + this._icon.addEventListener("click", () => { + const opening = this._flyout.hidden; + this._flyout.hidden = !opening; + this._icon.classList.toggle("active", opening); + if (opening) this._loadPlugins(); + }); + + sidebar.appendChild(this._container); + } + + async _loadPlugins() { + this._list.innerHTML = `
  • Loading…
  • `; + let plugins; + try { + plugins = await (await fetch("/api/plugins")).json(); + } catch (err) { + this._list.innerHTML = `
  • Could not load plugins
  • `; + return; + } + + if (plugins.length === 0) { + this._list.innerHTML = `
  • No plugins installed — see docs/development.adoc
  • `; + return; + } + this._list.innerHTML = ""; + plugins.forEach((plugin) => this._list.appendChild(this._renderItem(plugin))); + } + + _renderItem(plugin) { + const li = document.createElement("li"); + li.className = "plugins-panel__item"; + li.innerHTML = ` + + + + `; + li.querySelector(".plugins-panel__label").textContent = plugin.label; + + const button = li.querySelector(".plugins-panel__run"); + const status = li.querySelector(".plugins-panel__status"); + button.addEventListener("click", () => this._run(plugin, button, status)); + return li; + } + + async _run(plugin, button, status) { + button.disabled = true; + status.textContent = "Running…"; + try { + const bounds = this._map.getBounds(); + const body = new URLSearchParams({ + south: bounds.getSouth(), + west: bounds.getWest(), + north: bounds.getNorth(), + east: bounds.getEast(), + }); + const response = await fetch(`/api/plugins/${encodeURIComponent(plugin.name)}/run`, { method: "POST", body }); + if (!response.ok) throw new Error(`run failed: ${response.status}`); + const { path } = await response.json(); + notifyGpxStoreChanged(path); + status.textContent = "Done"; + } catch (err) { + status.textContent = "Failed — see server log"; + } finally { + button.disabled = false; + } + } +} diff --git a/test/fixtures/plugins/test-plugin/plugin.json b/test/fixtures/plugins/test-plugin/plugin.json new file mode 100644 index 0000000..00b604d --- /dev/null +++ b/test/fixtures/plugins/test-plugin/plugin.json @@ -0,0 +1,4 @@ +{ + "label": "Test plugin", + "command": "./run.sh" +} diff --git a/test/fixtures/plugins/test-plugin/run.sh b/test/fixtures/plugins/test-plugin/run.sh new file mode 100755 index 0000000..7d5bbff --- /dev/null +++ b/test/fixtures/plugins/test-plugin/run.sh @@ -0,0 +1,5 @@ +#!/bin/sh +# A fixture plugin for test_app.rb's /api/plugins route tests — no +# network, just echoes a single fixed point back as GeoJSON so those tests +# stay deterministic and offline like the rest of the suite. +echo '{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"name":"Test POI"},"geometry":{"type":"Point","coordinates":[17.0,48.0]}}]}' diff --git a/test/test_app.rb b/test/test_app.rb index eb0331e..42687e2 100644 --- a/test/test_app.rb +++ b/test/test_app.rb @@ -49,6 +49,7 @@ class AppTest < Minitest::Test assert_equal 401, request.delete("/api/gpx/file/x.gpx").status assert_equal 401, request.delete("/api/gpx/folder/x").status assert_equal 401, request.post("/api/gpx/move").status + assert_equal 401, request.post("/api/plugins/test-plugin/run").status end # --- GPX/FIT upload and the tree --- @@ -290,6 +291,42 @@ class AppTest < Minitest::Test assert_equal 404, response.status end + # --- Import plugins (lib/maptool/plugins.rb) --- + + def test_plugins_lists_whatever_is_on_disk + response = request.get("/api/plugins") + assert_equal 200, response.status + assert_equal [{ "name" => "test-plugin", "label" => "Test plugin" }], json_body(response) + end + + def test_plugins_run_saves_the_output_as_a_new_geojson_file + response = request.post( + "/api/plugins/test-plugin/run", + params: { south: "48.0", west: "17.0", north: "48.1", east: "17.1" }, + **logged_in_env, + ) + assert_equal 200, response.status + body = json_body(response) + assert_equal "test-plugin.geojson", body["path"] + + saved = json_body(request.get("/api/gpx/file/#{body["path"]}")) + assert_equal "Test POI", saved["features"][0]["properties"]["name"] + end + + def test_plugins_run_requires_a_bbox + response = request.post("/api/plugins/test-plugin/run", **logged_in_env) + assert_equal 400, response.status + end + + def test_plugins_run_unknown_plugin_is_502 + response = request.post( + "/api/plugins/nope/run", + params: { south: "48.0", west: "17.0", north: "48.1", east: "17.1" }, + **logged_in_env, + ) + assert_equal 502, response.status + end + # --- LiveTrack webhook --- def test_livetrack_webhook_requires_the_configured_secret diff --git a/test/test_helper.rb b/test/test_helper.rb index c0461a9..c5c319e 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -14,6 +14,11 @@ require "json" ENV["MAPTOOL_DATA_DIR"] = Dir.mktmpdir("maptool-test-data") Minitest.after_run { FileUtils.remove_entry(ENV["MAPTOOL_DATA_DIR"]) } +# A fixture plugin (test/fixtures/plugins/test-plugin), not the real +# plugins/ — keeps /api/plugins route tests offline/deterministic instead +# of actually running plugins/overpass-pois against the real Overpass API. +ENV["MAPTOOL_PLUGINS_DIR"] = File.join(__dir__, "fixtures", "plugins") + $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) require_relative "../app" diff --git a/test/test_plugins.rb b/test/test_plugins.rb new file mode 100644 index 0000000..c78b6d2 --- /dev/null +++ b/test/test_plugins.rb @@ -0,0 +1,55 @@ +require_relative "test_helper" +require "maptool/plugins" +require "tmpdir" +require "fileutils" + +class PluginsTest < Minitest::Test + def setup + @dir = Dir.mktmpdir("maptool-plugins-test") + @plugin_dir = File.join(@dir, "hello") + FileUtils.mkdir_p(@plugin_dir) + File.write(File.join(@plugin_dir, "plugin.json"), JSON.generate(label: "Hello", command: "./run.sh")) + File.write(File.join(@plugin_dir, "run.sh"), "#!/bin/sh\necho '{\"type\":\"FeatureCollection\",\"features\":[]}'\n") + File.chmod(0o755, File.join(@plugin_dir, "run.sh")) + end + + def teardown + FileUtils.remove_entry(@dir) + end + + def test_list_finds_plugins_by_their_manifest + assert_equal [{ "name" => "hello", "label" => "Hello" }], Maptool::Plugins.list(@dir) + end + + def test_list_is_empty_for_a_directory_with_no_plugins + assert_empty Maptool::Plugins.list(Dir.mktmpdir) + end + + def test_run_returns_the_plugins_geojson_output + result = Maptool::Plugins.run(@dir, "hello", [1, 2, 3, 4]) + assert_equal({ "type" => "FeatureCollection", "features" => [] }, result) + end + + def test_run_raises_for_an_unknown_plugin + assert_raises(Maptool::Plugins::RunError) { Maptool::Plugins.run(@dir, "nope", [1, 2, 3, 4]) } + end + + # A directory-name-shaped ".." would otherwise resolve one level above + # dir via File.join — the same class of path-escape GpxStore guards + # against for uploaded file paths. + def test_run_rejects_a_path_traversal_attempt_as_the_name + assert_raises(Maptool::Plugins::RunError) { Maptool::Plugins.run(@dir, "..", [1, 2, 3, 4]) } + end + + def test_run_raises_when_the_script_exits_non_zero + File.write(File.join(@plugin_dir, "run.sh"), "#!/bin/sh\nexit 1\n") + File.chmod(0o755, File.join(@plugin_dir, "run.sh")) + assert_raises(Maptool::Plugins::RunError) { Maptool::Plugins.run(@dir, "hello", [1, 2, 3, 4]) } + end + + def test_run_raises_when_the_script_does_not_print_a_feature_collection + File.write(File.join(@plugin_dir, "run.sh"), "#!/bin/sh\necho 'not json'\n") + File.chmod(0o755, File.join(@plugin_dir, "run.sh")) + assert_raises(Maptool::Plugins::RunError) { Maptool::Plugins.run(@dir, "hello", [1, 2, 3, 4]) } + end +end diff --git a/views/layout.erb b/views/layout.erb index 13f16e6..f7bbaa5 100644 --- a/views/layout.erb +++ b/views/layout.erb @@ -30,11 +30,16 @@