Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Shield: Country Rules #3828

Merged
merged 33 commits into from
Feb 27, 2024
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
4ea0110
Migration: add country rules
aerosol Feb 26, 2024
4357cc7
Add CountryRule schema
aerosol Feb 26, 2024
688f102
Implement CountryRule cache
aerosol Feb 26, 2024
7184efa
Add country rules context interface
aerosol Feb 26, 2024
907ea21
Start country rules cache
aerosol Feb 26, 2024
69ad586
Lookup country rules on ingestion
aerosol Feb 26, 2024
766cf8f
Remove :shields feature flag from test helpers
aerosol Feb 26, 2024
fd19f5a
Add nested sidebar menu for Shields
aerosol Feb 26, 2024
a3c13a4
Fix typo
aerosol Feb 26, 2024
b3dd745
IP Rules: hide description on mobile view
aerosol Feb 26, 2024
4dee1c8
Prepare SiteController to handle multiple shield types
aerosol Feb 26, 2024
d8447d7
Seed some country shield
aerosol Feb 26, 2024
c5f1a9f
Implement LV for country rules
aerosol Feb 26, 2024
4bfc48a
Remove "YOU" indicator from country rules
aerosol Feb 26, 2024
64ad8e1
Fix small build
aerosol Feb 26, 2024
4870cde
Format
aerosol Feb 26, 2024
5705183
Update typespecs
aerosol Feb 26, 2024
9c344a0
Make docs link point at /countries
aerosol Feb 26, 2024
1b4d81c
Fix flash on top of modal for Safari
zoldar Feb 26, 2024
7349060
Build the rule struct with site_id provided up-front
aerosol Feb 26, 2024
4863661
Clarify why we're messaging the ComboBox component
aerosol Feb 26, 2024
8f56ecb
Re-open combobox suggestions after pressing Escape
aerosol Feb 26, 2024
9d41560
Update changelog
aerosol Feb 26, 2024
bdfe17b
Fix font size in country table cells
aerosol Feb 26, 2024
b83497e
Pass `added_by` via rule add options
aerosol Feb 27, 2024
f3b5cd3
Display site's timezone timestamps in rule tooltips
aerosol Feb 27, 2024
3168175
Display formatted timestamps in site's timezone
aerosol Feb 27, 2024
ef5b20e
Remove no-op atom
aerosol Feb 27, 2024
1d95375
Display the maximum number of rules when reached
aerosol Feb 27, 2024
adeb5f4
Improve readability of remove button tests
aerosol Feb 27, 2024
6e11598
Merge branch 'master' into shield-countries
aerosol Feb 27, 2024
7cffe00
Credo
aerosol Feb 27, 2024
26a497a
Merge branch 'master' into shield-countries
aerosol Feb 27, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions lib/plausible/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@ defmodule Plausible.Application do
interval: :timer.seconds(35),
warmer_fn: :refresh_updated_recently
]},
{Plausible.Shield.CountryRuleCache, []},
{Plausible.Cache.Warmer,
[
child_name: Plausible.Shield.CountryRuleCache.All,
cache_impl: Plausible.Shield.CountryRuleCache,
interval: :timer.minutes(3) + Enum.random(1..:timer.seconds(10)),
warmer_fn: :refresh_all
]},
{Plausible.Cache.Warmer,
[
child_name: Plausible.Shield.CountryRuleCache.RecentlyUpdated,
cache_impl: Plausible.Shield.CountryRuleCache,
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)},
Expand Down
20 changes: 19 additions & 1 deletion lib/plausible/ingestion/event.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ defmodule Plausible.Ingestion.Event do
| :invalid
| :dc_ip
| :site_ip_blocklist
| :site_country_blocklist

@type t() :: %__MODULE__{
domain: String.t() | nil,
Expand Down Expand Up @@ -97,11 +98,12 @@ defmodule Plausible.Ingestion.Event do
[
&drop_datacenter_ip/1,
&drop_shield_rule_ip/1,
&put_geolocation/1,
&drop_shield_rule_country/1,
&put_user_agent/1,
&put_basic_info/1,
&put_referrer/1,
&put_utm_tags/1,
&put_geolocation/1,
&put_props/1,
&put_revenue/1,
&put_salts/1,
Expand Down Expand Up @@ -226,6 +228,22 @@ defmodule Plausible.Ingestion.Event do
update_attrs(event, result)
end

defp drop_shield_rule_country(
%__MODULE__{domain: domain, clickhouse_event_attrs: %{country_code: cc}} = event
)
when is_binary(domain) and is_binary(cc) do
case Plausible.Shield.CountryRuleCache.get({domain, String.upcase(cc)}) do
%Plausible.Shield.CountryRule{action: :deny} ->
drop(event, :site_country_blocklist)

_ ->
:lookup_failed
aerosol marked this conversation as resolved.
Show resolved Hide resolved
event
end
end

defp drop_shield_rule_country(%__MODULE__{} = event), do: event

defp put_props(%__MODULE__{request: %{props: %{} = props}} = event) do
# defensive: ensuring the keys/values are always in the same order
{keys, values} = Enum.unzip(props)
Expand Down
38 changes: 38 additions & 0 deletions lib/plausible/shield/country_rule.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
defmodule Plausible.Shield.CountryRule do
@moduledoc """
Schema for Country Block List
"""
use Ecto.Schema
import Ecto.Changeset

@type t() :: %__MODULE__{}

@primary_key {:id, :binary_id, autogenerate: true}
schema "shield_rules_country" do
belongs_to :site, Plausible.Site
field :country_code, :string
field :action, Ecto.Enum, values: [:deny, :allow], default: :deny
field :added_by, :string

# If `from_cache?` is set, the struct might be incomplete - see `Plausible.Site.Shield.Rules.Country.Cache`
field :from_cache?, :boolean, virtual: true, default: false
timestamps()
end

def changeset(rule, attrs) do
rule
|> cast(attrs, [:site_id, :country_code, :added_by])
|> validate_required([:site_id, :country_code])
|> validate_length(:country_code, is: 2)
|> Ecto.Changeset.validate_change(:country_code, fn :country_code, cc ->
if cc in Enum.map(Location.Country.all(), & &1.alpha_2) do
[]
else
[country_code: "is invalid"]
end
end)
|> unique_constraint(:country_code,
name: :shield_rules_country_site_id_country_code_index
)
end
end
65 changes: 65 additions & 0 deletions lib/plausible/shield/country_rule_cache.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
defmodule Plausible.Shield.CountryRuleCache do
@moduledoc """
Allows retrieving Country Rules by domain and country code
"""
alias Plausible.Shield.CountryRule

import Ecto.Query
use Plausible.Cache

@cache_name :country_blocklist_by_domain

@cached_schema_fields ~w(
id
country_code
action
)a

@impl true
def name(), do: @cache_name

@impl true
def child_id(), do: :cachex_country_blocklist

@impl true
def count_all() do
Plausible.Repo.aggregate(CountryRule, :count)
end

@impl true
def base_db_query() do
from rule in CountryRule,
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, country_code}) do
macobo marked this conversation as resolved.
Show resolved Hide resolved
query =
base_db_query()
|> where([rule, site], rule.country_code == ^country_code and site.domain == ^domain)

case Plausible.Repo.one(query) do
{_, _, rule} -> %CountryRule{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, String.upcase(object.country_code)}, object} | acc]

{domain, domain_changed_from, object}, acc ->
[
{{domain, String.upcase(object.country_code)}, object},
{{domain_changed_from, String.upcase(object.country_code)}, object} | acc
]
end)
end
end
99 changes: 71 additions & 28 deletions lib/plausible/shields.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,38 +5,87 @@ defmodule Plausible.Shields do
import Ecto.Query
alias Plausible.Repo
alias Plausible.Shield
alias Plausible.Site

@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
@maximum_country_rules 30
def maximum_country_rules(), do: @maximum_country_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 add_ip_rule(Site.t() | non_neg_integer(), map()) ::
{:ok, Shield.IPRule.t()} | {:error, Ecto.Changeset.t()}
def add_ip_rule(site_or_id, params) do
add(Shield.IPRule, site_or_id, {:inet, @maximum_ip_rules}, params)
end

@spec remove_ip_rule(Site.t() | non_neg_integer(), String.t()) :: :ok
def remove_ip_rule(site_or_id, rule_id) do
remove(Shield.IPRule, site_or_id, rule_id)
end

@spec count_ip_rules(Site.t() | non_neg_integer()) :: non_neg_integer()
def count_ip_rules(site_or_id) do
count(Shield.IPRule, site_or_id)
end

@spec list_country_rules(Site.t() | non_neg_integer()) :: [Shield.CountryRule.t()]
def list_country_rules(site_or_id) do
list(Shield.CountryRule, site_or_id)
end

@spec add_country_rule(Site.t() | non_neg_integer(), map()) ::
{:ok, Shield.CountryRule.t()} | {:error, Ecto.Changeset.t()}
def add_country_rule(site_or_id, params) do
add(Shield.CountryRule, site_or_id, {:country_code, @maximum_country_rules}, params)
end

@spec remove_country_rule(Site.t() | non_neg_integer(), String.t()) :: :ok
def remove_country_rule(site_or_id, rule_id) do
remove(Shield.CountryRule, site_or_id, rule_id)
end

@spec count_country_rules(Site.t() | non_neg_integer()) :: non_neg_integer()
def count_country_rules(site_or_id) do
count(Shield.CountryRule, site_or_id)
end

defp list(schema, %Site{id: id}) do
list(schema, id)
end

defp list(schema, site_id) when is_integer(site_id) do
Repo.all(
from r in Shield.IPRule,
from r in schema,
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)
defp add(schema, %Site{id: id}, max, params) do
add(schema, id, max, params)
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
defp add(schema, site_id, {field, max}, params) when is_integer(site_id) do
Repo.transaction(fn ->
result =
if count_ip_rules(site_id) >= @maximum_ip_rules do
if count(schema, site_id) >= max do
zoldar marked this conversation as resolved.
Show resolved Hide resolved
changeset =
%Shield.IPRule{}
|> Shield.IPRule.changeset(Map.put(params, "site_id", site_id))
|> Ecto.Changeset.add_error(:inet, "maximum reached")
schema
|> struct()
|> schema.changeset(Map.put(params, "site_id", site_id))
zoldar marked this conversation as resolved.
Show resolved Hide resolved
|> Ecto.Changeset.add_error(field, "maximum reached")

{:error, changeset}
else
%Shield.IPRule{}
|> Shield.IPRule.changeset(Map.put(params, "site_id", site_id))
schema
|> struct()
|> schema.changeset(Map.put(params, "site_id", site_id))
|> Repo.insert()
end

Expand All @@ -47,26 +96,20 @@ defmodule Plausible.Shields do
end)
end

def add_ip_rule(%Plausible.Site{id: id}, params) do
add_ip_rule(id, params)
defp remove(schema, %Site{id: id}, rule_id) do
remove(schema, id, rule_id)
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))
defp remove(schema, site_id, rule_id) when is_integer(site_id) do
Repo.delete_all(from(r in schema, 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)
defp count(schema, %Site{id: id}) do
count(schema, id)
end

def count_ip_rules(%Plausible.Site{id: id}) do
count_ip_rules(id)
defp count(schema, site_id) when is_integer(site_id) do
Repo.aggregate(from(r in schema, where: r.site_id == ^site_id), :count)
end
end
16 changes: 11 additions & 5 deletions lib/plausible/stats/breakdown.ex
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,20 @@ defmodule Plausible.Stats.Breakdown do

if !Keyword.get(opts, :skip_tracing), do: trace(query, property, metrics)

no_revenue = {nil, metrics -- @revenue_metrics}

{revenue_goals, metrics} =
if full_build?() && Plausible.Billing.Feature.RevenueGoals.enabled?(site) do
revenue_goals = Enum.filter(event_goals, &Plausible.Goal.Revenue.revenue?/1)
metrics = if Enum.empty?(revenue_goals), do: metrics -- @revenue_metrics, else: metrics
on_full_build do
aerosol marked this conversation as resolved.
Show resolved Hide resolved
if Plausible.Billing.Feature.RevenueGoals.enabled?(site) do
revenue_goals = Enum.filter(event_goals, &Plausible.Goal.Revenue.revenue?/1)
metrics = if Enum.empty?(revenue_goals), do: metrics -- @revenue_metrics, else: metrics

{revenue_goals, metrics}
{revenue_goals, metrics}
else
no_revenue
end
else
{nil, metrics -- @revenue_metrics}
no_revenue
end

metrics_to_select = Util.maybe_add_visitors_metric(metrics) -- @computed_metrics
Expand Down
6 changes: 4 additions & 2 deletions lib/plausible_web/controllers/site_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -253,13 +253,15 @@ defmodule PlausibleWeb.SiteController do
)
end

def settings_shields(conn, _params) do
def settings_shields(conn, %{"shield" => shield})
when shield in ["ip_addresses", "countries"] do
site = conn.assigns.site

conn
|> render("settings_shields.html",
site: site,
dogfood_page_path: "/:dashboard/settings/shields",
shield: shield,
dogfood_page_path: "/:dashboard/settings/shields/#{shield}",
connect_live_socket: true,
layout: {PlausibleWeb.LayoutView, "site_settings.html"}
)
Expand Down
2 changes: 1 addition & 1 deletion lib/plausible_web/live/components/combo_box.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
and updates the suggestions asynchronously. This way, you can render
the component without having to wait for suggestions to load.

If you explicitly need to make the operation sychronous, you may
If you explicitly need to make the operation synchronous, you may
pass `async={false}` option.

If your initial `options` are not provided up-front at initial render,
Expand Down
2 changes: 1 addition & 1 deletion lib/plausible_web/live/components/modal.ex
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ defmodule PlausibleWeb.Live.Components.Modal do
~H"""
<div
id={@id}
class="relative z-50 [&[data-phx-ref]_div.modal-dialog]:hidden [&[data-phx-ref]_div.modal-loading]:block"
class="relative z-[49] [&[data-phx-ref]_div.modal-dialog]:hidden [&[data-phx-ref]_div.modal-loading]:block"
data-modal
x-cloak
x-data="{
Expand Down
Loading