Files
maptool/lib/maptool/plugins.rb
T
randy ea9cd5fc40 Fix three bugs found reviewing this session's changes
- notes.js's ICON_LABELS wasn't extended for the shop_supermarket/
  shop_convenience keys icons.js added — the note icon picker showed
  the raw internal key as the title instead of a real label.
- Plugins.run mislabeled a malformed plugin.json as "plugin did not
  print valid JSON" — a message written for bad script stdout, not a
  bad manifest, since both paths shared one rescue.
- overpass_pois.rb's description_for joined raw OSM tag values with
  "\n" — an operator/opening_hours tag with an embedded newline could
  inject an extra line into the popup that gpx.js's formatDescription
  (built for p4n2gpx's own well-formed lines) would render as its own
  independent, legitimate-looking link. Tag values are stripped of
  embedded newlines before joining.
2026-09-09 18:21:47 +02:00

82 lines
3.7 KiB
Ruby

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 one of `dirs` 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.
#
# `dirs` takes either one directory or an array of them (wrapped with
# Array() below) — app.rb passes both the built-in plugins/ (baked into
# the image, wiped and replaced on every deploy along with the rest of
# the checkout) and an optional second directory under the persistent
# data/ bind mount, so a private plugin dropped there survives a
# redeploy the same way an uploaded GPX file does.
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(dirs)
Array(dirs).flat_map do |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
end.uniq { |plugin| plugin["name"] }.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. When the same name exists under more
# than one of `dirs`, the first one that has it wins — same precedence
# as list's `uniq` above.
def self.run(dirs, name, bbox)
raise RunError, "unknown plugin" unless name =~ NAME_PATTERN
plugin_dir = Array(dirs).map { |dir| File.join(dir, name) }.find { |dir| File.file?(File.join(dir, "plugin.json")) }
raise RunError, "unknown plugin" unless plugin_dir
manifest_path = File.join(plugin_dir, "plugin.json")
command = begin
JSON.parse(File.read(manifest_path))["command"]
rescue JSON::ParserError
raise RunError, "plugin has an invalid plugin.json"
end
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