mirror of
https://github.com/plausible/analytics.git
synced 2024-09-11 18:07:33 +03:00
IP Block List (#3761)
* Add Ecto.Network dependency * Migration: Add ip block list table * If Cachex errors out, mark the cache as not ready * Add IPRule schema * Seed IPRules * Add Shields context module * Implement IPRuleCache * Start IPRuleCache * Drop blocklisted IPs on ingestion * Cosmetic rename * Add settings sidebar item * Consider IPRuleCache readiness on health checks * Fix typo * Implement IP blocklist live view * Update moduledocs * Extend contextual module tests * Convert IPRules LiveView into LiveComponent * Keep live flashes on the tabs view * Update changelog * Format * Credo * Remove garbage * Update drop reason typespecs * Update typespecs for cache keys * Keep track of who added a rule and when * Test if adding via LV prefills the updated_by tooltip * Update ecto_network dependency * s/updated_by/added_by * s/drop_blocklist_ip/drop_shield_rule_ip * Add docs link * s/Updated/Added
This commit is contained in:
parent
bc467996ab
commit
99fe03701e
@ -2,6 +2,7 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
### Added
|
||||
- IP Block List in Site Settings
|
||||
- Allow filtering by multiple custom properties
|
||||
- Wildcard and member filtering on the Stats API `event:goal` property
|
||||
- Allow filtering with `contains`/`matches` operator for custom properties
|
||||
|
@ -41,6 +41,21 @@ defmodule Plausible.Application do
|
||||
interval: :timer.seconds(30),
|
||||
warmer_fn: :refresh_updated_recently
|
||||
]},
|
||||
{Plausible.Shield.IPRuleCache, []},
|
||||
{Plausible.Cache.Warmer,
|
||||
[
|
||||
child_name: Plausible.Shield.IPRuleCache.All,
|
||||
cache_impl: Plausible.Shield.IPRuleCache,
|
||||
interval: :timer.minutes(3) + Enum.random(1..:timer.seconds(10)),
|
||||
warmer_fn: :refresh_all
|
||||
]},
|
||||
{Plausible.Cache.Warmer,
|
||||
[
|
||||
child_name: Plausible.Shield.IPRuleCache.RecentlyUpdated,
|
||||
cache_impl: Plausible.Shield.IPRuleCache,
|
||||
interval: :timer.seconds(35),
|
||||
warmer_fn: :refresh_updated_recently
|
||||
]},
|
||||
{Plausible.Auth.TOTP.Vault, key: totp_vault_key()},
|
||||
PlausibleWeb.Endpoint,
|
||||
{Oban, Application.get_env(:plausible, Oban)},
|
||||
|
@ -53,6 +53,7 @@ defmodule Plausible.Cache do
|
||||
Application.fetch_env!(:plausible, __MODULE__)[:enabled] == true
|
||||
end
|
||||
|
||||
# credo:disable-for-this-file Credo.Check.Refactor.LongQuoteBlocks
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
require Logger
|
||||
@ -60,7 +61,7 @@ defmodule Plausible.Cache do
|
||||
@behaviour Plausible.Cache
|
||||
@modes [:all, :updated_recently]
|
||||
|
||||
@spec get(String.t(), Keyword.t()) :: any() | nil
|
||||
@spec get(any(), Keyword.t()) :: any() | nil
|
||||
def get(key, opts \\ []) do
|
||||
cache_name = Keyword.get(opts, :cache_name, name())
|
||||
force? = Keyword.get(opts, :force?, false)
|
||||
@ -158,6 +159,9 @@ defmodule Plausible.Cache do
|
||||
|
||||
0 ->
|
||||
count_all() == 0
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -26,6 +26,7 @@ defmodule Plausible.Ingestion.Event do
|
||||
| GateKeeper.policy()
|
||||
| :invalid
|
||||
| :dc_ip
|
||||
| :site_ip_blocklist
|
||||
|
||||
@type t() :: %__MODULE__{
|
||||
domain: String.t() | nil,
|
||||
@ -94,7 +95,8 @@ defmodule Plausible.Ingestion.Event do
|
||||
|
||||
defp pipeline() do
|
||||
[
|
||||
&put_ip_classification/1,
|
||||
&drop_datacenter_ip/1,
|
||||
&drop_shield_rule_ip/1,
|
||||
&put_user_agent/1,
|
||||
&put_basic_info/1,
|
||||
&put_referrer/1,
|
||||
@ -141,7 +143,7 @@ defmodule Plausible.Ingestion.Event do
|
||||
struct!(event, clickhouse_event_attrs: Map.merge(event.clickhouse_event_attrs, attrs))
|
||||
end
|
||||
|
||||
defp put_ip_classification(%__MODULE__{} = event) do
|
||||
defp drop_datacenter_ip(%__MODULE__{} = event) do
|
||||
case event.request.ip_classification do
|
||||
"dc_ip" ->
|
||||
drop(event, :dc_ip)
|
||||
@ -151,6 +153,19 @@ defmodule Plausible.Ingestion.Event do
|
||||
end
|
||||
end
|
||||
|
||||
defp drop_shield_rule_ip(%__MODULE__{} = event) do
|
||||
domain = event.domain
|
||||
address = event.request.remote_ip
|
||||
|
||||
case Plausible.Shield.IPRuleCache.get({domain, address}) do
|
||||
%Plausible.Shield.IPRule{action: :deny} ->
|
||||
drop(event, :site_ip_blocklist)
|
||||
|
||||
_ ->
|
||||
event
|
||||
end
|
||||
end
|
||||
|
||||
defp put_user_agent(%__MODULE__{} = event) do
|
||||
case parse_user_agent(event.request) do
|
||||
%UAInspector.Result{client: %UAInspector.Result.Client{name: "Headless Chrome"}} ->
|
||||
|
@ -104,7 +104,7 @@ defmodule Plausible.Ingestion.Request do
|
||||
end
|
||||
|
||||
defp put_remote_ip(changeset, conn) do
|
||||
Changeset.put_change(changeset, :remote_ip, PlausibleWeb.RemoteIp.get(conn))
|
||||
Changeset.put_change(changeset, :remote_ip, PlausibleWeb.RemoteIP.get(conn))
|
||||
end
|
||||
|
||||
defp parse_body(conn) do
|
||||
|
42
lib/plausible/shield/ip_rule.ex
Normal file
42
lib/plausible/shield/ip_rule.ex
Normal file
@ -0,0 +1,42 @@
|
||||
defmodule Plausible.Shield.IPRule do
|
||||
@moduledoc """
|
||||
Schema for IP block list
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@type t() :: %__MODULE__{}
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
schema "shield_rules_ip" do
|
||||
belongs_to :site, Plausible.Site
|
||||
field :inet, EctoNetwork.INET
|
||||
field :action, Ecto.Enum, values: [:deny, :allow], default: :deny
|
||||
field :description, :string
|
||||
field :added_by, :string
|
||||
|
||||
# If `from_cache?` is set, the struct might be incomplete - see `Plausible.Site.Shield.Rules.IP.Cache`
|
||||
field :from_cache?, :boolean, virtual: true, default: false
|
||||
timestamps()
|
||||
end
|
||||
|
||||
def changeset(rule, attrs) do
|
||||
rule
|
||||
|> cast(attrs, [:site_id, :inet, :description, :added_by])
|
||||
|> validate_required([:site_id, :inet])
|
||||
|> disallow_netmask(:inet)
|
||||
|> unique_constraint(:inet,
|
||||
name: :shield_rules_ip_site_id_inet_index
|
||||
)
|
||||
end
|
||||
|
||||
defp disallow_netmask(changeset, field) do
|
||||
case get_field(changeset, field) do
|
||||
%Postgrex.INET{netmask: netmask} when netmask != 32 and netmask != 128 ->
|
||||
add_error(changeset, field, "netmask unsupported")
|
||||
|
||||
_ ->
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
65
lib/plausible/shield/ip_rule_cache.ex
Normal file
65
lib/plausible/shield/ip_rule_cache.ex
Normal file
@ -0,0 +1,65 @@
|
||||
defmodule Plausible.Shield.IPRuleCache do
|
||||
@moduledoc """
|
||||
Allows retrieving IP Rules by domain and IP
|
||||
"""
|
||||
alias Plausible.Shield.IPRule
|
||||
|
||||
import Ecto.Query
|
||||
use Plausible.Cache
|
||||
|
||||
@cache_name :ip_blocklist_by_domain
|
||||
|
||||
@cached_schema_fields ~w(
|
||||
id
|
||||
inet
|
||||
action
|
||||
)a
|
||||
|
||||
@impl true
|
||||
def name(), do: @cache_name
|
||||
|
||||
@impl true
|
||||
def child_id(), do: :cachex_ip_blocklist
|
||||
|
||||
@impl true
|
||||
def count_all() do
|
||||
Plausible.Repo.aggregate(IPRule, :count)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def base_db_query() do
|
||||
from rule in IPRule,
|
||||
inner_join: s in assoc(rule, :site),
|
||||
select: {
|
||||
s.domain,
|
||||
s.domain_changed_from,
|
||||
%{struct(rule, ^@cached_schema_fields) | from_cache?: true}
|
||||
}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def get_from_source({domain, address}) do
|
||||
query =
|
||||
base_db_query()
|
||||
|> where([rule, site], rule.inet == ^address and site.domain == ^domain)
|
||||
|
||||
case Plausible.Repo.one(query) do
|
||||
{_, _, rule} -> %IPRule{rule | from_cache?: false}
|
||||
_any -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def unwrap_cache_keys(items) do
|
||||
Enum.reduce(items, [], fn
|
||||
{domain, nil, object}, acc ->
|
||||
[{{domain, to_string(object.inet)}, object} | acc]
|
||||
|
||||
{domain, domain_changed_from, object}, acc ->
|
||||
[
|
||||
{{domain, to_string(object.inet)}, object},
|
||||
{{domain_changed_from, to_string(object.inet)}, object} | acc
|
||||
]
|
||||
end)
|
||||
end
|
||||
end
|
72
lib/plausible/shields.ex
Normal file
72
lib/plausible/shields.ex
Normal file
@ -0,0 +1,72 @@
|
||||
defmodule Plausible.Shields do
|
||||
@moduledoc """
|
||||
Contextual interface for shields.
|
||||
"""
|
||||
import Ecto.Query
|
||||
alias Plausible.Repo
|
||||
alias Plausible.Shield
|
||||
|
||||
@maximum_ip_rules 30
|
||||
def maximum_ip_rules(), do: @maximum_ip_rules
|
||||
|
||||
@spec list_ip_rules(Plausible.Site.t() | non_neg_integer()) :: [Shield.IPRule.t()]
|
||||
def list_ip_rules(site_id) when is_integer(site_id) do
|
||||
Repo.all(
|
||||
from r in Shield.IPRule,
|
||||
where: r.site_id == ^site_id,
|
||||
order_by: [desc: r.inserted_at]
|
||||
)
|
||||
end
|
||||
|
||||
def list_ip_rules(%Plausible.Site{id: id}) do
|
||||
list_ip_rules(id)
|
||||
end
|
||||
|
||||
@spec add_ip_rule(Plausible.Site.t() | non_neg_integer(), map()) ::
|
||||
{:ok, Shield.IPRule.t()} | {:error, Ecto.Changeset.t()}
|
||||
def add_ip_rule(site_id, params) when is_integer(site_id) do
|
||||
Repo.transaction(fn ->
|
||||
result =
|
||||
if count_ip_rules(site_id) >= @maximum_ip_rules do
|
||||
changeset =
|
||||
%Shield.IPRule{}
|
||||
|> Shield.IPRule.changeset(Map.put(params, "site_id", site_id))
|
||||
|> Ecto.Changeset.add_error(:inet, "maximum reached")
|
||||
|
||||
{:error, changeset}
|
||||
else
|
||||
%Shield.IPRule{}
|
||||
|> Shield.IPRule.changeset(Map.put(params, "site_id", site_id))
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, rule} -> rule
|
||||
{:error, changeset} -> Repo.rollback(changeset)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def add_ip_rule(%Plausible.Site{id: id}, params) do
|
||||
add_ip_rule(id, params)
|
||||
end
|
||||
|
||||
@spec remove_ip_rule(Plausible.Site.t() | non_neg_integer(), String.t()) :: :ok
|
||||
def remove_ip_rule(site_id, rule_id) when is_integer(site_id) do
|
||||
Repo.delete_all(from(r in Shield.IPRule, where: r.site_id == ^site_id and r.id == ^rule_id))
|
||||
:ok
|
||||
end
|
||||
|
||||
def remove_ip_rule(%Plausible.Site{id: site_id}, rule_id) do
|
||||
remove_ip_rule(site_id, rule_id)
|
||||
end
|
||||
|
||||
@spec count_ip_rules(Plausible.Site.t() | non_neg_integer()) :: non_neg_integer()
|
||||
def count_ip_rules(site_id) when is_integer(site_id) do
|
||||
Repo.aggregate(from(r in Shield.IPRule, where: r.site_id == ^site_id), :count)
|
||||
end
|
||||
|
||||
def count_ip_rules(%Plausible.Site{id: id}) do
|
||||
count_ip_rules(id)
|
||||
end
|
||||
end
|
@ -134,9 +134,10 @@ defmodule PlausibleWeb.Components.Generic do
|
||||
end
|
||||
|
||||
attr :id, :any, default: nil
|
||||
attr :href, :string, required: true
|
||||
attr :href, :string, default: "#"
|
||||
attr :new_tab, :boolean, default: false
|
||||
attr :class, :string, default: ""
|
||||
attr :rest, :global
|
||||
slot :inner_block
|
||||
|
||||
def styled_link(assigns) do
|
||||
@ -145,6 +146,7 @@ defmodule PlausibleWeb.Components.Generic do
|
||||
new_tab={@new_tab}
|
||||
href={@href}
|
||||
class={"text-indigo-600 hover:text-indigo-700 dark:text-indigo-500 dark:hover:text-indigo-600 " <> @class}
|
||||
{@rest}
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</.unstyled_link>
|
||||
|
@ -60,13 +60,14 @@ defmodule PlausibleWeb.Api.ExternalController do
|
||||
e -> "error: #{inspect(e)}"
|
||||
end
|
||||
|
||||
sites_cache_health =
|
||||
if postgres_health == "ok" and Plausible.Site.Cache.ready?() do
|
||||
cache_health =
|
||||
if postgres_health == "ok" and Plausible.Site.Cache.ready?() and
|
||||
Plausible.Shield.IPRuleCache.ready?() do
|
||||
"ok"
|
||||
end
|
||||
|
||||
status =
|
||||
case {postgres_health, clickhouse_health, sites_cache_health} do
|
||||
case {postgres_health, clickhouse_health, cache_health} do
|
||||
{"ok", "ok", "ok"} -> 200
|
||||
_ -> 500
|
||||
end
|
||||
@ -75,7 +76,7 @@ defmodule PlausibleWeb.Api.ExternalController do
|
||||
|> json(%{
|
||||
postgres: postgres_health,
|
||||
clickhouse: clickhouse_health,
|
||||
sites_cache: sites_cache_health
|
||||
sites_cache: cache_health
|
||||
})
|
||||
end
|
||||
|
||||
|
@ -304,7 +304,7 @@ defmodule PlausibleWeb.AuthController do
|
||||
@email_change_interval :timer.hours(1)
|
||||
|
||||
defp check_ip_rate_limit(conn) do
|
||||
ip_address = PlausibleWeb.RemoteIp.get(conn)
|
||||
ip_address = PlausibleWeb.RemoteIP.get(conn)
|
||||
|
||||
case RateLimit.check_rate("login:ip:#{ip_address}", @login_interval, @login_limit) do
|
||||
{:allow, _} -> :ok
|
||||
|
@ -253,6 +253,18 @@ defmodule PlausibleWeb.SiteController do
|
||||
)
|
||||
end
|
||||
|
||||
def settings_shields(conn, _params) do
|
||||
site = conn.assigns.site
|
||||
|
||||
conn
|
||||
|> render("settings_shields.html",
|
||||
site: site,
|
||||
dogfood_page_path: "/:dashboard/settings/shields",
|
||||
connect_live_socket: true,
|
||||
layout: {PlausibleWeb.LayoutView, "site_settings.html"}
|
||||
)
|
||||
end
|
||||
|
||||
def update_google_auth(conn, %{"google_auth" => attrs}) do
|
||||
site = conn.assigns[:site] |> Repo.preload(:google_auth)
|
||||
|
||||
|
@ -53,7 +53,7 @@ defmodule PlausibleWeb.Live.Components.Modal do
|
||||
to ensure that.
|
||||
* `Modal.close/2` - to close the modal from the backend; usually
|
||||
done inside wrapped component's `handle_event/2`. The example
|
||||
qouted above shows one way to implement this, under that assumption
|
||||
quoted above shows one way to implement this, under that assumption
|
||||
that the component exposes a callback, like this:
|
||||
|
||||
```
|
||||
|
@ -45,7 +45,7 @@ defmodule PlausibleWeb.Live.Flash do
|
||||
|
||||
def flash_messages(assigns) do
|
||||
~H"""
|
||||
<div id="liveview-flash">
|
||||
<div>
|
||||
<div
|
||||
:if={@flash != %{} or Application.get_env(:plausible, :environment) == "dev"}
|
||||
class="inset-0 z-50 fixed flex flex-col-reverse items-center sm:items-end justify-start sm:justify-end px-4 py-6 pointer-events-none sm:p-6"
|
||||
|
288
lib/plausible_web/live/shields/ip_rules.ex
Normal file
288
lib/plausible_web/live/shields/ip_rules.ex
Normal file
@ -0,0 +1,288 @@
|
||||
defmodule PlausibleWeb.Live.Shields.IPRules do
|
||||
@moduledoc """
|
||||
LiveView allowing IP Rules management
|
||||
"""
|
||||
|
||||
use Phoenix.LiveComponent, global_prefixes: ~w(x-)
|
||||
use Phoenix.HTML
|
||||
|
||||
alias PlausibleWeb.Live.Components.Modal
|
||||
alias Plausible.Shields
|
||||
alias Plausible.Shield
|
||||
import PlausibleWeb.Live.Components.Form
|
||||
import PlausibleWeb.Components.Generic
|
||||
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(
|
||||
ip_rules_count: assigns.ip_rules_count,
|
||||
remote_ip: assigns.remote_ip,
|
||||
site: assigns.site,
|
||||
current_user: assigns.current_user,
|
||||
form: new_form()
|
||||
)
|
||||
|> assign_new(:ip_rules, fn %{site: site} ->
|
||||
Shields.list_ip_rules(site)
|
||||
end)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<section class="shadow bg-white dark:bg-gray-800 sm:rounded-md sm:overflow-hidden">
|
||||
<div class="py-6 px-4 sm:p-6">
|
||||
<header class="relative">
|
||||
<h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">
|
||||
IP Block List
|
||||
</h2>
|
||||
<p class="mt-1 mb-4 text-sm leading-5 text-gray-500 dark:text-gray-200">
|
||||
Reject incoming traffic from specific IP addresses
|
||||
</p>
|
||||
|
||||
<PlausibleWeb.Components.Generic.docs_info slug="excluding" />
|
||||
</header>
|
||||
<div class="border-t border-gray-200 pt-4 grid">
|
||||
<div
|
||||
:if={@ip_rules_count < Shields.maximum_ip_rules()}
|
||||
class="mt-4 sm:ml-4 sm:mt-0 justify-self-end"
|
||||
>
|
||||
<PlausibleWeb.Components.Generic.button
|
||||
id="add-ip-rule"
|
||||
x-data
|
||||
x-on:click={Modal.JS.open("ip-rule-form-modal")}
|
||||
>
|
||||
+ Add IP Address
|
||||
</PlausibleWeb.Components.Generic.button>
|
||||
</div>
|
||||
<PlausibleWeb.Components.Generic.notice
|
||||
:if={@ip_rules_count >= Shields.maximum_ip_rules()}
|
||||
class="mt-4"
|
||||
title="Maximum number of addresses reached"
|
||||
>
|
||||
<p>
|
||||
You've reached the maximum number of IP addresses you can block. Please remove one before adding another.
|
||||
</p>
|
||||
</PlausibleWeb.Components.Generic.notice>
|
||||
</div>
|
||||
|
||||
<.live_component module={Modal} id="ip-rule-form-modal">
|
||||
<.form
|
||||
:let={f}
|
||||
for={@form}
|
||||
phx-submit="save-ip-rule"
|
||||
phx-target={@myself}
|
||||
class="max-w-md w-full mx-auto bg-white dark:bg-gray-800"
|
||||
>
|
||||
<h2 class="text-xl font-black dark:text-gray-100 mb-8">Add IP to Block List</h2>
|
||||
|
||||
<.input
|
||||
autofocus
|
||||
field={f[:inet]}
|
||||
label="IP Address"
|
||||
class="focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-900 dark:text-gray-300 block w-7/12 rounded-md sm:text-sm border-gray-300 dark:border-gray-500 w-full p-2 mt-2"
|
||||
placeholder="e.g. 192.168.127.12"
|
||||
/>
|
||||
|
||||
<div class="mt-4">
|
||||
<p
|
||||
:if={not ip_rule_present?(@ip_rules, @remote_ip)}
|
||||
class="text-sm text-gray-500 dark:text-gray-200 mb-4"
|
||||
>
|
||||
Your current IP address is: <span class="font-mono"><%= @remote_ip %></span>
|
||||
<br />
|
||||
<.styled_link phx-target={@myself} phx-click="prefill-own-ip-rule">
|
||||
Click here
|
||||
</.styled_link>
|
||||
to block your own traffic, or enter a custom address.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<.input
|
||||
field={f[:description]}
|
||||
label="Description"
|
||||
class="focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-900 dark:text-gray-300 block w-7/12 rounded-md sm:text-sm border-gray-300 dark:border-gray-500 w-full p-2 mt-2"
|
||||
placeholder="e.g. The Office"
|
||||
/>
|
||||
|
||||
<p class="text-sm mt-2 text-gray-500 dark:text-gray-200">
|
||||
Once added, we will start rejecting traffic from this IP within a few minutes.
|
||||
</p>
|
||||
<div class="py-4 mt-8">
|
||||
<PlausibleWeb.Components.Generic.button type="submit" class="w-full">
|
||||
Add IP Address →
|
||||
</PlausibleWeb.Components.Generic.button>
|
||||
</div>
|
||||
</.form>
|
||||
</.live_component>
|
||||
|
||||
<p
|
||||
:if={Enum.empty?(@ip_rules)}
|
||||
class="text-sm text-gray-800 dark:text-gray-200 mt-12 mb-8 text-center"
|
||||
>
|
||||
No IP Rules configured for this Site.
|
||||
</p>
|
||||
<div
|
||||
:if={not Enum.empty?(@ip_rules)}
|
||||
class="mt-8 overflow-hidden border-b border-gray-200 shadow dark:border-gray-900 sm:rounded-lg"
|
||||
>
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-900">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-100"
|
||||
>
|
||||
IP Address
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-100"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-100"
|
||||
>
|
||||
Description
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3">
|
||||
<span class="sr-only">Remove</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for rule <- @ip_rules do %>
|
||||
<tr class="text-gray-900 dark:text-gray-100">
|
||||
<td class="px-6 py-4 text-xs font-medium">
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
id={"inet-#{rule.id}"}
|
||||
class="font-mono mr-4 cursor-help border-b border-dotted border-gray-400"
|
||||
title={"Added at #{rule.updated_at} by #{rule.added_by}"}
|
||||
>
|
||||
<%= rule.inet %>
|
||||
</span>
|
||||
|
||||
<span
|
||||
:if={to_string(rule.inet) == @remote_ip}
|
||||
class="inline-flex items-center gap-x-1.5 rounded-md px-2 py-1 text-xs font-medium text-gray-700 dark:text-white ring-1 ring-inset ring-gray-300 dark:ring-gray-700"
|
||||
>
|
||||
<svg class="h-1.5 w-1.5 fill-green-400" viewBox="0 0 6 6" aria-hidden="true">
|
||||
<circle cx="3" cy="3" r="3" />
|
||||
</svg>
|
||||
YOU
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
<span :if={rule.action == :deny}>
|
||||
Blocked
|
||||
</span>
|
||||
<span :if={rule.action == :allow}>
|
||||
Allowed
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm font-normal whitespace-nowrap truncate max-w-xs">
|
||||
<span :if={rule.description} title={rule.description}>
|
||||
<%= rule.description %>
|
||||
</span>
|
||||
<span :if={!rule.description} class="text-gray-400 dark:text-gray-600">
|
||||
--
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm font-medium text-right">
|
||||
<button
|
||||
id={"remove-ip-rule-#{rule.id}"}
|
||||
phx-target={@myself}
|
||||
phx-click="remove-ip-rule"
|
||||
phx-value-rule-id={rule.id}
|
||||
class="text-sm text-red-600"
|
||||
data-confirm="Are you sure you want to revoke this rule?"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
|
||||
def handle_event("prefill-own-ip-rule", %{}, socket) do
|
||||
form =
|
||||
%Plausible.Shield.IPRule{}
|
||||
|> Plausible.Shield.IPRule.changeset(%{
|
||||
inet: socket.assigns.remote_ip,
|
||||
description: socket.assigns.current_user.name
|
||||
})
|
||||
|> to_form()
|
||||
|
||||
{:noreply, assign(socket, form: form)}
|
||||
end
|
||||
|
||||
def handle_event("save-ip-rule", %{"ip_rule" => params}, socket) do
|
||||
user = socket.assigns.current_user
|
||||
|
||||
case Shields.add_ip_rule(
|
||||
socket.assigns.site.id,
|
||||
Map.put(params, "added_by", "#{user.name} <#{user.email}>")
|
||||
) do
|
||||
{:ok, rule} ->
|
||||
socket =
|
||||
socket
|
||||
|> Modal.close("ip-rule-form-modal")
|
||||
|> assign(
|
||||
form: new_form(),
|
||||
ip_rules: [rule | socket.assigns.ip_rules],
|
||||
ip_rules_count: socket.assigns.ip_rules_count + 1
|
||||
)
|
||||
|
||||
send_flash(
|
||||
:success,
|
||||
"IP rule added successfully. Traffic will be rejected within a few minutes."
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("remove-ip-rule", %{"rule-id" => rule_id}, socket) do
|
||||
Shields.remove_ip_rule(socket.assigns.site.id, rule_id)
|
||||
|
||||
send_flash(
|
||||
:success,
|
||||
"IP rule removed successfully. Traffic will be resumed within a few minutes."
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(
|
||||
ip_rules_count: socket.assigns.ip_rules_count - 1,
|
||||
ip_rules: Enum.reject(socket.assigns.ip_rules, &(&1.id == rule_id))
|
||||
)}
|
||||
end
|
||||
|
||||
def send_flash(kind, message) do
|
||||
send(self(), {:flash, kind, message})
|
||||
end
|
||||
|
||||
defp new_form() do
|
||||
%Shield.IPRule{}
|
||||
|> Shield.IPRule.changeset(%{})
|
||||
|> to_form()
|
||||
end
|
||||
|
||||
defp ip_rule_present?(rules, ip) do
|
||||
not is_nil(Enum.find(rules, &(to_string(&1.inet) == ip)))
|
||||
end
|
||||
end
|
59
lib/plausible_web/live/shields/tabs.ex
Normal file
59
lib/plausible_web/live/shields/tabs.ex
Normal file
@ -0,0 +1,59 @@
|
||||
defmodule PlausibleWeb.Live.Shields.Tabs do
|
||||
@moduledoc """
|
||||
Currently only a placeholder module. Once more shields
|
||||
are implemented it will display tabs with counters,
|
||||
linking to their respective live views.
|
||||
"""
|
||||
use PlausibleWeb, :live_view
|
||||
use Phoenix.HTML
|
||||
|
||||
alias Plausible.Shields
|
||||
alias Plausible.Sites
|
||||
|
||||
def mount(
|
||||
_params,
|
||||
%{
|
||||
"remote_ip" => remote_ip,
|
||||
"domain" => domain,
|
||||
"current_user_id" => user_id
|
||||
},
|
||||
socket
|
||||
) do
|
||||
socket =
|
||||
socket
|
||||
|> assign_new(:site, fn ->
|
||||
Sites.get_for_user!(user_id, domain, [:owner, :admin, :super_admin])
|
||||
end)
|
||||
|> assign_new(:ip_rules_count, fn %{site: site} ->
|
||||
Shields.count_ip_rules(site)
|
||||
end)
|
||||
|> assign_new(:current_user, fn ->
|
||||
Plausible.Repo.get(Plausible.Auth.User, user_id)
|
||||
end)
|
||||
|> assign_new(:remote_ip, fn -> remote_ip end)
|
||||
|> assign(:current_tab, :ip_rules)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<.flash_messages flash={@flash} />
|
||||
<.live_component
|
||||
module={PlausibleWeb.Live.Shields.IPRules}
|
||||
current_user={@current_user}
|
||||
ip_rules_count={@ip_rules_count}
|
||||
site={@site}
|
||||
remote_ip={@remote_ip}
|
||||
id="ip-rules-#{@current_user.id}"
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def handle_info({:flash, kind, message}, socket) do
|
||||
socket = put_live_flash(socket, kind, message)
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
@ -1,4 +1,8 @@
|
||||
defmodule PlausibleWeb.RemoteIp do
|
||||
defmodule PlausibleWeb.RemoteIP do
|
||||
@moduledoc """
|
||||
Implements the strategy of retrieving client's remote IP
|
||||
"""
|
||||
|
||||
def get(conn) do
|
||||
x_plausible_ip = List.first(Plug.Conn.get_req_header(conn, "x-plausible-ip"))
|
||||
cf_connecting_ip = List.first(Plug.Conn.get_req_header(conn, "cf-connecting-ip"))
|
||||
@ -24,7 +28,7 @@ defmodule PlausibleWeb.RemoteIp do
|
||||
|> Map.get("for")
|
||||
# IPv6 addresses are enclosed in quote marks and square brackets: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded
|
||||
|> String.trim("\"")
|
||||
|> clean_ip
|
||||
|> clean_ip()
|
||||
|
||||
true ->
|
||||
to_string(:inet_parse.ntoa(conn.remote_ip))
|
||||
@ -50,6 +54,6 @@ defmodule PlausibleWeb.RemoteIp do
|
||||
String.split(header, ",")
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> List.first()
|
||||
|> clean_ip
|
||||
|> clean_ip()
|
||||
end
|
||||
end
|
||||
|
@ -354,6 +354,7 @@ defmodule PlausibleWeb.Router do
|
||||
get "/:website/settings/email-reports", SiteController, :settings_email_reports
|
||||
get "/:website/settings/danger-zone", SiteController, :settings_danger_zone
|
||||
get "/:website/settings/integrations", SiteController, :settings_integrations
|
||||
get "/:website/settings/shields", SiteController, :settings_shields
|
||||
|
||||
put "/:website/settings/features/visibility/:setting",
|
||||
SiteController,
|
||||
|
@ -0,0 +1,7 @@
|
||||
<%= live_render(@conn, PlausibleWeb.Live.Shields.Tabs,
|
||||
session: %{
|
||||
"site_id" => @site.id,
|
||||
"domain" => @site.domain,
|
||||
"remote_ip" => PlausibleWeb.RemoteIP.get(@conn)
|
||||
}
|
||||
) %>
|
@ -51,6 +51,7 @@ defmodule PlausibleWeb.LayoutView do
|
||||
end,
|
||||
[key: "Custom Properties", value: "properties", icon: :document_text],
|
||||
[key: "Integrations", value: "integrations", icon: :arrow_path_rounded_square],
|
||||
[key: "Shields", value: "shields", icon: :shield_exclamation],
|
||||
[key: "Email Reports", value: "email-reports", icon: :envelope],
|
||||
if conn.assigns[:current_user_role] == :owner do
|
||||
[key: "Danger Zone", value: "danger-zone", icon: :exclamation_triangle]
|
||||
|
3
mix.exs
3
mix.exs
@ -134,7 +134,8 @@ defmodule Plausible.MixProject do
|
||||
{:scrivener_ecto, "~> 2.0"},
|
||||
{:esbuild, "~> 0.7", runtime: Mix.env() in [:dev, :small_dev]},
|
||||
{:tailwind, "~> 0.2.0", runtime: Mix.env() in [:dev, :small_dev]},
|
||||
{:ex_json_logger, "~> 1.4.0"}
|
||||
{:ex_json_logger, "~> 1.4.0"},
|
||||
{:ecto_network, "~> 1.5.0"}
|
||||
]
|
||||
end
|
||||
|
||||
|
1
mix.lock
1
mix.lock
@ -33,6 +33,7 @@
|
||||
"earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"},
|
||||
"ecto": {:hex, :ecto, "3.11.1", "4b4972b717e7ca83d30121b12998f5fcdc62ba0ed4f20fd390f16f3270d85c3e", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebd3d3772cd0dfcd8d772659e41ed527c28b2a8bde4b00fe03e0463da0f1983b"},
|
||||
"ecto_ch": {:hex, :ecto_ch, "0.3.2", "b6e7d0a6ad412662d7727ba1b5128a1a30a0835e1b4168db79e5b2551a27ba50", [:mix], [{:ch, "~> 0.2.0", [hex: :ch, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "a195d0f09d1177d9b11f00ef608895db1e02711d0a18730e98fbb50055758e6d"},
|
||||
"ecto_network": {:hex, :ecto_network, "1.5.0", "a930c910975e7a91237b858ebf0f4ad7b2aae32fa846275aa203cb858459ec73", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 0.0.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.14.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "4d614434ae3e6d373a2f693d56aafaa3f3349714668ffd6d24e760caf578aa2f"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.11.1", "e9abf28ae27ef3916b43545f9578b4750956ccea444853606472089e7d169470", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ce14063ab3514424276e7e360108ad6c2308f6d88164a076aac8a387e1fea634"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.7.7", "7128c60c2476019ed978210c245badf08b03dbec4f24d05790ef791da11aa17c", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "5bc19fff950fad52bbe5f211b12db9ec82c6b34a9647da0c2224b8b8464c7e6c"},
|
||||
"envy": {:hex, :envy, "1.1.1", "0bc9bd654dec24fcdf203f7c5aa1b8f30620f12cfb28c589d5e9c38fe1b07475", [:mix], [], "hexpm", "7061eb1a47415fd757145d8dec10dc0b1e48344960265cb108f194c4252c3a89"},
|
||||
|
@ -49,6 +49,8 @@ site =
|
||||
]
|
||||
)
|
||||
|
||||
Plausible.Factory.insert_list(29, :ip_rule, site: site)
|
||||
|
||||
Plausible.Factory.insert(:google_auth,
|
||||
user: user,
|
||||
site: site,
|
||||
|
@ -52,6 +52,10 @@ defmodule Plausible.CacheTest do
|
||||
|
||||
assert log =~ "Error retrieving key from 'NonExistingCache': :no_cache"
|
||||
end
|
||||
|
||||
test "cache is not ready when it doesn't exist", %{test: test} do
|
||||
refute ExampleCache.ready?(test)
|
||||
end
|
||||
end
|
||||
|
||||
describe "merging cache items" do
|
||||
|
@ -117,6 +117,26 @@ defmodule Plausible.Ingestion.EventTest do
|
||||
assert dropped.drop_reason == :dc_ip
|
||||
end
|
||||
|
||||
test "event pipeline drops a request when ip is on blocklist" do
|
||||
site = insert(:site)
|
||||
|
||||
payload = %{
|
||||
name: "pageview",
|
||||
url: "http://dummy.site",
|
||||
domain: site.domain
|
||||
}
|
||||
|
||||
conn = build_conn(:post, "/api/events", payload)
|
||||
conn = %{conn | remote_ip: {127, 7, 7, 7}}
|
||||
|
||||
{:ok, _} = Plausible.Shields.add_ip_rule(site, %{"inet" => "127.7.7.7"})
|
||||
|
||||
assert {:ok, request} = Request.build(conn)
|
||||
|
||||
assert {:ok, %{buffered: [], dropped: [dropped]}} = Event.build_and_buffer(request)
|
||||
assert dropped.drop_reason == :site_ip_blocklist
|
||||
end
|
||||
|
||||
test "event pipeline drops events for site with accept_trafic_until in the past" do
|
||||
yesterday = Date.add(Date.utc_today(), -1)
|
||||
|
||||
|
80
test/plausible/shield/ip_rule_cache_test.exs
Normal file
80
test/plausible/shield/ip_rule_cache_test.exs
Normal file
@ -0,0 +1,80 @@
|
||||
defmodule Plausible.Shield.IPRuleCacheTest do
|
||||
use Plausible.DataCase, async: true
|
||||
|
||||
alias Plausible.Shield.IPRule
|
||||
alias Plausible.Shield.IPRuleCache
|
||||
alias Plausible.Shields
|
||||
|
||||
describe "public cache interface" do
|
||||
test "cache caches IP rules", %{test: test} do
|
||||
{:ok, _} =
|
||||
Supervisor.start_link([{IPRuleCache, [cache_name: test, child_id: :ip_rules_cache_id]}],
|
||||
strategy: :one_for_one,
|
||||
name: :"cache_supervisor_#{test}"
|
||||
)
|
||||
|
||||
site = insert(:site, domain: "site1.example.com")
|
||||
|
||||
{:ok, %{id: rid1}} = Shields.add_ip_rule(site, %{"inet" => "1.1.1.1"})
|
||||
{:ok, %{id: rid2}} = Shields.add_ip_rule(site, %{"inet" => "2.2.2.2"})
|
||||
|
||||
:ok = IPRuleCache.refresh_all(cache_name: test)
|
||||
|
||||
:ok = Shields.remove_ip_rule(site, rid1)
|
||||
|
||||
assert IPRuleCache.size(test) == 2
|
||||
|
||||
assert %IPRule{from_cache?: true, id: ^rid1} =
|
||||
IPRuleCache.get({site.domain, "1.1.1.1"}, force?: true, cache_name: test)
|
||||
|
||||
assert %IPRule{from_cache?: true, id: ^rid2} =
|
||||
IPRuleCache.get({site.domain, "2.2.2.2"}, force?: true, cache_name: test)
|
||||
|
||||
refute IPRuleCache.get({site.domain, "3.3.3.3"}, cache_name: test, force?: true)
|
||||
end
|
||||
|
||||
test "cache allows lookups for sites with changed domain", %{test: test} do
|
||||
{:ok, _} = start_test_cache(test)
|
||||
site = insert(:site, domain: "new.example.com", domain_changed_from: "old.example.com")
|
||||
|
||||
{:ok, _} = Shields.add_ip_rule(site, %{"inet" => "1.1.1.1"})
|
||||
:ok = IPRuleCache.refresh_all(cache_name: test)
|
||||
|
||||
assert IPRuleCache.get({"old.example.com", "1.1.1.1"}, force?: true, cache_name: test)
|
||||
assert IPRuleCache.get({"new.example.com", "1.1.1.1"}, force?: true, cache_name: test)
|
||||
end
|
||||
|
||||
test "refreshes only recently added rules", %{test: test} do
|
||||
{:ok, _} = start_test_cache(test)
|
||||
|
||||
domain = "site1.example.com"
|
||||
site = insert(:site, domain: domain)
|
||||
|
||||
cache_opts = [cache_name: test, force?: true]
|
||||
|
||||
yesterday = DateTime.utc_now() |> DateTime.add(-1 * 60 * 60 * 24)
|
||||
|
||||
insert(:ip_rule,
|
||||
site: site,
|
||||
inserted_at: yesterday,
|
||||
updated_at: yesterday,
|
||||
inet: "1.1.1.1"
|
||||
)
|
||||
|
||||
insert(:ip_rule, site: site, inet: "2.2.2.2")
|
||||
|
||||
assert IPRuleCache.get({domain, "1.1.1.1"}, cache_opts) == nil
|
||||
assert IPRuleCache.get({domain, "2.2.2.2"}, cache_opts) == nil
|
||||
|
||||
assert :ok = IPRuleCache.refresh_updated_recently(cache_opts)
|
||||
|
||||
refute IPRuleCache.get({domain, "1.1.1.1"}, cache_opts)
|
||||
assert %IPRule{} = IPRuleCache.get({domain, "2.2.2.2"}, cache_opts)
|
||||
end
|
||||
end
|
||||
|
||||
defp start_test_cache(cache_name) do
|
||||
%{start: {m, f, a}} = IPRuleCache.child_spec(cache_name: cache_name)
|
||||
apply(m, f, a)
|
||||
end
|
||||
end
|
141
test/plausible/shields_test.exs
Normal file
141
test/plausible/shields_test.exs
Normal file
@ -0,0 +1,141 @@
|
||||
defmodule Plausible.ShieldsTest do
|
||||
use Plausible.DataCase
|
||||
import Plausible.Shields
|
||||
|
||||
setup do
|
||||
site = insert(:site)
|
||||
{:ok, %{site: site}}
|
||||
end
|
||||
|
||||
describe "add_ip_rule/2" do
|
||||
test "no input", %{site: site} do
|
||||
assert {:error, changeset} = add_ip_rule(site, %{})
|
||||
assert changeset.errors == [inet: {"can't be blank", [validation: :required]}]
|
||||
refute changeset.valid?
|
||||
end
|
||||
|
||||
test "unsupported netmask", %{site: site} do
|
||||
assert {:error, changeset} = add_ip_rule(site, %{"inet" => "127.0.0.0/24"})
|
||||
assert changeset.errors == [inet: {"netmask unsupported", []}]
|
||||
refute changeset.valid?
|
||||
assert {:ok, _} = add_ip_rule(site, %{"inet" => "127.0.0.0/32"})
|
||||
end
|
||||
|
||||
test "incorrect ip", %{site: site} do
|
||||
assert {:error, changeset} = add_ip_rule(site, %{"inet" => "999.999.999.999"})
|
||||
|
||||
assert changeset.errors == [
|
||||
inet: {"is invalid", [{:type, EctoNetwork.INET}, {:validation, :cast}]}
|
||||
]
|
||||
|
||||
refute changeset.valid?
|
||||
end
|
||||
|
||||
test "non-strict IPs", %{site: site} do
|
||||
assert {:error, _} = add_ip_rule(site, %{"inet" => "111"})
|
||||
end
|
||||
|
||||
test "double insert", %{site: site} do
|
||||
assert {:ok, _} = add_ip_rule(site, %{"inet" => "0.0.0.111"})
|
||||
assert {:error, changeset} = add_ip_rule(site, %{"inet" => "0.0.0.111"})
|
||||
refute changeset.valid?
|
||||
|
||||
assert changeset.errors == [
|
||||
inet:
|
||||
{"has already been taken",
|
||||
[
|
||||
{:constraint, :unique},
|
||||
{:constraint_name, "shield_rules_ip_site_id_inet_index"}
|
||||
]}
|
||||
]
|
||||
end
|
||||
|
||||
test "ipv6", %{site: site} do
|
||||
assert {:ok, rule} =
|
||||
add_ip_rule(site, %{"inet" => "2001:0000:130F:0000:0000:09C0:876A:130B"})
|
||||
|
||||
assert ^rule = Repo.get(Plausible.Shield.IPRule, rule.id)
|
||||
end
|
||||
|
||||
test "ipv4", %{site: site} do
|
||||
assert {:ok, rule} =
|
||||
add_ip_rule(site, %{"inet" => "1.1.1.1"})
|
||||
|
||||
assert ^rule = Repo.get(Plausible.Shield.IPRule, rule.id)
|
||||
end
|
||||
|
||||
test "over limit", %{site: site} do
|
||||
for i <- 1..maximum_ip_rules() do
|
||||
assert {:ok, _} =
|
||||
add_ip_rule(site, %{"inet" => "1.1.1.#{i}"})
|
||||
end
|
||||
|
||||
assert count_ip_rules(site) == maximum_ip_rules()
|
||||
|
||||
assert {:error, changeset} =
|
||||
add_ip_rule(site, %{"inet" => "1.1.1.31"})
|
||||
|
||||
refute changeset.valid?
|
||||
assert changeset.errors == [inet: {"maximum reached", []}]
|
||||
end
|
||||
|
||||
test "with added_by", %{site: site} do
|
||||
assert {:ok, rule} = add_ip_rule(site, %{"inet" => "1.1.1.1", "added_by" => "test"})
|
||||
assert rule.added_by == "test"
|
||||
end
|
||||
end
|
||||
|
||||
describe "remove_ip_rule/2" do
|
||||
test "is idempontent", %{site: site} do
|
||||
{:ok, rule} = add_ip_rule(site, %{"inet" => "127.0.0.1"})
|
||||
assert remove_ip_rule(site, rule.id) == :ok
|
||||
refute Repo.get(Plausible.Shield.IPRule, rule.id)
|
||||
assert remove_ip_rule(site, rule.id) == :ok
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_ip_rules/1" do
|
||||
test "empty", %{site: site} do
|
||||
assert(list_ip_rules(site) == [])
|
||||
end
|
||||
|
||||
@tag :slow
|
||||
test "many", %{site: site} do
|
||||
{:ok, r1} = add_ip_rule(site, %{"inet" => "127.0.0.1"})
|
||||
:timer.sleep(1000)
|
||||
{:ok, r2} = add_ip_rule(site, %{"inet" => "127.0.0.2"})
|
||||
assert [^r2, ^r1] = list_ip_rules(site)
|
||||
end
|
||||
end
|
||||
|
||||
describe "count_ip_rules/1" do
|
||||
test "counts", %{site: site} do
|
||||
assert count_ip_rules(site) == 0
|
||||
{:ok, _} = add_ip_rule(site, %{"inet" => "127.0.0.1"})
|
||||
assert count_ip_rules(site) == 1
|
||||
{:ok, _} = add_ip_rule(site, %{"inet" => "127.0.0.2"})
|
||||
assert count_ip_rules(site) == 2
|
||||
end
|
||||
end
|
||||
|
||||
describe "IP Rules" do
|
||||
test "end to end", %{site: site} do
|
||||
site2 = insert(:site)
|
||||
|
||||
assert count_ip_rules(site.id) == 0
|
||||
assert list_ip_rules(site.id) == []
|
||||
|
||||
assert {:ok, rule} =
|
||||
add_ip_rule(site.id, %{"inet" => "127.0.0.1", "description" => "Localhost"})
|
||||
|
||||
add_ip_rule(site2, %{"inet" => "127.0.0.1", "description" => "Localhost"})
|
||||
|
||||
assert count_ip_rules(site) == 1
|
||||
assert [^rule] = list_ip_rules(site)
|
||||
assert rule.inet == %Postgrex.INET{address: {127, 0, 0, 1}, netmask: 32}
|
||||
assert rule.description == "Localhost"
|
||||
assert rule.action == :deny
|
||||
refute rule.from_cache?
|
||||
end
|
||||
end
|
||||
end
|
208
test/plausible_web/live/shields_test.exs
Normal file
208
test/plausible_web/live/shields_test.exs
Normal file
@ -0,0 +1,208 @@
|
||||
defmodule PlausibleWeb.Live.ShieldsTest do
|
||||
use PlausibleWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Plausible.Test.Support.HTML
|
||||
|
||||
alias Plausible.Shields
|
||||
|
||||
setup [:create_user, :create_site, :log_in]
|
||||
|
||||
describe "IP Rules - static" do
|
||||
test "renders ip rules page with empty list", %{conn: conn, site: site} do
|
||||
conn = get(conn, "/#{site.domain}/settings/shields")
|
||||
resp = html_response(conn, 200)
|
||||
|
||||
assert resp =~ "No IP Rules configured for this Site"
|
||||
assert resp =~ "IP Block List"
|
||||
end
|
||||
|
||||
test "lists ip rules with remove actions", %{conn: conn, site: site} do
|
||||
{:ok, r1} =
|
||||
Shields.add_ip_rule(site, %{"inet" => "127.0.0.1", "description" => "Alice"})
|
||||
|
||||
{:ok, r2} =
|
||||
Shields.add_ip_rule(site, %{"inet" => "127.0.0.2", "description" => "Bob"})
|
||||
|
||||
conn = get(conn, "/#{site.domain}/settings/shields")
|
||||
resp = html_response(conn, 200)
|
||||
|
||||
assert resp =~ "127.0.0.1"
|
||||
assert resp =~ "Alice"
|
||||
|
||||
assert resp =~ "127.0.0.2"
|
||||
assert resp =~ "Bob"
|
||||
|
||||
assert element_exists?(
|
||||
resp,
|
||||
~s/button[phx-click="remove-ip-rule"][phx-value-rule-id="#{r1.id}"]#remove-ip-rule-#{r1.id}/
|
||||
)
|
||||
|
||||
assert element_exists?(
|
||||
resp,
|
||||
~s/button[phx-click="remove-ip-rule"][phx-value-rule-id="#{r2.id}"]#remove-ip-rule-#{r2.id}/
|
||||
)
|
||||
end
|
||||
|
||||
test "add rule button is rendered", %{conn: conn, site: site} do
|
||||
conn = get(conn, "/#{site.domain}/settings/shields")
|
||||
resp = html_response(conn, 200)
|
||||
|
||||
assert element_exists?(resp, ~s/button#add-ip-rule[x-data]/)
|
||||
attr = text_of_attr(resp, ~s/button#add-ip-rule/, "x-on:click")
|
||||
|
||||
assert attr =~ "open-modal"
|
||||
assert attr =~ "ip-rule-form-modal"
|
||||
end
|
||||
|
||||
test "add rule button is not rendered when maximum reached", %{conn: conn, site: site} do
|
||||
for i <- 1..Shields.maximum_ip_rules() do
|
||||
assert {:ok, _} =
|
||||
Shields.add_ip_rule(site, %{"inet" => "1.1.1.#{i}"})
|
||||
end
|
||||
|
||||
conn = get(conn, "/#{site.domain}/settings/shields")
|
||||
resp = html_response(conn, 200)
|
||||
|
||||
refute element_exists?(resp, ~s/button#add-ip-rule[x-data]/)
|
||||
assert resp =~ "Maximum number of addresses reached"
|
||||
end
|
||||
end
|
||||
|
||||
describe "IP Rules - LiveView" do
|
||||
test "modal contains form", %{site: site, conn: conn} do
|
||||
lv = get_liveview(conn, site)
|
||||
html = render(lv)
|
||||
|
||||
assert element_exists?(
|
||||
html,
|
||||
~s/form[phx-submit="save-ip-rule"] input[name="ip_rule\[inet\]"]/
|
||||
)
|
||||
|
||||
assert element_exists?(
|
||||
html,
|
||||
~s/form[phx-submit="save-ip-rule"] input[name="ip_rule\[description\]"]/
|
||||
)
|
||||
|
||||
assert submit_button(html, ~s/form[phx-submit="save-ip-rule"]/)
|
||||
end
|
||||
|
||||
test "form modal contains link to add own IP", %{site: site, conn: conn} do
|
||||
ip = PlausibleWeb.RemoteIP.get(conn)
|
||||
lv = get_liveview(conn, site)
|
||||
html = render(lv)
|
||||
|
||||
assert text(html) =~ "Your current IP address is: #{ip}"
|
||||
assert element_exists?(html, ~s/a[phx-click="prefill-own-ip-rule"]/)
|
||||
end
|
||||
|
||||
test "form modal does not contains link to add own IP if already added", %{
|
||||
site: site,
|
||||
conn: conn
|
||||
} do
|
||||
ip = PlausibleWeb.RemoteIP.get(conn)
|
||||
|
||||
{:ok, _} =
|
||||
Shields.add_ip_rule(site, %{
|
||||
"inet" => ip,
|
||||
"description" => "Alice"
|
||||
})
|
||||
|
||||
lv = get_liveview(conn, site)
|
||||
html = render(lv)
|
||||
|
||||
refute text(html) =~ "Your current IP address is: #{ip}"
|
||||
refute element_exists?(html, ~s/a[phx-click="prefill-own-ip-rule"]/)
|
||||
end
|
||||
|
||||
test "clicking the link prefills own IP", %{conn: conn, site: site, user: user} do
|
||||
lv = get_liveview(conn, site)
|
||||
lv |> element(~s/a[phx-click="prefill-own-ip-rule"]/) |> render_click()
|
||||
|
||||
html = render(lv)
|
||||
|
||||
assert text_of_attr(html, "input[name=\"ip_rule[inet]\"]", "value") ==
|
||||
PlausibleWeb.RemoteIP.get(conn)
|
||||
|
||||
assert text_of_attr(html, "input[name=\"ip_rule[description]\"]", "value") == user.name
|
||||
end
|
||||
|
||||
test "submitting own IP saves it", %{conn: conn, site: site, user: user} do
|
||||
ip = PlausibleWeb.RemoteIP.get(conn)
|
||||
assert [] = Shields.list_ip_rules(site)
|
||||
|
||||
lv = get_liveview(conn, site)
|
||||
lv |> element(~s/a[phx-click="prefill-own-ip-rule"]/) |> render_click()
|
||||
lv |> element(~s/form/) |> render_submit()
|
||||
|
||||
html = render(lv)
|
||||
|
||||
assert html =~ ip
|
||||
assert html =~ user.name
|
||||
|
||||
assert [%{id: id}] = Shields.list_ip_rules(site)
|
||||
|
||||
tooltip = text_of_attr(html, "#inet-#{id}", "title")
|
||||
assert tooltip =~ "Added at #{Date.utc_today()}"
|
||||
assert tooltip =~ "by #{user.name} <#{user.email}>"
|
||||
|
||||
assert [_] = Shields.list_ip_rules(site)
|
||||
end
|
||||
|
||||
test "submitting a valid IP saves it", %{conn: conn, site: site} do
|
||||
lv = get_liveview(conn, site)
|
||||
|
||||
lv
|
||||
|> element("form")
|
||||
|> render_submit(%{
|
||||
"ip_rule[inet]" => "1.1.1.1",
|
||||
"ip_rule[description]" => "A happy song"
|
||||
})
|
||||
|
||||
html = render(lv)
|
||||
|
||||
assert html =~ "1.1.1.1"
|
||||
assert html =~ "A happy song"
|
||||
|
||||
assert [%{inet: ip, description: "A happy song"}] = Shields.list_ip_rules(site)
|
||||
assert to_string(ip) == "1.1.1.1"
|
||||
end
|
||||
|
||||
test "submitting invalid IP renders error", %{conn: conn, site: site} do
|
||||
lv = get_liveview(conn, site)
|
||||
|
||||
lv
|
||||
|> element("form")
|
||||
|> render_submit(%{
|
||||
"ip_rule[inet]" => "WRONG"
|
||||
})
|
||||
|
||||
html = render(lv)
|
||||
assert html =~ "is invalid"
|
||||
end
|
||||
|
||||
test "clicking Remove deletes the rule", %{conn: conn, site: site} do
|
||||
{:ok, _} =
|
||||
Shields.add_ip_rule(site, %{"inet" => "2.2.2.2", "description" => "Alice"})
|
||||
|
||||
lv = get_liveview(conn, site)
|
||||
|
||||
html = render(lv)
|
||||
assert html =~ "2.2.2.2"
|
||||
|
||||
lv |> element(~s/button[phx-click="remove-ip-rule"]/) |> render_click()
|
||||
|
||||
html = render(lv)
|
||||
refute html =~ "2.2.2.2"
|
||||
|
||||
assert Shields.count_ip_rules(site) == 0
|
||||
end
|
||||
|
||||
defp get_liveview(conn, site) do
|
||||
conn = assign(conn, :live_module, PlausibleWeb.Live.Shields)
|
||||
{:ok, lv, _html} = live(conn, "/#{site.domain}/settings/shields")
|
||||
|
||||
lv
|
||||
end
|
||||
end
|
||||
end
|
@ -278,6 +278,14 @@ defmodule Plausible.Factory do
|
||||
}
|
||||
end
|
||||
|
||||
def ip_rule_factory do
|
||||
%Plausible.Shield.IPRule{
|
||||
inet: Plausible.TestUtils.random_ip(),
|
||||
description: "Test IP Rule",
|
||||
added_by: "Mr Seed <user@plausible.test>"
|
||||
}
|
||||
end
|
||||
|
||||
defp hash_key() do
|
||||
Keyword.fetch!(
|
||||
Application.get_env(:plausible, PlausibleWeb.Endpoint),
|
||||
|
Loading…
Reference in New Issue
Block a user