Merge branch 'master' into manual-filters

This commit is contained in:
Vignesh Joglekar 2021-05-25 15:28:21 -05:00 committed by GitHub
commit 806975ede9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 362 additions and 86 deletions

View File

@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file.
- Glob (wildcard) based page, entry page and exit page filters plausible/analytics#1067 - Glob (wildcard) based page, entry page and exit page filters plausible/analytics#1067
- Exclusion filters for page, entry page and exit page filters plausible/analytics#1067 - Exclusion filters for page, entry page and exit page filters plausible/analytics#1067
- Menu to add new and edit existing filters directly plausible/analytics#1067 - Menu to add new and edit existing filters directly plausible/analytics#1067
- Added `CLICKHOUSE_FLUSH_INTERVAL_MS` and `CLICKHOUSE_MAX_BUFFER_SIZE` configuration parameters plausible/analytics#1073
### Fixed ### Fixed
- Fix weekly report time range plausible/analytics#951 - Fix weekly report time range plausible/analytics#951

View File

@ -23,3 +23,10 @@ Make sure Docker, Elixir, Erlang and Node.js are all installed on your developme
3. Fill in the rest of the forms and for the domain use `dummy.site` 3. Fill in the rest of the forms and for the domain use `dummy.site`
4. Run `make dummy_event` from the terminal to generate a fake pageview event for the dummy site. 4. Run `make dummy_event` from the terminal to generate a fake pageview event for the dummy site.
5. You should now be all set! 5. You should now be all set!
### Stopping Docker containers
1. Stop and remove the Postgres container with `make postgres-stop`.
2. Stop and remove the Clickhouse container with `make clickhouse-stop`.
Volumes are preserved. You'll find that the Postgres and Clickhouse state are retained when you bring them up again the next time: no need to re-register and so on.

View File

@ -1,8 +1,14 @@
clickhouse: clickhouse:
docker run --detach -p 8123:8123 --ulimit nofile=262144:262144 --volume=$$PWD/.clickhouse_db_vol:/var/lib/clickhouse yandex/clickhouse-server:21.3.2.5 docker run --detach -p 8123:8123 --ulimit nofile=262144:262144 --volume=$$PWD/.clickhouse_db_vol:/var/lib/clickhouse --name plausible_clickhouse yandex/clickhouse-server:21.3.2.5
clickhouse-stop:
docker stop plausible_clickhouse && docker rm plausible_clickhouse
postgres: postgres:
docker run --detach -e POSTGRES_PASSWORD="postgres" -p 5432:5432 postgres:12 docker run --detach -e POSTGRES_PASSWORD="postgres" -p 5432:5432 --volume=plausible_db:/var/lib/postgresql/data --name plausible_db postgres:12
postgres-stop:
docker stop plausible_db && docker rm plausible_db
dummy_event: dummy_event:
curl 'http://localhost:8000/api/event' \ curl 'http://localhost:8000/api/event' \

View File

@ -308,7 +308,7 @@ iframe[hidden] {
} }
.pagination-link[disabled] { .pagination-link[disabled] {
@apply cursor-default bg-gray-100 pointer-events-none; @apply cursor-default bg-gray-100 dark:bg-gray-300 pointer-events-none;
} }
.filter-list-text:hover ~ .filter-list-edit { .filter-list-text:hover ~ .filter-list-edit {

View File

@ -50,6 +50,9 @@ app_version = System.get_env("APP_VERSION", "0.0.1")
ch_db_url = ch_db_url =
System.get_env("CLICKHOUSE_DATABASE_URL", "http://plausible_events_db:8123/plausible_events_db") System.get_env("CLICKHOUSE_DATABASE_URL", "http://plausible_events_db:8123/plausible_events_db")
{ch_flush_interval_ms, ""} = Integer.parse(System.get_env("CLICKHOUSE_FLUSH_INTERVAL_MS", "5000"))
{ch_max_buffer_size, ""} = Integer.parse(System.get_env("CLICKHOUSE_MAX_BUFFER_SIZE", "10000"))
### Mandatory params End ### Mandatory params End
sentry_dsn = System.get_env("SENTRY_DSN") sentry_dsn = System.get_env("SENTRY_DSN")
@ -78,7 +81,6 @@ disable_registration = String.to_existing_atom(System.get_env("DISABLE_REGISTRAT
hcaptcha_sitekey = System.get_env("HCAPTCHA_SITEKEY") hcaptcha_sitekey = System.get_env("HCAPTCHA_SITEKEY")
hcaptcha_secret = System.get_env("HCAPTCHA_SECRET") hcaptcha_secret = System.get_env("HCAPTCHA_SECRET")
log_level = String.to_existing_atom(System.get_env("LOG_LEVEL", "warn")) log_level = String.to_existing_atom(System.get_env("LOG_LEVEL", "warn"))
log_format = System.get_env("LOG_FORMAT", "elixir")
is_selfhost = String.to_existing_atom(System.get_env("SELFHOST", "true")) is_selfhost = String.to_existing_atom(System.get_env("SELFHOST", "true"))
{site_limit, ""} = Integer.parse(System.get_env("SITE_LIMIT", "20")) {site_limit, ""} = Integer.parse(System.get_env("SITE_LIMIT", "20"))
disable_cron = String.to_existing_atom(System.get_env("DISABLE_CRON", "false")) disable_cron = String.to_existing_atom(System.get_env("DISABLE_CRON", "false"))
@ -130,7 +132,9 @@ config :plausible, Plausible.ClickhouseRepo,
loggers: [Ecto.LogEntry], loggers: [Ecto.LogEntry],
queue_target: 500, queue_target: 500,
queue_interval: 2000, queue_interval: 2000,
url: ch_db_url url: ch_db_url,
flush_interval_ms: ch_flush_interval_ms,
max_buffer_size: ch_max_buffer_size
case mailer_adapter do case mailer_adapter do
"Bamboo.PostmarkAdapter" -> "Bamboo.PostmarkAdapter" ->
@ -251,6 +255,9 @@ config :plausible, :user_agent_cache,
limit: user_agent_cache_limit, limit: user_agent_cache_limit,
stats: user_agent_cache_stats stats: user_agent_cache_stats
config :hammer,
backend: {Hammer.Backend.ETS, [expiry_ms: 60_000 * 60 * 4, cleanup_interval_ms: 60_000 * 10]}
config :kaffy, config :kaffy,
otp_app: :plausible, otp_app: :plausible,
ecto_repo: Plausible.Repo, ecto_repo: Plausible.Repo,
@ -280,22 +287,11 @@ if config_env() != :test && geolite2_country_db do
] ]
end end
logger_backends = %{
"elixir" => [:console],
"json" => [Ink]
}
config :logger, config :logger,
level: log_level, level: log_level,
backends: logger_backends[log_format] backends: [:console]
config :logger, Sentry.LoggerBackend, config :logger, Sentry.LoggerBackend,
capture_log_messages: true, capture_log_messages: true,
level: :error, level: :error,
excluded_domains: [] excluded_domains: []
if log_format == "json" do
config :logger, Ink,
name: "plausible",
level: log_level
end

View File

@ -23,7 +23,10 @@ config :geolix,
%{ %{
id: :country, id: :country,
adapter: Geolix.Adapter.Fake, adapter: Geolix.Adapter.Fake,
data: %{{1, 1, 1, 1} => %{country: %{iso_code: "US"}}} data: %{
{1, 1, 1, 1} => %{country: %{iso_code: "US"}},
{1, 1, 1, 1, 1, 1, 1, 1} => %{country: %{iso_code: "US"}}
}
} }
] ]

View File

@ -3,10 +3,11 @@ defmodule Plausible.Auth.ApiKey do
import Ecto.Changeset import Ecto.Changeset
@required [:user_id, :key, :name] @required [:user_id, :key, :name]
@optional [:scopes] @optional [:scopes, :hourly_request_limit]
schema "api_keys" do schema "api_keys" do
field :name, :string field :name, :string
field :scopes, {:array, :string}, default: ["stats:read:*"] field :scopes, {:array, :string}, default: ["stats:read:*"]
field :hourly_request_limit, :integer
field :key, :string, virtual: true field :key, :string, virtual: true
field :key_hash, :string field :key_hash, :string

View File

@ -3,6 +3,10 @@ defmodule Plausible.Billing.Plans do
%{limit: 150_000_000, yearly_product_id: "648089", yearly_cost: "$4800"} %{limit: 150_000_000, yearly_product_id: "648089", yearly_cost: "$4800"}
] ]
@unlisted_plans_v2 [
%{limit: 10_000_000, monthly_product_id: "655350", yearly_cost: "$250"}
]
@v2_pricing_date ~D[2021-05-13] @v2_pricing_date ~D[2021-05-13]
def plans_for(user) do def plans_for(user) do
@ -70,7 +74,7 @@ defmodule Plausible.Billing.Plans do
end end
defp all_plans() do defp all_plans() do
plans_v1() ++ @unlisted_plans_v1 ++ plans_v2() plans_v1() ++ @unlisted_plans_v1 ++ plans_v2() ++ @unlisted_plans_v2
end end
defp plans_v1() do defp plans_v1() do

View File

@ -1,8 +1,6 @@
defmodule Plausible.Event.WriteBuffer do defmodule Plausible.Event.WriteBuffer do
use GenServer use GenServer
require Logger require Logger
@flush_interval_ms 5_000
@max_buffer_size 10_000
def start_link(_opts) do def start_link(_opts) do
GenServer.start_link(__MODULE__, [], name: __MODULE__) GenServer.start_link(__MODULE__, [], name: __MODULE__)
@ -10,7 +8,7 @@ defmodule Plausible.Event.WriteBuffer do
def init(buffer) do def init(buffer) do
Process.flag(:trap_exit, true) Process.flag(:trap_exit, true)
timer = Process.send_after(self(), :tick, @flush_interval_ms) timer = Process.send_after(self(), :tick, flush_interval_ms())
{:ok, %{buffer: buffer, timer: timer}} {:ok, %{buffer: buffer, timer: timer}}
end end
@ -27,11 +25,11 @@ defmodule Plausible.Event.WriteBuffer do
def handle_cast({:insert, event}, %{buffer: buffer} = state) do def handle_cast({:insert, event}, %{buffer: buffer} = state) do
new_buffer = [event | buffer] new_buffer = [event | buffer]
if length(new_buffer) >= @max_buffer_size do if length(new_buffer) >= max_buffer_size() do
Logger.info("Buffer full, flushing to disk") Logger.info("Buffer full, flushing to disk")
Process.cancel_timer(state[:timer]) Process.cancel_timer(state[:timer])
do_flush(new_buffer) do_flush(new_buffer)
new_timer = Process.send_after(self(), :tick, @flush_interval_ms) new_timer = Process.send_after(self(), :tick, flush_interval_ms())
{:noreply, %{buffer: [], timer: new_timer}} {:noreply, %{buffer: [], timer: new_timer}}
else else
{:noreply, %{state | buffer: new_buffer}} {:noreply, %{state | buffer: new_buffer}}
@ -40,14 +38,14 @@ defmodule Plausible.Event.WriteBuffer do
def handle_info(:tick, %{buffer: buffer}) do def handle_info(:tick, %{buffer: buffer}) do
do_flush(buffer) do_flush(buffer)
timer = Process.send_after(self(), :tick, @flush_interval_ms) timer = Process.send_after(self(), :tick, flush_interval_ms())
{:noreply, %{buffer: [], timer: timer}} {:noreply, %{buffer: [], timer: timer}}
end end
def handle_call(:flush, _from, %{buffer: buffer} = state) do def handle_call(:flush, _from, %{buffer: buffer} = state) do
Process.cancel_timer(state[:timer]) Process.cancel_timer(state[:timer])
do_flush(buffer) do_flush(buffer)
new_timer = Process.send_after(self(), :tick, @flush_interval_ms) new_timer = Process.send_after(self(), :tick, flush_interval_ms())
{:reply, nil, %{buffer: [], timer: new_timer}} {:reply, nil, %{buffer: [], timer: new_timer}}
end end
@ -67,4 +65,12 @@ defmodule Plausible.Event.WriteBuffer do
Plausible.ClickhouseRepo.insert_all(Plausible.ClickhouseEvent, events) Plausible.ClickhouseRepo.insert_all(Plausible.ClickhouseEvent, events)
end end
end end
defp flush_interval_ms() do
Keyword.fetch!(Application.get_env(:plausible, Plausible.ClickhouseRepo), :flush_interval_ms)
end
defp max_buffer_size() do
Keyword.fetch!(Application.get_env(:plausible, Plausible.ClickhouseRepo), :max_buffer_size)
end
end end

View File

@ -1,8 +1,6 @@
defmodule Plausible.Session.WriteBuffer do defmodule Plausible.Session.WriteBuffer do
use GenServer use GenServer
require Logger require Logger
@flush_interval_ms 1000
@max_buffer_size 10_000
def start_link(_opts) do def start_link(_opts) do
GenServer.start_link(__MODULE__, [], name: __MODULE__) GenServer.start_link(__MODULE__, [], name: __MODULE__)
@ -10,7 +8,7 @@ defmodule Plausible.Session.WriteBuffer do
def init(buffer) do def init(buffer) do
Process.flag(:trap_exit, true) Process.flag(:trap_exit, true)
timer = Process.send_after(self(), :tick, @flush_interval_ms) timer = Process.send_after(self(), :tick, flush_interval_ms())
{:ok, %{buffer: buffer, timer: timer}} {:ok, %{buffer: buffer, timer: timer}}
end end
@ -22,11 +20,11 @@ defmodule Plausible.Session.WriteBuffer do
def handle_cast({:insert, sessions}, %{buffer: buffer} = state) do def handle_cast({:insert, sessions}, %{buffer: buffer} = state) do
new_buffer = sessions ++ buffer new_buffer = sessions ++ buffer
if length(new_buffer) >= @max_buffer_size do if length(new_buffer) >= max_buffer_size() do
Logger.info("Buffer full, flushing to disk") Logger.info("Buffer full, flushing to disk")
Process.cancel_timer(state[:timer]) Process.cancel_timer(state[:timer])
flush(new_buffer) flush(new_buffer)
new_timer = Process.send_after(self(), :tick, @flush_interval_ms) new_timer = Process.send_after(self(), :tick, flush_interval_ms())
{:noreply, %{buffer: [], timer: new_timer}} {:noreply, %{buffer: [], timer: new_timer}}
else else
{:noreply, %{state | buffer: new_buffer}} {:noreply, %{state | buffer: new_buffer}}
@ -35,7 +33,7 @@ defmodule Plausible.Session.WriteBuffer do
def handle_info(:tick, %{buffer: buffer}) do def handle_info(:tick, %{buffer: buffer}) do
flush(buffer) flush(buffer)
timer = Process.send_after(self(), :tick, @flush_interval_ms) timer = Process.send_after(self(), :tick, flush_interval_ms())
{:noreply, %{buffer: [], timer: timer}} {:noreply, %{buffer: [], timer: timer}}
end end
@ -60,4 +58,12 @@ defmodule Plausible.Session.WriteBuffer do
Plausible.ClickhouseRepo.insert_all(Plausible.ClickhouseSession, sessions) Plausible.ClickhouseRepo.insert_all(Plausible.ClickhouseSession, sessions)
end end
end end
defp flush_interval_ms() do
Keyword.fetch!(Application.get_env(:plausible, Plausible.ClickhouseRepo), :flush_interval_ms)
end
defp max_buffer_size() do
Keyword.fetch!(Application.get_env(:plausible, Plausible.ClickhouseRepo), :max_buffer_size)
end
end end

View File

@ -679,7 +679,7 @@ defmodule Plausible.Stats.Clickhouse do
|> Enum.into(%{}) |> Enum.into(%{})
end end
defp page_times_by_page_url(site, query, page_list) do def page_times_by_page_url(site, query, page_list) do
q = q =
from( from(
e in base_query_w_sessions(site, %Query{ e in base_query_w_sessions(site, %Query{
@ -713,7 +713,10 @@ defmodule Plausible.Stats.Clickhouse do
FROM (#{base_query_raw})) FROM (#{base_query_raw}))
WHERE s=s2 AND p IN tuple(?) WHERE s=s2 AND p IN tuple(?)
GROUP BY p,p2,s) GROUP BY p,p2,s)
GROUP BY p" |> ClickhouseRepo.query(base_query_raw_params ++ [page_list ++ ["/"]]) GROUP BY p"
|> ClickhouseRepo.query(
base_query_raw_params ++ [(Enum.count(page_list) > 0 && page_list) || ["/"]]
)
end end
defp add_percentages(stat_list) do defp add_percentages(stat_list) do

View File

@ -21,4 +21,11 @@ defmodule PlausibleWeb.Api.Helpers do
|> Phoenix.Controller.json(%{error: msg}) |> Phoenix.Controller.json(%{error: msg})
|> halt() |> halt()
end end
def too_many_requests(conn, msg) do
conn
|> put_status(429)
|> Phoenix.Controller.json(%{error: msg})
|> halt()
end
end end

View File

@ -104,6 +104,51 @@ defmodule PlausibleWeb.Api.StatsController do
} }
end end
time_on_page =
if query.filters["page"] do
[{success, duration}, {prev_success, prev_duration}] =
Task.yield_many(
[
Task.async(fn ->
{:ok, page_times} =
Stats.page_times_by_page_url(site, query, [query.filters["page"]])
page_times
end),
Task.async(fn ->
{:ok, page_times} =
Stats.page_times_by_page_url(site, prev_query, [query.filters["page"]])
page_times
end)
],
5000
)
|> Enum.map(fn {task, response} ->
case response do
nil ->
Task.shutdown(task, :brutal_kill)
{nil, nil}
{:ok, page_times} ->
result = Enum.at(page_times.rows, 0)
result = if result, do: Enum.at(result, 1), else: nil
if result, do: {:ok, round(result)}, else: {:ok, 0}
_ ->
response
end
end)
if success == :ok && prev_success == :ok do
%{
name: "Time on Page",
duration: duration,
change: percent_change(prev_duration, duration)
}
end
end
stats = stats =
[ [
%{ %{
@ -117,7 +162,8 @@ defmodule PlausibleWeb.Api.StatsController do
change: percent_change(prev_pageviews, pageviews) change: percent_change(prev_pageviews, pageviews)
}, },
%{name: "Bounce rate", percentage: bounce_rate, change: change_bounce_rate}, %{name: "Bounce rate", percentage: bounce_rate, change: change_bounce_rate},
visit_duration visit_duration,
time_on_page
] ]
|> Enum.filter(& &1) |> Enum.filter(& &1)

View File

@ -227,41 +227,77 @@ defmodule PlausibleWeb.AuthController do
end end
def login(conn, %{"email" => email, "password" => password}) do def login(conn, %{"email" => email, "password" => password}) do
alias Plausible.Auth.Password with :ok <- check_ip_rate_limit(conn),
{:ok, user} <- find_user(email),
:ok <- check_user_rate_limit(user),
:ok <- check_password(user, password) do
login_dest = get_session(conn, :login_dest) || "/sites"
conn
|> put_session(:current_user_id, user.id)
|> put_resp_cookie("logged_in", "true",
http_only: false,
max_age: 60 * 60 * 24 * 365 * 5000
)
|> put_session(:login_dest, nil)
|> redirect(to: login_dest)
else
:wrong_password ->
render(conn, "login_form.html",
error: "Wrong email or password. Please try again.",
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
:user_not_found ->
Plausible.Auth.Password.dummy_calculation()
render(conn, "login_form.html",
error: "Wrong email or password. Please try again.",
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
{:rate_limit, _} ->
render_error(
conn,
429,
"Too many login attempts. Wait a minute before trying again."
)
end
end
@login_interval 60_000
@login_limit 5
defp check_ip_rate_limit(conn) do
ip_address = PlausibleWeb.RemoteIp.get(conn)
case Hammer.check_rate("login:ip:#{ip_address}", @login_interval, @login_limit) do
{:allow, _} -> :ok
{:deny, _} -> {:rate_limit, :ip_address}
end
end
defp find_user(email) do
user = user =
Repo.one( Repo.one(
from u in Plausible.Auth.User, from u in Plausible.Auth.User,
where: u.email == ^email where: u.email == ^email
) )
if user do if user, do: {:ok, user}, else: :user_not_found
if Password.match?(password, user.password_hash || "") do end
login_dest = get_session(conn, :login_dest) || "/sites"
conn defp check_user_rate_limit(user) do
|> put_session(:current_user_id, user.id) case Hammer.check_rate("login:user:#{user.id}", @login_interval, @login_limit) do
|> put_resp_cookie("logged_in", "true", {:allow, _} -> :ok
http_only: false, {:deny, _} -> {:rate_limit, :user}
max_age: 60 * 60 * 24 * 365 * 5000 end
) end
|> put_session(:login_dest, nil)
|> redirect(to: login_dest) defp check_password(user, password) do
else if Plausible.Auth.Password.match?(password, user.password_hash || "") do
conn :ok
|> render("login_form.html",
error: "Wrong email or password. Please try again.",
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
end
else else
Password.dummy_calculation() :wrong_password
conn
|> render("login_form.html",
error: "Wrong email or password. Please try again.",
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
end end
end end

View File

@ -9,7 +9,9 @@ defmodule PlausibleWeb.AuthorizeStatsApiPlug do
end end
def call(conn, _opts) do def call(conn, _opts) do
with {:ok, api_key} <- get_bearer_token(conn), with {:ok, token} <- get_bearer_token(conn),
{:ok, api_key} <- find_api_key(token),
:ok <- check_api_key_rate_limit(api_key),
{:ok, site} <- verify_access(api_key, conn.params["site_id"]) do {:ok, site} <- verify_access(api_key, conn.params["site_id"]) do
assign(conn, :site, site) assign(conn, :site, site)
else else
@ -25,6 +27,12 @@ defmodule PlausibleWeb.AuthorizeStatsApiPlug do
"Missing site ID. Please provide the required site_id parameter with your request." "Missing site ID. Please provide the required site_id parameter with your request."
) )
{:error, :rate_limit, limit} ->
H.too_many_requests(
conn,
"Too many API requests. Your API key is limited to #{limit} requests per hour."
)
{:error, :invalid_api_key} -> {:error, :invalid_api_key} ->
H.unauthorized( H.unauthorized(
conn, conn,
@ -36,13 +44,11 @@ defmodule PlausibleWeb.AuthorizeStatsApiPlug do
defp verify_access(_api_key, nil), do: {:error, :missing_site_id} defp verify_access(_api_key, nil), do: {:error, :missing_site_id}
defp verify_access(api_key, site_id) do defp verify_access(api_key, site_id) do
hashed_key = ApiKey.do_hash(api_key)
found_key = Repo.get_by(ApiKey, key_hash: hashed_key)
site = Repo.get_by(Plausible.Site, domain: site_id) site = Repo.get_by(Plausible.Site, domain: site_id)
is_owner = site && found_key && Plausible.Sites.is_owner?(found_key.user_id, site) is_owner = site && Plausible.Sites.is_owner?(api_key.user_id, site)
cond do cond do
found_key && site && is_owner -> {:ok, site} site && is_owner -> {:ok, site}
true -> {:error, :invalid_api_key} true -> {:error, :invalid_api_key}
end end
end end
@ -57,4 +63,18 @@ defmodule PlausibleWeb.AuthorizeStatsApiPlug do
_ -> {:error, :missing_api_key} _ -> {:error, :missing_api_key}
end end
end end
defp find_api_key(token) do
hashed_key = ApiKey.do_hash(token)
found_key = Repo.get_by(ApiKey, key_hash: hashed_key)
if found_key, do: {:ok, found_key}, else: {:error, :invalid_api_key}
end
@one_hour 60 * 60 * 1000
defp check_api_key_rate_limit(api_key) do
case Hammer.check_rate("api_request:#{api_key.id}", @one_hour, api_key.hourly_request_limit) do
{:allow, _} -> :ok
{:deny, _} -> {:error, :rate_limit, api_key.hourly_request_limit}
end
end
end end

View File

@ -2,6 +2,7 @@ defmodule PlausibleWeb.RemoteIp do
def get(conn) do def get(conn) do
cf_connecting_ip = List.first(Plug.Conn.get_req_header(conn, "cf-connecting-ip")) cf_connecting_ip = List.first(Plug.Conn.get_req_header(conn, "cf-connecting-ip"))
forwarded_for = List.first(Plug.Conn.get_req_header(conn, "x-forwarded-for")) forwarded_for = List.first(Plug.Conn.get_req_header(conn, "x-forwarded-for"))
forwarded = List.first(Plug.Conn.get_req_header(conn, "forwarded"))
cond do cond do
cf_connecting_ip -> cf_connecting_ip ->
@ -12,6 +13,14 @@ defmodule PlausibleWeb.RemoteIp do
|> Enum.map(&String.trim/1) |> Enum.map(&String.trim/1)
|> List.first() |> List.first()
forwarded ->
Regex.named_captures(~r/for=(?<for>[^;,]+).*$/, forwarded)
|> Map.get("for")
# IPv6 addresses are enclosed in quote marks and square brackets: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded
|> String.trim("\"")
|> String.trim_leading("[")
|> String.trim_trailing("]")
true -> true ->
to_string(:inet_parse.ntoa(conn.remote_ip)) to_string(:inet_parse.ntoa(conn.remote_ip))
end end

View File

@ -5,11 +5,11 @@ We're super excited to have you on board!
<br /><br /> <br /><br />
Here are five steps to get the most out of your Plausible experience: Here are five steps to get the most out of your Plausible experience:
<br /><br /> <br /><br />
* Serve the script as a <%= link("first party connection from your custom domain", to: "https://docs.plausible.io/custom-domain") %><br /> * <%= link("Enable email reports", to: "https://docs.plausible.io/email-reports/") %> and notifications for <%= link("traffic spikes", to: "https://plausible.io/docs/traffic-spikes") %><br />
* <%= link("Enable email reports", to: "https://docs.plausible.io/email-reports/") %> and notifications for <%= link("traffic spikes", to: "https://docs.plausible.io/traffic-spikes/") %><br /> * <%= link("Integrate with Search Console", to: "https://plausible.io/docs/google-search-console-integration") %> to get keyword phrases people find your site with<br />
* <%= link("Integrate with Search Console", to: "https://docs.plausible.io/google-search-console-integration/") %> to get keyword phrases people find your site with<br /> * Set up some easy goals including <%= link("404 error pages", to: "https://plausible.io/docs/404-error-pages-tracking") %> and <%= link("outbound link clicks", to: "https://plausible.io/docs/outbound-link-click-tracking/") %><br />
* Set up some easy goals including <%= link("404 error pages", to: "https://docs.plausible.io/404-error-pages-tracking") %> and <%= link("outbound link clicks", to: "https://docs.plausible.io/outbound-link-click-tracking/") %><br /> * <%= link("Opt out from counting your own visits", to: "https://plausible.io/docs/excluding") %><br />
* <%= link("Opt out from counting your own visits", to: "https://docs.plausible.io/excluding/") %><br /> * If you're concerned about adblockers, <%= link("set up a proxy to bypass them", to: "https://plausible.io/docs/proxy/introduction") %><br />
<br /><br /> <br /><br />
Then you're ready to start exploring your fast loading, ethical and actionable <%= link("Plausible dashboard", to: "https://plausible.io/sites") %>. Then you're ready to start exploring your fast loading, ethical and actionable <%= link("Plausible dashboard", to: "https://plausible.io/sites") %>.
<br /><br /> <br /><br />

View File

@ -1,4 +1,5 @@
<%= if !Application.get_env(:plausible, :is_selfhost) && !@conn.assigns[:skip_plausible_tracking] do %> <%= if !Application.get_env(:plausible, :is_selfhost) && !@conn.assigns[:skip_plausible_tracking] do %>
<script async defer src="<%="#{plausible_url()}/js/script.js"%>"></script> <script defer src="https://testing-plausible-io-proxy.uku-taht.workers.dev/js/script.js"></script>
<script defer src="<%="#{plausible_url()}/js/script.js"%>"></script>
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script> <script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
<% end %> <% end %>

View File

@ -40,9 +40,9 @@
<%= if @pagination.total_pages > 1 do %> <%= if @pagination.total_pages > 1 do %>
<%= pagination @conn, @pagination, [current_class: "is-current"], fn p -> %> <%= pagination @conn, @pagination, [current_class: "is-current"], fn p -> %>
<nav class="px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6" aria-label="Pagination"> <nav class="px-4 py-3 flex items-center justify-between border-t border-gray-200 dark:border-gray-500 sm:px-6" aria-label="Pagination">
<div class="hidden sm:block"> <div class="hidden sm:block">
<p class="text-sm text-gray-700"> <p class="text-sm text-gray-700 dark:text-gray-300">
Showing page Showing page
<span class="font-medium"><%= @pagination.page %></span> <span class="font-medium"><%= @pagination.page %></span>
of of
@ -51,8 +51,8 @@
</p> </p>
</div> </div>
<div class="flex-1 flex justify-between sm:justify-end"> <div class="flex-1 flex justify-between sm:justify-end">
<%= pagination_link(p, :previous, label: "← Previous", class: "pagination-link relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50", force_show: true) %> <%= pagination_link(p, :previous, label: "← Previous", class: "pagination-link relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white dark:bg-gray-100 hover:bg-gray-50", force_show: true) %>
<%= pagination_link(p, :next, label: "Next →", class: "pagination-link ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50", force_show: true) %> <%= pagination_link(p, :next, label: "Next →", class: "pagination-link ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white dark:bg-gray-100 hover:bg-gray-50", force_show: true) %>
</div> </div>
</nav> </nav>
<% end %> <% end %>

View File

@ -1,3 +1,24 @@
<div class="rounded-md bg-yellow-50 dark:bg-transparent dark:border border-yellow-200 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400 dark:text-yellow-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-400">
Deprecated feature
</h3>
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
<p>
We are moving away from CNAME-based custom domains. If you're concerned about adblockers, we recommend
<%= link("setting up a proxy", class: "underline text-yellow-800 dark:text-yellow-400", to: "https://plausible.io/docs/proxy/introduction", target: "_blank") %> for your analytics script instead.
</p>
</div>
</div>
</div>
</div>
<div class="shadow bg-white dark:bg-gray-800 sm:rounded-md sm:overflow-hidden py-6 px-4 sm:p-6"> <div class="shadow bg-white dark:bg-gray-800 sm:rounded-md sm:overflow-hidden py-6 px-4 sm:p-6">
<header class="relative"> <header class="relative">
<h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">Custom domain</h2> <h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">Custom domain</h2>

View File

@ -95,8 +95,8 @@ defmodule Plausible.MixProject do
{:credo, "~> 1.5", only: [:dev, :test], runtime: false}, {:credo, "~> 1.5", only: [:dev, :test], runtime: false},
{:kaffy, "~> 0.9.0"}, {:kaffy, "~> 0.9.0"},
{:envy, "~> 1.1.1"}, {:envy, "~> 1.1.1"},
{:ink, "~> 1.0"}, {:phoenix_pagination, "~> 0.7.0"},
{:phoenix_pagination, "~> 0.7.0"} {:hammer, "~> 6.0"}
] ]
end end

View File

@ -44,6 +44,7 @@
"geolix_adapter_mmdb2": {:hex, :geolix_adapter_mmdb2, "0.5.0", "5912723d9538ecddc6b29b1d8041b917b735a78fd3c122bfea8c44aa782e3369", [:mix], [{:geolix, "~> 1.1", [hex: :geolix, repo: "hexpm", optional: false]}, {:mmdb2_decoder, "~> 3.0", [hex: :mmdb2_decoder, repo: "hexpm", optional: false]}], "hexpm", "cb1485b6a0a2d3e541949207428a245718dbf1258453a0df0e5fdd925bcecd3e"}, "geolix_adapter_mmdb2": {:hex, :geolix_adapter_mmdb2, "0.5.0", "5912723d9538ecddc6b29b1d8041b917b735a78fd3c122bfea8c44aa782e3369", [:mix], [{:geolix, "~> 1.1", [hex: :geolix, repo: "hexpm", optional: false]}, {:mmdb2_decoder, "~> 3.0", [hex: :mmdb2_decoder, repo: "hexpm", optional: false]}], "hexpm", "cb1485b6a0a2d3e541949207428a245718dbf1258453a0df0e5fdd925bcecd3e"},
"gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"}, "gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"},
"hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"}, "hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"},
"hammer": {:hex, :hammer, "6.0.0", "72ec6fff10e9d63856968988a22ee04c4d6d5248071ddccfbda50aa6c455c1d7", [:mix], [{:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm", "d8e1ec2e534c4aae508b906759e077c3c1eb3e2b9425235d4b7bbab0b016210a"},
"httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"}, "httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"},
"hut": {:hex, :hut, "1.3.0", "71f2f054e657c03f959cf1acc43f436ea87580696528ca2a55c8afb1b06c85e7", [:"erlang.mk", :rebar, :rebar3], [], "hexpm", "7e15d28555d8a1f2b5a3a931ec120af0753e4853a4c66053db354f35bf9ab563"}, "hut": {:hex, :hut, "1.3.0", "71f2f054e657c03f959cf1acc43f436ea87580696528ca2a55c8afb1b06c85e7", [:"erlang.mk", :rebar, :rebar3], [], "hexpm", "7e15d28555d8a1f2b5a3a931ec120af0753e4853a4c66053db354f35bf9ab563"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},

View File

@ -0,0 +1,9 @@
defmodule Plausible.Repo.Migrations.AddRateLimitToApiKeys do
use Ecto.Migration
def change do
alter table(:api_keys) do
add :hourly_request_limit, :integer, null: false, default: 1000
end
end
end

View File

@ -1,5 +0,0 @@
defmodule Plausible.Billing.PlansTest do
use Plausible.DataCase
use Bamboo.Test, shared: true
alias Plausible.Billing.Plans
end

View File

@ -510,6 +510,45 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
assert pageview.country_code == "US" assert pageview.country_code == "US"
end end
test "Uses the Forwarded header when cf-connecting-ip and x-forwarded-for are missing", %{
conn: conn
} do
params = %{
name: "pageview",
domain: "external-controller-test-forwarded.com",
url: "http://gigride.live/"
}
conn
|> put_req_header("content-type", "text/plain")
|> put_req_header("forwarded", "by=0.0.0.0;for=1.1.1.1;host=somehost.com;proto=https")
|> post("/api/event", Jason.encode!(params))
pageview = get_event("external-controller-test-forwarded.com")
assert pageview.country_code == "US"
end
test "Forwarded header can parse ipv6", %{conn: conn} do
params = %{
name: "pageview",
domain: "external-controller-test-forwarded-ipv6.com",
url: "http://gigride.live/"
}
conn
|> put_req_header("content-type", "text/plain")
|> put_req_header(
"forwarded",
"by=0.0.0.0;for=\"[1:1:1:1:1:1:1:1]\",for=0.0.0.0;host=somehost.com;proto=https"
)
|> post("/api/event", Jason.encode!(params))
pageview = get_event("external-controller-test-forwarded-ipv6.com")
assert pageview.country_code == "US"
end
test "URL is decoded", %{conn: conn} do test "URL is decoded", %{conn: conn} do
params = %{ params = %{
name: "pageview", name: "pageview",

View File

@ -51,4 +51,29 @@ defmodule PlausibleWeb.Api.ExternalStatsController.AuthTest do
"Missing site ID. Please provide the required site_id parameter with your request." "Missing site ID. Please provide the required site_id parameter with your request."
} }
end end
test "limits the rate of API requests", %{user: user} do
api_key = insert(:api_key, user_id: user.id, hourly_request_limit: 3)
build_conn()
|> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}")
|> get("/api/v1/stats/aggregate")
build_conn()
|> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}")
|> get("/api/v1/stats/aggregate")
build_conn()
|> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}")
|> get("/api/v1/stats/aggregate")
conn =
build_conn()
|> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}")
|> get("/api/v1/stats/aggregate")
assert json_response(conn, 429) == %{
"error" => "Too many API requests. Your API key is limited to 3 requests per hour."
}
end
end end

View File

@ -203,6 +203,38 @@ defmodule PlausibleWeb.AuthControllerTest do
assert get_session(conn, :current_user_id) == nil assert get_session(conn, :current_user_id) == nil
assert html_response(conn, 200) =~ "Enter your email and password" assert html_response(conn, 200) =~ "Enter your email and password"
end end
test "limits login attempts to 5 per minute" do
user = insert(:user, password: "password")
build_conn()
|> put_req_header("x-forwarded-for", "1.1.1.1")
|> post("/login", email: user.email, password: "wrong")
build_conn()
|> put_req_header("x-forwarded-for", "1.1.1.1")
|> post("/login", email: user.email, password: "wrong")
build_conn()
|> put_req_header("x-forwarded-for", "1.1.1.1")
|> post("/login", email: user.email, password: "wrong")
build_conn()
|> put_req_header("x-forwarded-for", "1.1.1.1")
|> post("/login", email: user.email, password: "wrong")
build_conn()
|> put_req_header("x-forwarded-for", "1.1.1.1")
|> post("/login", email: user.email, password: "wrong")
conn =
build_conn()
|> put_req_header("x-forwarded-for", "1.1.1.1")
|> post("/login", email: user.email, password: "wrong")
assert get_session(conn, :current_user_id) == nil
assert html_response(conn, 429) =~ "Too many login attempts"
end
end end
describe "GET /password/request-reset" do describe "GET /password/request-reset" do

View File

@ -2,9 +2,11 @@
// with some early customers. This script uses a cookie but this was an old version of Plausible. // with some early customers. This script uses a cookie but this was an old version of Plausible.
// Current script can be found in the tracker/src/plausible.js file // Current script can be found in the tracker/src/plausible.js file
(function(window, plausibleHost){ (function(){
'use strict'; 'use strict';
var plausibleHost = new URL(scriptEl.src).origin
function setCookie(name,value) { function setCookie(name,value) {
var date = new Date(); var date = new Date();
date.setTime(date.getTime() + (3*365*24*60*60*1000)); // 3 YEARS date.setTime(date.getTime() + (3*365*24*60*60*1000)); // 3 YEARS
@ -117,4 +119,4 @@
} catch (e) { } catch (e) {
new Image().src = plausibleHost + '/api/error?message=' + encodeURIComponent(e.message); new Image().src = plausibleHost + '/api/error?message=' + encodeURIComponent(e.message);
} }
})(window, '<%= base_url %>'); })();