diff --git a/lib/philomena_query/ip_mask.ex b/lib/philomena_query/ip_mask.ex new file mode 100644 index 00000000..6bf7c192 --- /dev/null +++ b/lib/philomena_query/ip_mask.ex @@ -0,0 +1,109 @@ +defmodule PhilomenaQuery.IpMask do + @moduledoc """ + Postgres IP masks. + """ + + @doc """ + Parse a netmask from a string parameter, producing an `m:Postgrex.INET` type suitable for use in + a containment (<<=, <<, >>, >>=) query. Ignores invalid strings and passes the IP through on + error. [Postgres documentation](https://www.postgresql.org/docs/current/functions-net.html) + has more information on `inet` operations. + + > #### Info {: .info} + > + > Netmasks lower than /8 are clamped to a minimum of /8. Such low masks are unlikely to be + > useful and this avoids producing very expensive masks to evaluate. + + ## Examples + + iex> parse_mask(%Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32}, %{"mask" => "12"}) + %Postgrex.INET{address: {192, 160, 0, 0}, netmask: 12} + + iex> parse_mask(%Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32}, %{"mask" => "4"}) + %Postgrex.INET{address: {192, 0, 0, 0}, netmask: 8} + + iex> parse_mask(%Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32}, %{"mask" => "64"}) + %Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32} + + iex> parse_mask(%Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32}, %{"mask" => "e"}) + %Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32} + + iex> parse_mask(%Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32}, %{}) + %Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32} + + iex> parse_mask(%Postgrex.INET{ + ...> address: {0x2001, 0xab0, 0x33a8, 0xd6e2, 0x10e9, 0xac1b, 0x9b0f, 0x67bc}, + ...> netmask: 128 + ...> }, %{"mask" => "64"}) + %Postgrex.INET{address: {8193, 2736, 13224, 55010, 0, 0, 0, 0}, netmask: 64} + + """ + @spec parse_mask(Postgrex.INET.t(), map()) :: Postgrex.INET.t() + def parse_mask(ip, params) + + def parse_mask(ip, %{"mask" => mask}) when is_binary(mask) do + case Integer.parse(mask) do + {mask, _rest} -> + mask = clamp_mask(ip.address, mask) + address = apply_mask(ip.address, mask) + + %Postgrex.INET{address: address, netmask: mask} + + _ -> + ip + end + end + + def parse_mask(ip, _params), do: ip + + defp clamp(n, min, _max) when n < min, do: min + defp clamp(n, _min, max) when n > max, do: max + defp clamp(n, _min, _max), do: n + + defp clamp_mask(ip, mask) do + # Clamp mask length: + # - low end 8 (too taxing to evaluate) + # - high end address_bits (limit of address) + case tuple_size(ip) do + 4 -> + clamp(mask, 8, 32) + + 8 -> + clamp(mask, 8, 128) + end + end + + defp unit_length(ip) when tuple_size(ip) == 4, do: 8 + defp unit_length(ip) when tuple_size(ip) == 8, do: 16 + + defp apply_mask(ip, mask) when is_tuple(ip) do + # Determine whether elements are octets or hexadectets + length = unit_length(ip) + + # 1. Convert tuple to list of octets/hexadectets + # 2. Convert list to bitstring + # 3. Perform truncation operation on bitstring + # 4. Convert bitstring back to list of octets/hexadectets + # 5. Convert list to tuple + + ip + |> Tuple.to_list() + |> list_to_bits(length) + |> apply_mask(mask) + |> bits_to_list(length) + |> List.to_tuple() + end + + defp apply_mask(ip, mask) when is_binary(ip) do + # Truncate bit size of ip to mask length and zero-fill the remainder + <> + end + + defp list_to_bits(list, unit_length) do + for u <- list, into: <<>>, do: <> + end + + defp bits_to_list(bits, unit_length) do + for <>, do: u + end +end diff --git a/lib/philomena_web/controllers/ip_profile/source_change_controller.ex b/lib/philomena_web/controllers/ip_profile/source_change_controller.ex index f5bf868c..d82359e2 100644 --- a/lib/philomena_web/controllers/ip_profile/source_change_controller.ex +++ b/lib/philomena_web/controllers/ip_profile/source_change_controller.ex @@ -1,25 +1,27 @@ defmodule PhilomenaWeb.IpProfile.SourceChangeController do use PhilomenaWeb, :controller + alias PhilomenaQuery.IpMask alias Philomena.SourceChanges.SourceChange alias Philomena.Repo import Ecto.Query plug :verify_authorized - def index(conn, %{"ip_profile_id" => ip}) do + def index(conn, %{"ip_profile_id" => ip} = params) do {:ok, ip} = EctoNetwork.INET.cast(ip) + range = IpMask.parse_mask(ip, params) source_changes = SourceChange - |> where(ip: ^ip) + |> where(fragment("? >>= ip", ^range)) |> order_by(desc: :id) |> preload([:user, image: [:user, :sources, tags: :aliases]]) |> Repo.paginate(conn.assigns.scrivener) render(conn, "index.html", title: "Source Changes for IP `#{ip}'", - ip: ip, + ip: range, source_changes: source_changes ) end diff --git a/lib/philomena_web/controllers/ip_profile/tag_change_controller.ex b/lib/philomena_web/controllers/ip_profile/tag_change_controller.ex index b9779913..bdfebc29 100644 --- a/lib/philomena_web/controllers/ip_profile/tag_change_controller.ex +++ b/lib/philomena_web/controllers/ip_profile/tag_change_controller.ex @@ -1,6 +1,7 @@ defmodule PhilomenaWeb.IpProfile.TagChangeController do use PhilomenaWeb, :controller + alias PhilomenaQuery.IpMask alias Philomena.TagChanges.TagChange alias Philomena.Repo import Ecto.Query @@ -9,10 +10,11 @@ defmodule PhilomenaWeb.IpProfile.TagChangeController do def index(conn, %{"ip_profile_id" => ip} = params) do {:ok, ip} = EctoNetwork.INET.cast(ip) + range = IpMask.parse_mask(ip, params) tag_changes = TagChange - |> where(ip: ^ip) + |> where(fragment("? >>= ip", ^range)) |> added_filter(params) |> preload([:tag, :user, image: [:user, :sources, tags: :aliases]]) |> order_by(desc: :id) @@ -20,7 +22,7 @@ defmodule PhilomenaWeb.IpProfile.TagChangeController do render(conn, "index.html", title: "Tag Changes for IP `#{ip}'", - ip: ip, + ip: range, tag_changes: tag_changes ) end diff --git a/lib/philomena_web/templates/ip_profile/show.html.slime b/lib/philomena_web/templates/ip_profile/show.html.slime index eb3ac25c..da8acd3d 100644 --- a/lib/philomena_web/templates/ip_profile/show.html.slime +++ b/lib/philomena_web/templates/ip_profile/show.html.slime @@ -11,8 +11,17 @@ ul h2 Administration Options ul - li = link "View tag changes", to: ~p"/ip_profiles/#{to_string(@ip)}/tag_changes" - li = link "View source URL history", to: ~p"/ip_profiles/#{to_string(@ip)}/source_changes" + li + => link "View tag changes", to: ~p"/ip_profiles/#{to_string(@ip)}/tag_changes" + = if ipv6?(@ip) do + ' … + = link "(/64)", to: ~p"/ip_profiles/#{to_string(@ip)}/tag_changes?mask=64" + li + => link "View source URL history", to: ~p"/ip_profiles/#{to_string(@ip)}/source_changes" + = if ipv6?(@ip) do + ' … + = link "(/64)", to: ~p"/ip_profiles/#{to_string(@ip)}/source_changes?mask=64" + li = link "View reports this IP has made", to: ~p"/admin/reports?#{[rq: "ip:#{@ip}"]}" li = link "View IP ban history", to: ~p"/admin/subnet_bans?#{[ip: to_string(@ip)]}" li = link "Ban this sucker", to: ~p"/admin/subnet_bans/new?#{[specification: to_string(@ip)]}" diff --git a/lib/philomena_web/views/ip_profile_view.ex b/lib/philomena_web/views/ip_profile_view.ex index 9aef6c29..a9f99f20 100644 --- a/lib/philomena_web/views/ip_profile_view.ex +++ b/lib/philomena_web/views/ip_profile_view.ex @@ -1,3 +1,8 @@ defmodule PhilomenaWeb.IpProfileView do use PhilomenaWeb, :view + + @spec ipv6?(Postgrex.INET.t()) :: boolean() + def ipv6?(ip) do + tuple_size(ip.address) == 8 + end end