diff --git a/.gitignore b/.gitignore index 5000f4b..e64e987 100644 --- a/.gitignore +++ b/.gitignore @@ -23,17 +23,24 @@ erl_crash.dump /tmp/ # Ignore package tarball (built via "mix hex.build"). -docmark-*.tar - -docs/*.pdf data +# Ignore my stack of pdfs +/docs/ + +# Ignore application logs +/priv/logs/ + # Ignore assets that are produced by build tools. /priv/static/assets/ /priv/static/*.js /priv/static/*.html /priv/static/*.css +# Ignore mnesia data +Mnesia*/ +/priv/mnesia/ + # Ignore digested assets cache. /priv/static/cache_manifest.json diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..f7652a0 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,27 @@ +import Config + +config :mound_hunters, + python_cli_path: System.get_env("PYTHON_CLI_PATH", "./cli.py"), + las2mound_script_path: System.get_env("LAS2MOUND_PATH", "./tooling/las2mound.py"), + tile_output_dir: "data", + tile_temp_dir: "priv/tmp", + mnesia_dir: ~c"priv/mnesia", + http_port: String.to_integer(System.get_env("PORT", "4000")), + log_dir: "priv/logs" + +# Logger configuration +config :logger, + backends: [:console, {LoggerFileBackend, :request_log}] + +config :logger, :console, + format: "[$level] $message\n", + metadata: [:request_id] + +config :logger, :request_log, + path: "priv/logs/requests.jsonl", + level: :info, + format: "$message\n", + metadata: [] + +# Import environment specific config +import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..af20ee1 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,6 @@ +import Config + +# Development-specific configuration +config :logger, :console, + format: "[$level] $message\n", + metadata: [:request_id] diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..477c907 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,3 @@ +import Config + +config :logger, level: :info diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..b063881 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,7 @@ +import Config + +config :mound_hunters, + http_port: 4001, + mnesia_dir: ~c"priv/mnesia_test" + +config :logger, level: :warning diff --git a/lib/mound_hunters/application.ex b/lib/mound_hunters/application.ex new file mode 100644 index 0000000..cf43adb --- /dev/null +++ b/lib/mound_hunters/application.ex @@ -0,0 +1,112 @@ +defmodule MoundHunters.Application do + @moduledoc false + + use Application + require Logger + + @impl true + def start(_type, _args) do + # Ensure log directory exists + ensure_log_dir() + + # Initialize Mnesia before starting supervision tree + setup_mnesia() + + children = [ + # Tile processing GenServer + MoundHunters.TileProcessor, + # HTTP server + {Plug.Cowboy, scheme: :http, plug: MoundHuntersWeb.Router, options: [port: http_port()]} + ] + + opts = [strategy: :one_for_one, name: MoundHunters.Supervisor] + Supervisor.start_link(children, opts) + end + + defp ensure_log_dir do + log_dir = Application.get_env(:mound_hunters, :log_dir, "priv/logs") + File.mkdir_p!(log_dir) + end + + defp http_port do + Application.get_env(:mound_hunters, :http_port, 4000) + end + + defp setup_mnesia do + mnesia_dir = Application.get_env(:mound_hunters, :mnesia_dir) + + # Ensure mnesia directory exists + mnesia_dir + |> to_string() + |> File.mkdir_p!() + + # Stop mnesia if running + :mnesia.stop() + + # Create schema if it doesn't exist + case :mnesia.create_schema([node()]) do + :ok -> + Logger.info("Created Mnesia schema") + + {:error, {_node1, {:already_exists, _node2}}} -> + Logger.debug("Mnesia schema already exists") + + {:error, reason} -> + Logger.warning("Failed to create Mnesia schema: #{inspect(reason)}") + end + + # Start Mnesia + :ok = :mnesia.start() + Logger.info("Mnesia started") + + # Create tables if they don't exist + create_tables() + end + + defp create_tables do + # Tiles table + case :mnesia.create_table(:tiles, + attributes: [ + :id, + :min_lat, + :max_lat, + :min_lng, + :max_lng, + :status, + :error_message, + :created_at, + :updated_at + ], + disc_copies: [node()], + type: :set + ) do + {:atomic, :ok} -> + Logger.info("Created :tiles table") + + {:aborted, {:already_exists, :tiles}} -> + Logger.debug(":tiles table already exists") + + {:aborted, reason} -> + Logger.error("Failed to create :tiles table: #{inspect(reason)}") + end + + # Geometries table + case :mnesia.create_table(:geometries, + attributes: [:id, :geojson, :created_at], + disc_copies: [node()], + type: :set + ) do + {:atomic, :ok} -> + Logger.info("Created :geometries table") + + {:aborted, {:already_exists, :geometries}} -> + Logger.debug(":geometries table already exists") + + {:aborted, reason} -> + Logger.error("Failed to create :geometries table: #{inspect(reason)}") + end + + # Wait for tables to be ready + :mnesia.wait_for_tables([:tiles, :geometries], 5000) + end +end diff --git a/lib/mound_hunters/boundary.ex b/lib/mound_hunters/boundary.ex new file mode 100644 index 0000000..f470f89 --- /dev/null +++ b/lib/mound_hunters/boundary.ex @@ -0,0 +1,121 @@ +defmodule MoundHunters.Boundary do + @moduledoc """ + Ohio state boundary checking using point-in-polygon algorithm. + """ + + # Simplified Ohio bounding box for fast preliminary check + # More precise polygon would be loaded from GeoJSON + @ohio_bbox %{ + min_lat: 38.403, + max_lat: 42.327, + min_lng: -84.820, + max_lng: -80.519 + } + + # Ohio state boundary polygon (simplified) + # Source: Github -> PublicaMundi/MappingAPI us-states.json + # Coordinates are [lng, lat] pairs per GeoJSON spec + @ohio_polygon [ + {-80.518598, 41.978802}, + {-80.518598, 40.636951}, + {-80.666475, 40.582182}, + {-80.595275, 40.472643}, + {-80.600752, 40.319289}, + {-80.737675, 40.078303}, + {-80.830783, 39.711348}, + {-81.219646, 39.388209}, + {-81.345616, 39.344393}, + {-81.455155, 39.410117}, + {-81.570170, 39.267716}, + {-81.685186, 39.273193}, + {-81.811156, 39.081500}, + {-81.783771, 38.966484}, + {-81.887833, 38.873376}, + {-82.035710, 39.026731}, + {-82.221926, 38.785745}, + {-82.172634, 38.632391}, + {-82.293127, 38.577622}, + {-82.331465, 38.446175}, + {-82.594358, 38.424267}, + {-82.731282, 38.561191}, + {-82.846298, 38.588575}, + {-82.890113, 38.758361}, + {-83.032514, 38.725499}, + {-83.142052, 38.626914}, + {-83.519961, 38.703591}, + {-83.678792, 38.632391}, + {-83.903347, 38.769315}, + {-84.215533, 38.807653}, + {-84.231963, 38.895284}, + {-84.434610, 39.103408}, + {-84.817996, 39.103408}, + {-84.801565, 40.500028}, + {-84.807042, 41.694001}, + {-83.454238, 41.732339}, + {-83.065375, 41.595416}, + {-82.933929, 41.513262}, + {-82.835344, 41.589939}, + {-82.616266, 41.431108}, + {-82.479343, 41.381815}, + {-82.013803, 41.513262}, + {-81.739956, 41.485877}, + {-81.444201, 41.672093}, + {-81.011523, 41.852832}, + {-80.518598, 41.978802}, + {-80.518598, 41.978802} + ] + + @doc """ + Check if coordinates are within Ohio boundaries. + Returns :ok or {:error, reason} + """ + def check_bounds(lat, lng) when is_number(lat) and is_number(lng) do + cond do + not in_bounding_box?(lat, lng) -> + {:error, "Coordinates outside Ohio bounding box"} + + not in_polygon?(lng, lat, @ohio_polygon) -> + {:error, "Coordinates outside Ohio boundary"} + + true -> + :ok + end + end + + def check_bounds(_lat, _lng) do + {:error, "Invalid coordinates"} + end + + defp in_bounding_box?(lat, lng) do + lat >= @ohio_bbox.min_lat and lat <= @ohio_bbox.max_lat and + lng >= @ohio_bbox.min_lng and lng <= @ohio_bbox.max_lng + end + + # Ray casting algorithm for point-in-polygon test + # Returns true if point (x, y) is inside the polygon + defp in_polygon?(x, y, polygon) do + n = length(polygon) + + polygon + |> Enum.with_index() + |> Enum.reduce(false, fn {{xi, yi}, i}, inside -> + j = rem(i + n - 1, n) + {xj, yj} = Enum.at(polygon, j) + + intersects = + yi > y != yj > y and + x < (xj - xi) * (y - yi) / (yj - yi) + xi + + if intersects, do: not inside, else: inside + end) + end + + @doc """ + Format coordinates to 6 decimal places for consistent lookups. + """ + def format_lookup_id(lat, lng) do + lat_str = :erlang.float_to_binary(lat / 1.0, decimals: 6) + lng_str = :erlang.float_to_binary(lng / 1.0, decimals: 6) + "#{lat_str},#{lng_str}" + end +end diff --git a/lib/mound_hunters/ohio_lidar.ex b/lib/mound_hunters/ohio_lidar.ex new file mode 100644 index 0000000..6eea286 --- /dev/null +++ b/lib/mound_hunters/ohio_lidar.ex @@ -0,0 +1,196 @@ +defmodule MoundHunters.OhioLidar do + @moduledoc """ + Functions for querying Ohio's ArcGIS tile service and coordinate conversions. + """ + require Logger + + # Ohio ArcGIS tile service + @tile_service_url "https://maps.ohio.gov/arcgis/rest/services/OGRIP/3DepTiles/MapServer/0/query" + + # OGRIP download URL template + @download_url_template "https://gis1.oit.ohio.gov/ZIPARCHIVES_III/ELEVATION/3DEP/LIDAR/{county}/{tile_name}.zip" + + @doc """ + Convert lon/lat (WGS84) to Web Mercator (EPSG:3857). + + Formula from: https://en.wikipedia.org/wiki/Web_Mercator_projection + """ + def lonlat_to_webmercator(lon, lat) do + # Earth radius in meters + r = 6378137.0 + + # Convert to radians + lon_rad = lon * :math.pi() / 180.0 + lat_rad = lat * :math.pi() / 180.0 + + # Web Mercator formulas + x = r * lon_rad + y = r * :math.log(:math.tan(:math.pi() / 4.0 + lat_rad / 2.0)) + + {x, y} + end + + @doc """ + Query Ohio ArcGIS service for tile information at given coordinates. + + Returns {:ok, tile_info} or {:error, reason} + + tile_info contains: %{ + tile_name: "BS19820747", + county: "LIC", + year: "2020", + block: "4", + note: "..." + } + """ + def query_tile_info(lon, lat) do + {x, y} = lonlat_to_webmercator(lon, lat) + + geometry = + Jason.encode!(%{ + x: x, + y: y, + spatialReference: %{wkid: 3857} + }) + + params = %{ + "f" => "json", + "returnGeometry" => "false", + "spatialRel" => "esriSpatialRelIntersects", + "geometry" => geometry, + "geometryType" => "esriGeometryPoint", + "inSR" => "3857", + "outFields" => "*", + "outSR" => "3857" + } + + Logger.debug("Querying ArcGIS for tile at (#{lon}, #{lat}) -> WebMercator (#{x}, #{y})") + + case HTTPoison.get(@tile_service_url, [], params: params, timeout: 10_000) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + parse_tile_response(body) + + {:ok, %HTTPoison.Response{status_code: status}} -> + Logger.error("ArcGIS returned status #{status}") + {:error, "ArcGIS service returned status #{status}"} + + {:error, %HTTPoison.Error{reason: reason}} -> + Logger.error("Failed to query ArcGIS: #{inspect(reason)}") + {:error, "Network error: #{inspect(reason)}"} + end + end + + defp parse_tile_response(body) do + case Jason.decode(body) do + {:ok, %{"features" => []}} -> + {:error, :no_tile_found} + + {:ok, %{"features" => [feature | _]}} -> + attrs = feature["attributes"] + + tile_info = %{ + tile_name: attrs["TileName"], + county: attrs["County"], + year: attrs["Year"], + block: attrs["Block"], + note: attrs["note"] + } + + {:ok, tile_info} + + {:ok, _} -> + {:error, :invalid_response} + + {:error, _} -> + {:error, :json_parse_error} + end + end + + @doc """ + Get the download URL for a tile. + """ + def get_download_url(tile_name, county) do + @download_url_template + |> String.replace("{county}", county) + |> String.replace("{tile_name}", tile_name) + end + + @doc """ + Download a tile ZIP file from OGRIP. + + Returns {:ok, file_path} or {:error, reason} + """ + def download_tile(tile_name, county, output_path) do + url = get_download_url(tile_name, county) + Logger.info("Downloading #{tile_name} from #{url}") + + # Ensure output directory exists + output_path + |> Path.dirname() + |> File.mkdir_p!() + + case HTTPoison.get(url, [], timeout: 60_000, recv_timeout: 60_000, stream_to: self()) do + {:ok, %HTTPoison.AsyncResponse{id: id}} -> + receive_download(id, output_path, 0) + + {:error, %HTTPoison.Error{reason: reason}} -> + Logger.error("Failed to start download: #{inspect(reason)}") + {:error, "Download failed: #{inspect(reason)}"} + end + end + + defp receive_download(id, output_path, bytes_received) do + receive do + %HTTPoison.AsyncStatus{id: ^id, code: 200} -> + Logger.debug("Download started, status 200") + receive_download(id, output_path, bytes_received) + + %HTTPoison.AsyncStatus{id: ^id, code: status} -> + Logger.error("Download failed with status #{status}") + {:error, "HTTP status #{status}"} + + %HTTPoison.AsyncHeaders{id: ^id} -> + # Start writing to file + File.open(output_path, [:write, :binary], fn file -> + receive_download_chunks(id, file, bytes_received) + end) + + %HTTPoison.AsyncEnd{id: ^id} -> + Logger.error("Download ended prematurely") + {:error, :unexpected_end} + + {:error, reason} -> + Logger.error("Download error: #{inspect(reason)}") + {:error, reason} + after + 70_000 -> + {:error, :timeout} + end + end + + defp receive_download_chunks(id, file, bytes_received) do + receive do + %HTTPoison.AsyncChunk{id: ^id, chunk: chunk} -> + IO.binwrite(file, chunk) + new_bytes = bytes_received + byte_size(chunk) + + # Log progress every 10MB + if div(new_bytes, 10_485_760) > div(bytes_received, 10_485_760) do + Logger.debug("Downloaded #{div(new_bytes, 1_048_576)} MB") + end + + receive_download_chunks(id, file, new_bytes) + + %HTTPoison.AsyncEnd{id: ^id} -> + size_mb = bytes_received / 1_048_576 + Logger.info("Download complete: #{Float.round(size_mb, 2)} MB") + {:ok, bytes_received} + + {:error, reason} -> + {:error, reason} + after + 70_000 -> + {:error, :timeout} + end + end +end diff --git a/lib/mound_hunters/repo.ex b/lib/mound_hunters/repo.ex new file mode 100644 index 0000000..f38b16e --- /dev/null +++ b/lib/mound_hunters/repo.ex @@ -0,0 +1,107 @@ +defmodule MoundHunters.Repo do + @moduledoc """ + Mnesia database helpers for tiles and geometries. + """ + + require Logger + + # Tile statuses + @type tile_status :: :processing | :ready | :error + + @doc """ + Get a tile by ID from Mnesia. + """ + def get_tile(tile_id) do + case :mnesia.transaction(fn -> + :mnesia.read(:tiles, tile_id) + end) do + {:atomic, [tile]} -> {:ok, tile_to_map(tile)} + {:atomic, []} -> {:error, :not_found} + {:aborted, reason} -> {:error, reason} + end + end + + @doc """ + Insert or update a tile record. + """ + def upsert_tile(attrs) do + tile_id = Map.fetch!(attrs, :id) + now = System.system_time(:second) + + tile_record = + {:tiles, tile_id, Map.get(attrs, :min_lat), Map.get(attrs, :max_lat), + Map.get(attrs, :min_lng), Map.get(attrs, :max_lng), Map.get(attrs, :status), + Map.get(attrs, :error_message), Map.get(attrs, :created_at, now), now} + + case :mnesia.transaction(fn -> + :mnesia.write(tile_record) + end) do + {:atomic, :ok} -> {:ok, tile_to_map(tile_record)} + {:aborted, reason} -> {:error, reason} + end + end + + @doc """ + Get a shared geometry by ID. + """ + def get_geometry(geometry_id) do + case :mnesia.transaction(fn -> + :mnesia.read(:geometries, geometry_id) + end) do + {:atomic, [geometry]} -> {:ok, geometry_to_map(geometry)} + {:atomic, []} -> {:error, :not_found} + {:aborted, reason} -> {:error, reason} + end + end + + @doc """ + Create a new shared geometry. + """ + def create_geometry(geojson) do + geometry_id = generate_id() + now = System.system_time(:second) + + geometry_record = {:geometries, geometry_id, geojson, now} + + case :mnesia.transaction(fn -> + :mnesia.write(geometry_record) + end) do + {:atomic, :ok} -> {:ok, %{id: geometry_id}} + {:aborted, reason} -> {:error, reason} + end + end + + # Convert Mnesia tile record to map + defp tile_to_map( + {:tiles, id, min_lat, max_lat, min_lng, max_lng, status, error_message, created_at, + updated_at} + ) do + %{ + id: id, + min_lat: min_lat, + max_lat: max_lat, + min_lng: min_lng, + max_lng: max_lng, + status: status, + error_message: error_message, + created_at: created_at, + updated_at: updated_at + } + end + + # Convert Mnesia geometry record to map + defp geometry_to_map({:geometries, id, geojson, created_at}) do + %{ + id: id, + geojson: geojson, + created_at: created_at + } + end + + # Generate random 8-character alphanumeric ID + defp generate_id do + :crypto.strong_rand_bytes(6) + |> Base.url_encode64(padding: false) + |> binary_part(0, 8) + end +end diff --git a/lib/mound_hunters/tile_processor.ex b/lib/mound_hunters/tile_processor.ex new file mode 100644 index 0000000..76ceaee --- /dev/null +++ b/lib/mound_hunters/tile_processor.ex @@ -0,0 +1,335 @@ +defmodule MoundHunters.TileProcessor do + @moduledoc """ + GenServer that processes tile requests: lookup tile IDs and download/convert tiles. + + Processing pipeline: + 1. :looking_up - Query ArcGIS for tile metadata + 2. :downloading - Download ZIP from OGRIP + 3. :extracting - Unzip the archive + 4. :converting - Run las2mound.py to create .mound file + 5. :done - Files ready in priv/tiles/ + """ + use GenServer + require Logger + + alias MoundHunters.OhioLidar + + # Processing statuses + @type lookup_status :: :pending | {:ok, String.t()} | {:error, String.t()} + @type processing_status :: + :queued | :downloading | :extracting | :converting | :done | {:error, String.t()} + + # Client API + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc """ + Request processing for a lat/lng coordinate pair. + Returns the lookup_id that can be used to poll progress. + """ + def request_tile(lat, lng) do + lookup_id = MoundHunters.Boundary.format_lookup_id(lat, lng) + GenServer.cast(__MODULE__, {:request_tile, lookup_id, lat, lng}) + {:ok, lookup_id} + end + + @doc """ + Get the current lookup status for a coordinate pair. + """ + def get_lookup_status(lookup_id) do + case :ets.lookup(:tile_lookups, lookup_id) do + [{^lookup_id, status}] -> {:ok, status} + [] -> {:error, :not_found} + end + end + + @doc """ + Get the current processing status for a tile. + """ + def get_processing_status(tile_id) do + case :ets.lookup(:tile_processing, tile_id) do + [{^tile_id, status, _metadata}] -> {:ok, status} + [] -> {:error, :not_found} + end + end + + @doc """ + Get full metadata for a processing tile. + """ + def get_processing_metadata(tile_id) do + case :ets.lookup(:tile_processing, tile_id) do + [{^tile_id, status, metadata}] -> {:ok, status, metadata} + [] -> {:error, :not_found} + end + end + + # Server callbacks + + @impl true + def init(_opts) do + # Create ETS tables + :ets.new(:tile_lookups, [:set, :public, :named_table]) + :ets.new(:tile_processing, [:set, :public, :named_table]) + + # Ensure temp directory exists + temp_dir = Application.get_env(:mound_hunters, :tile_temp_dir) + File.mkdir_p!(temp_dir) + + state = %{ + las2mound_script: Application.get_env(:mound_hunters, :las2mound_script_path), + tile_output_dir: Application.get_env(:mound_hunters, :tile_output_dir), + tile_temp_dir: temp_dir, + processing_queue: :queue.new(), + current_job: nil + } + + Logger.info("TileProcessor started") + {:ok, state} + end + + @impl true + def handle_cast({:request_tile, lookup_id, lat, lng}, state) do + # Check if we already have this lookup in progress or completed + case :ets.lookup(:tile_lookups, lookup_id) do + [] -> + # New request - insert as pending and start lookup + :ets.insert(:tile_lookups, {lookup_id, :pending}) + Logger.info("New tile request for #{lookup_id} (#{lat}, #{lng})") + + # Start lookup asynchronously + send(self(), {:lookup_tile, lookup_id, lat, lng}) + + [{^lookup_id, _status}] -> + # Already in progress or completed, ignore duplicate request + Logger.debug("Duplicate tile request for #{lookup_id}, ignoring") + end + + {:noreply, state} + end + + @impl true + def handle_info({:lookup_tile, lookup_id, lat, lng}, state) do + Logger.info("Looking up tile for coordinates (#{lat}, #{lng})") + + case OhioLidar.query_tile_info(lng, lat) do + {:ok, tile_info} -> + tile_name = tile_info.tile_name + Logger.info("Found tile: #{tile_name} in #{tile_info.county} county") + + # Update lookup table with success + :ets.insert(:tile_lookups, {lookup_id, {:ok, tile_name}}) + + # Check if tile already exists + output_file = Path.join(state.tile_output_dir, "#{tile_name}.mound") + + if File.exists?(output_file) do + Logger.info("Tile #{tile_name} already processed, marking as done") + :ets.insert(:tile_processing, {tile_name, :done, %{tile_info: tile_info}}) + else + # Queue for processing + state = queue_tile_for_processing(state, tile_name, tile_info) + {:noreply, state} + end + + {:error, :no_tile_found} -> + Logger.warning("No tile found for coordinates (#{lat}, #{lng})") + :ets.insert(:tile_lookups, {lookup_id, {:error, "No tile found at coordinates"}}) + {:noreply, state} + + {:error, reason} -> + Logger.error("Failed to lookup tile: #{inspect(reason)}") + :ets.insert(:tile_lookups, {lookup_id, {:error, "Lookup failed: #{inspect(reason)}"}}) + {:noreply, state} + end + end + + @impl true + def handle_info({:process_tile, tile_name, tile_info}, state) do + Logger.info("Starting processing for tile #{tile_name}") + + # Update status to downloading + :ets.insert(:tile_processing, {tile_name, :downloading, %{tile_info: tile_info}}) + + # Download ZIP + temp_zip = Path.join(state.tile_temp_dir, "#{tile_name}.zip") + + case OhioLidar.download_tile(tile_name, tile_info.county, temp_zip) do + {:ok, _bytes} -> + send(self(), {:extract_tile, tile_name, tile_info, temp_zip}) + {:noreply, state} + + {:error, reason} -> + Logger.error("Failed to download tile #{tile_name}: #{inspect(reason)}") + :ets.insert(:tile_processing, {tile_name, {:error, "Download failed: #{inspect(reason)}"}, %{}}) + + # Clean up + File.rm(temp_zip) + + # Process next in queue + state = process_next_in_queue(state) + {:noreply, state} + end + end + + @impl true + def handle_info({:extract_tile, tile_name, tile_info, zip_path}, state) do + Logger.info("Extracting tile #{tile_name}") + :ets.insert(:tile_processing, {tile_name, :extracting, %{tile_info: tile_info}}) + + extract_dir = Path.join(state.tile_temp_dir, tile_name) + File.mkdir_p!(extract_dir) + + case unzip_file(zip_path, extract_dir) do + :ok -> + # Find the .las file + case find_las_file(extract_dir) do + {:ok, las_file} -> + send(self(), {:convert_tile, tile_name, tile_info, las_file, extract_dir}) + {:noreply, state} + + {:error, reason} -> + Logger.error("Failed to find LAS file in #{extract_dir}: #{reason}") + :ets.insert(:tile_processing, {tile_name, {:error, "No LAS file found"}, %{}}) + + # Clean up + File.rm_rf!(extract_dir) + File.rm(zip_path) + + state = process_next_in_queue(state) + {:noreply, state} + end + + {:error, reason} -> + Logger.error("Failed to extract #{zip_path}: #{inspect(reason)}") + :ets.insert(:tile_processing, {tile_name, {:error, "Extraction failed: #{inspect(reason)}"}, %{}}) + + # Clean up + File.rm_rf!(extract_dir) + File.rm(zip_path) + + state = process_next_in_queue(state) + {:noreply, state} + end + end + + @impl true + def handle_info({:convert_tile, tile_name, tile_info, las_file, temp_dir}, state) do + Logger.info("Converting tile #{tile_name} to mound format") + :ets.insert(:tile_processing, {tile_name, :converting, %{tile_info: tile_info}}) + + output_file = Path.join(state.tile_output_dir, "#{tile_name}.mound") + File.mkdir_p!(state.tile_output_dir) + + case run_las2mound(state.las2mound_script, las_file, output_file) do + :ok -> + Logger.info("Successfully converted #{tile_name}") + :ets.insert(:tile_processing, {tile_name, :done, %{tile_info: tile_info}}) + + # Update Mnesia + MoundHunters.Repo.upsert_tile(%{ + id: tile_name, + status: :ready, + min_lat: nil, # TODO: Extract from tile_info or LAS bounds + max_lat: nil, + min_lng: nil, + max_lng: nil + }) + + # Clean up temp files + File.rm_rf!(temp_dir) + File.rm(Path.join(state.tile_temp_dir, "#{tile_name}.zip")) + + state = process_next_in_queue(state) + {:noreply, state} + + {:error, reason} -> + Logger.error("Failed to convert #{tile_name}: #{inspect(reason)}") + :ets.insert(:tile_processing, {tile_name, {:error, "Conversion failed: #{inspect(reason)}"}, %{}}) + + # Clean up + File.rm_rf!(temp_dir) + File.rm(Path.join(state.tile_temp_dir, "#{tile_name}.zip")) + + state = process_next_in_queue(state) + {:noreply, state} + end + end + + # Helper functions + + defp queue_tile_for_processing(state, tile_name, tile_info) do + # Check if already in queue or processing + case :ets.lookup(:tile_processing, tile_name) do + [] -> + # Add to queue + :ets.insert(:tile_processing, {tile_name, :queued, %{tile_info: tile_info}}) + new_queue = :queue.in({tile_name, tile_info}, state.processing_queue) + state = %{state | processing_queue: new_queue} + + # If nothing currently processing, start now + if state.current_job == nil do + process_next_in_queue(state) + else + state + end + + [{^tile_name, _status, _metadata}] -> + Logger.debug("Tile #{tile_name} already in processing queue") + state + end + end + + defp process_next_in_queue(state) do + case :queue.out(state.processing_queue) do + {{:value, {tile_name, tile_info}}, new_queue} -> + Logger.info("Processing next tile in queue: #{tile_name}") + send(self(), {:process_tile, tile_name, tile_info}) + %{state | processing_queue: new_queue, current_job: tile_name} + + {:empty, _} -> + Logger.debug("Processing queue empty") + %{state | current_job: nil} + end + end + + defp unzip_file(zip_path, extract_dir) do + case :zip.unzip(String.to_charlist(zip_path), cwd: String.to_charlist(extract_dir)) do + {:ok, _files} -> + :ok + + {:error, reason} -> + {:error, reason} + end + end + + defp find_las_file(dir) do + case File.ls(dir) do + {:ok, files} -> + las_files = Enum.filter(files, &String.ends_with?(&1, ".las")) + + case las_files do + [las_file | _] -> {:ok, Path.join(dir, las_file)} + [] -> {:error, :no_las_file} + end + + {:error, reason} -> + {:error, reason} + end + end + + defp run_las2mound(script_path, input_las, output_mound) do + Logger.info("Running: #{script_path} #{input_las} #{output_mound}") + + case System.cmd("python3", [script_path, input_las, output_mound], stderr_to_stdout: true) do + {output, 0} -> + Logger.debug("las2mound output: #{output}") + :ok + + {output, exit_code} -> + Logger.error("las2mound failed with exit code #{exit_code}: #{output}") + {:error, "Exit code #{exit_code}"} + end + end +end diff --git a/lib/mound_hunters_web/controllers/api_controller.ex b/lib/mound_hunters_web/controllers/api_controller.ex new file mode 100644 index 0000000..227013c --- /dev/null +++ b/lib/mound_hunters_web/controllers/api_controller.ex @@ -0,0 +1,72 @@ +defmodule MoundHuntersWeb.ApiController do + @moduledoc """ + API endpoints for sharing geometries. + """ + import Plug.Conn + require Logger + + @doc """ + Create a new shared geometry. + POST /api/share + Body: {"geojson": {...}} + Returns: {"id": "abc12345"} + """ + def create_share(conn) do + with {:ok, body, conn} <- read_body(conn), + {:ok, params} <- Jason.decode(body), + geojson when is_map(geojson) <- params["geojson"], + geojson_str <- Jason.encode!(geojson), + {:ok, %{id: id}} <- MoundHunters.Repo.create_geometry(geojson_str) do + conn + |> put_resp_content_type("application/json") + |> put_resp_header("cache-control", "no-cache") + |> send_resp(200, Jason.encode!(%{id: id})) + else + {:error, :invalid_json} -> + send_error(conn, 400, "Invalid JSON") + + nil -> + send_error(conn, 400, "Missing geojson field") + + {:error, reason} -> + Logger.error("Failed to create geometry: #{inspect(reason)}") + send_error(conn, 500, "Failed to create geometry") + + _ -> + send_error(conn, 400, "Invalid request") + end + end + + @doc """ + Get a shared geometry by ID. + GET /api/share/:id + Returns: {"geojson": {...}} + """ + def get_share(conn) do + geometry_id = conn.path_params["id"] + + case MoundHunters.Repo.get_geometry(geometry_id) do + {:ok, geometry} -> + geojson = Jason.decode!(geometry.geojson) + + conn + |> put_resp_content_type("application/json") + |> put_resp_header("cache-control", "no-cache") + |> send_resp(200, Jason.encode!(%{geojson: geojson})) + + {:error, :not_found} -> + send_error(conn, 404, "Geometry not found") + + {:error, reason} -> + Logger.error("Failed to get geometry: #{inspect(reason)}") + send_error(conn, 500, "Failed to retrieve geometry") + end + end + + defp send_error(conn, status, message) do + conn + |> put_private(:error_message, message) + |> put_resp_content_type("application/json") + |> send_resp(status, Jason.encode!(%{error: message})) + end +end diff --git a/lib/mound_hunters_web/controllers/tile_controller.ex b/lib/mound_hunters_web/controllers/tile_controller.ex new file mode 100644 index 0000000..8762b0b --- /dev/null +++ b/lib/mound_hunters_web/controllers/tile_controller.ex @@ -0,0 +1,227 @@ +defmodule MoundHuntersWeb.TileController do + @moduledoc """ + Handles tile requests via Server-Sent Events and serves tile files. + """ + import Plug.Conn + require Logger + + @doc """ + SSE endpoint for tile requests. + GET /tiles/request?lat=40.0&lng=-82.5 + + Flow: + 1. Validate coords (done by BoundsCheck plug) + 2. Format lookup_id from lat/lng + 3. Request tile processing + 4. Poll :tile_lookups until we get tile_id or error + 5. Poll :tile_processing for status updates + 6. Send SSE events for progress + 7. When done, send final event with tile_id and URLs + """ + def request(conn) do + lat = String.to_float(conn.query_params["lat"]) + lng = String.to_float(conn.query_params["lng"]) + + # Start SSE stream + conn = + conn + |> put_resp_header("content-type", "text/event-stream") + |> put_resp_header("cache-control", "no-cache") + |> put_resp_header("connection", "keep-alive") + |> send_chunked(200) + + # Request tile processing + {:ok, lookup_id} = MoundHunters.TileProcessor.request_tile(lat, lng) + Logger.info("Tile request started for #{lookup_id}") + + # Store lookup_id in conn for logging + conn = put_private(conn, :lookup_id, lookup_id) + + # Poll for lookup completion + tile_id = + case poll_lookup(conn, lookup_id) do + {:ok, id} -> + id + + {:error, reason} -> + conn = put_private(conn, :error_message, reason) + send_sse_event(conn, %{status: "error", message: reason}) + nil + end + + # If we got a tile_id, poll for processing status + if tile_id do + conn = put_private(conn, :tile_id, tile_id) + poll_processing(conn, tile_id) + end + + conn + end + + @doc """ + Serve a tile file. + GET /tiles/MOUND/:tile_id.mound + GET /tiles/JPG/:tile_id.jpg + GET /tiles/PNG/:tile_id.jpng + GET /tiles/JSON/:tile_id.json + """ + def serve(conn) do + raw_tile_id = conn.path_params["tile_id"] + format = conn.path_params["format"] + + # Strip extension if present + tile_id = + raw_tile_id + |> Path.rootname() + + # Store tile_id for logging + conn = put_private(conn, :tile_id, tile_id) + + tile_dir = Application.get_env(:mound_hunters, :tile_output_dir) + + file_path = + case format do + "MOUND" -> Path.join(tile_dir, "MOUND/#{tile_id}.mound") + "JSON" -> Path.join(tile_dir, "JSON/#{tile_id}.json") + "JPG" -> Path.join(tile_dir, "JPG/#{tile_id}.jpg") + "PNG" -> Path.join(tile_dir, "PNG/#{tile_id}.png") + end + + Logger.info("Tile request started for #{file_path}") + + if File.exists?(file_path) do + conn + |> put_resp_header("content-type", MIME.from_path(file_path)) + |> put_resp_header("cache-control", "public, max-age=31536000, immutable") + |> send_file(200, file_path) + else + conn + |> put_private(:error_message, "Tile file not found") + |> put_resp_content_type("application/json") + |> send_resp(404, Jason.encode!(%{error: "Tile file not found"})) + end + end + + # Poll :tile_lookups ETS table until we get a tile_id or error + defp poll_lookup(conn, lookup_id, attempts \\ 0) do + # 60 seconds at 500ms intervals + max_attempts = 120 + + case MoundHunters.TileProcessor.get_lookup_status(lookup_id) do + {:ok, :pending} -> + if attempts < max_attempts do + send_sse_event(conn, %{status: "looking_up", message: "Finding tile..."}) + Process.sleep(500) + poll_lookup(conn, lookup_id, attempts + 1) + else + {:error, "Lookup timeout"} + end + + {:ok, {:ok, tile_id}} -> + send_sse_event(conn, %{status: "found", tile_id: tile_id}) + {:ok, tile_id} + + {:ok, {:error, reason}} -> + {:error, reason} + + {:error, :not_found} -> + # Request hasn't been picked up yet + if attempts < max_attempts do + Process.sleep(500) + poll_lookup(conn, lookup_id, attempts + 1) + else + {:error, "Lookup not started"} + end + end + end + + # Poll :tile_processing ETS table for status updates + defp poll_processing(conn, tile_id, last_status \\ nil, attempts \\ 0) do + # 5 minutes at 500ms intervals + max_attempts = 600 + + case MoundHunters.TileProcessor.get_processing_status(tile_id) do + {:ok, status} when status != last_status -> + # Status changed, send update + event = build_status_event(status, tile_id) + send_sse_event(conn, event) + + case status do + :done -> + # Processing complete + :ok + + {:error, _reason} -> + # Error occurred, stop polling + :ok + + _ -> + # Continue polling + if attempts < max_attempts do + Process.sleep(500) + poll_processing(conn, tile_id, status, attempts + 1) + else + send_sse_event(conn, %{status: "error", message: "Processing timeout"}) + end + end + + {:ok, status} -> + # Status unchanged, keep polling + if attempts < max_attempts do + Process.sleep(500) + poll_processing(conn, tile_id, status, attempts + 1) + else + send_sse_event(conn, %{status: "error", message: "Processing timeout"}) + end + + {:error, :not_found} -> + # Tile not in processing queue yet + if attempts < max_attempts do + Process.sleep(500) + poll_processing(conn, tile_id, last_status, attempts + 1) + else + send_sse_event(conn, %{status: "error", message: "Tile not queued"}) + end + end + end + + defp build_status_event(:queued, _tile_id) do + %{status: "processing", message: "Queued for processing..."} + end + + defp build_status_event(:looking_up, _tile_id) do + %{status: "processing", message: "Looking up tile information..."} + end + + defp build_status_event(:downloading, _tile_id) do + %{status: "processing", message: "Downloading lidar data..."} + end + + defp build_status_event(:extracting, _tile_id) do + %{status: "processing", message: "Extracting point cloud..."} + end + + defp build_status_event(:converting, _tile_id) do + %{status: "processing", message: "Converting to mound format..."} + end + + defp build_status_event(:done, tile_id) do + %{ + status: "ready", + tile_id: tile_id, + urls: %{ + mound: "/tiles/#{tile_id}.mound", + jpg: "/tiles/#{tile_id}.jpg" + } + } + end + + defp build_status_event({:error, reason}, _tile_id) do + %{status: "error", message: to_string(reason)} + end + + defp send_sse_event(conn, data) do + json = Jason.encode!(data) + chunk(conn, "data: #{json}\n\n") + end +end diff --git a/lib/mound_hunters_web/plugs/bounds_check.ex b/lib/mound_hunters_web/plugs/bounds_check.ex new file mode 100644 index 0000000..6485169 --- /dev/null +++ b/lib/mound_hunters_web/plugs/bounds_check.ex @@ -0,0 +1,75 @@ +defmodule MoundHuntersWeb.Plugs.BoundsCheck do + @moduledoc """ + Plug to validate that coordinates are within Ohio boundaries. + Applies to tile requests but not to geometry sharing API. + """ + import Plug.Conn + require Logger + + def init(opts), do: opts + + def call(conn, _opts) do + # Skip bounds check for non-tile endpoints + if skip_bounds_check?(conn.request_path) do + conn + else + check_bounds(conn) + end + end + + defp skip_bounds_check?(path) do + # Skip bounds check for geometry API and static files + String.starts_with?(path, "/api/share") or + String.starts_with?(path, "/static/") or + path == "/" + end + + defp check_bounds(conn) do + cond do + # Check query params for tile request + conn.query_params["lat"] != nil and conn.query_params["lng"] != nil -> + check_query_params(conn) + + # For tile file serving, we assume tiles in storage are valid + # (they were validated when created) + String.starts_with?(conn.request_path, "/tiles/") -> + conn + + true -> + conn + end + end + + defp check_query_params(conn) do + with {:ok, lat} <- parse_float(conn.query_params["lat"]), + {:ok, lng} <- parse_float(conn.query_params["lng"]), + :ok <- MoundHunters.Boundary.check_bounds(lat, lng) do + conn + else + {:error, :invalid_number} -> + send_error(conn, 400, "Invalid coordinate format") + + {:error, reason} -> + send_error(conn, 400, reason) + end + end + + defp parse_float(str) when is_binary(str) do + case Float.parse(str) do + {num, ""} -> {:ok, num} + _ -> {:error, :invalid_number} + end + end + + defp parse_float(_), do: {:error, :invalid_number} + + defp send_error(conn, status, message) do + Logger.warning("Bounds check failed: #{message}") + + conn + |> put_private(:error_message, message) + |> put_resp_content_type("application/json") + |> send_resp(status, Jason.encode!(%{error: message})) + |> halt() + end +end diff --git a/lib/mound_hunters_web/plugs/request_logger.ex b/lib/mound_hunters_web/plugs/request_logger.ex new file mode 100644 index 0000000..9085d7d --- /dev/null +++ b/lib/mound_hunters_web/plugs/request_logger.ex @@ -0,0 +1,101 @@ +defmodule MoundHuntersWeb.Plugs.RequestLogger do + @moduledoc """ + Logs each HTTP request as a single JSON line in combined log format plus custom fields. + + Log format includes: + - Standard combined log fields: remote_ip, timestamp, method, path, status, bytes, referer, user_agent + - Custom fields: request_id, duration_ms, query_params, lat, lng, tile_id, lookup_id, error + """ + import Plug.Conn + require Logger + + def init(opts), do: opts + + def call(conn, _opts) do + start_time = System.monotonic_time(:microsecond) + request_id = generate_request_id() + + conn = + conn + |> put_private(:request_start_time, start_time) + |> put_private(:request_id, request_id) + + register_before_send(conn, fn conn -> + log_request(conn, start_time, request_id) + conn + end) + end + + defp log_request(conn, start_time, request_id) do + end_time = System.monotonic_time(:microsecond) + duration_ms = (end_time - start_time) / 1000.0 + + log_entry = %{ + # Standard combined log format fields + remote_ip: format_remote_ip(conn.remote_ip), + timestamp: DateTime.utc_now() |> DateTime.to_iso8601(), + method: conn.method, + path: conn.request_path, + query_string: conn.query_string, + status: conn.status, + bytes_sent: get_resp_header(conn, "content-length") |> List.first() || "-", + referer: get_req_header(conn, "referer") |> List.first() || "-", + user_agent: get_req_header(conn, "user-agent") |> List.first() || "-", + + # Custom fields + request_id: request_id, + duration_ms: Float.round(duration_ms, 2), + + # Tile-specific fields (if present) + lat: get_query_param(conn, "lat"), + lng: get_query_param(conn, "lng"), + tile_id: get_private_field(conn, :tile_id), + lookup_id: get_private_field(conn, :lookup_id), + + # Error information + error: get_private_field(conn, :error_message) + } + + # Remove nil values for cleaner logs + log_entry = Enum.reject(log_entry, fn {_k, v} -> is_nil(v) end) |> Map.new() + + # Log to file as JSON line + json_line = Jason.encode!(log_entry) + Logger.info(json_line, logger: :request_log) + + # Also log summary to console + console_msg = + "#{conn.method} #{conn.request_path} - #{conn.status} - #{Float.round(duration_ms, 2)}ms" + Logger.info(console_msg) + end + + defp format_remote_ip({a, b, c, d}) do + "#{a}.#{b}.#{c}.#{d}" + end + + defp format_remote_ip({a, b, c, d, e, f, g, h}) do + parts = [a, b, c, d, e, f, g, h] + parts + |> Enum.map(&Integer.to_string(&1, 16)) + |> Enum.join(":") + end + + defp get_query_param(conn, key) do + case conn.query_params do + %{^key => value} when value != "" -> value + _ -> nil + end + end + + defp get_private_field(conn, key) do + case conn.private do + %{^key => value} -> value + _ -> nil + end + end + + defp generate_request_id do + :crypto.strong_rand_bytes(8) + |> Base.url_encode64(padding: false) + end +end diff --git a/lib/mound_hunters_web/router.ex b/lib/mound_hunters_web/router.ex new file mode 100644 index 0000000..a7db9ad --- /dev/null +++ b/lib/mound_hunters_web/router.ex @@ -0,0 +1,158 @@ +defmodule MoundHuntersWeb.Router do + @moduledoc """ + Main HTTP router for Hopewell Road application. + """ + use Plug.Router + require Logger + + # Request logging (must be first to capture timing) + plug(MoundHuntersWeb.Plugs.RequestLogger) + + # Parse query params and request body + plug(Plug.Parsers, + parsers: [:urlencoded, :json], + json_decoder: Jason + ) + + # Bounds checking for Ohio coordinates + plug(MoundHuntersWeb.Plugs.BoundsCheck) + + # Match routes + plug(:match) + + # Dispatch to matched route + plug(:dispatch) + + # Tile endpoints + get "/tiles/request" do + MoundHuntersWeb.TileController.request(conn) + end + + match "/tiles/:format/:tile_id", via: [:get, :head] do + conn = put_private(conn, :path_params, %{"tile_id" => tile_id, "format" => format}) + MoundHuntersWeb.TileController.serve(conn) + end + + # Geometry sharing API + post "/api/share" do + MoundHuntersWeb.ApiController.create_share(conn) + end + + get "/api/share/:id" do + conn = put_private(conn, :path_params, %{"id" => id}) + MoundHuntersWeb.ApiController.get_share(conn) + end + + # Health check + get "/health" do + send_resp(conn, 200, Jason.encode!(%{status: "ok"})) + end + + get "/" do + static_dir = Application.app_dir(:mound_hunters, "priv/static") + index_path = Path.join(static_dir, "index.html") + + if File.exists?(index_path) do + conn + |> put_resp_header("content-type", "text/html; charset=utf-8") + |> put_resp_header("cache-control", "no-cache") + |> send_file(200, index_path) + else + send_resp(conn, 404, "Not found") + end + end + + # Static files (with cache busting support via manifest) + get "/*path" do + serve_static(conn, path) + end + + # 404 handler + match _ do + send_resp(conn, 404, Jason.encode!(%{error: "Not found"})) + end + + # Serve static files from priv/static + defp serve_static(conn, path) do + static_dir = Application.app_dir(:mound_hunters, "priv/static") + file_path = resolve_static_path(path, static_dir) + + cond do + file_path && File.regular?(file_path) -> + # Determine content type + content_type = MIME.from_path(file_path) + + # Set cache headers (immutable for hashed files) + cache_control = + if String.contains?(file_path, "-") do + "public, max-age=31536000, immutable" + else + "public, max-age=3600" + end + + conn + |> put_resp_header("content-type", content_type) + |> put_resp_header("cache-control", cache_control) + |> send_file(200, file_path) + + path == [] or path == [""] -> + # Serve index.html for root + index_path = Path.join(static_dir, "index.html") + + if File.exists?(index_path) do + conn + |> put_resp_header("content-type", "text/html") + |> put_resp_header("cache-control", "no-cache") + |> send_file(200, index_path) + else + send_resp(conn, 404, "Not found") + end + + true -> + send_resp(conn, 404, "Not found") + end + end + + # Resolve static file path, checking manifest for hashed versions + defp resolve_static_path(path, static_dir) do + requested = Path.join(path) + direct_path = Path.join(static_dir, requested) + + cond do + File.regular?(direct_path) -> + direct_path + + # Check manifest for hashed version + manifest_path = Path.join(static_dir, "manifest.json") -> + case load_manifest(manifest_path) do + {:ok, manifest} -> + hashed = Map.get(manifest, requested) + + if hashed do + hashed_path = Path.join(static_dir, hashed) + if File.regular?(hashed_path), do: hashed_path, else: nil + else + nil + end + + _ -> + nil + end + + true -> + nil + end + end + + # Load and cache manifest + defp load_manifest(path) do + if File.exists?(path) do + case File.read(path) do + {:ok, content} -> Jason.decode(content) + _ -> {:error, :read_failed} + end + else + {:error, :not_found} + end + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..d0ddec3 --- /dev/null +++ b/mix.exs @@ -0,0 +1,31 @@ +defmodule MoundHunters.MixProject do + use Mix.Project + + def project do + [ + app: :mound_hunters, + version: "0.1.0", + elixir: "~> 1.14", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + extra_applications: [:logger, :mnesia, :inets, :ssl], + mod: {MoundHunters.Application, []} + ] + end + + defp deps do + [ + {:plug_cowboy, "~> 2.7"}, + {:jason, "~> 1.4"}, + {:geo, "~> 3.6"}, + {:logger_file_backend, "~> 0.0.13"}, + {:httpoison, "~> 2.2"}, + {:mime, "~> 2.0"} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..2d61459 --- /dev/null +++ b/mix.lock @@ -0,0 +1,23 @@ +%{ + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, + "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, + "geo": {:hex, :geo, "3.6.0", "00c9c6338579f67e91cd5950af4ae2eb25cdce0c3398718c232539f61625d0bd", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "1dbdebf617183b54bc3c8ad7a36531a9a76ada8ca93f75f573b0ae94006168da"}, + "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, + "httpoison": {:hex, :httpoison, "2.3.0", "10eef046405bc44ba77dc5b48957944df8952cc4966364b3cf6aa71dce6de587", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d388ee70be56d31a901e333dbcdab3682d356f651f93cf492ba9f06056436a2c"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "logger_file_backend": {:hex, :logger_file_backend, "0.0.14", "774bb661f1c3fed51b624d2859180c01e386eb1273dc22de4f4a155ef749a602", [:mix], [], "hexpm", "071354a18196468f3904ef09413af20971d55164267427f6257b52cfba03f9e6"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.7.5", "261f21b67aea8162239b2d6d3b4c31efde4daa22a20d80b19c2c0f21b34b270e", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "20884bf58a90ff5a5663420f5d2c368e9e15ed1ad5e911daf0916ea3c57f77ac"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, +}