diff --git a/CHANGELOG.md b/CHANGELOG.md
index ba2a7cd1dc..733a4b394a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
All notable changes to this project will be documented in this file.
### Added
+- Hostname Allow List in Site Settings
- Pages Block List in Site Settings
- Add `conversion_rate` to Stats API Timeseries and on the main graph
- Add `total_conversions` and `conversion_rate` to `visitors.csv` in a goal-filtered CSV export
diff --git a/lib/plausible/application.ex b/lib/plausible/application.ex
index ee917857cf..2d219ce54b 100644
--- a/lib/plausible/application.ex
+++ b/lib/plausible/application.ex
@@ -92,6 +92,21 @@ defmodule Plausible.Application do
interval: :timer.seconds(35),
warmer_fn: :refresh_updated_recently
]},
+ {Plausible.Shield.HostnameRuleCache, ttl_check_interval: false, ets_options: [:bag]},
+ {Plausible.Cache.Warmer,
+ [
+ child_name: Plausible.Shield.HostnameRuleCache.All,
+ cache_impl: Plausible.Shield.HostnameRuleCache,
+ interval: :timer.minutes(3) + Enum.random(1..:timer.seconds(10)),
+ warmer_fn: :refresh_all
+ ]},
+ {Plausible.Cache.Warmer,
+ [
+ child_name: Plausible.Shield.HostnameRuleCache.RecentlyUpdated,
+ cache_impl: Plausible.Shield.HostnameRuleCache,
+ interval: :timer.seconds(25),
+ warmer_fn: :refresh_updated_recently
+ ]},
{Plausible.Auth.TOTP.Vault, key: totp_vault_key()},
PlausibleWeb.Endpoint,
{Oban, Application.get_env(:plausible, Oban)},
diff --git a/lib/plausible/ecto/types/compiled_regex.ex b/lib/plausible/ecto/types/compiled_regex.ex
new file mode 100644
index 0000000000..3728a11942
--- /dev/null
+++ b/lib/plausible/ecto/types/compiled_regex.ex
@@ -0,0 +1,14 @@
+defmodule Plausible.Ecto.Types.CompiledRegex do
+ @moduledoc """
+ Ensures that the regex is compiled on load
+ """
+ use Ecto.Type
+
+ def type, do: :string
+
+ def cast(val) when is_binary(val), do: {:ok, val}
+ def cast(_), do: :error
+
+ def load(val), do: {:ok, Regex.compile!(val)}
+ def dump(val), do: {:ok, val}
+end
diff --git a/lib/plausible/ingestion/event.ex b/lib/plausible/ingestion/event.ex
index 567f66bdb0..8baf66a923 100644
--- a/lib/plausible/ingestion/event.ex
+++ b/lib/plausible/ingestion/event.ex
@@ -30,6 +30,7 @@ defmodule Plausible.Ingestion.Event do
| :site_ip_blocklist
| :site_country_blocklist
| :site_page_blocklist
+ | :site_hostname_allowlist
@type t() :: %__MODULE__{
domain: String.t() | nil,
@@ -104,6 +105,7 @@ defmodule Plausible.Ingestion.Event do
defp pipeline() do
[
drop_datacenter_ip: &drop_datacenter_ip/1,
+ drop_shield_rule_hostname: &drop_shield_rule_hostname/1,
drop_shield_rule_page: &drop_shield_rule_page/1,
drop_shield_rule_ip: &drop_shield_rule_ip/1,
put_geolocation: &put_geolocation/1,
@@ -183,6 +185,14 @@ defmodule Plausible.Ingestion.Event do
end
end
+ defp drop_shield_rule_hostname(%__MODULE__{} = event) do
+ if Plausible.Shields.hostname_allowed?(event.domain, event.request.hostname) do
+ event
+ else
+ drop(event, :site_hostname_allowlist)
+ end
+ end
+
defp drop_shield_rule_page(%__MODULE__{} = event) do
if Plausible.Shields.page_blocked?(event.domain, event.request.pathname) do
drop(event, :site_page_blocklist)
diff --git a/lib/plausible/shield/hostname_rule.ex b/lib/plausible/shield/hostname_rule.ex
new file mode 100644
index 0000000000..27d212ff5c
--- /dev/null
+++ b/lib/plausible/shield/hostname_rule.ex
@@ -0,0 +1,67 @@
+defmodule Plausible.Shield.HostnameRule do
+ @moduledoc """
+ Schema for Hostnames allow list
+ """
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @type t() :: %__MODULE__{}
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ schema "shield_rules_hostname" do
+ belongs_to :site, Plausible.Site
+ field :hostname, :string
+ field :hostname_pattern, Plausible.Ecto.Types.CompiledRegex
+ field :action, Ecto.Enum, values: [:deny, :allow], default: :allow
+ 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 \\ %__MODULE__{}, attrs) do
+ rule
+ |> cast(attrs, [:site_id, :hostname])
+ |> validate_required([:site_id, :hostname])
+ |> validate_length(:hostname, max: 250)
+ |> store_regex()
+ |> unique_constraint(:hostname_pattern,
+ name: :shield_rules_hostname_site_id_hostname_pattern_index,
+ error_key: :hostname,
+ message: "rule already exists"
+ )
+ end
+
+ defp store_regex(changeset) do
+ case fetch_change(changeset, :hostname) do
+ {:ok, hostname} ->
+ hostname
+ |> build_regex()
+ |> verify_and_put_regex(changeset)
+
+ :error ->
+ changeset
+ end
+ end
+
+ defp build_regex(hostname) do
+ regex =
+ hostname
+ |> Regex.escape()
+ |> String.replace("\\*\\*", ".*")
+ |> String.replace("\\*", ".*")
+
+ "^#{regex}$"
+ end
+
+ defp verify_and_put_regex(regex, changeset) do
+ case Regex.compile(regex) do
+ {:ok, _} ->
+ put_change(changeset, :hostname_pattern, regex)
+
+ {:error, _} ->
+ add_error(changeset, :hostname, "could not compile regular expression")
+ end
+ end
+end
diff --git a/lib/plausible/shield/hostname_rule_cache.ex b/lib/plausible/shield/hostname_rule_cache.ex
new file mode 100644
index 0000000000..14f2d3aa19
--- /dev/null
+++ b/lib/plausible/shield/hostname_rule_cache.ex
@@ -0,0 +1,70 @@
+defmodule Plausible.Shield.HostnameRuleCache do
+ @moduledoc """
+ Allows retrieving Hostname Rules by domain
+ """
+ alias Plausible.Shield.HostnameRule
+
+ import Ecto.Query
+ use Plausible.Cache
+
+ @cache_name :hostname_allowlist_by_domain
+
+ @cached_schema_fields ~w(
+ id
+ hostname_pattern
+ action
+ )a
+
+ @impl true
+ def name(), do: @cache_name
+
+ @impl true
+ def child_id(), do: :cache_hostname_blocklist
+
+ @impl true
+ def count_all() do
+ Plausible.Repo.aggregate(HostnameRule, :count)
+ end
+
+ @impl true
+ def base_db_query() do
+ from rule in HostnameRule,
+ 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) do
+ query =
+ base_db_query()
+ |> where([..., site], site.domain == ^domain)
+
+ case Plausible.Repo.all(query) do
+ [_ | _] = results ->
+ Enum.map(results, fn {_, _, rule} ->
+ %HostnameRule{rule | from_cache?: false}
+ end)
+
+ _ ->
+ nil
+ end
+ end
+
+ @impl true
+ def unwrap_cache_keys(items) do
+ Enum.reduce(items, [], fn
+ {domain, nil, object}, acc ->
+ [{domain, object} | acc]
+
+ {domain, domain_changed_from, object}, acc ->
+ [
+ {domain, object},
+ {domain_changed_from, object} | acc
+ ]
+ end)
+ end
+end
diff --git a/lib/plausible/shield/page_rule.ex b/lib/plausible/shield/page_rule.ex
index e874e185c4..0a238b75b6 100644
--- a/lib/plausible/shield/page_rule.ex
+++ b/lib/plausible/shield/page_rule.ex
@@ -1,18 +1,3 @@
-defmodule CompiledRegex do
- @moduledoc """
- Ensures that the regex is compiled on load
- """
- use Ecto.Type
-
- def type, do: :string
-
- def cast(val) when is_binary(val), do: {:ok, val}
- def cast(_), do: :error
-
- def load(val), do: {:ok, Regex.compile!(val)}
- def dump(val), do: {:ok, val}
-end
-
defmodule Plausible.Shield.PageRule do
@moduledoc """
Schema for Pages block list
@@ -26,7 +11,7 @@ defmodule Plausible.Shield.PageRule do
schema "shield_rules_page" do
belongs_to :site, Plausible.Site
field :page_path, :string
- field :page_path_pattern, CompiledRegex
+ field :page_path_pattern, Plausible.Ecto.Types.CompiledRegex
field :action, Ecto.Enum, values: [:deny, :allow], default: :deny
field :added_by, :string
diff --git a/lib/plausible/shields.ex b/lib/plausible/shields.ex
index 942b1fba09..0bf16f79f9 100644
--- a/lib/plausible/shields.ex
+++ b/lib/plausible/shields.ex
@@ -16,11 +16,51 @@ defmodule Plausible.Shields do
@maximum_page_rules 30
def maximum_page_rules(), do: @maximum_page_rules
+ @maximum_hostname_rules 10
+ def maximum_hostname_rules(), do: @maximum_hostname_rules
+
@spec list_ip_rules(Site.t() | non_neg_integer()) :: [Shield.IPRule.t()]
def list_ip_rules(site_or_id) do
list(Shield.IPRule, site_or_id)
end
+ @spec hostname_allowed?(Site.t() | String.t(), String.t()) :: boolean()
+ def hostname_allowed?(%Site{domain: domain}, hostname) do
+ hostname_allowed?(domain, hostname)
+ end
+
+ def hostname_allowed?(domain, hostname) when is_binary(domain) and is_binary(hostname) do
+ hostname_rules = Shield.HostnameRuleCache.get(domain)
+
+ if hostname_rules do
+ hostname_rules
+ |> List.wrap()
+ |> Enum.find_value(false, fn rule ->
+ rule.action == :allow and
+ Regex.match?(rule.hostname_pattern, hostname)
+ end)
+ else
+ true
+ end
+ end
+
+ @spec allowed_hostname_patterns(Site.t() | String.t()) :: list(String.t()) | :all
+ def allowed_hostname_patterns(%Site{domain: domain}) do
+ allowed_hostname_patterns(domain)
+ end
+
+ def allowed_hostname_patterns(domain) when is_binary(domain) do
+ hostname_rules = Shield.HostnameRuleCache.get(domain)
+
+ if hostname_rules do
+ hostname_rules
+ |> List.wrap()
+ |> Enum.map(&Regex.source(&1.hostname_pattern))
+ else
+ :all
+ end
+ end
+
@spec ip_blocked?(Site.t() | String.t(), String.t()) :: boolean()
def ip_blocked?(%Site{domain: domain}, address) do
ip_blocked?(domain, address)
@@ -133,6 +173,28 @@ defmodule Plausible.Shields do
count(Shield.PageRule, site_or_id)
end
+ @spec list_hostname_rules(Site.t() | non_neg_integer()) :: [Shield.HostnameRule.t()]
+ def list_hostname_rules(site_or_id) do
+ list(Shield.HostnameRule, site_or_id)
+ end
+
+ @spec add_hostname_rule(Site.t() | non_neg_integer(), map(), Keyword.t()) ::
+ {:ok, Shield.HostnameRule.t()} | {:error, Ecto.Changeset.t()}
+ def add_hostname_rule(site_or_id, params, opts \\ []) do
+ opts = Keyword.put(opts, :limit, {:hostname, @maximum_hostname_rules})
+ add(Shield.HostnameRule, site_or_id, params, opts)
+ end
+
+ @spec remove_hostname_rule(Site.t() | non_neg_integer(), String.t()) :: :ok
+ def remove_hostname_rule(site_or_id, rule_id) do
+ remove(Shield.HostnameRule, site_or_id, rule_id)
+ end
+
+ @spec count_hostname_rules(Site.t() | non_neg_integer()) :: non_neg_integer()
+ def count_hostname_rules(site_or_id) do
+ count(Shield.HostnameRule, site_or_id)
+ end
+
defp list(schema, %Site{id: id}) do
list(schema, id)
end
diff --git a/lib/plausible/stats/filter_suggestions.ex b/lib/plausible/stats/filter_suggestions.ex
index fb95ac13ac..ec357b9943 100644
--- a/lib/plausible/stats/filter_suggestions.ex
+++ b/lib/plausible/stats/filter_suggestions.ex
@@ -3,6 +3,7 @@ defmodule Plausible.Stats.FilterSuggestions do
use Plausible.ClickhouseRepo
use Plausible.Stats.Fragments
import Plausible.Stats.Base
+ import Ecto.Query
alias Plausible.Stats.Query
def filter_suggestions(site, query, "country", filter_search) do
@@ -232,10 +233,21 @@ defmodule Plausible.Stats.FilterSuggestions do
)
:hostname ->
- from(e in q,
- select: e.hostname,
- where: fragment("? ilike ?", e.hostname, ^filter_query)
- )
+ q =
+ from(e in q,
+ select: e.hostname,
+ where: fragment("? ilike ?", e.hostname, ^filter_query)
+ )
+
+ case Plausible.Shields.allowed_hostname_patterns(site.domain) do
+ :all ->
+ q
+
+ limited_to when is_list(limited_to) ->
+ from(e in q,
+ where: fragment("multiMatchAny(?, ?)", e.hostname, ^limited_to)
+ )
+ end
:entry_page ->
from(e in q,
diff --git a/lib/plausible_web/controllers/site_controller.ex b/lib/plausible_web/controllers/site_controller.ex
index 1463565d7a..06350bdeee 100644
--- a/lib/plausible_web/controllers/site_controller.ex
+++ b/lib/plausible_web/controllers/site_controller.ex
@@ -259,7 +259,7 @@ defmodule PlausibleWeb.SiteController do
end
def settings_shields(conn, %{"shield" => shield})
- when shield in ["ip_addresses", "countries", "pages"] do
+ when shield in ["ip_addresses", "countries", "pages", "hostnames"] do
site = conn.assigns.site
conn
diff --git a/lib/plausible_web/live/shields/hostname_rules.ex b/lib/plausible_web/live/shields/hostname_rules.ex
new file mode 100644
index 0000000000..a78027f30e
--- /dev/null
+++ b/lib/plausible_web/live/shields/hostname_rules.ex
@@ -0,0 +1,312 @@
+defmodule PlausibleWeb.Live.Shields.HostnameRules do
+ @moduledoc """
+ LiveView allowing hostname 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.ErrorHelpers
+
+ def update(assigns, socket) do
+ socket =
+ socket
+ |> assign(
+ hostname_rules_count: assigns.hostname_rules_count,
+ site: assigns.site,
+ current_user: assigns.current_user,
+ form: new_form()
+ )
+ |> assign_new(:hostname_rules, fn %{site: site} ->
+ Shields.list_hostname_rules(site)
+ end)
+ |> assign_new(:redundant_rules, fn %{hostname_rules: hostname_rules} ->
+ detect_redundancy(hostname_rules)
+ end)
+
+ {:ok, socket}
+ end
+
+ def render(assigns) do
+ ~H"""
+
+
+
+
+
+
= Shields.maximum_hostname_rules()}
+ class="mt-4"
+ title="Maximum number of hostnames reached"
+ >
+
+ You've reached the maximum number of hostnames you can block (<%= Shields.maximum_hostname_rules() %>). Please remove one before adding another.
+
+
+
+
+ <.live_component module={Modal} id="hostname-rule-form-modal">
+ <.form
+ :let={f}
+ for={@form}
+ phx-submit="save-hostname-rule"
+ phx-target={@myself}
+ class="max-w-md w-full mx-auto bg-white dark:bg-gray-800"
+ >
+
Add Hostname to Allow List
+
+ <.live_component
+ submit_name="hostname_rule[hostname]"
+ submit_value={f[:hostname].value}
+ display_value={f[:hostname].value || ""}
+ module={PlausibleWeb.Live.Components.ComboBox}
+ suggest_fun={fn input, options -> suggest_hostnames(input, options, @site) end}
+ id={f[:hostname].id}
+ creatable
+ />
+
+ <%= error_tag(f, :hostname) %>
+
+
+ You can use a wildcard (*
) to match multiple hostnames. For example,
+ *.<%= @site.domain %>
+ will match all subdomains.
+
+ <%= if @hostname_rules_count >= 1 do %>
+ Once added, we will start accepting traffic from this hostname within a few minutes.
+ <% else %>
+ NB: Once added, we will start rejecting traffic from non-matching hostnames within a few minutes.
+ <% end %>
+
+
+
+
+
+
+ No Hostname Rules configured for this Site.
+
+ Traffic from all hostnames is currently accepted.
+
+
+
+
+
+
+
+ hostname
+
+
+ Status
+
+
+ Remove
+
+
+
+
+ <%= for rule <- @hostname_rules do %>
+
+
+
+
+ <%= rule.hostname %>
+
+
+
+
+
+
+ Blocked
+
+
+ Allowed
+
+
+
+
+
+
+
+
+
+
+ Remove
+
+
+
+ <% end %>
+
+
+
+
+
+ """
+ end
+
+ def handle_event("save-hostname-rule", %{"hostname_rule" => params}, socket) do
+ user = socket.assigns.current_user
+
+ case Shields.add_hostname_rule(
+ socket.assigns.site.id,
+ params,
+ added_by: user
+ ) do
+ {:ok, rule} ->
+ hostname_rules = [rule | socket.assigns.hostname_rules]
+
+ socket =
+ socket
+ |> Modal.close("hostname-rule-form-modal")
+ |> assign(
+ form: new_form(),
+ hostname_rules: hostname_rules,
+ hostname_rules_count: socket.assigns.hostname_rules_count + 1,
+ redundant_rules: detect_redundancy(hostname_rules)
+ )
+
+ # Make sure to clear the combobox input after adding a hostname rule, on subsequent modal reopening
+ send_update(PlausibleWeb.Live.Components.ComboBox,
+ id: "hostname_rule_hostname_code",
+ display_value: ""
+ )
+
+ send_flash(
+ :success,
+ "Hostname rule added successfully. Traffic will be limited within a few minutes."
+ )
+
+ {:noreply, socket}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply, assign(socket, form: to_form(changeset))}
+ end
+ end
+
+ def handle_event("remove-hostname-rule", %{"rule-id" => rule_id}, socket) do
+ Shields.remove_hostname_rule(socket.assigns.site.id, rule_id)
+
+ send_flash(
+ :success,
+ "Hostname rule removed successfully. Traffic will be re-adjusted within a few minutes."
+ )
+
+ hostname_rules = Enum.reject(socket.assigns.hostname_rules, &(&1.id == rule_id))
+
+ {:noreply,
+ socket
+ |> assign(
+ hostname_rules_count: socket.assigns.hostname_rules_count - 1,
+ hostname_rules: hostname_rules,
+ redundant_rules: detect_redundancy(hostname_rules)
+ )}
+ end
+
+ def send_flash(kind, message) do
+ send(self(), {:flash, kind, message})
+ end
+
+ defp new_form() do
+ %Shield.HostnameRule{}
+ |> Shield.HostnameRule.changeset(%{})
+ |> to_form()
+ end
+
+ defp format_added_at(dt, tz) do
+ dt
+ |> Plausible.Timezones.to_datetime_in_timezone(tz)
+ |> Timex.format!("{YYYY}-{0M}-{0D} {h24}:{m}:{s}")
+ end
+
+ def suggest_hostnames(input, _options, site) do
+ query = Plausible.Stats.Query.from(site, %{})
+
+ site
+ |> Plausible.Stats.filter_suggestions(query, "hostname", input)
+ |> Enum.map(fn %{label: label, value: value} -> {label, value} end)
+ end
+
+ defp detect_redundancy(hostname_rules) do
+ hostname_rules
+ |> Enum.reduce(%{}, fn rule, acc ->
+ {[^rule], remaining_rules} =
+ Enum.split_with(
+ hostname_rules,
+ fn r -> r == rule end
+ )
+
+ conflicting =
+ remaining_rules
+ |> Enum.filter(fn candidate ->
+ rule
+ |> Map.fetch!(:hostname_pattern)
+ |> maybe_compile()
+ |> Regex.match?(candidate.hostname)
+ end)
+ |> Enum.map(& &1.id)
+
+ Enum.reduce(conflicting, acc, fn conflicting_rule_id, acc ->
+ Map.update(acc, conflicting_rule_id, [rule.hostname], fn existing ->
+ [rule.hostname | existing]
+ end)
+ end)
+ end)
+ end
+
+ defp maybe_compile(pattern) when is_binary(pattern), do: Regex.compile!(pattern)
+ defp maybe_compile(%Regex{} = pattern), do: pattern
+end
diff --git a/lib/plausible_web/live/shields/hostnames.ex b/lib/plausible_web/live/shields/hostnames.ex
new file mode 100644
index 0000000000..3bc7b424ca
--- /dev/null
+++ b/lib/plausible_web/live/shields/hostnames.ex
@@ -0,0 +1,53 @@
+defmodule PlausibleWeb.Live.Shields.Hostnames do
+ @moduledoc """
+ LiveView for Hostnames Shield
+ """
+ use PlausibleWeb, :live_view
+ use Phoenix.HTML
+
+ alias Plausible.Shields
+ alias Plausible.Sites
+
+ def mount(
+ _params,
+ %{
+ "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(:hostname_rules_count, fn %{site: site} ->
+ Shields.count_hostname_rules(site)
+ end)
+ |> assign_new(:current_user, fn ->
+ Plausible.Repo.get(Plausible.Auth.User, user_id)
+ end)
+
+ {:ok, socket}
+ end
+
+ def render(assigns) do
+ ~H"""
+
+ <.flash_messages flash={@flash} />
+ <.live_component
+ module={PlausibleWeb.Live.Shields.HostnameRules}
+ current_user={@current_user}
+ hostname_rules_count={@hostname_rules_count}
+ site={@site}
+ id="hostname-rules-#{@current_user.id}"
+ />
+
+ """
+ end
+
+ def handle_info({:flash, kind, message}, socket) do
+ socket = put_live_flash(socket, kind, message)
+ {:noreply, socket}
+ end
+end
diff --git a/lib/plausible_web/templates/site/settings_shields.html.heex b/lib/plausible_web/templates/site/settings_shields.html.heex
index 489d49daf7..a96814822b 100644
--- a/lib/plausible_web/templates/site/settings_shields.html.heex
+++ b/lib/plausible_web/templates/site/settings_shields.html.heex
@@ -21,4 +21,11 @@
"domain" => @site.domain
}
) %>
+ <% "hostnames" -> %>
+ <%= live_render(@conn, PlausibleWeb.Live.Shields.Hostnames,
+ session: %{
+ "site_id" => @site.id,
+ "domain" => @site.domain
+ }
+ ) %>
<% end %>
diff --git a/lib/plausible_web/views/layout_view.ex b/lib/plausible_web/views/layout_view.ex
index dad3cb9ae9..b291234850 100644
--- a/lib/plausible_web/views/layout_view.ex
+++ b/lib/plausible_web/views/layout_view.ex
@@ -69,8 +69,9 @@ defmodule PlausibleWeb.LayoutView do
[
%{key: "IP Addresses", value: "shields/ip_addresses"},
%{key: "Countries", value: "shields/countries"},
- if FunWithFlags.enabled?(:shield_pages, for: conn.assigns[:site]) do
- %{key: "Pages", value: "shields/pages"}
+ %{key: "Pages", value: "shields/pages"},
+ if FunWithFlags.enabled?(:shield_hostnames, for: conn.assigns[:site]) do
+ %{key: "Hostnames", value: "shields/hostnames"}
end
]
|> Enum.reject(&is_nil/1)
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs
index e9828b97e5..08f7214590 100644
--- a/priv/repo/seeds.exs
+++ b/priv/repo/seeds.exs
@@ -11,8 +11,8 @@
# and so on) as they will fail if something goes wrong.
FunWithFlags.enable(:imports_exports)
-FunWithFlags.enable(:shield_pages)
FunWithFlags.enable(:hostname_filter)
+FunWithFlags.enable(:shield_hostnames)
user = Plausible.Factory.insert(:user, email: "user@plausible.test", password: "plausible")
diff --git a/test/plausible/ingestion/event_test.exs b/test/plausible/ingestion/event_test.exs
index 8714e0848b..9176089203 100644
--- a/test/plausible/ingestion/event_test.exs
+++ b/test/plausible/ingestion/event_test.exs
@@ -177,6 +177,43 @@ defmodule Plausible.Ingestion.EventTest do
assert dropped.drop_reason == :site_page_blocklist
end
+ test "event pipeline drops a request when hostname allowlist is defined and hostname is not on the list" do
+ site = insert(:site)
+
+ payload = %{
+ name: "pageview",
+ url: "http://dummy.site",
+ domain: site.domain
+ }
+
+ conn = build_conn(:post, "/api/events", payload)
+
+ {:ok, _} = Plausible.Shields.add_hostname_rule(site, %{"hostname" => "subdomain.dummy.site"})
+
+ assert {:ok, request} = Request.build(conn)
+
+ assert {:ok, %{buffered: [], dropped: [dropped]}} = Event.build_and_buffer(request)
+ assert dropped.drop_reason == :site_hostname_allowlist
+ end
+
+ test "event pipeline passes a request when hostname allowlist is defined and hostname is on the list" do
+ site = insert(:site)
+
+ payload = %{
+ name: "pageview",
+ url: "http://subdomain.dummy.site",
+ domain: site.domain
+ }
+
+ conn = build_conn(:post, "/api/events", payload)
+
+ {:ok, _} = Plausible.Shields.add_hostname_rule(site, %{"hostname" => "subdomain.dummy.site"})
+
+ assert {:ok, request} = Request.build(conn)
+
+ assert {:ok, %{buffered: [_], dropped: []}} = Event.build_and_buffer(request)
+ end
+
test "event pipeline drops events for site with accept_trafic_until in the past" do
yesterday = Date.add(Date.utc_today(), -1)
diff --git a/test/plausible/shield/hostname_rule_cache_test.exs b/test/plausible/shield/hostname_rule_cache_test.exs
new file mode 100644
index 0000000000..0cc66995be
--- /dev/null
+++ b/test/plausible/shield/hostname_rule_cache_test.exs
@@ -0,0 +1,128 @@
+defmodule Plausible.Shield.HostnameRuleCacheTest do
+ use Plausible.DataCase, async: true
+
+ alias Plausible.Shield.HostnameRule
+ alias Plausible.Shield.HostnameRuleCache
+ alias Plausible.Shields
+
+ describe "public cache interface" do
+ test "cache caches hostname rules", %{test: test} do
+ cache_opts = [force?: true, cache_name: test]
+
+ {:ok, _} =
+ Supervisor.start_link(
+ [
+ {HostnameRuleCache,
+ [cache_name: test, child_id: :hostname_rules_cache_id, ets_options: [:bag]]}
+ ],
+ strategy: :one_for_one,
+ name: :"cache_supervisor_#{test}"
+ )
+
+ site1 = insert(:site)
+ site2 = insert(:site)
+
+ {:ok, %{id: rid1}} = Shields.add_hostname_rule(site1, %{"hostname" => "1.example.com"})
+ {:ok, %{id: rid2}} = Shields.add_hostname_rule(site1, %{"hostname" => "2.example.com"})
+ {:ok, %{id: rid3}} = Shields.add_hostname_rule(site2, %{"hostname" => "3.example.com"})
+
+ :ok = HostnameRuleCache.refresh_all(cache_name: test)
+
+ :ok = Shields.remove_hostname_rule(site1, rid1)
+
+ # cache is stale
+ assert HostnameRuleCache.size(test) == 3
+
+ # the rule order should be deterministic, but with 1s timestamp sorting precision
+ # race conditions may happen during tests
+ assert rules = HostnameRuleCache.get(site1.domain, cache_opts)
+ rule_ids = Enum.map(rules, & &1.id)
+ assert rid1 in rule_ids
+ assert rid2 in rule_ids
+ assert length(rules) == 2
+
+ assert %HostnameRule{from_cache?: true, id: ^rid3} =
+ HostnameRuleCache.get(site2.domain, cache_opts)
+
+ refute HostnameRuleCache.get("rogue.example.com", cache_opts)
+ end
+
+ test "hostname path patterns are already compiled when fetched from cache", %{test: test} do
+ site = insert(:site)
+
+ {:ok, _} = start_test_cache(test)
+ cache_opts = [force?: true, cache_name: test]
+
+ {:ok, _} = Shields.add_hostname_rule(site, %{"hostname" => "*example.com"})
+ :ok = HostnameRuleCache.refresh_all(cache_name: test)
+ assert regex = HostnameRuleCache.get(site.domain, cache_opts).hostname_pattern
+ assert regex == ~r/^.*example\.com$/
+ end
+
+ test "cache allows lookups for hostname paths on sites with changed domain", %{test: test} do
+ {:ok, _} = start_test_cache(test)
+ cache_opts = [force?: true, cache_name: test]
+ site = insert(:site, domain: "new.example.com", domain_changed_from: "old.example.com")
+
+ {:ok, _} = Shields.add_hostname_rule(site, %{"hostname" => "#{test}"})
+ :ok = HostnameRuleCache.refresh_all(cache_name: test)
+
+ assert HostnameRuleCache.get("old.example.com", cache_opts)
+ assert HostnameRuleCache.get("new.example.com", cache_opts)
+ refute HostnameRuleCache.get("rogue.example.com", cache_opts)
+ end
+
+ test "refreshes only recently added hostnames 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 =
+ NaiveDateTime.utc_now()
+ |> NaiveDateTime.add(-1 * 60 * 60 * 24)
+ |> NaiveDateTime.truncate(:second)
+
+ {:ok, r1} = Plausible.Shields.add_hostname_rule(site, %{"hostname" => "test1.example.com"})
+ Ecto.Changeset.change(r1, inserted_at: yesterday, updated_at: yesterday) |> Repo.update!()
+ {:ok, _} = Plausible.Shields.add_hostname_rule(site, %{"hostname" => "test2.example.com"})
+
+ assert HostnameRuleCache.get(domain, cache_opts) == nil
+
+ assert :ok = HostnameRuleCache.refresh_updated_recently(cache_opts)
+
+ assert %{hostname_pattern: ~r/^test2\.example\.com$/} =
+ HostnameRuleCache.get(domain, cache_opts)
+
+ assert :ok = HostnameRuleCache.refresh_all(cache_opts)
+
+ assert [_, _] = HostnameRuleCache.get(domain, cache_opts)
+ end
+ end
+
+ test "get_from_source", %{test: test} do
+ {:ok, _} = start_test_cache(test)
+
+ domain = "site1.example.com"
+ site = insert(:site, domain: domain)
+
+ cache_opts = [cache_name: test, force?: true]
+
+ {:ok, _} = Shields.add_hostname_rule(site, %{"hostname" => "#{test}"})
+ {:ok, _} = Shields.add_hostname_rule(site, %{"hostname" => "#{test}*"})
+
+ :ok = HostnameRuleCache.refresh_all(cache_opts)
+
+ assert length(HostnameRuleCache.get(domain, cache_opts)) ==
+ length(HostnameRuleCache.get_from_source(domain))
+ end
+
+ defp start_test_cache(cache_name) do
+ %{start: {m, f, a}} =
+ HostnameRuleCache.child_spec(cache_name: cache_name, ets_options: [:bag])
+
+ apply(m, f, a)
+ end
+end
diff --git a/lib/plausible/shields/country_test.exs b/test/plausible/shields/country_test.exs
similarity index 100%
rename from lib/plausible/shields/country_test.exs
rename to test/plausible/shields/country_test.exs
diff --git a/test/plausible/shields/hostname_test.exs b/test/plausible/shields/hostname_test.exs
new file mode 100644
index 0000000000..75c7ca9951
--- /dev/null
+++ b/test/plausible/shields/hostname_test.exs
@@ -0,0 +1,188 @@
+defmodule Plausible.Shields.HostnameTest do
+ use Plausible.DataCase
+ import Plausible.Shields
+
+ setup do
+ site = insert(:site)
+ {:ok, %{site: site}}
+ end
+
+ describe "add_hostname_rule/2" do
+ test "no input", %{site: site} do
+ assert {:error, changeset} = add_hostname_rule(site, %{})
+ assert changeset.errors == [hostname: {"can't be blank", [validation: :required]}]
+ refute changeset.valid?
+ end
+
+ test "lengthy", %{site: site} do
+ long = "/" <> :binary.copy("a", 251)
+ assert {:error, changeset} = add_hostname_rule(site, %{"hostname" => long})
+ assert [hostname: {"should be at most %{count} character(s)", _}] = changeset.errors
+ refute changeset.valid?
+ end
+
+ test "double insert", %{site: site} do
+ assert {:ok, _} = add_hostname_rule(site, %{"hostname" => "/test"})
+ assert {:error, changeset} = add_hostname_rule(site, %{"hostname" => "/test"})
+ refute changeset.valid?
+
+ assert changeset.errors == [
+ hostname:
+ {"rule already exists",
+ [
+ {:constraint, :unique},
+ {:constraint_name, "shield_rules_hostname_site_id_hostname_pattern_index"}
+ ]}
+ ]
+ end
+
+ test "equivalent rules are counted as dupes", %{site: site} do
+ assert {:ok, _} = add_hostname_rule(site, %{"hostname" => "test*"})
+ assert {:error, changeset} = add_hostname_rule(site, %{"hostname" => "test**"})
+
+ assert changeset.errors == [
+ hostname:
+ {"rule already exists",
+ [
+ {:constraint, :unique},
+ {:constraint_name, "shield_rules_hostname_site_id_hostname_pattern_index"}
+ ]}
+ ]
+ end
+
+ test "regex storage: wildcard", %{site: site} do
+ assert {:ok, rule} = add_hostname_rule(site, %{"hostname" => "*test"})
+ assert rule.hostname_pattern == "^.*test$"
+ end
+
+ test "regex storage: no wildcard", %{site: site} do
+ assert {:ok, rule} = add_hostname_rule(site, %{"hostname" => "test"})
+ assert rule.hostname_pattern == "^test$"
+ end
+
+ test "regex storage: escaping", %{site: site} do
+ assert {:ok, rule} = add_hostname_rule(site, %{"hostname" => "test.*.**.|+[0-9]"})
+ assert rule.hostname_pattern == "^test\\..*\\..*\\.\\|\\+\\[0\\-9\\]$"
+ end
+
+ test "over limit", %{site: site} do
+ for i <- 1..maximum_hostname_rules() do
+ assert {:ok, _} =
+ add_hostname_rule(site, %{"hostname" => "test-#{i}"})
+ end
+
+ assert count_hostname_rules(site) == maximum_hostname_rules()
+
+ assert {:error, changeset} =
+ add_hostname_rule(site, %{"hostname" => "test.limit"})
+
+ refute changeset.valid?
+ assert changeset.errors == [hostname: {"maximum reached", []}]
+ end
+
+ test "with added_by", %{site: site} do
+ assert {:ok, rule} =
+ add_hostname_rule(site, %{"hostname" => "test.example.com"},
+ added_by: build(:user, name: "Joe", email: "joe@example.com")
+ )
+
+ assert rule.added_by == "Joe "
+ end
+ end
+
+ describe "hostname pattern matching" do
+ test "no wildcard", %{site: site} do
+ assert {:ok, _} = add_hostname_rule(site, %{"hostname" => "test.example.com"})
+ assert hostname_allowed?(site, "test.example.com")
+ refute hostname_allowed?(site, "subdmoain.example.com")
+ refute hostname_allowed?(site, "test")
+ end
+
+ test "wildcard - subdomains", %{site: site} do
+ assert {:ok, _} = add_hostname_rule(site, %{"hostname" => "*.example.com"})
+ refute hostname_allowed?(site, "example.com")
+ refute hostname_allowed?(site, "example.com.pl")
+ assert hostname_allowed?(site, "subdomain.example.com")
+ end
+
+ test "wildcard - any prefix", %{site: site} do
+ assert {:ok, _} = add_hostname_rule(site, %{"hostname" => "*example.com"})
+ assert hostname_allowed?(site, "example.com")
+ refute hostname_allowed?(site, "example.com.pl")
+ assert hostname_allowed?(site, "subdomain.example.com")
+ end
+ end
+
+ describe "remove_hostname_rule/2" do
+ test "is idempontent", %{site: site} do
+ {:ok, rule} = add_hostname_rule(site, %{"hostname" => "test"})
+ assert remove_hostname_rule(site, rule.id) == :ok
+ refute Repo.get(Plausible.Shield.HostnameRule, rule.id)
+ assert remove_hostname_rule(site, rule.id) == :ok
+ end
+ end
+
+ describe "list_hostname_rules/1" do
+ test "empty", %{site: site} do
+ assert(list_hostname_rules(site) == [])
+ end
+
+ @tag :slow
+ test "many", %{site: site} do
+ {:ok, %{id: id1}} = add_hostname_rule(site, %{"hostname" => "test1.example.com"})
+ :timer.sleep(1000)
+ {:ok, %{id: id2}} = add_hostname_rule(site, %{"hostname" => "test2.example.com"})
+ assert [%{id: ^id2}, %{id: ^id1}] = list_hostname_rules(site)
+ end
+ end
+
+ describe "count_hostname_rules/1" do
+ test "counts", %{site: site} do
+ assert count_hostname_rules(site) == 0
+ {:ok, _} = add_hostname_rule(site, %{"hostname" => "test1"})
+ assert count_hostname_rules(site) == 1
+ {:ok, _} = add_hostname_rule(site, %{"hostname" => "test2"})
+ assert count_hostname_rules(site) == 2
+ end
+ end
+
+ describe "allowed_hostname_patterns/1" do
+ test "returns a list of regular expressions when rules are defined", %{site: site} do
+ {:ok, _} = add_hostname_rule(site, %{"hostname" => "example.com"})
+ {:ok, _} = add_hostname_rule(site, %{"hostname" => "another.example.com"})
+ {:ok, _} = add_hostname_rule(site, %{"hostname" => "app.*"})
+
+ allowed = allowed_hostname_patterns(site.domain)
+
+ assert length(allowed) == 3
+ assert "^example\\.com$" in allowed
+ assert "^another\\.example\\.com$" in allowed
+ assert "^app\\..*$" in allowed
+ end
+ end
+
+ describe "Hostname Rules" do
+ test "end to end", %{site: site} do
+ site2 = insert(:site)
+
+ assert count_hostname_rules(site.id) == 0
+ assert list_hostname_rules(site.id) == []
+
+ assert {:ok, rule} =
+ add_hostname_rule(site.id, %{
+ "hostname" => "blog.example.com"
+ })
+
+ add_hostname_rule(site2, %{"hostname" => "portral.example.com"})
+
+ assert count_hostname_rules(site) == 1
+ assert [%{id: rule_id}] = list_hostname_rules(site)
+ assert rule.id == rule_id
+ assert rule.hostname == "blog.example.com"
+ assert rule.action == :allow
+ refute rule.from_cache?
+ assert hostname_allowed?(site, "blog.example.com")
+ refute hostname_allowed?(site, "portal.example.com")
+ end
+ end
+end
diff --git a/lib/plausible/shields/ip_test.exs b/test/plausible/shields/ip_test.exs
similarity index 100%
rename from lib/plausible/shields/ip_test.exs
rename to test/plausible/shields/ip_test.exs
diff --git a/lib/plausible/shields/page_test.exs b/test/plausible/shields/page_test.exs
similarity index 96%
rename from lib/plausible/shields/page_test.exs
rename to test/plausible/shields/page_test.exs
index 6134e9903e..121672568f 100644
--- a/lib/plausible/shields/page_test.exs
+++ b/test/plausible/shields/page_test.exs
@@ -130,10 +130,10 @@ defmodule Plausible.Shields.PageTest do
@tag :slow
test "many", %{site: site} do
- {:ok, r1} = add_page_rule(site, %{"page_path" => "/test"})
+ {:ok, %{id: id1}} = add_page_rule(site, %{"page_path" => "/test1"})
:timer.sleep(1000)
- {:ok, r2} = add_page_rule(site, %{"page_path" => "/test"})
- assert [^r2, ^r1] = list_page_rules(site)
+ {:ok, %{id: id2}} = add_page_rule(site, %{"page_path" => "/test2"})
+ assert [%{id: ^id2}, %{id: ^id1}] = list_page_rules(site)
end
end
diff --git a/test/plausible_web/controllers/api/stats_controller/suggestions_test.exs b/test/plausible_web/controllers/api/stats_controller/suggestions_test.exs
index 07b931901c..8017a534b0 100644
--- a/test/plausible_web/controllers/api/stats_controller/suggestions_test.exs
+++ b/test/plausible_web/controllers/api/stats_controller/suggestions_test.exs
@@ -279,6 +279,64 @@ defmodule PlausibleWeb.Api.StatsController.SuggestionsTest do
assert %{"label" => "host-bob.example.com", "value" => "host-bob.example.com"} in suggestions
end
+ test "returns suggestions for hostnames limited by shields", %{conn: conn1, user: user} do
+ {:ok, [site: site]} = create_new_site(%{user: user})
+ Plausible.Shields.add_hostname_rule(site, %{"hostname" => "*.example.com"})
+ Plausible.Shields.add_hostname_rule(site, %{"hostname" => "erin.rogue.com"})
+
+ populate_stats(site, [
+ build(:pageview,
+ pathname: "/",
+ hostname: "host-alice.example.com"
+ ),
+ build(:pageview,
+ pathname: "/some-other-page",
+ hostname: "host-bob.example.com",
+ user_id: 123
+ ),
+ build(:pageview, pathname: "/exit", hostname: "host-carol.example.com", user_id: 123),
+ build(:pageview,
+ pathname: "/",
+ hostname: "host-dave.rogue.com"
+ ),
+ build(:pageview,
+ pathname: "/",
+ hostname: "erin.rogue.com"
+ )
+ ])
+
+ conn =
+ get(
+ conn1,
+ "/api/stats/#{site.domain}/suggestions/hostname?q=host"
+ )
+
+ assert json_response(conn, 200) ==
+ [
+ %{"label" => "host-alice.example.com", "value" => "host-alice.example.com"},
+ %{"label" => "host-carol.example.com", "value" => "host-carol.example.com"},
+ %{"label" => "host-bob.example.com", "value" => "host-bob.example.com"}
+ ]
+
+ conn =
+ get(
+ conn1,
+ "/api/stats/#{site.domain}/suggestions/hostname?q=dave"
+ )
+
+ assert json_response(conn, 200) == []
+
+ conn =
+ get(
+ conn1,
+ "/api/stats/#{site.domain}/suggestions/hostname?q=rogue"
+ )
+
+ assert json_response(conn, 200) == [
+ %{"label" => "erin.rogue.com", "value" => "erin.rogue.com"}
+ ]
+ end
+
test "returns suggestions for referrers", %{conn: conn, site: site} do
populate_stats(site, [
build(:pageview,
diff --git a/test/plausible_web/live/shields/hostnames_test.exs b/test/plausible_web/live/shields/hostnames_test.exs
new file mode 100644
index 0000000000..cc30500189
--- /dev/null
+++ b/test/plausible_web/live/shields/hostnames_test.exs
@@ -0,0 +1,199 @@
+defmodule PlausibleWeb.Live.Shields.HostnamesTest 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 "Hostname Rules - static" do
+ test "renders hostname rules hostname with empty list", %{conn: conn, site: site} do
+ conn = get(conn, "/#{site.domain}/settings/shields/hostnames")
+ resp = html_response(conn, 200)
+
+ assert resp =~ "No Hostname Rules configured for this Site"
+ assert resp =~ "Hostnames Allow List"
+ assert resp =~ "Traffic from all hostnames is currently accepted."
+ end
+
+ test "lists hostname rules with remove actions", %{conn: conn, site: site} do
+ {:ok, r1} =
+ Shields.add_hostname_rule(site, %{"hostname" => "example.com"})
+
+ {:ok, r2} =
+ Shields.add_hostname_rule(site, %{"hostname" => "example.org"})
+
+ conn = get(conn, "/#{site.domain}/settings/shields/hostnames")
+ resp = html_response(conn, 200)
+
+ assert resp =~ "example.com"
+ assert resp =~ "example.org"
+
+ assert remove_button_1 = find(resp, "#remove-hostname-rule-#{r1.id}")
+ assert remove_button_2 = find(resp, "#remove-hostname-rule-#{r2.id}")
+
+ assert text_of_attr(remove_button_1, "phx-click" == "remove-hostname-rule")
+ assert text_of_attr(remove_button_1, "phx-value-rule-id" == r1.id)
+ assert text_of_attr(remove_button_2, "phx-click" == "remove-hostname-rule")
+ assert text_of_attr(remove_button_2, "phx-value-rule-id" == r2.id)
+ end
+
+ test "add rule button is rendered", %{conn: conn, site: site} do
+ conn = get(conn, "/#{site.domain}/settings/shields/hostnames")
+ resp = html_response(conn, 200)
+
+ assert element_exists?(resp, ~s/button#add-hostname-rule[x-data]/)
+ attr = text_of_attr(resp, ~s/button#add-hostname-rule/, "x-on:click")
+
+ assert attr =~ "open-modal"
+ assert attr =~ "hostname-rule-form-modal"
+ end
+
+ test "add rule button is not rendered when maximum reached", %{conn: conn, site: site} do
+ for i <- 1..Shields.maximum_hostname_rules() do
+ assert {:ok, _} =
+ Shields.add_hostname_rule(site, %{"hostname" => "#{i}.example.com"})
+ end
+
+ conn = get(conn, "/#{site.domain}/settings/shields/hostnames")
+ resp = html_response(conn, 200)
+
+ refute element_exists?(resp, ~s/button#add-hostname-rule[x-data]/)
+ assert resp =~ "Maximum number of hostnames reached"
+ assert resp =~ "You've reached the maximum number of hostnames you can block (10)"
+ end
+ end
+
+ describe "Hostname 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-hostname-rule"] input[name="hostname_rule\[hostname\]"]/
+ )
+
+ assert submit_button(html, ~s/form[phx-submit="save-hostname-rule"]/)
+ end
+
+ test "if no rules are added yet, form displays hint", %{site: site, conn: conn} do
+ lv = get_liveview(conn, site)
+ html = render(lv)
+
+ assert text(html) =~
+ "NB: Once added, we will start rejecting traffic from non-matching hostnames within a few minutes.
+"
+
+ refute text(html) =~ "we will start accepting"
+ end
+
+ test "if rules are added, form changes the hint", %{site: site, conn: conn} do
+ {:ok, _} =
+ Shields.add_hostname_rule(site, %{"hostname" => "*.example.com"})
+
+ lv = get_liveview(conn, site)
+ html = render(lv)
+
+ refute text(html) =~ "we will start rejecting traffic"
+
+ assert text(html) =~
+ "Once added, we will start accepting traffic from this hostname within a few minutes."
+ end
+
+ test "submitting a valid Hostname saves it", %{conn: conn, site: site} do
+ lv = get_liveview(conn, site)
+
+ lv
+ |> element("form")
+ |> render_submit(%{
+ "hostname_rule[hostname]" => "*.example.com"
+ })
+
+ html = render(lv)
+
+ assert html =~ "*.example.com"
+
+ assert [%{hostname: "*.example.com"}] = Shields.list_hostname_rules(site)
+ end
+
+ test "submitting invalid Hostname renders error", %{conn: conn, site: site} do
+ lv = get_liveview(conn, site)
+
+ lv
+ |> element("form")
+ |> render_submit(%{
+ "hostname_rule[hostname]" => :binary.copy("a", 256)
+ })
+
+ html = render(lv)
+ assert html =~ "should be at most 250 character(s)"
+ end
+
+ test "clicking Remove deletes the rule", %{conn: conn, site: site} do
+ {:ok, _} =
+ Shields.add_hostname_rule(site, %{"hostname" => "*.example.com"})
+
+ lv = get_liveview(conn, site)
+
+ html = render(lv)
+ assert html =~ "*.example.com"
+
+ lv |> element(~s/button[phx-click="remove-hostname-rule"]/) |> render_click()
+
+ html = render(lv)
+ refute html =~ "*.example.com"
+
+ assert Shields.count_hostname_rules(site) == 0
+ end
+
+ test "conclicting rules are annotated with a warning", %{conn: conn, site: site} do
+ lv = get_liveview(conn, site)
+
+ lv
+ |> element("form")
+ |> render_submit(%{
+ "hostname_rule[hostname]" => "*example.com"
+ })
+
+ html = render(lv)
+ refute html =~ "This rule might be redundant"
+
+ lv
+ |> element("form")
+ |> render_submit(%{
+ "hostname_rule[hostname]" => "subdomain.example.com"
+ })
+
+ html = render(lv)
+
+ assert html =~ "*example.com"
+ assert html =~ "subdomain.example.com"
+
+ assert html =~
+ "This rule might be redundant because the following rules may match first:\n\n*example.com"
+
+ broader_rule_id =
+ site
+ |> Shields.list_hostname_rules()
+ |> Enum.find(&(&1.hostname == "*example.com"))
+ |> Map.fetch!(:id)
+
+ lv |> element(~s/button#remove-hostname-rule-#{broader_rule_id}/) |> render_click()
+ html = render(lv)
+
+ assert html =~ "subdomain.example.com"
+ refute html =~ "*example.com"
+ refute html =~ "This rule might be redundant"
+ end
+
+ defp get_liveview(conn, site) do
+ conn = assign(conn, :live_module, PlausibleWeb.Live.Shields)
+ {:ok, lv, _html} = live(conn, "/#{site.domain}/settings/shields/hostnames")
+
+ lv
+ end
+ end
+end
diff --git a/test/test_helper.exs b/test/test_helper.exs
index fb033e53f1..196f780659 100644
--- a/test/test_helper.exs
+++ b/test/test_helper.exs
@@ -1,8 +1,11 @@
+if not Enum.empty?(Path.wildcard("lib/**/*_test.exs")) do
+ raise "Oops, test(s) found in `lib/` directory. Move them to `test/`."
+end
+
{:ok, _} = Application.ensure_all_started(:ex_machina)
Mox.defmock(Plausible.HTTPClient.Mock, for: Plausible.HTTPClient.Interface)
Application.ensure_all_started(:double)
FunWithFlags.enable(:imports_exports)
-FunWithFlags.enable(:shield_pages)
# Temporary flag to test `experimental_reduced_joins` flag on all tests.
if System.get_env("TEST_EXPERIMENTAL_REDUCED_JOINS") == "1" do