mirror of
https://github.com/plausible/analytics.git
synced 2024-11-22 18:52:38 +03:00
Shield: Country Rules (#3828)
* Migration: add country rules * Add CountryRule schema * Implement CountryRule cache * Add country rules context interface * Start country rules cache * Lookup country rules on ingestion * Remove :shields feature flag from test helpers * Add nested sidebar menu for Shields * Fix typo * IP Rules: hide description on mobile view * Prepare SiteController to handle multiple shield types * Seed some country shield * Implement LV for country rules * Remove "YOU" indicator from country rules * Fix small build * Format * Update typespecs * Make docs link point at /countries * Fix flash on top of modal for Safari * Build the rule struct with site_id provided up-front * Clarify why we're messaging the ComboBox component * Re-open combobox suggestions after pressing Escape * Update changelog * Fix font size in country table cells * Pass `added_by` via rule add options * Display site's timezone timestamps in rule tooltips * Display formatted timestamps in site's timezone And simplify+test Timezone module; an input timestamp converted to UTC can never be ambiguous. * Remove no-op atom * Display the maximum number of rules when reached * Improve readability of remove button tests * Credo --------- Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>
This commit is contained in:
parent
b7b5dcf4c9
commit
518cdb3307
@ -2,6 +2,7 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- County Block List in Site Settings
|
||||||
- Query the `views_per_visit` metric based on imported data as well if possible
|
- Query the `views_per_visit` metric based on imported data as well if possible
|
||||||
- Group `operating_system_versions` by `operating_system` in Stats API breakdown
|
- Group `operating_system_versions` by `operating_system` in Stats API breakdown
|
||||||
- Add `operating_system_versions.csv` into the CSV export
|
- Add `operating_system_versions.csv` into the CSV export
|
||||||
|
@ -18,8 +18,10 @@ export default (id) => ({
|
|||||||
this.selectionInProgress = false;
|
this.selectionInProgress = false;
|
||||||
},
|
},
|
||||||
open() {
|
open() {
|
||||||
this.initFocus()
|
if (!this.isOpen) {
|
||||||
this.isOpen = true
|
this.initFocus()
|
||||||
|
this.isOpen = true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
suggestionsCount() {
|
suggestionsCount() {
|
||||||
return this.$refs.suggestions?.querySelectorAll('li').length
|
return this.$refs.suggestions?.querySelectorAll('li').length
|
||||||
@ -65,7 +67,7 @@ export default (id) => ({
|
|||||||
focusNext() {
|
focusNext() {
|
||||||
const nextIndex = this.nextFocusableIndex()
|
const nextIndex = this.nextFocusableIndex()
|
||||||
|
|
||||||
if (!this.isOpen) this.open()
|
this.open()
|
||||||
|
|
||||||
this.setFocus(nextIndex)
|
this.setFocus(nextIndex)
|
||||||
this.scrollTo(nextIndex)
|
this.scrollTo(nextIndex)
|
||||||
@ -73,7 +75,7 @@ export default (id) => ({
|
|||||||
focusPrev() {
|
focusPrev() {
|
||||||
const prevIndex = this.prevFocusableIndex()
|
const prevIndex = this.prevFocusableIndex()
|
||||||
|
|
||||||
if (!this.isOpen) this.open()
|
this.open()
|
||||||
|
|
||||||
this.setFocus(prevIndex)
|
this.setFocus(prevIndex)
|
||||||
this.scrollTo(prevIndex)
|
this.scrollTo(prevIndex)
|
||||||
|
@ -59,6 +59,21 @@ defmodule Plausible.Application do
|
|||||||
interval: :timer.seconds(35),
|
interval: :timer.seconds(35),
|
||||||
warmer_fn: :refresh_updated_recently
|
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()},
|
{Plausible.Auth.TOTP.Vault, key: totp_vault_key()},
|
||||||
PlausibleWeb.Endpoint,
|
PlausibleWeb.Endpoint,
|
||||||
{Oban, Application.get_env(:plausible, Oban)},
|
{Oban, Application.get_env(:plausible, Oban)},
|
||||||
|
@ -27,6 +27,7 @@ defmodule Plausible.Ingestion.Event do
|
|||||||
| :invalid
|
| :invalid
|
||||||
| :dc_ip
|
| :dc_ip
|
||||||
| :site_ip_blocklist
|
| :site_ip_blocklist
|
||||||
|
| :site_country_blocklist
|
||||||
|
|
||||||
@type t() :: %__MODULE__{
|
@type t() :: %__MODULE__{
|
||||||
domain: String.t() | nil,
|
domain: String.t() | nil,
|
||||||
@ -97,11 +98,12 @@ defmodule Plausible.Ingestion.Event do
|
|||||||
[
|
[
|
||||||
&drop_datacenter_ip/1,
|
&drop_datacenter_ip/1,
|
||||||
&drop_shield_rule_ip/1,
|
&drop_shield_rule_ip/1,
|
||||||
|
&put_geolocation/1,
|
||||||
|
&drop_shield_rule_country/1,
|
||||||
&put_user_agent/1,
|
&put_user_agent/1,
|
||||||
&put_basic_info/1,
|
&put_basic_info/1,
|
||||||
&put_referrer/1,
|
&put_referrer/1,
|
||||||
&put_utm_tags/1,
|
&put_utm_tags/1,
|
||||||
&put_geolocation/1,
|
|
||||||
&put_props/1,
|
&put_props/1,
|
||||||
&put_revenue/1,
|
&put_revenue/1,
|
||||||
&put_salts/1,
|
&put_salts/1,
|
||||||
@ -232,6 +234,21 @@ defmodule Plausible.Ingestion.Event do
|
|||||||
end
|
end
|
||||||
end
|
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)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
event
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp drop_shield_rule_country(%__MODULE__{} = event), do: event
|
||||||
|
|
||||||
defp put_props(%__MODULE__{request: %{props: %{} = props}} = event) do
|
defp put_props(%__MODULE__{request: %{props: %{} = props}} = event) do
|
||||||
# defensive: ensuring the keys/values are always in the same order
|
# defensive: ensuring the keys/values are always in the same order
|
||||||
{keys, values} = Enum.unzip(props)
|
{keys, values} = Enum.unzip(props)
|
||||||
|
38
lib/plausible/shield/country_rule.ex
Normal file
38
lib/plausible/shield/country_rule.ex
Normal file
@ -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])
|
||||||
|
|> 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
lib/plausible/shield/country_rule_cache.ex
Normal file
65
lib/plausible/shield/country_rule_cache.ex
Normal file
@ -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
|
||||||
|
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
|
@ -22,7 +22,7 @@ defmodule Plausible.Shield.IPRule do
|
|||||||
|
|
||||||
def changeset(rule, attrs) do
|
def changeset(rule, attrs) do
|
||||||
rule
|
rule
|
||||||
|> cast(attrs, [:site_id, :inet, :description, :added_by])
|
|> cast(attrs, [:site_id, :inet, :description])
|
||||||
|> validate_required([:site_id, :inet])
|
|> validate_required([:site_id, :inet])
|
||||||
|> disallow_netmask(:inet)
|
|> disallow_netmask(:inet)
|
||||||
|> unique_constraint(:inet,
|
|> unique_constraint(:inet,
|
||||||
|
@ -5,38 +5,93 @@ defmodule Plausible.Shields do
|
|||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
alias Plausible.Repo
|
alias Plausible.Repo
|
||||||
alias Plausible.Shield
|
alias Plausible.Shield
|
||||||
|
alias Plausible.Site
|
||||||
|
|
||||||
@maximum_ip_rules 30
|
@maximum_ip_rules 30
|
||||||
def maximum_ip_rules(), do: @maximum_ip_rules
|
def maximum_ip_rules(), do: @maximum_ip_rules
|
||||||
|
|
||||||
@spec list_ip_rules(Plausible.Site.t() | non_neg_integer()) :: [Shield.IPRule.t()]
|
@maximum_country_rules 30
|
||||||
def list_ip_rules(site_id) when is_integer(site_id) do
|
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(), Keyword.t()) ::
|
||||||
|
{:ok, Shield.IPRule.t()} | {:error, Ecto.Changeset.t()}
|
||||||
|
def add_ip_rule(site_or_id, params, opts \\ []) do
|
||||||
|
opts =
|
||||||
|
Keyword.put(opts, :limit, {:inet, @maximum_ip_rules})
|
||||||
|
|
||||||
|
add(Shield.IPRule, site_or_id, params, opts)
|
||||||
|
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(), Keyword.t()) ::
|
||||||
|
{:ok, Shield.CountryRule.t()} | {:error, Ecto.Changeset.t()}
|
||||||
|
def add_country_rule(site_or_id, params, opts \\ []) do
|
||||||
|
opts = Keyword.put(opts, :limit, {:country_code, @maximum_country_rules})
|
||||||
|
add(Shield.CountryRule, site_or_id, params, opts)
|
||||||
|
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(
|
Repo.all(
|
||||||
from r in Shield.IPRule,
|
from r in schema,
|
||||||
where: r.site_id == ^site_id,
|
where: r.site_id == ^site_id,
|
||||||
order_by: [desc: r.inserted_at]
|
order_by: [desc: r.inserted_at]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def list_ip_rules(%Plausible.Site{id: id}) do
|
defp add(schema, %Site{id: id}, params, opts) do
|
||||||
list_ip_rules(id)
|
add(schema, id, params, opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec add_ip_rule(Plausible.Site.t() | non_neg_integer(), map()) ::
|
defp add(schema, site_id, params, opts) when is_integer(site_id) do
|
||||||
{:ok, Shield.IPRule.t()} | {:error, Ecto.Changeset.t()}
|
{field, max} = Keyword.fetch!(opts, :limit)
|
||||||
def add_ip_rule(site_id, params) when is_integer(site_id) do
|
|
||||||
Repo.transaction(fn ->
|
Repo.transaction(fn ->
|
||||||
result =
|
result =
|
||||||
if count_ip_rules(site_id) >= @maximum_ip_rules do
|
if count(schema, site_id) >= max do
|
||||||
changeset =
|
changeset =
|
||||||
%Shield.IPRule{}
|
schema
|
||||||
|> Shield.IPRule.changeset(Map.put(params, "site_id", site_id))
|
|> struct(site_id: site_id)
|
||||||
|> Ecto.Changeset.add_error(:inet, "maximum reached")
|
|> schema.changeset(params)
|
||||||
|
|> Ecto.Changeset.add_error(field, "maximum reached")
|
||||||
|
|
||||||
{:error, changeset}
|
{:error, changeset}
|
||||||
else
|
else
|
||||||
%Shield.IPRule{}
|
schema
|
||||||
|> Shield.IPRule.changeset(Map.put(params, "site_id", site_id))
|
|> struct(site_id: site_id, added_by: format_added_by(opts[:added_by]))
|
||||||
|
|> schema.changeset(params)
|
||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -47,26 +102,23 @@ defmodule Plausible.Shields do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
def add_ip_rule(%Plausible.Site{id: id}, params) do
|
defp remove(schema, %Site{id: id}, rule_id) do
|
||||||
add_ip_rule(id, params)
|
remove(schema, id, rule_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec remove_ip_rule(Plausible.Site.t() | non_neg_integer(), String.t()) :: :ok
|
defp remove(schema, site_id, rule_id) when is_integer(site_id) do
|
||||||
def remove_ip_rule(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))
|
||||||
Repo.delete_all(from(r in Shield.IPRule, where: r.site_id == ^site_id and r.id == ^rule_id))
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
def remove_ip_rule(%Plausible.Site{id: site_id}, rule_id) do
|
defp count(schema, %Site{id: id}) do
|
||||||
remove_ip_rule(site_id, rule_id)
|
count(schema, id)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec count_ip_rules(Plausible.Site.t() | non_neg_integer()) :: non_neg_integer()
|
defp count(schema, site_id) when is_integer(site_id) do
|
||||||
def count_ip_rules(site_id) when is_integer(site_id) do
|
Repo.aggregate(from(r in schema, where: r.site_id == ^site_id), :count)
|
||||||
Repo.aggregate(from(r in Shield.IPRule, where: r.site_id == ^site_id), :count)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def count_ip_rules(%Plausible.Site{id: id}) do
|
defp format_added_by(nil), do: ""
|
||||||
count_ip_rules(id)
|
defp format_added_by(%Plausible.Auth.User{} = user), do: "#{user.name} <#{user.email}>"
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
@ -34,14 +34,20 @@ defmodule Plausible.Stats.Breakdown do
|
|||||||
|
|
||||||
if !Keyword.get(opts, :skip_tracing), do: trace(query, property, metrics)
|
if !Keyword.get(opts, :skip_tracing), do: trace(query, property, metrics)
|
||||||
|
|
||||||
{revenue_goals, metrics} =
|
no_revenue = {nil, metrics -- @revenue_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
|
|
||||||
|
|
||||||
{revenue_goals, metrics}
|
{revenue_goals, metrics} =
|
||||||
|
on_full_build do
|
||||||
|
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}
|
||||||
|
else
|
||||||
|
no_revenue
|
||||||
|
end
|
||||||
else
|
else
|
||||||
{nil, metrics -- @revenue_metrics}
|
no_revenue
|
||||||
end
|
end
|
||||||
|
|
||||||
metrics_to_select = Util.maybe_add_visitors_metric(metrics) -- @computed_metrics
|
metrics_to_select = Util.maybe_add_visitors_metric(metrics) -- @computed_metrics
|
||||||
|
@ -22,25 +22,13 @@ defmodule Plausible.Timezones do
|
|||||||
|
|
||||||
@spec to_date_in_timezone(Date.t() | NaiveDateTime.t() | DateTime.t(), String.t()) :: Date.t()
|
@spec to_date_in_timezone(Date.t() | NaiveDateTime.t() | DateTime.t(), String.t()) :: Date.t()
|
||||||
def to_date_in_timezone(dt, timezone) do
|
def to_date_in_timezone(dt, timezone) do
|
||||||
utc_dt =
|
to_datetime_in_timezone(dt, timezone) |> Timex.to_date()
|
||||||
case dt do
|
end
|
||||||
%Date{} ->
|
|
||||||
Timex.to_datetime(dt, "UTC")
|
|
||||||
|
|
||||||
dt ->
|
@spec to_datetime_in_timezone(Date.t() | NaiveDateTime.t() | DateTime.t(), String.t()) ::
|
||||||
Timex.Timezone.convert(dt, "UTC")
|
DateTime.t()
|
||||||
end
|
def to_datetime_in_timezone(dt, timezone) do
|
||||||
|
dt |> Timex.to_datetime("UTC") |> Timex.Timezone.convert(timezone)
|
||||||
case Timex.Timezone.convert(utc_dt, timezone) do
|
|
||||||
%DateTime{} = tz_dt ->
|
|
||||||
Timex.to_date(tz_dt)
|
|
||||||
|
|
||||||
%Timex.AmbiguousDateTime{after: after_dt} ->
|
|
||||||
Timex.to_date(after_dt)
|
|
||||||
|
|
||||||
{:error, {:could_not_resolve_timezone, _, _, _}} ->
|
|
||||||
dt
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_option(timezone_code, acc, now) do
|
defp build_option(timezone_code, acc, now) do
|
||||||
|
@ -253,13 +253,15 @@ defmodule PlausibleWeb.SiteController do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def settings_shields(conn, _params) do
|
def settings_shields(conn, %{"shield" => shield})
|
||||||
|
when shield in ["ip_addresses", "countries"] do
|
||||||
site = conn.assigns.site
|
site = conn.assigns.site
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> render("settings_shields.html",
|
|> render("settings_shields.html",
|
||||||
site: site,
|
site: site,
|
||||||
dogfood_page_path: "/:dashboard/settings/shields",
|
shield: shield,
|
||||||
|
dogfood_page_path: "/:dashboard/settings/shields/#{shield}",
|
||||||
connect_live_socket: true,
|
connect_live_socket: true,
|
||||||
layout: {PlausibleWeb.LayoutView, "site_settings.html"}
|
layout: {PlausibleWeb.LayoutView, "site_settings.html"}
|
||||||
)
|
)
|
||||||
|
@ -22,7 +22,7 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
|
|||||||
and updates the suggestions asynchronously. This way, you can render
|
and updates the suggestions asynchronously. This way, you can render
|
||||||
the component without having to wait for suggestions to load.
|
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.
|
pass `async={false}` option.
|
||||||
|
|
||||||
If your initial `options` are not provided up-front at initial render,
|
If your initial `options` are not provided up-front at initial render,
|
||||||
@ -101,6 +101,7 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
|
|||||||
placeholder={@placeholder}
|
placeholder={@placeholder}
|
||||||
x-on:focus="open"
|
x-on:focus="open"
|
||||||
phx-change="search"
|
phx-change="search"
|
||||||
|
x-on:keydown="open"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
phx-debounce={200}
|
phx-debounce={200}
|
||||||
value={@display_value}
|
value={@display_value}
|
||||||
|
@ -134,7 +134,7 @@ defmodule PlausibleWeb.Live.Components.Modal do
|
|||||||
~H"""
|
~H"""
|
||||||
<div
|
<div
|
||||||
id={@id}
|
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
|
data-modal
|
||||||
x-cloak
|
x-cloak
|
||||||
x-data="{
|
x-data="{
|
||||||
|
53
lib/plausible_web/live/shields/countries.ex
Normal file
53
lib/plausible_web/live/shields/countries.ex
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
defmodule PlausibleWeb.Live.Shields.Countries do
|
||||||
|
@moduledoc """
|
||||||
|
LiveView for IP Addresses 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(:country_rules_count, fn %{site: site} ->
|
||||||
|
Shields.count_country_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"""
|
||||||
|
<div>
|
||||||
|
<.flash_messages flash={@flash} />
|
||||||
|
<.live_component
|
||||||
|
module={PlausibleWeb.Live.Shields.CountryRules}
|
||||||
|
current_user={@current_user}
|
||||||
|
country_rules_count={@country_rules_count}
|
||||||
|
site={@site}
|
||||||
|
id="country-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
|
251
lib/plausible_web/live/shields/country_rules.ex
Normal file
251
lib/plausible_web/live/shields/country_rules.ex
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
defmodule PlausibleWeb.Live.Shields.CountryRules do
|
||||||
|
@moduledoc """
|
||||||
|
LiveView allowing Country Rules management
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Phoenix.LiveComponent, global_prefixes: ~w(x-)
|
||||||
|
use Phoenix.HTML
|
||||||
|
|
||||||
|
alias PlausibleWeb.Live.Components.Modal
|
||||||
|
alias Plausible.Shields
|
||||||
|
alias Plausible.Shield
|
||||||
|
|
||||||
|
def update(assigns, socket) do
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(
|
||||||
|
country_rules_count: assigns.country_rules_count,
|
||||||
|
site: assigns.site,
|
||||||
|
current_user: assigns.current_user,
|
||||||
|
form: new_form()
|
||||||
|
)
|
||||||
|
|> assign_new(:country_rules, fn %{site: site} ->
|
||||||
|
Shields.list_country_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">
|
||||||
|
Country 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 countries
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<PlausibleWeb.Components.Generic.docs_info slug="countries" />
|
||||||
|
</header>
|
||||||
|
<div class="border-t border-gray-200 pt-4 grid">
|
||||||
|
<div
|
||||||
|
:if={@country_rules_count < Shields.maximum_country_rules()}
|
||||||
|
class="mt-4 sm:ml-4 sm:mt-0 justify-self-end"
|
||||||
|
>
|
||||||
|
<PlausibleWeb.Components.Generic.button
|
||||||
|
id="add-country-rule"
|
||||||
|
x-data
|
||||||
|
x-on:click={Modal.JS.open("country-rule-form-modal")}
|
||||||
|
>
|
||||||
|
+ Add Country
|
||||||
|
</PlausibleWeb.Components.Generic.button>
|
||||||
|
</div>
|
||||||
|
<PlausibleWeb.Components.Generic.notice
|
||||||
|
:if={@country_rules_count >= Shields.maximum_country_rules()}
|
||||||
|
class="mt-4"
|
||||||
|
title="Maximum number of countries reached"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
You've reached the maximum number of countries you can block (<%= Shields.maximum_country_rules() %>). Please remove one before adding another.
|
||||||
|
</p>
|
||||||
|
</PlausibleWeb.Components.Generic.notice>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.live_component module={Modal} id="country-rule-form-modal">
|
||||||
|
<.form
|
||||||
|
:let={f}
|
||||||
|
for={@form}
|
||||||
|
phx-submit="save-country-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 Country to Block List</h2>
|
||||||
|
|
||||||
|
<.live_component
|
||||||
|
submit_name="country_rule[country_code]"
|
||||||
|
submit_value={f[:country_code].value}
|
||||||
|
display_value=""
|
||||||
|
module={PlausibleWeb.Live.Components.ComboBox}
|
||||||
|
suggest_fun={&PlausibleWeb.Live.Components.ComboBox.StaticSearch.suggest/2}
|
||||||
|
id={f[:country_code].id}
|
||||||
|
suggestions_limit={300}
|
||||||
|
options={options(@country_rules)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="text-sm mt-2 text-gray-500 dark:text-gray-200">
|
||||||
|
Once added, we will start rejecting traffic from this country within a few minutes.
|
||||||
|
</p>
|
||||||
|
<div class="py-4 mt-8">
|
||||||
|
<PlausibleWeb.Components.Generic.button type="submit" class="w-full">
|
||||||
|
Add Country →
|
||||||
|
</PlausibleWeb.Components.Generic.button>
|
||||||
|
</div>
|
||||||
|
</.form>
|
||||||
|
</.live_component>
|
||||||
|
|
||||||
|
<p
|
||||||
|
:if={Enum.empty?(@country_rules)}
|
||||||
|
class="text-sm text-gray-800 dark:text-gray-200 mt-12 mb-8 text-center"
|
||||||
|
>
|
||||||
|
No Country Rules configured for this Site.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
:if={not Enum.empty?(@country_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"
|
||||||
|
>
|
||||||
|
Country
|
||||||
|
</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">
|
||||||
|
<span class="sr-only">Remove</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<%= for rule <- @country_rules do %>
|
||||||
|
<% country = Location.Country.get_country(rule.country_code) %>
|
||||||
|
<tr class="text-gray-900 dark:text-gray-100">
|
||||||
|
<td class="px-6 py-4 text-sm font-medium">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span
|
||||||
|
id={"country-#{rule.id}"}
|
||||||
|
class="mr-4 cursor-help border-b border-dotted border-gray-400"
|
||||||
|
title={"Added at #{format_added_at(rule.inserted_at, @site.timezone)} by #{rule.added_by}"}
|
||||||
|
>
|
||||||
|
<%= country.flag %> <%= country.name %>
|
||||||
|
</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-medium text-right">
|
||||||
|
<button
|
||||||
|
id={"remove-country-rule-#{rule.id}"}
|
||||||
|
phx-target={@myself}
|
||||||
|
phx-click="remove-country-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("save-country-rule", %{"country_rule" => params}, socket) do
|
||||||
|
user = socket.assigns.current_user
|
||||||
|
|
||||||
|
case Shields.add_country_rule(
|
||||||
|
socket.assigns.site.id,
|
||||||
|
Map.put(params, "added_by", "#{user.name} <#{user.email}>")
|
||||||
|
) do
|
||||||
|
{:ok, rule} ->
|
||||||
|
country_rules = [rule | socket.assigns.country_rules]
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> Modal.close("country-rule-form-modal")
|
||||||
|
|> assign(
|
||||||
|
form: new_form(),
|
||||||
|
country_rules: country_rules,
|
||||||
|
country_rules_count: socket.assigns.country_rules_count + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
# Make sure to clear the combobox input after adding a country rule, on subsequent modal reopening
|
||||||
|
send_update(PlausibleWeb.Live.Components.ComboBox,
|
||||||
|
id: "country_rule_country_code",
|
||||||
|
display_value: ""
|
||||||
|
)
|
||||||
|
|
||||||
|
send_flash(
|
||||||
|
:success,
|
||||||
|
"Country 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-country-rule", %{"rule-id" => rule_id}, socket) do
|
||||||
|
Shields.remove_country_rule(socket.assigns.site.id, rule_id)
|
||||||
|
|
||||||
|
send_flash(
|
||||||
|
:success,
|
||||||
|
"Country rule removed successfully. Traffic will be resumed within a few minutes."
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(
|
||||||
|
country_rules_count: socket.assigns.country_rules_count - 1,
|
||||||
|
country_rules: Enum.reject(socket.assigns.country_rules, &(&1.id == rule_id))
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_flash(kind, message) do
|
||||||
|
send(self(), {:flash, kind, message})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp new_form() do
|
||||||
|
%Shield.CountryRule{}
|
||||||
|
|> Shield.CountryRule.changeset(%{})
|
||||||
|
|> to_form()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp options(country_rules) do
|
||||||
|
Location.Country.all()
|
||||||
|
|> Enum.sort_by(& &1.name)
|
||||||
|
|> Enum.map(fn c -> {c.alpha_2, c.flag <> " " <> c.name} end)
|
||||||
|
|> Enum.reject(fn {country_code, _} ->
|
||||||
|
country_code in Enum.map(country_rules, & &1.country_code)
|
||||||
|
end)
|
||||||
|
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
|
||||||
|
end
|
@ -1,8 +1,6 @@
|
|||||||
defmodule PlausibleWeb.Live.Shields.Tabs do
|
defmodule PlausibleWeb.Live.Shields.IPAddresses do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Currently only a placeholder module. Once more shields
|
LiveView for IP Addresses Shield
|
||||||
are implemented it will display tabs with counters,
|
|
||||||
linking to their respective live views.
|
|
||||||
"""
|
"""
|
||||||
use PlausibleWeb, :live_view
|
use PlausibleWeb, :live_view
|
||||||
use Phoenix.HTML
|
use Phoenix.HTML
|
||||||
@ -31,7 +29,6 @@ defmodule PlausibleWeb.Live.Shields.Tabs do
|
|||||||
Plausible.Repo.get(Plausible.Auth.User, user_id)
|
Plausible.Repo.get(Plausible.Auth.User, user_id)
|
||||||
end)
|
end)
|
||||||
|> assign_new(:remote_ip, fn -> remote_ip end)
|
|> assign_new(:remote_ip, fn -> remote_ip end)
|
||||||
|> assign(:current_tab, :ip_rules)
|
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
end
|
end
|
@ -62,7 +62,7 @@ defmodule PlausibleWeb.Live.Shields.IPRules do
|
|||||||
title="Maximum number of addresses reached"
|
title="Maximum number of addresses reached"
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
You've reached the maximum number of IP addresses you can block. Please remove one before adding another.
|
You've reached the maximum number of IP addresses you can block (<%= Shields.maximum_ip_rules() %>). Please remove one before adding another.
|
||||||
</p>
|
</p>
|
||||||
</PlausibleWeb.Components.Generic.notice>
|
</PlausibleWeb.Components.Generic.notice>
|
||||||
</div>
|
</div>
|
||||||
@ -144,7 +144,7 @@ defmodule PlausibleWeb.Live.Shields.IPRules do
|
|||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-6 py-3 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-100"
|
class="px-6 py-3 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-100 md:block hidden"
|
||||||
>
|
>
|
||||||
Description
|
Description
|
||||||
</th>
|
</th>
|
||||||
@ -161,7 +161,7 @@ defmodule PlausibleWeb.Live.Shields.IPRules do
|
|||||||
<span
|
<span
|
||||||
id={"inet-#{rule.id}"}
|
id={"inet-#{rule.id}"}
|
||||||
class="font-mono mr-4 cursor-help border-b border-dotted border-gray-400"
|
class="font-mono mr-4 cursor-help border-b border-dotted border-gray-400"
|
||||||
title={"Added at #{rule.updated_at} by #{rule.added_by}"}
|
title={"Added at #{format_added_at(rule.inserted_at, @site.timezone)} by #{rule.added_by}"}
|
||||||
>
|
>
|
||||||
<%= rule.inet %>
|
<%= rule.inet %>
|
||||||
</span>
|
</span>
|
||||||
@ -185,7 +185,7 @@ defmodule PlausibleWeb.Live.Shields.IPRules do
|
|||||||
Allowed
|
Allowed
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm font-normal whitespace-nowrap truncate max-w-xs">
|
<td class="px-6 py-4 text-sm font-normal whitespace-nowrap truncate max-w-xs md:block hidden">
|
||||||
<span :if={rule.description} title={rule.description}>
|
<span :if={rule.description} title={rule.description}>
|
||||||
<%= rule.description %>
|
<%= rule.description %>
|
||||||
</span>
|
</span>
|
||||||
@ -232,7 +232,8 @@ defmodule PlausibleWeb.Live.Shields.IPRules do
|
|||||||
|
|
||||||
case Shields.add_ip_rule(
|
case Shields.add_ip_rule(
|
||||||
socket.assigns.site.id,
|
socket.assigns.site.id,
|
||||||
Map.put(params, "added_by", "#{user.name} <#{user.email}>")
|
params,
|
||||||
|
added_by: user
|
||||||
) do
|
) do
|
||||||
{:ok, rule} ->
|
{:ok, rule} ->
|
||||||
socket =
|
socket =
|
||||||
@ -285,4 +286,10 @@ defmodule PlausibleWeb.Live.Shields.IPRules do
|
|||||||
defp ip_rule_present?(rules, ip) do
|
defp ip_rule_present?(rules, ip) do
|
||||||
not is_nil(Enum.find(rules, &(to_string(&1.inet) == ip)))
|
not is_nil(Enum.find(rules, &(to_string(&1.inet) == ip)))
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
@ -359,7 +359,7 @@ defmodule PlausibleWeb.Router do
|
|||||||
get "/:website/settings/email-reports", SiteController, :settings_email_reports
|
get "/:website/settings/email-reports", SiteController, :settings_email_reports
|
||||||
get "/:website/settings/danger-zone", SiteController, :settings_danger_zone
|
get "/:website/settings/danger-zone", SiteController, :settings_danger_zone
|
||||||
get "/:website/settings/integrations", SiteController, :settings_integrations
|
get "/:website/settings/integrations", SiteController, :settings_integrations
|
||||||
get "/:website/settings/shields", SiteController, :settings_shields
|
get "/:website/settings/shields/:shield", SiteController, :settings_shields
|
||||||
|
|
||||||
put "/:website/settings/features/visibility/:setting",
|
put "/:website/settings/features/visibility/:setting",
|
||||||
SiteController,
|
SiteController,
|
||||||
|
@ -1,13 +1,23 @@
|
|||||||
<a
|
<a
|
||||||
href={"/" <> URI.encode_www_form(@site.domain) <> "/settings/" <> @this_tab}
|
href={@this_tab && "/" <> URI.encode_www_form(@site.domain) <> "/settings/" <> @this_tab}
|
||||||
class={[
|
class={[
|
||||||
"flex items-center px-3 py-2 text-sm leading-5 font-medium rounded-md outline-none focus:outline-none transition ease-in-out duration-150 cursor-default",
|
"flex items-center px-3 py-2 text-sm leading-5 font-medium rounded-md outline-none focus:outline-none transition ease-in-out duration-150 cursor-default",
|
||||||
is_current_tab(@conn, @this_tab) &&
|
is_current_tab(@conn, @this_tab) &&
|
||||||
"cursor-default text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-900 hover:text-gray-900 hover:bg-gray-100 focus:bg-gray-200 dark:focus:bg-gray-800",
|
"cursor-default text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-900 hover:text-gray-900 hover:bg-gray-100 focus:bg-gray-200 dark:focus:bg-gray-800",
|
||||||
not is_current_tab(@conn, @this_tab) &&
|
@this_tab && not is_current_tab(@conn, @this_tab) &&
|
||||||
"cursor-pointer text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800 focus:text-gray-900 focus:bg-gray-50 dark:focus:text-gray-100 dark:focus:bg-gray-800"
|
"cursor-pointer text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800 focus:text-gray-900 focus:bg-gray-50 dark:focus:text-gray-100 dark:focus:bg-gray-800",
|
||||||
|
!@this_tab && "text-gray-600 dark:text-gray-400",
|
||||||
|
@submenu? && "text-xs"
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<PlausibleWeb.Components.Generic.dynamic_icon name={@icon} class="h-4 w-4 mr-2" />
|
<PlausibleWeb.Components.Generic.dynamic_icon
|
||||||
|
:if={not @submenu? && @icon}
|
||||||
|
name={@icon}
|
||||||
|
class="h-4 w-4 mr-2"
|
||||||
|
/>
|
||||||
<%= @text %>
|
<%= @text %>
|
||||||
|
<Heroicons.chevron_down
|
||||||
|
:if={is_nil(@this_tab)}
|
||||||
|
class="h-3 w-3 ml-2 text-gray-400 dark:text-gray-500"
|
||||||
|
/>
|
||||||
</a>
|
</a>
|
||||||
|
@ -1,26 +0,0 @@
|
|||||||
<%= render_layout "app.html", assigns do %>
|
|
||||||
<div class="container pt-6">
|
|
||||||
<%= link("← Back to Stats", to: "/#{URI.encode_www_form(@site.domain)}", class: "text-sm text-indigo-600 font-bold") %>
|
|
||||||
<div class="pb-5 border-b border-gray-200 dark:border-gray-500">
|
|
||||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 dark:text-gray-100 sm:text-3xl sm:leading-9 sm:truncate">
|
|
||||||
Settings for <%= @site.domain %>
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div class="lg:grid lg:grid-cols-12 lg:gap-x-5 lg:mt-4">
|
|
||||||
<div class="py-4 g:py-0 lg:col-span-3">
|
|
||||||
<%= form_for @conn, "/sites/#{URI.encode_www_form(@site.domain)}/monthly-report/recipients", [class: "lg:hidden"], fn f -> %>
|
|
||||||
<%= select f, :tab, settings_tabs(@conn), class: "dark:bg-gray-800 mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 dark:border-gray-500 outline-none focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md dark:text-gray-100", onchange: "location.href = location.href.replace(/[^\/\/]*$/, event.target.value)", selected: List.last(@conn.path_info) %>
|
|
||||||
<% end %>
|
|
||||||
<div class="hidden lg:block">
|
|
||||||
<%= for [key: key, value: val, icon: icon] <- settings_tabs(@conn) do %>
|
|
||||||
<%= render("_settings_tab.html", icon: icon, this_tab: val, text: key, site: @site, conn: @conn) %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-6 lg:col-span-9 lg:mt-4">
|
|
||||||
<%= @inner_content %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
64
lib/plausible_web/templates/layout/site_settings.html.heex
Normal file
64
lib/plausible_web/templates/layout/site_settings.html.heex
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<%= render_layout "app.html", assigns do %>
|
||||||
|
<div class="container pt-6">
|
||||||
|
<%= link("← Back to Stats",
|
||||||
|
to: "/#{URI.encode_www_form(@site.domain)}",
|
||||||
|
class: "text-sm text-indigo-600 font-bold"
|
||||||
|
) %>
|
||||||
|
<div class="pb-5 border-b border-gray-200 dark:border-gray-500">
|
||||||
|
<h2 class="text-2xl font-bold leading-7 text-gray-900 dark:text-gray-100 sm:text-3xl sm:leading-9 sm:truncate">
|
||||||
|
Settings for <%= @site.domain %>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="lg:grid lg:grid-cols-12 lg:gap-x-5 lg:mt-4">
|
||||||
|
<div class="py-4 g:py-0 lg:col-span-3">
|
||||||
|
<%= form_for @conn, "/sites/#{URI.encode_www_form(@site.domain)}/monthly-report/recipients", [class: "lg:hidden"], fn f -> %>
|
||||||
|
<%= select(f, :tab, flat_settings_options(@conn),
|
||||||
|
class:
|
||||||
|
"dark:bg-gray-800 mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 dark:border-gray-500 outline-none focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md dark:text-gray-100",
|
||||||
|
onchange: "location.href = location.href.replace(/[^\/\/]*$/, event.target.value)",
|
||||||
|
selected: List.last(@conn.path_info)
|
||||||
|
) %>
|
||||||
|
<% end %>
|
||||||
|
<div class="hidden lg:block">
|
||||||
|
<%= for %{key: key, value: value, icon: icon} <- settings_tabs(@conn) do %>
|
||||||
|
<%= if is_binary(value) do %>
|
||||||
|
<%= render("_settings_tab.html",
|
||||||
|
icon: icon,
|
||||||
|
this_tab: value,
|
||||||
|
text: key,
|
||||||
|
site: @site,
|
||||||
|
conn: @conn,
|
||||||
|
submenu?: false
|
||||||
|
) %>
|
||||||
|
<% else %>
|
||||||
|
<%= render("_settings_tab.html",
|
||||||
|
icon: icon,
|
||||||
|
this_tab: nil,
|
||||||
|
text: key,
|
||||||
|
site: @site,
|
||||||
|
conn: @conn,
|
||||||
|
submenu?: false
|
||||||
|
) %>
|
||||||
|
<div class="ml-8">
|
||||||
|
<%= for %{key: key, value: val} <- value do %>
|
||||||
|
<%= render("_settings_tab.html",
|
||||||
|
icon: nil,
|
||||||
|
this_tab: val,
|
||||||
|
text: key,
|
||||||
|
site: @site,
|
||||||
|
conn: @conn,
|
||||||
|
submenu?: true
|
||||||
|
) %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6 lg:col-span-9 lg:mt-4">
|
||||||
|
<%= @inner_content %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
@ -1,7 +1,17 @@
|
|||||||
<%= live_render(@conn, PlausibleWeb.Live.Shields.Tabs,
|
<%= case @shield do %>
|
||||||
session: %{
|
<% "ip_addresses" -> %>
|
||||||
"site_id" => @site.id,
|
<%= live_render(@conn, PlausibleWeb.Live.Shields.IPAddresses,
|
||||||
"domain" => @site.domain,
|
session: %{
|
||||||
"remote_ip" => PlausibleWeb.RemoteIP.get(@conn)
|
"site_id" => @site.id,
|
||||||
}
|
"domain" => @site.domain,
|
||||||
) %>
|
"remote_ip" => PlausibleWeb.RemoteIP.get(@conn)
|
||||||
|
}
|
||||||
|
) %>
|
||||||
|
<% "countries" -> %>
|
||||||
|
<%= live_render(@conn, PlausibleWeb.Live.Shields.Countries,
|
||||||
|
session: %{
|
||||||
|
"site_id" => @site.id,
|
||||||
|
"domain" => @site.domain
|
||||||
|
}
|
||||||
|
) %>
|
||||||
|
<% end %>
|
||||||
|
@ -50,23 +50,44 @@ defmodule PlausibleWeb.LayoutView do
|
|||||||
|
|
||||||
def settings_tabs(conn) do
|
def settings_tabs(conn) do
|
||||||
[
|
[
|
||||||
[key: "General", value: "general", icon: :rocket_launch],
|
%{key: "General", value: "general", icon: :rocket_launch},
|
||||||
[key: "People", value: "people", icon: :users],
|
%{key: "People", value: "people", icon: :users},
|
||||||
[key: "Visibility", value: "visibility", icon: :eye],
|
%{key: "Visibility", value: "visibility", icon: :eye},
|
||||||
[key: "Goals", value: "goals", icon: :check_circle],
|
%{key: "Goals", value: "goals", icon: :check_circle},
|
||||||
on_full_build do
|
on_full_build do
|
||||||
[key: "Funnels", value: "funnels", icon: :funnel]
|
%{key: "Funnels", value: "funnels", icon: :funnel}
|
||||||
end,
|
end,
|
||||||
[key: "Custom Properties", value: "properties", icon: :document_text],
|
%{key: "Custom Properties", value: "properties", icon: :document_text},
|
||||||
[key: "Integrations", value: "integrations", icon: :arrow_path_rounded_square],
|
%{key: "Integrations", value: "integrations", icon: :arrow_path_rounded_square},
|
||||||
if FunWithFlags.enabled?(:shields, for: conn.assigns[:current_user]) do
|
%{
|
||||||
[key: "Shields", value: "shields", icon: :shield_exclamation]
|
key: "Shields",
|
||||||
end,
|
icon: :shield_exclamation,
|
||||||
[key: "Email Reports", value: "email-reports", icon: :envelope],
|
value: [
|
||||||
|
%{key: "IP Addresses", value: "shields/ip_addresses"},
|
||||||
|
%{key: "Countries", value: "shields/countries"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
%{key: "Email Reports", value: "email-reports", icon: :envelope},
|
||||||
if conn.assigns[:current_user_role] == :owner do
|
if conn.assigns[:current_user_role] == :owner do
|
||||||
[key: "Danger Zone", value: "danger-zone", icon: :exclamation_triangle]
|
%{key: "Danger Zone", value: "danger-zone", icon: :exclamation_triangle}
|
||||||
end
|
end
|
||||||
]
|
]
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
def flat_settings_options(conn) do
|
||||||
|
conn
|
||||||
|
|> settings_tabs()
|
||||||
|
|> Enum.map(fn
|
||||||
|
%{value: value, key: key} when is_binary(value) ->
|
||||||
|
{key, value}
|
||||||
|
|
||||||
|
%{value: submenu_items, key: parent_key} when is_list(submenu_items) ->
|
||||||
|
Enum.map(submenu_items, fn submenu_item ->
|
||||||
|
{"#{parent_key}: #{submenu_item.key}", submenu_item.value}
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|> List.flatten()
|
||||||
end
|
end
|
||||||
|
|
||||||
def trial_notificaton(user) do
|
def trial_notificaton(user) do
|
||||||
@ -97,7 +118,11 @@ defmodule PlausibleWeb.LayoutView do
|
|||||||
render(layout, Map.put(assigns, :inner_layout, content))
|
render(layout, Map.put(assigns, :inner_layout, content))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def is_current_tab(_, nil) do
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
def is_current_tab(conn, tab) do
|
def is_current_tab(conn, tab) do
|
||||||
List.last(conn.path_info) == tab
|
String.ends_with?(Enum.join(conn.path_info, "/"), tab)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -50,6 +50,8 @@ site =
|
|||||||
)
|
)
|
||||||
|
|
||||||
Plausible.Factory.insert_list(29, :ip_rule, site: site)
|
Plausible.Factory.insert_list(29, :ip_rule, site: site)
|
||||||
|
Plausible.Factory.insert(:country_rule, site: site, country_code: "PL")
|
||||||
|
Plausible.Factory.insert(:country_rule, site: site, country_code: "EE")
|
||||||
|
|
||||||
Plausible.Factory.insert(:google_auth,
|
Plausible.Factory.insert(:google_auth,
|
||||||
user: user,
|
user: user,
|
||||||
@ -82,7 +84,13 @@ put_random_time = fn
|
|||||||
date, 0 ->
|
date, 0 ->
|
||||||
current_hour = Time.utc_now().hour
|
current_hour = Time.utc_now().hour
|
||||||
current_minute = Time.utc_now().minute
|
current_minute = Time.utc_now().minute
|
||||||
random_time = Time.new!(:rand.uniform(current_hour), :rand.uniform(current_minute - 1), 0)
|
|
||||||
|
random_time =
|
||||||
|
Time.new!(
|
||||||
|
Enum.random(0..current_hour),
|
||||||
|
Enum.random(0..current_minute),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
date
|
date
|
||||||
|> NaiveDateTime.new!(random_time)
|
|> NaiveDateTime.new!(random_time)
|
||||||
|
@ -137,6 +137,27 @@ defmodule Plausible.Ingestion.EventTest do
|
|||||||
assert dropped.drop_reason == :site_ip_blocklist
|
assert dropped.drop_reason == :site_ip_blocklist
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "event pipeline drops a request when country 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: {216, 160, 83, 56}}
|
||||||
|
|
||||||
|
%{country_code: cc} = Plausible.Ingestion.Geolocation.lookup("216.160.83.56")
|
||||||
|
{:ok, _} = Plausible.Shields.add_country_rule(site, %{"country_code" => cc})
|
||||||
|
|
||||||
|
assert {:ok, request} = Request.build(conn)
|
||||||
|
|
||||||
|
assert {:ok, %{buffered: [], dropped: [dropped]}} = Event.build_and_buffer(request)
|
||||||
|
assert dropped.drop_reason == :site_country_blocklist
|
||||||
|
end
|
||||||
|
|
||||||
test "event pipeline drops events for site with accept_trafic_until in the past" do
|
test "event pipeline drops events for site with accept_trafic_until in the past" do
|
||||||
yesterday = Date.add(Date.utc_today(), -1)
|
yesterday = Date.add(Date.utc_today(), -1)
|
||||||
|
|
||||||
|
81
test/plausible/shield/country_rule_cache_test.exs
Normal file
81
test/plausible/shield/country_rule_cache_test.exs
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
defmodule Plausible.Shield.CountryRuleCacheTest do
|
||||||
|
use Plausible.DataCase, async: true
|
||||||
|
|
||||||
|
alias Plausible.Shield.CountryRule
|
||||||
|
alias Plausible.Shield.CountryRuleCache
|
||||||
|
alias Plausible.Shields
|
||||||
|
|
||||||
|
describe "public cache interface" do
|
||||||
|
test "cache caches country rules", %{test: test} do
|
||||||
|
{:ok, _} =
|
||||||
|
Supervisor.start_link(
|
||||||
|
[{CountryRuleCache, [cache_name: test, child_id: :country_rules_cache_id]}],
|
||||||
|
strategy: :one_for_one,
|
||||||
|
name: :"cache_supervisor_#{test}"
|
||||||
|
)
|
||||||
|
|
||||||
|
site = insert(:site, domain: "site1.example.com")
|
||||||
|
|
||||||
|
{:ok, %{id: rid1}} = Shields.add_country_rule(site, %{"country_code" => "EE"})
|
||||||
|
{:ok, %{id: rid2}} = Shields.add_country_rule(site, %{"country_code" => "PL"})
|
||||||
|
|
||||||
|
:ok = CountryRuleCache.refresh_all(cache_name: test)
|
||||||
|
|
||||||
|
:ok = Shields.remove_country_rule(site, rid1)
|
||||||
|
|
||||||
|
assert CountryRuleCache.size(test) == 2
|
||||||
|
|
||||||
|
assert %CountryRule{from_cache?: true, id: ^rid1} =
|
||||||
|
CountryRuleCache.get({site.domain, "EE"}, force?: true, cache_name: test)
|
||||||
|
|
||||||
|
assert %CountryRule{from_cache?: true, id: ^rid2} =
|
||||||
|
CountryRuleCache.get({site.domain, "PL"}, force?: true, cache_name: test)
|
||||||
|
|
||||||
|
refute CountryRuleCache.get({site.domain, "RO"}, cache_name: test, force?: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cache allows lookups for countries on 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_country_rule(site, %{"country_code" => "EE"})
|
||||||
|
:ok = CountryRuleCache.refresh_all(cache_name: test)
|
||||||
|
|
||||||
|
assert CountryRuleCache.get({"old.example.com", "EE"}, force?: true, cache_name: test)
|
||||||
|
assert CountryRuleCache.get({"new.example.com", "EE"}, force?: true, cache_name: test)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "refreshes only recently added country 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(:country_rule,
|
||||||
|
site: site,
|
||||||
|
inserted_at: yesterday,
|
||||||
|
updated_at: yesterday,
|
||||||
|
country_code: "EE"
|
||||||
|
)
|
||||||
|
|
||||||
|
insert(:country_rule, site: site, country_code: "PL")
|
||||||
|
|
||||||
|
assert CountryRuleCache.get({domain, "EE"}, cache_opts) == nil
|
||||||
|
assert CountryRuleCache.get({domain, "PL"}, cache_opts) == nil
|
||||||
|
|
||||||
|
assert :ok = CountryRuleCache.refresh_updated_recently(cache_opts)
|
||||||
|
|
||||||
|
refute CountryRuleCache.get({domain, "EE"}, cache_opts)
|
||||||
|
assert %CountryRule{} = CountryRuleCache.get({domain, "PL"}, cache_opts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp start_test_cache(cache_name) do
|
||||||
|
%{start: {m, f, a}} = CountryRuleCache.child_spec(cache_name: cache_name)
|
||||||
|
apply(m, f, a)
|
||||||
|
end
|
||||||
|
end
|
@ -80,8 +80,12 @@ defmodule Plausible.ShieldsTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "with added_by", %{site: site} do
|
test "with added_by", %{site: site} do
|
||||||
assert {:ok, rule} = add_ip_rule(site, %{"inet" => "1.1.1.1", "added_by" => "test"})
|
assert {:ok, rule} =
|
||||||
assert rule.added_by == "test"
|
add_ip_rule(site, %{"inet" => "1.1.1.1"},
|
||||||
|
added_by: build(:user, name: "Joe", email: "joe@example.com")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rule.added_by == "Joe <joe@example.com>"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -138,4 +142,129 @@ defmodule Plausible.ShieldsTest do
|
|||||||
refute rule.from_cache?
|
refute rule.from_cache?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "add_country_rule/2" do
|
||||||
|
test "no input", %{site: site} do
|
||||||
|
assert {:error, changeset} = add_country_rule(site, %{})
|
||||||
|
assert changeset.errors == [country_code: {"can't be blank", [validation: :required]}]
|
||||||
|
refute changeset.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unsupported country", %{site: site} do
|
||||||
|
assert {:error, changeset} = add_country_rule(site, %{"country_code" => "0X"})
|
||||||
|
assert changeset.errors == [country_code: {"is invalid", []}]
|
||||||
|
refute changeset.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "incorrect country format", %{site: site} do
|
||||||
|
assert {:error, changeset} = add_country_rule(site, %{"country_code" => "Germany"})
|
||||||
|
|
||||||
|
assert changeset.errors ==
|
||||||
|
[
|
||||||
|
{:country_code, {"is invalid", []}},
|
||||||
|
{:country_code,
|
||||||
|
{"should be %{count} character(s)",
|
||||||
|
[count: 2, validation: :length, kind: :is, type: :string]}}
|
||||||
|
]
|
||||||
|
|
||||||
|
refute changeset.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "double insert", %{site: site} do
|
||||||
|
assert {:ok, _} = add_country_rule(site, %{"country_code" => "EE"})
|
||||||
|
assert {:error, changeset} = add_country_rule(site, %{"country_code" => "EE"})
|
||||||
|
refute changeset.valid?
|
||||||
|
|
||||||
|
assert changeset.errors == [
|
||||||
|
country_code:
|
||||||
|
{"has already been taken",
|
||||||
|
[
|
||||||
|
{:constraint, :unique},
|
||||||
|
{:constraint_name, "shield_rules_country_site_id_country_code_index"}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "over limit", %{site: site} do
|
||||||
|
country_codes =
|
||||||
|
Location.Country.all()
|
||||||
|
|> Enum.take(Plausible.Shields.maximum_country_rules())
|
||||||
|
|> Enum.map(& &1.alpha_2)
|
||||||
|
|
||||||
|
for cc <- country_codes do
|
||||||
|
assert {:ok, _} =
|
||||||
|
add_country_rule(site, %{"country_code" => cc})
|
||||||
|
end
|
||||||
|
|
||||||
|
assert count_country_rules(site) == maximum_country_rules()
|
||||||
|
|
||||||
|
assert {:error, changeset} =
|
||||||
|
add_country_rule(site, %{"country_code" => "US"})
|
||||||
|
|
||||||
|
refute changeset.valid?
|
||||||
|
assert changeset.errors == [country_code: {"maximum reached", []}]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "with added_by", %{site: site} do
|
||||||
|
assert {:ok, rule} =
|
||||||
|
add_country_rule(site, %{"country_code" => "EE"},
|
||||||
|
added_by: build(:user, name: "Joe", email: "joe@example.com")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rule.added_by == "Joe <joe@example.com>"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "remove_country_rule/2" do
|
||||||
|
test "is idempontent", %{site: site} do
|
||||||
|
{:ok, rule} = add_country_rule(site, %{"country_code" => "EE"})
|
||||||
|
assert remove_country_rule(site, rule.id) == :ok
|
||||||
|
refute Repo.get(Plausible.Shield.CountryRule, rule.id)
|
||||||
|
assert remove_country_rule(site, rule.id) == :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "list_country_rules/1" do
|
||||||
|
test "empty", %{site: site} do
|
||||||
|
assert(list_country_rules(site) == [])
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag :slow
|
||||||
|
test "many", %{site: site} do
|
||||||
|
{:ok, r1} = add_country_rule(site, %{"country_code" => "EE"})
|
||||||
|
:timer.sleep(1000)
|
||||||
|
{:ok, r2} = add_country_rule(site, %{"country_code" => "PL"})
|
||||||
|
assert [^r2, ^r1] = list_country_rules(site)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "count_country_rules/1" do
|
||||||
|
test "counts", %{site: site} do
|
||||||
|
assert count_country_rules(site) == 0
|
||||||
|
{:ok, _} = add_country_rule(site, %{"country_code" => "EE"})
|
||||||
|
assert count_country_rules(site) == 1
|
||||||
|
{:ok, _} = add_country_rule(site, %{"country_code" => "PL"})
|
||||||
|
assert count_country_rules(site) == 2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "Country Rules" do
|
||||||
|
test "end to end", %{site: site} do
|
||||||
|
site2 = insert(:site)
|
||||||
|
|
||||||
|
assert count_country_rules(site.id) == 0
|
||||||
|
assert list_country_rules(site.id) == []
|
||||||
|
|
||||||
|
assert {:ok, rule} =
|
||||||
|
add_country_rule(site.id, %{"country_code" => "EE"})
|
||||||
|
|
||||||
|
add_country_rule(site2, %{"country_code" => "PL"})
|
||||||
|
|
||||||
|
assert count_country_rules(site) == 1
|
||||||
|
assert [^rule] = list_country_rules(site)
|
||||||
|
assert rule.country_code == "EE"
|
||||||
|
assert rule.action == :deny
|
||||||
|
refute rule.from_cache?
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
defmodule Plausible.TimezonesTest do
|
defmodule Plausible.TimezonesTest do
|
||||||
use ExUnit.Case, async: true
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
import Plausible.Timezones
|
||||||
|
|
||||||
test "options/0 returns a list of timezones" do
|
test "options/0 returns a list of timezones" do
|
||||||
options = Plausible.Timezones.options()
|
options = options()
|
||||||
refute Enum.empty?(options)
|
refute Enum.empty?(options)
|
||||||
|
|
||||||
gmt12 = Enum.find(options, &(&1[:value] == "Etc/GMT+12"))
|
gmt12 = Enum.find(options, &(&1[:value] == "Etc/GMT+12"))
|
||||||
@ -13,7 +15,59 @@ defmodule Plausible.TimezonesTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "options/0 does not fail during time changes" do
|
test "options/0 does not fail during time changes" do
|
||||||
options = Plausible.Timezones.options(~N[2021-10-03 02:31:07])
|
options = options(~N[2021-10-03 02:31:07])
|
||||||
refute Enum.empty?(options)
|
refute Enum.empty?(options)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "to_utc_datetime/2" do
|
||||||
|
assert to_utc_datetime(~N[2022-09-11 00:00:00], "Etc/UTC") == ~U[2022-09-11 00:00:00Z]
|
||||||
|
|
||||||
|
assert to_utc_datetime(~N[2022-09-11 00:00:00], "America/Santiago") ==
|
||||||
|
~U[2022-09-11 00:00:00Z]
|
||||||
|
|
||||||
|
assert to_utc_datetime(~N[2023-10-29 00:00:00], "Atlantic/Azores") == ~U[2023-10-29 01:00:00Z]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "to_date_in_timezone/1" do
|
||||||
|
assert to_date_in_timezone(~D[2021-01-03], "Etc/UTC") == ~D[2021-01-03]
|
||||||
|
assert to_date_in_timezone(~U[2015-01-13 13:00:07Z], "Etc/UTC") == ~D[2015-01-13]
|
||||||
|
assert to_date_in_timezone(~N[2015-01-13 13:00:07], "Etc/UTC") == ~D[2015-01-13]
|
||||||
|
assert to_date_in_timezone(~N[2015-01-13 19:00:07], "Etc/GMT+12") == ~D[2015-01-14]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "to_datetime_in_timezone/1" do
|
||||||
|
assert to_datetime_in_timezone(~D[2021-01-03], "Etc/UTC") == ~U[2021-01-03 00:00:00Z]
|
||||||
|
assert to_datetime_in_timezone(~N[2015-01-13 13:00:07], "Etc/UTC") == ~U[2015-01-13 13:00:07Z]
|
||||||
|
|
||||||
|
assert to_datetime_in_timezone(~N[2015-01-13 19:00:07], "Etc/GMT+12") ==
|
||||||
|
%DateTime{
|
||||||
|
microsecond: {0, 0},
|
||||||
|
second: 7,
|
||||||
|
calendar: Calendar.ISO,
|
||||||
|
month: 1,
|
||||||
|
day: 14,
|
||||||
|
year: 2015,
|
||||||
|
minute: 0,
|
||||||
|
hour: 7,
|
||||||
|
time_zone: "Etc/GMT-12",
|
||||||
|
zone_abbr: "+12",
|
||||||
|
utc_offset: 43_200,
|
||||||
|
std_offset: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
assert to_datetime_in_timezone(~N[2016-03-27 02:30:00], "Europe/Copenhagen") == %DateTime{
|
||||||
|
microsecond: {0, 0},
|
||||||
|
second: 0,
|
||||||
|
calendar: Calendar.ISO,
|
||||||
|
month: 3,
|
||||||
|
day: 27,
|
||||||
|
year: 2016,
|
||||||
|
minute: 30,
|
||||||
|
hour: 4,
|
||||||
|
time_zone: "Europe/Copenhagen",
|
||||||
|
zone_abbr: "CEST",
|
||||||
|
utc_offset: 3600,
|
||||||
|
std_offset: 3600
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
126
test/plausible_web/live/shields/countries_test.exs
Normal file
126
test/plausible_web/live/shields/countries_test.exs
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
defmodule PlausibleWeb.Live.Shields.CountriesTest 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 "Country Rules - static" do
|
||||||
|
test "renders country rules page with empty list", %{conn: conn, site: site} do
|
||||||
|
conn = get(conn, "/#{site.domain}/settings/shields/countries")
|
||||||
|
resp = html_response(conn, 200)
|
||||||
|
|
||||||
|
assert resp =~ "No Country Rules configured for this Site"
|
||||||
|
assert resp =~ "Country Block List"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "lists country rules with remove actions", %{conn: conn, site: site} do
|
||||||
|
{:ok, r1} =
|
||||||
|
Shields.add_country_rule(site, %{"country_code" => "PL"})
|
||||||
|
|
||||||
|
{:ok, r2} =
|
||||||
|
Shields.add_country_rule(site, %{"country_code" => "EE"})
|
||||||
|
|
||||||
|
conn = get(conn, "/#{site.domain}/settings/shields/countries")
|
||||||
|
resp = html_response(conn, 200)
|
||||||
|
|
||||||
|
assert resp =~ "Poland"
|
||||||
|
assert resp =~ "Estonia"
|
||||||
|
|
||||||
|
assert remove_button_1 = find(resp, "#remove-country-rule-#{r1.id}")
|
||||||
|
assert remove_button_2 = find(resp, "#remove-country-rule-#{r2.id}")
|
||||||
|
|
||||||
|
assert text_of_attr(remove_button_1, "phx-click" == "remove-country-rule")
|
||||||
|
assert text_of_attr(remove_button_1, "phx-value-rule-id" == r1.id)
|
||||||
|
assert text_of_attr(remove_button_2, "phx-click" == "remove-country-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/countries")
|
||||||
|
resp = html_response(conn, 200)
|
||||||
|
|
||||||
|
assert element_exists?(resp, ~s/button#add-country-rule[x-data]/)
|
||||||
|
attr = text_of_attr(resp, ~s/button#add-country-rule/, "x-on:click")
|
||||||
|
|
||||||
|
assert attr =~ "open-modal"
|
||||||
|
assert attr =~ "country-rule-form-modal"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "add rule button is not rendered when maximum reached", %{conn: conn, site: site} do
|
||||||
|
country_codes =
|
||||||
|
Location.Country.all()
|
||||||
|
|> Enum.take(Shields.maximum_country_rules())
|
||||||
|
|> Enum.map(& &1.alpha_2)
|
||||||
|
|
||||||
|
for cc <- country_codes do
|
||||||
|
assert {:ok, _} =
|
||||||
|
Shields.add_country_rule(site, %{"country_code" => "#{cc}"})
|
||||||
|
end
|
||||||
|
|
||||||
|
conn = get(conn, "/#{site.domain}/settings/shields/countries")
|
||||||
|
resp = html_response(conn, 200)
|
||||||
|
|
||||||
|
refute element_exists?(resp, ~s/button#add-country-rule[x-data]/)
|
||||||
|
assert resp =~ "Maximum number of countries reached"
|
||||||
|
assert resp =~ "You've reached the maximum number of countries you can block (30)"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "Country 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-country-rule"] input[name="country_rule\[country_code\]"]/
|
||||||
|
)
|
||||||
|
|
||||||
|
assert submit_button(html, ~s/form[phx-submit="save-country-rule"]/)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "submitting a valid country saves it", %{conn: conn, site: site} do
|
||||||
|
lv = get_liveview(conn, site)
|
||||||
|
|
||||||
|
lv
|
||||||
|
|> element("form")
|
||||||
|
|> render_submit(%{
|
||||||
|
"country_rule[country_code]" => "EE"
|
||||||
|
})
|
||||||
|
|
||||||
|
html = render(lv)
|
||||||
|
|
||||||
|
assert html =~ "Estonia"
|
||||||
|
|
||||||
|
assert [%{country_code: "EE"}] = Shields.list_country_rules(site)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "clicking Remove deletes the rule", %{conn: conn, site: site} do
|
||||||
|
{:ok, _} =
|
||||||
|
Shields.add_country_rule(site, %{"country_code" => "EE"})
|
||||||
|
|
||||||
|
lv = get_liveview(conn, site)
|
||||||
|
|
||||||
|
html = render(lv)
|
||||||
|
assert text_of_element(html, "table tbody td") =~ "Estonia"
|
||||||
|
|
||||||
|
lv |> element(~s/button[phx-click="remove-country-rule"]/) |> render_click()
|
||||||
|
|
||||||
|
html = render(lv)
|
||||||
|
refute text_of_element(html, "table tbody td") =~ "Estonia"
|
||||||
|
|
||||||
|
assert Shields.count_country_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/countries")
|
||||||
|
|
||||||
|
lv
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -1,4 +1,4 @@
|
|||||||
defmodule PlausibleWeb.Live.ShieldsTest do
|
defmodule PlausibleWeb.Live.Shields.IPAddressesTest do
|
||||||
use PlausibleWeb.ConnCase, async: true
|
use PlausibleWeb.ConnCase, async: true
|
||||||
|
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
@ -10,7 +10,7 @@ defmodule PlausibleWeb.Live.ShieldsTest do
|
|||||||
|
|
||||||
describe "IP Rules - static" do
|
describe "IP Rules - static" do
|
||||||
test "renders ip rules page with empty list", %{conn: conn, site: site} do
|
test "renders ip rules page with empty list", %{conn: conn, site: site} do
|
||||||
conn = get(conn, "/#{site.domain}/settings/shields")
|
conn = get(conn, "/#{site.domain}/settings/shields/ip_addresses")
|
||||||
resp = html_response(conn, 200)
|
resp = html_response(conn, 200)
|
||||||
|
|
||||||
assert resp =~ "No IP Rules configured for this Site"
|
assert resp =~ "No IP Rules configured for this Site"
|
||||||
@ -24,7 +24,7 @@ defmodule PlausibleWeb.Live.ShieldsTest do
|
|||||||
{:ok, r2} =
|
{:ok, r2} =
|
||||||
Shields.add_ip_rule(site, %{"inet" => "127.0.0.2", "description" => "Bob"})
|
Shields.add_ip_rule(site, %{"inet" => "127.0.0.2", "description" => "Bob"})
|
||||||
|
|
||||||
conn = get(conn, "/#{site.domain}/settings/shields")
|
conn = get(conn, "/#{site.domain}/settings/shields/ip_addresses")
|
||||||
resp = html_response(conn, 200)
|
resp = html_response(conn, 200)
|
||||||
|
|
||||||
assert resp =~ "127.0.0.1"
|
assert resp =~ "127.0.0.1"
|
||||||
@ -33,19 +33,17 @@ defmodule PlausibleWeb.Live.ShieldsTest do
|
|||||||
assert resp =~ "127.0.0.2"
|
assert resp =~ "127.0.0.2"
|
||||||
assert resp =~ "Bob"
|
assert resp =~ "Bob"
|
||||||
|
|
||||||
assert element_exists?(
|
assert remove_button_1 = find(resp, "#remove-ip-rule-#{r1.id}")
|
||||||
resp,
|
assert remove_button_2 = find(resp, "#remove-ip-rule-#{r2.id}")
|
||||||
~s/button[phx-click="remove-ip-rule"][phx-value-rule-id="#{r1.id}"]#remove-ip-rule-#{r1.id}/
|
|
||||||
)
|
|
||||||
|
|
||||||
assert element_exists?(
|
assert text_of_attr(remove_button_1, "phx-click" == "remove-ip-rule")
|
||||||
resp,
|
assert text_of_attr(remove_button_1, "phx-value-rule-id" == r1.id)
|
||||||
~s/button[phx-click="remove-ip-rule"][phx-value-rule-id="#{r2.id}"]#remove-ip-rule-#{r2.id}/
|
assert text_of_attr(remove_button_2, "phx-click" == "remove-ip-rule")
|
||||||
)
|
assert text_of_attr(remove_button_2, "phx-value-rule-id" == r2.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "add rule button is rendered", %{conn: conn, site: site} do
|
test "add rule button is rendered", %{conn: conn, site: site} do
|
||||||
conn = get(conn, "/#{site.domain}/settings/shields")
|
conn = get(conn, "/#{site.domain}/settings/shields/ip_addresses")
|
||||||
resp = html_response(conn, 200)
|
resp = html_response(conn, 200)
|
||||||
|
|
||||||
assert element_exists?(resp, ~s/button#add-ip-rule[x-data]/)
|
assert element_exists?(resp, ~s/button#add-ip-rule[x-data]/)
|
||||||
@ -61,11 +59,12 @@ defmodule PlausibleWeb.Live.ShieldsTest do
|
|||||||
Shields.add_ip_rule(site, %{"inet" => "1.1.1.#{i}"})
|
Shields.add_ip_rule(site, %{"inet" => "1.1.1.#{i}"})
|
||||||
end
|
end
|
||||||
|
|
||||||
conn = get(conn, "/#{site.domain}/settings/shields")
|
conn = get(conn, "/#{site.domain}/settings/shields/ip_addresses")
|
||||||
resp = html_response(conn, 200)
|
resp = html_response(conn, 200)
|
||||||
|
|
||||||
refute element_exists?(resp, ~s/button#add-ip-rule[x-data]/)
|
refute element_exists?(resp, ~s/button#add-ip-rule[x-data]/)
|
||||||
assert resp =~ "Maximum number of addresses reached"
|
assert resp =~ "Maximum number of addresses reached"
|
||||||
|
assert resp =~ "You've reached the maximum number of IP addresses you can block (30)"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -200,7 +199,7 @@ defmodule PlausibleWeb.Live.ShieldsTest do
|
|||||||
|
|
||||||
defp get_liveview(conn, site) do
|
defp get_liveview(conn, site) do
|
||||||
conn = assign(conn, :live_module, PlausibleWeb.Live.Shields)
|
conn = assign(conn, :live_module, PlausibleWeb.Live.Shields)
|
||||||
{:ok, lv, _html} = live(conn, "/#{site.domain}/settings/shields")
|
{:ok, lv, _html} = live(conn, "/#{site.domain}/settings/shields/ip_addresses")
|
||||||
|
|
||||||
lv
|
lv
|
||||||
end
|
end
|
@ -286,6 +286,12 @@ defmodule Plausible.Factory do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def country_rule_factory do
|
||||||
|
%Plausible.Shield.CountryRule{
|
||||||
|
added_by: "Mr Seed <user@plausible.test>"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
defp hash_key() do
|
defp hash_key() do
|
||||||
Keyword.fetch!(
|
Keyword.fetch!(
|
||||||
Application.get_env(:plausible, PlausibleWeb.Endpoint),
|
Application.get_env(:plausible, PlausibleWeb.Endpoint),
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
Mox.defmock(Plausible.HTTPClient.Mock, for: Plausible.HTTPClient.Interface)
|
Mox.defmock(Plausible.HTTPClient.Mock, for: Plausible.HTTPClient.Interface)
|
||||||
Application.ensure_all_started(:double)
|
Application.ensure_all_started(:double)
|
||||||
FunWithFlags.enable(:window_time_on_page)
|
FunWithFlags.enable(:window_time_on_page)
|
||||||
FunWithFlags.enable(:shields)
|
|
||||||
Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, :manual)
|
Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, :manual)
|
||||||
|
|
||||||
if Mix.env() == :small_test do
|
if Mix.env() == :small_test do
|
||||||
|
Loading…
Reference in New Issue
Block a user