create backend
This commit is contained in:
13
.gitignore
vendored
13
.gitignore
vendored
@@ -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
|
||||
|
||||
|
||||
27
config/config.exs
Normal file
27
config/config.exs
Normal file
@@ -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"
|
||||
6
config/dev.exs
Normal file
6
config/dev.exs
Normal file
@@ -0,0 +1,6 @@
|
||||
import Config
|
||||
|
||||
# Development-specific configuration
|
||||
config :logger, :console,
|
||||
format: "[$level] $message\n",
|
||||
metadata: [:request_id]
|
||||
3
config/prod.exs
Normal file
3
config/prod.exs
Normal file
@@ -0,0 +1,3 @@
|
||||
import Config
|
||||
|
||||
config :logger, level: :info
|
||||
7
config/test.exs
Normal file
7
config/test.exs
Normal file
@@ -0,0 +1,7 @@
|
||||
import Config
|
||||
|
||||
config :mound_hunters,
|
||||
http_port: 4001,
|
||||
mnesia_dir: ~c"priv/mnesia_test"
|
||||
|
||||
config :logger, level: :warning
|
||||
112
lib/mound_hunters/application.ex
Normal file
112
lib/mound_hunters/application.ex
Normal file
@@ -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
|
||||
121
lib/mound_hunters/boundary.ex
Normal file
121
lib/mound_hunters/boundary.ex
Normal file
@@ -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
|
||||
196
lib/mound_hunters/ohio_lidar.ex
Normal file
196
lib/mound_hunters/ohio_lidar.ex
Normal file
@@ -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
|
||||
107
lib/mound_hunters/repo.ex
Normal file
107
lib/mound_hunters/repo.ex
Normal file
@@ -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
|
||||
335
lib/mound_hunters/tile_processor.ex
Normal file
335
lib/mound_hunters/tile_processor.ex
Normal file
@@ -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
|
||||
72
lib/mound_hunters_web/controllers/api_controller.ex
Normal file
72
lib/mound_hunters_web/controllers/api_controller.ex
Normal file
@@ -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
|
||||
227
lib/mound_hunters_web/controllers/tile_controller.ex
Normal file
227
lib/mound_hunters_web/controllers/tile_controller.ex
Normal file
@@ -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
|
||||
75
lib/mound_hunters_web/plugs/bounds_check.ex
Normal file
75
lib/mound_hunters_web/plugs/bounds_check.ex
Normal file
@@ -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
|
||||
101
lib/mound_hunters_web/plugs/request_logger.ex
Normal file
101
lib/mound_hunters_web/plugs/request_logger.ex
Normal file
@@ -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
|
||||
158
lib/mound_hunters_web/router.ex
Normal file
158
lib/mound_hunters_web/router.ex
Normal file
@@ -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
|
||||
31
mix.exs
Normal file
31
mix.exs
Normal file
@@ -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
|
||||
23
mix.lock
Normal file
23
mix.lock
Normal file
@@ -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"},
|
||||
}
|
||||
Reference in New Issue
Block a user