create backend

This commit is contained in:
2026-01-24 10:23:37 +01:00
parent 73625bf6a5
commit f059b54936
17 changed files with 1611 additions and 3 deletions

View 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

View 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