defmodule ToweropsWeb.Api.V1.GeoipController do @moduledoc """ API controller for managing GeoIP database imports. Allows superusers to trigger GeoIP database imports from MaxMind CSV files. All endpoints require superuser API token authentication. """ use ToweropsWeb, :controller require Logger @doc """ Import GeoIP database batch from processed CSV data. Requires superuser API token. ## Request POST /admin/api/geoip/import Content-Type: application/json { "batch_type": "locations", // or "blocks" "data": [...], // Array of location or block records "truncate": true // Optional: truncate table before inserting (first batch only) } ## Response 200 OK { "status": "ok", "inserted": 5000 } """ def import_database(conn, %{"batch_type" => batch_type, "data" => data} = params) do with :ok <- require_superuser(conn) do truncate = Map.get(params, "truncate", false) Logger.info( "GeoIP import batch requested by #{conn.assigns[:current_user].email}: #{batch_type}, #{length(data)} records, truncate=#{truncate}" ) case import_batch(batch_type, data, truncate) do {:ok, inserted_count} -> conn |> put_status(:ok) |> json(%{ status: "ok", inserted: inserted_count }) {:error, reason} -> Logger.error("GeoIP batch import failed: #{inspect(reason)}") conn |> put_status(:internal_server_error) |> json(%{error: "Import failed: #{inspect(reason)}"}) end end end def import_database(conn, _params) do conn |> put_status(:bad_request) |> json(%{ error: "Missing required parameters: batch_type and data" }) end defp require_superuser(conn) do user = conn.assigns[:current_user] if !user || !user.is_superuser do Logger.warning("GeoIP import attempted by non-superuser: #{inspect(user && user.email)}") conn |> put_status(:forbidden) |> json(%{ error: "Superuser access required. Only API tokens created by superusers can import GeoIP data." }) |> halt() else :ok end end defp import_batch("locations", data, truncate) do alias Towerops.GeoIP.Location alias Towerops.Repo if truncate do Repo.delete_all(Location) end now = DateTime.truncate(DateTime.utc_now(), :second) entries = Enum.map(data, fn location -> %{ geoname_id: location["geoname_id"], country_code: location["country_code"], country_name: location["country_name"], city_name: location["city_name"], subdivision_1_name: location["subdivision_1_name"], subdivision_2_name: location["subdivision_2_name"], latitude: location["latitude"], longitude: location["longitude"], inserted_at: now, updated_at: now } end) # Use upsert to avoid duplicates - replace existing records with same geoname_id {inserted, _} = Repo.insert_all(Location, entries, on_conflict: {:replace_all_except, [:inserted_at]}, conflict_target: :geoname_id ) {:ok, inserted} rescue e -> {:error, Exception.message(e)} end defp import_batch("blocks", data, truncate) do alias Towerops.GeoIP.Block alias Towerops.Repo if truncate do Repo.delete_all(Block) end now = DateTime.truncate(DateTime.utc_now(), :second) entries = Enum.map(data, fn block -> %{ id: Ecto.UUID.generate(), network: block["network"], start_ip_int: block["start_ip_int"], end_ip_int: block["end_ip_int"], geoname_id: block["geoname_id"], registered_country_geoname_id: block["registered_country_geoname_id"], inserted_at: now, updated_at: now } end) # Use upsert to avoid duplicates - replace existing records with same network {inserted, _} = Repo.insert_all(Block, entries, on_conflict: {:replace_all_except, [:id, :inserted_at]}, conflict_target: :network ) {:ok, inserted} rescue e -> {:error, Exception.message(e)} end defp import_batch(_type, _data, _truncate) do {:error, "Invalid batch_type. Must be 'locations' or 'blocks'"} end end