201 lines
5.2 KiB
Elixir
201 lines
5.2 KiB
Elixir
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} when is_binary(reason) ->
|
|
# Validation error - safe to return to user
|
|
Logger.warning("GeoIP batch import validation error: #{reason}")
|
|
|
|
conn
|
|
|> put_status(:internal_server_error)
|
|
|> json(%{error: reason})
|
|
|
|
{:error, reason} ->
|
|
# Internal error - log details but return generic message
|
|
Logger.error("GeoIP batch import failed: #{inspect(reason)}")
|
|
|
|
conn
|
|
|> put_status(:internal_server_error)
|
|
|> json(%{error: "Import failed"})
|
|
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.Block
|
|
alias Towerops.GeoIP.Location
|
|
alias Towerops.Repo
|
|
|
|
if truncate do
|
|
# Must delete blocks first due to foreign key constraint
|
|
Repo.delete_all(Block)
|
|
Repo.delete_all(Location)
|
|
end
|
|
|
|
now = Towerops.Time.now()
|
|
|
|
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 - update specific fields on conflict
|
|
# Don't use replace_all_except because it triggers DELETE+INSERT which violates foreign keys
|
|
{inserted, _} =
|
|
Repo.insert_all(Location, entries,
|
|
on_conflict:
|
|
{:replace,
|
|
[
|
|
:country_code,
|
|
:country_name,
|
|
:city_name,
|
|
:subdivision_1_name,
|
|
:subdivision_2_name,
|
|
:latitude,
|
|
:longitude,
|
|
:updated_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 = Towerops.Time.now()
|
|
|
|
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 - update specific fields on conflict
|
|
{inserted, _} =
|
|
Repo.insert_all(Block, entries,
|
|
on_conflict:
|
|
{:replace,
|
|
[
|
|
:start_ip_int,
|
|
:end_ip_int,
|
|
:geoname_id,
|
|
:registered_country_geoname_id,
|
|
:updated_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
|