diff --git a/CHANGELOG.md b/CHANGELOG.md index 314fa850da83..3417aadc74b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file. ### Added +- Snippet integration verification + ### Removed ### Changed diff --git a/Makefile b/Makefile index 3a9d6626854e..a85ee3b8f06b 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,9 @@ postgres-prod: ## Start a container with the same version of postgres as the one postgres-stop: ## Stop and remove the postgres container docker stop plausible_db && docker rm plausible_db +browserless: + docker run -e "TOKEN=dummy_token" -p 3000:3000 --network host ghcr.io/browserless/chromium + minio: ## Start a transient container with a recent version of minio (s3) docker run -d --rm -p 10000:10000 -p 10001:10001 --name plausible_minio minio/minio server /data --address ":10000" --console-address ":10001" while ! docker exec plausible_minio mc alias set local http://localhost:10000 minioadmin minioadmin; do sleep 1; done diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js index 2a0c16a75326..e665c42f87f4 100644 --- a/assets/tailwind.config.js +++ b/assets/tailwind.config.js @@ -5,7 +5,9 @@ module.exports = { content: [ "./js/**/*.js", "../lib/*_web.ex", - "../lib/*_web/**/*.*ex" + "../lib/*_web/**/*.*ex", + "../extra/*_web.ex", + "../extra/*_web/**/*.*ex" ], safelist: [ // PlausibleWeb.StatsView.stats_container_class/1 uses this class diff --git a/config/.env.dev b/config/.env.dev index 58e4ca827d15..d7625ed80679 100644 --- a/config/.env.dev +++ b/config/.env.dev @@ -28,3 +28,5 @@ S3_REGION=us-east-1 S3_ENDPOINT=http://localhost:10000 S3_EXPORTS_BUCKET=dev-exports S3_IMPORTS_BUCKET=dev-imports + +VERIFICATION_ENABLED=true diff --git a/config/runtime.exs b/config/runtime.exs index 23d9e1b7f8f6..fd1eee218524 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -701,6 +701,15 @@ config :plausible, Plausible.PromEx, grafana: :disabled, metrics_server: :disabled +config :plausible, Plausible.Verification, + enabled?: + get_var_from_path_or_env(config_dir, "VERIFICATION_ENABLED", "false") + |> String.to_existing_atom() + +config :plausible, Plausible.Verification.Checks.Installation, + token: get_var_from_path_or_env(config_dir, "BROWSERLESS_TOKEN", "dummy_token"), + endpoint: get_var_from_path_or_env(config_dir, "BROWSERLESS_ENDPOINT", "http://0.0.0.0:3000") + if not is_selfhost do site_default_ingest_threshold = case System.get_env("SITE_DEFAULT_INGEST_THRESHOLD") do diff --git a/config/test.exs b/config/test.exs index d1d9edfac067..5c76d8dff3e4 100644 --- a/config/test.exs +++ b/config/test.exs @@ -31,3 +31,13 @@ config :ex_money, api_module: Plausible.ExchangeRateMock config :plausible, Plausible.Ingestion.Counters, enabled: false config :plausible, Oban, testing: :manual + +config :plausible, Plausible.Verification.Checks.FetchBody, + req_opts: [ + plug: {Req.Test, Plausible.Verification.Checks.FetchBody} + ] + +config :plausible, Plausible.Verification.Checks.Installation, + req_opts: [ + plug: {Req.Test, Plausible.Verification.Checks.Installation} + ] diff --git a/extra/lib/plausible/ingestion/event/revenue.ex b/extra/lib/plausible/ingestion/event/revenue.ex index 94c4a3158043..96c38ab27fd4 100644 --- a/extra/lib/plausible/ingestion/event/revenue.ex +++ b/extra/lib/plausible/ingestion/event/revenue.ex @@ -22,7 +22,8 @@ defmodule Plausible.Ingestion.Event.Revenue do } matching_goal.currency != revenue_source.currency -> - converted = Money.to_currency!(revenue_source, matching_goal.currency) + converted = + Money.to_currency!(revenue_source, matching_goal.currency) %{ revenue_source_amount: Money.to_decimal(revenue_source), diff --git a/lib/plausible/ingestion/event.ex b/lib/plausible/ingestion/event.ex index ec3653b02a22..3f905416a202 100644 --- a/lib/plausible/ingestion/event.ex +++ b/lib/plausible/ingestion/event.ex @@ -21,6 +21,8 @@ defmodule Plausible.Ingestion.Event do salts: nil, changeset: nil + @verification_user_agent Plausible.Verification.user_agent() + @type drop_reason() :: :bot | :spam_referrer @@ -31,6 +33,7 @@ defmodule Plausible.Ingestion.Event do | :site_country_blocklist | :site_page_blocklist | :site_hostname_allowlist + | :verification_agent @type t() :: %__MODULE__{ domain: String.t() | nil, @@ -104,6 +107,7 @@ defmodule Plausible.Ingestion.Event do defp pipeline() do [ + drop_verification_agent: &drop_verification_agent/1, drop_datacenter_ip: &drop_datacenter_ip/1, drop_shield_rule_hostname: &drop_shield_rule_hostname/1, drop_shield_rule_page: &drop_shield_rule_page/1, @@ -167,6 +171,16 @@ defmodule Plausible.Ingestion.Event do struct!(event, clickhouse_session_attrs: Map.merge(event.clickhouse_session_attrs, attrs)) end + defp drop_verification_agent(%__MODULE__{} = event) do + case event.request.user_agent do + @verification_user_agent -> + drop(event, :verification_agent) + + _ -> + event + end + end + defp drop_datacenter_ip(%__MODULE__{} = event) do case event.request.ip_classification do "dc_ip" -> diff --git a/lib/plausible/verification.ex b/lib/plausible/verification.ex new file mode 100644 index 000000000000..61df2b9afb80 --- /dev/null +++ b/lib/plausible/verification.ex @@ -0,0 +1,26 @@ +defmodule Plausible.Verification do + @moduledoc """ + Module defining the user-agent used for site verification. + """ + use Plausible + + @feature_flag :verification + + def enabled?(user) do + enabled_via_config? = + :plausible |> Application.fetch_env!(__MODULE__) |> Keyword.fetch!(:enabled?) + + enabled_for_user? = not is_nil(user) and FunWithFlags.enabled?(@feature_flag, for: user) + enabled_via_config? or enabled_for_user? + end + + on_ee do + def user_agent() do + "Plausible Verification Agent - if abused, contact support@plausible.io" + end + else + def user_agent() do + "Plausible Community Edition" + end + end +end diff --git a/lib/plausible/verification/check.ex b/lib/plausible/verification/check.ex new file mode 100644 index 000000000000..f031eb18880d --- /dev/null +++ b/lib/plausible/verification/check.ex @@ -0,0 +1,37 @@ +defmodule Plausible.Verification.Check do + @moduledoc """ + Behaviour to be implemented by specific site verification checks. + `friendly_name()` doesn't necessarily reflect the actual check description, + it serves as a user-facing message grouping mechanism, to prevent frequent message flashing when checks rotate often. + Each check operates on `state()` and is expected to return it, optionally modified, by all means. + `perform_safe/1` is used to guarantee no exceptions are thrown by faulty implementations, not to interrupt LiveView. + """ + @type state() :: Plausible.Verification.State.t() + @callback friendly_name() :: String.t() + @callback perform(state()) :: state() + + defmacro __using__(_) do + quote do + import Plausible.Verification.State + + alias Plausible.Verification.Checks + alias Plausible.Verification.State + alias Plausible.Verification.Diagnostics + + require Logger + + @behaviour Plausible.Verification.Check + + def perform_safe(state) do + perform(state) + catch + _, e -> + Logger.error( + "Error running check #{inspect(__MODULE__)} on #{state.url}: #{inspect(e)}" + ) + + put_diagnostics(state, service_error: true) + end + end + end +end diff --git a/lib/plausible/verification/checks.ex b/lib/plausible/verification/checks.ex new file mode 100644 index 000000000000..960055d7de36 --- /dev/null +++ b/lib/plausible/verification/checks.ex @@ -0,0 +1,75 @@ +defmodule Plausible.Verification.Checks do + @moduledoc """ + Checks that are performed during site verification. + Each module defined in `@checks` implements the `Plausible.Verification.Check` behaviour. + Checks are normally run asynchronously, except when synchronous execution is optionally required + for tests. Slowdowns can be optionally added, the user doesn't benefit from running the checks too quickly. + + In async execution, each check notifies the caller by sending a message to it. + """ + alias Plausible.Verification.Checks + alias Plausible.Verification.State + + require Logger + + @checks [ + Checks.FetchBody, + Checks.CSP, + Checks.ScanBody, + Checks.Snippet, + Checks.SnippetCacheBust, + Checks.Installation + ] + + def run(url, data_domain, opts \\ []) do + checks = Keyword.get(opts, :checks, @checks) + report_to = Keyword.get(opts, :report_to, self()) + async? = Keyword.get(opts, :async?, true) + slowdown = Keyword.get(opts, :slowdown, 500) + + if async? do + Task.start_link(fn -> do_run(url, data_domain, checks, report_to, slowdown) end) + else + do_run(url, data_domain, checks, report_to, slowdown) + end + end + + def interpret_diagnostics(%State{} = state) do + Plausible.Verification.Diagnostics.interpret(state.diagnostics, state.url) + end + + defp do_run(url, data_domain, checks, report_to, slowdown) do + init_state = %State{url: url, data_domain: data_domain, report_to: report_to} + + state = + Enum.reduce( + checks, + init_state, + fn check, state -> + state + |> notify_start(check, slowdown) + |> check.perform_safe() + end + ) + + notify_verification_end(state, slowdown) + end + + defp notify_start(state, check, slowdown) do + if is_pid(state.report_to) do + if is_integer(slowdown) and slowdown > 0, do: :timer.sleep(slowdown) + send(state.report_to, {:verification_check_start, {check, state}}) + end + + state + end + + defp notify_verification_end(state, slowdown) do + if is_pid(state.report_to) do + if is_integer(slowdown) and slowdown > 0, do: :timer.sleep(slowdown) + send(state.report_to, {:verification_end, state}) + end + + state + end +end diff --git a/lib/plausible/verification/checks/csp.ex b/lib/plausible/verification/checks/csp.ex new file mode 100644 index 000000000000..5ed5fb9116e9 --- /dev/null +++ b/lib/plausible/verification/checks/csp.ex @@ -0,0 +1,34 @@ +defmodule Plausible.Verification.Checks.CSP do + @moduledoc """ + Scans the Content Security Policy header to ensure that the Plausible domain is allowed. + See `Plausible.Verification.Checks` for the execution sequence. + """ + use Plausible.Verification.Check + + @impl true + def friendly_name, do: "We're visiting your site to ensure that everything is working correctly" + + @impl true + def perform(%State{assigns: %{headers: headers}} = state) do + case headers["content-security-policy"] do + [policy] -> + directives = String.split(policy, ";") + + allowed? = + Enum.any?(directives, fn directive -> + String.contains?(directive, PlausibleWeb.Endpoint.host()) + end) + + if allowed? do + state + else + put_diagnostics(state, disallowed_via_csp?: true) + end + + _ -> + state + end + end + + def perform(state), do: state +end diff --git a/lib/plausible/verification/checks/fetch_body.ex b/lib/plausible/verification/checks/fetch_body.ex new file mode 100644 index 000000000000..206f1d2a2b4c --- /dev/null +++ b/lib/plausible/verification/checks/fetch_body.ex @@ -0,0 +1,64 @@ +defmodule Plausible.Verification.Checks.FetchBody do + @moduledoc """ + Fetches the body of the site and extracts the HTML document, if available, for + further processing. + See `Plausible.Verification.Checks` for the execution sequence. + """ + use Plausible.Verification.Check + + @impl true + def friendly_name, do: "We're visiting your site to ensure that everything is working correctly" + + @impl true + def perform(%State{url: "https://" <> _ = url} = state) do + fetch_body_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || [] + + opts = + Keyword.merge( + [ + base_url: url, + max_redirects: 2, + connect_options: [timeout: 4_000], + receive_timeout: 4_000, + max_retries: 3, + retry_log_level: :warning + ], + fetch_body_opts + ) + + req = Req.new(opts) + + case Req.get(req) do + {:ok, %Req.Response{status: status, body: body} = response} + when is_binary(body) and status in 200..299 -> + extract_document(state, response) + + _ -> + state + end + end + + defp extract_document(state, response) when byte_size(response.body) <= 500_000 do + with true <- html?(response), + {:ok, document} <- Floki.parse_document(response.body) do + state + |> assign(raw_body: response.body, document: document, headers: response.headers) + |> put_diagnostics(body_fetched?: true) + else + _ -> + state + end + end + + defp extract_document(state, response) when byte_size(response.body) > 500_000 do + state + end + + defp html?(%Req.Response{headers: headers}) do + headers + |> Map.get("content-type", "") + |> List.wrap() + |> List.first() + |> String.contains?("text/html") + end +end diff --git a/lib/plausible/verification/checks/installation.ex b/lib/plausible/verification/checks/installation.ex new file mode 100644 index 000000000000..163cf72e92bf --- /dev/null +++ b/lib/plausible/verification/checks/installation.ex @@ -0,0 +1,69 @@ +defmodule Plausible.Verification.Checks.Installation do + @verification_script_filename "verification/verify_plausible_installed.js" + @verification_script_path Path.join(:code.priv_dir(:plausible), @verification_script_filename) + @external_resource @verification_script_path + @code File.read!(@verification_script_path) + + @moduledoc """ + Calls the browserless.io service (local instance can be spawned with `make browserless`) + and runs #{@verification_script_filename} via the [function API](https://docs.browserless.io/HTTP-APIs/function). + + The successful execution assumes the following JSON payload: + - `data.plausibleInstalled` - boolean indicating whether the `plausible()` window function was found + - `data.callbackStatus` - integer. 202 indicates that the server acknowledged the test event. + + The test event ingestion is discarded based on user-agent, see: `Plausible.Verification.user_agent/0` + """ + use Plausible.Verification.Check + + @impl true + def friendly_name, do: "We're verifying that your visitors are being counted correctly" + + @impl true + def perform(%State{url: url} = state) do + opts = [ + headers: %{content_type: "application/json"}, + body: + Jason.encode!(%{ + code: @code, + context: %{ + url: Plausible.Verification.URL.bust_url(url), + userAgent: Plausible.Verification.user_agent(), + debug: Application.get_env(:plausible, :environment) == "dev" + } + }), + retry: :transient, + retry_log_level: :warning, + max_retries: 2, + receive_timeout: 6_000 + ] + + extra_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || [] + opts = Keyword.merge(opts, extra_opts) + + case Req.post(verification_endpoint(), opts) do + {:ok, + %{ + status: 200, + body: %{ + "data" => %{"plausibleInstalled" => installed?, "callbackStatus" => callback_status} + } + }} + when is_boolean(installed?) -> + put_diagnostics(state, plausible_installed?: installed?, callback_status: callback_status) + + {:ok, %{status: status}} -> + put_diagnostics(state, plausible_installed?: false, service_error: status) + + {:error, %{reason: reason}} -> + put_diagnostics(state, plausible_installed?: false, service_error: reason) + end + end + + defp verification_endpoint() do + config = Application.fetch_env!(:plausible, __MODULE__) + token = Keyword.fetch!(config, :token) + endpoint = Keyword.fetch!(config, :endpoint) + Path.join(endpoint, "function?token=#{token}") + end +end diff --git a/lib/plausible/verification/checks/scan_body.ex b/lib/plausible/verification/checks/scan_body.ex new file mode 100644 index 000000000000..1239ea8824c2 --- /dev/null +++ b/lib/plausible/verification/checks/scan_body.ex @@ -0,0 +1,65 @@ +defmodule Plausible.Verification.Checks.ScanBody do + @moduledoc """ + Naive way of detecting GTM and WordPress powered sites. + """ + use Plausible.Verification.Check + + @impl true + def friendly_name, do: "We're visiting your site to ensure that everything is working correctly" + + @impl true + def perform(%State{assigns: %{raw_body: body}} = state) when is_binary(body) do + state + |> scan_wp_plugin() + |> scan_gtm() + |> scan_wp() + end + + def perform(state), do: state + + defp scan_wp_plugin(%{assigns: %{document: document}} = state) do + case Floki.find(document, ~s|meta[name="plausible-analytics-version"]|) do + [] -> + state + + [_] -> + state + |> assign(skip_wordpress_check: true) + |> put_diagnostics(wordpress_likely?: true, wordpress_plugin?: true) + end + end + + defp scan_wp_plugin(state) do + state + end + + @gtm_signatures [ + "googletagmanager.com/gtm.js" + ] + + defp scan_gtm(state) do + if Enum.any?(@gtm_signatures, &String.contains?(state.assigns.raw_body, &1)) do + put_diagnostics(state, gtm_likely?: true) + else + state + end + end + + @wordpress_signatures [ + "wp-content", + "wp-includes", + "wp-json" + ] + + defp scan_wp(%{assigns: %{skip_wordpress_check: true}} = state) do + state + end + + defp scan_wp(state) do + if Enum.any?(@wordpress_signatures, &String.contains?(state.assigns.raw_body, &1)) do + put_diagnostics(state, wordpress_likely?: true) + else + state + end + end +end diff --git a/lib/plausible/verification/checks/snippet.ex b/lib/plausible/verification/checks/snippet.ex new file mode 100644 index 000000000000..f43950ec66cf --- /dev/null +++ b/lib/plausible/verification/checks/snippet.ex @@ -0,0 +1,52 @@ +defmodule Plausible.Verification.Checks.Snippet do + @moduledoc """ + The check looks for Plausible snippets and tries to address the common + integration issues, such as bad placement, data-domain typos, unknown + attributes frequently added by performance optimization plugins, etc. + """ + use Plausible.Verification.Check + + @impl true + def friendly_name, do: "We're looking for the Plausible snippet on your site" + + @impl true + def perform(%State{assigns: %{document: document}} = state) do + in_head = Floki.find(document, "head script[data-domain]") + in_body = Floki.find(document, "body script[data-domain]") + + all = in_head ++ in_body + + put_diagnostics(state, + snippets_found_in_head: Enum.count(in_head), + snippets_found_in_body: Enum.count(in_body), + proxy_likely?: proxy_likely?(all), + snippet_unknown_attributes?: unknown_attributes?(all), + data_domain_mismatch?: data_domain_mismatch?(all, state.data_domain) + ) + end + + def perform(state), do: state + + defp proxy_likely?(nodes) do + nodes + |> Floki.attribute("src") + |> Enum.any?(&(not String.starts_with?(&1, PlausibleWeb.Endpoint.url()))) + end + + @known_attributes ["data-domain", "src", "defer", "data-api", "data-exclude", "data-include"] + @known_prefix "event-" + + defp unknown_attributes?(nodes) do + Enum.any?(nodes, fn {_, attrs, _} -> + Enum.any?(attrs, fn {key, _} -> + key not in @known_attributes and not String.starts_with?(key, @known_prefix) + end) + end) + end + + defp data_domain_mismatch?(nodes, data_domain) do + nodes + |> Floki.attribute("data-domain") + |> Enum.any?(&(&1 != data_domain and data_domain not in String.split(&1, ","))) + end +end diff --git a/lib/plausible/verification/checks/snippet_cache_bust.ex b/lib/plausible/verification/checks/snippet_cache_bust.ex new file mode 100644 index 000000000000..0b1123c981f4 --- /dev/null +++ b/lib/plausible/verification/checks/snippet_cache_bust.ex @@ -0,0 +1,40 @@ +defmodule Plausible.Verification.Checks.SnippetCacheBust do + @moduledoc """ + A naive way of trying to figure out whether the latest site contents + is wrapped with some CDN/caching layer. + In case no snippets were found, we'll try to bust the cache by appending a random query parameter + and re-run `Plausible.Verification.Checks.FetchBody` and `Plausible.Verification.Checks.Snippet` checks. + If the result is different this time, we'll assume cache likely. + """ + use Plausible.Verification.Check + + @impl true + def friendly_name, do: "We're looking for the Plausible snippet on your site" + + @impl true + def perform( + %State{ + url: url, + diagnostics: %Diagnostics{ + snippets_found_in_head: 0, + snippets_found_in_body: 0, + body_fetched?: true + } + } = state + ) do + state2 = + %{state | url: Plausible.Verification.URL.bust_url(url)} + |> Plausible.Verification.Checks.FetchBody.perform() + |> Plausible.Verification.Checks.ScanBody.perform() + |> Plausible.Verification.Checks.Snippet.perform() + + if state2.diagnostics.snippets_found_in_head > 0 or + state2.diagnostics.snippets_found_in_body > 0 do + put_diagnostics(state2, snippet_found_after_busting_cache?: true) + else + state + end + end + + def perform(state), do: state +end diff --git a/lib/plausible/verification/diagnostics.ex b/lib/plausible/verification/diagnostics.ex new file mode 100644 index 000000000000..06f26d20f40a --- /dev/null +++ b/lib/plausible/verification/diagnostics.ex @@ -0,0 +1,378 @@ +defmodule Plausible.Verification.Diagnostics do + @moduledoc """ + Module responsible for translating diagnostics to user-friendly messages and recommendations. + """ + require Logger + + defstruct plausible_installed?: false, + snippets_found_in_head: 0, + snippets_found_in_body: 0, + snippet_found_after_busting_cache?: false, + snippet_unknown_attributes?: false, + disallowed_via_csp?: false, + service_error: nil, + body_fetched?: false, + wordpress_likely?: false, + gtm_likely?: false, + callback_status: -1, + proxy_likely?: false, + data_domain_mismatch?: false, + wordpress_plugin?: false + + @type t :: %__MODULE__{} + + defmodule Result do + @moduledoc """ + Diagnostics interpretation result. + """ + defstruct ok?: false, errors: [], recommendations: [] + @type t :: %__MODULE__{} + end + + @spec interpret(t(), String.t()) :: Result.t() + def interpret( + %__MODULE__{ + plausible_installed?: true, + snippets_found_in_head: 1, + snippets_found_in_body: 0, + callback_status: 202, + snippet_found_after_busting_cache?: false, + service_error: nil, + data_domain_mismatch?: false + }, + _url + ) do + %Result{ok?: true} + end + + def interpret(%__MODULE__{plausible_installed?: false, gtm_likely?: true}, _url) do + %Result{ + ok?: false, + errors: ["We encountered an issue with your Plausible integration"], + recommendations: [ + {"As you're using Google Tag Manager, you'll need to use a GTM-specific Plausible snippet", + "https://plausible.io/docs/google-tag-manager"} + ] + } + end + + def interpret( + %__MODULE__{ + plausible_installed?: false, + snippets_found_in_head: 1, + disallowed_via_csp?: true, + proxy_likely?: false + }, + _url + ) do + %Result{ + ok?: false, + errors: ["We encountered an issue with your site's CSP"], + recommendations: [ + {"Please add plausible.io domain specifically to the allowed list of domains in your Content Security Policy (CSP)", + "https://plausible.io/docs/troubleshoot-integration"} + ] + } + end + + def interpret( + %__MODULE__{ + plausible_installed?: false, + snippets_found_in_head: 0, + snippets_found_in_body: 0, + body_fetched?: true, + service_error: nil, + wordpress_likely?: false + }, + _url + ) do + %Result{ + ok?: false, + errors: ["We couldn't find the Plausible snippet on your site"], + recommendations: [ + {"Please insert the snippet into your site", "https://plausible.io/docs/plausible-script"} + ] + } + end + + def interpret( + %__MODULE__{ + plausible_installed?: false, + body_fetched?: false + }, + url + ) do + %Result{ + ok?: false, + errors: ["We couldn't reach #{url}. Is your site up?"], + recommendations: [ + {"If your site is running at a different location, please manually check your integration", + "https://plausible.io/docs/troubleshoot-integration"} + ] + } + end + + def interpret( + %__MODULE__{ + plausible_installed?: false, + service_error: service_error + }, + _url + ) + when not is_nil(service_error) do + %Result{ + ok?: false, + errors: ["We encountered a temporary problem verifying your website"], + recommendations: [ + {"Please try again in a few minutes or manually check your integration", + "https://plausible.io/docs/troubleshoot-integration"} + ] + } + end + + def interpret( + %__MODULE__{ + plausible_installed?: true, + service_error: nil, + body_fetched?: false + }, + url + ) do + %Result{ + ok?: false, + errors: ["We couldn't reach #{url}. Is your site up?"], + recommendations: [ + {"If your site is running at a different location, please manually check your integration", + "https://plausible.io/docs/troubleshoot-integration"} + ] + } + end + + def interpret( + %__MODULE__{ + plausible_installed?: true, + callback_status: callback_status, + proxy_likely?: true + }, + _url + ) + when callback_status != 202 do + %Result{ + ok?: false, + errors: ["We encountered an error with your Plausible proxy"], + recommendations: [ + {"Please check whether you've configured the /event route correctly", + "https://plausible.io/docs/proxy/introduction"} + ] + } + end + + def interpret( + %__MODULE__{ + plausible_installed?: false, + snippets_found_in_head: 1, + proxy_likely?: true, + wordpress_likely?: true, + wordpress_plugin?: false + }, + _url + ) do + %Result{ + ok?: false, + errors: ["We encountered an error with your Plausible proxy"], + recommendations: [ + {"Please re-enable the proxy in our WordPress plugin to start counting your visitors", + "https://plausible.io/wordpress-analytics-plugin"} + ] + } + end + + def interpret( + %__MODULE__{ + plausible_installed?: false, + snippets_found_in_head: 1, + proxy_likely?: true, + wordpress_likely?: false + }, + _url + ) do + %Result{ + ok?: false, + errors: ["We encountered an error with your Plausible proxy"], + recommendations: [ + {"Please check your proxy configuration to make sure it's set up correctly", + "https://plausible.io/docs/proxy/introduction"} + ] + } + end + + def interpret( + %__MODULE__{snippets_found_in_head: count_head, snippets_found_in_body: count_body}, + _url + ) + when count_head + count_body > 1 do + %Result{ + ok?: false, + errors: ["We've found multiple Plausible snippets on your site."], + recommendations: [ + {"Please ensure that only one snippet is used", + "https://plausible.io/docs/troubleshoot-integration"} + ] + } + end + + def interpret( + %__MODULE__{ + plausible_installed?: true, + callback_status: 202, + snippet_found_after_busting_cache?: true, + wordpress_likely?: true, + wordpress_plugin?: true + }, + _url + ) do + %Result{ + ok?: false, + errors: ["We encountered an issue with your site cache"], + recommendations: [ + {"Please clear your WordPress cache to ensure that the latest version of your site is being displayed to all your visitors", + "https://plausible.io/wordpress-analytics-plugin"} + ] + } + end + + def interpret( + %__MODULE__{ + plausible_installed?: true, + callback_status: 202, + snippet_found_after_busting_cache?: true, + wordpress_likely?: true, + wordpress_plugin?: false + }, + _url + ) do + %Result{ + ok?: false, + errors: ["We encountered an issue with your site cache"], + recommendations: [ + {"Please install and activate our WordPress plugin to start counting your visitors", + "https://plausible.io/wordpress-analytics-plugin"} + ] + } + end + + def interpret( + %__MODULE__{ + plausible_installed?: true, + callback_status: 202, + snippet_found_after_busting_cache?: true, + wordpress_likely?: false + }, + _url + ) do + %Result{ + ok?: false, + errors: ["We encountered an issue with your site cache"], + recommendations: [ + {"Please clear your cache (or wait for your provider to clear it) to ensure that the latest version of your site is being displayed to all your visitors", + "https://plausible.io/docs/troubleshoot-integration"} + ] + } + end + + def interpret(%__MODULE__{snippets_found_in_head: 0, snippets_found_in_body: n}, _url) + when n >= 1 do + %Result{ + ok?: false, + errors: ["Plausible snippet is placed in the body of your site"], + recommendations: [ + {"Please relocate the snippet to the header of your site", + "https://plausible.io/docs/troubleshoot-integration"} + ] + } + end + + def interpret(%__MODULE__{data_domain_mismatch?: true}, url) do + %Result{ + ok?: false, + errors: ["Your data-domain is different than #{url}"], + recommendations: [ + {"Please ensure that the site in the data-domain attribute is an exact match to the site as you added it to your Plausible account", + "https://plausible.io/docs/troubleshoot-integration"} + ] + } + end + + def interpret( + %__MODULE__{ + plausible_installed?: false, + snippet_unknown_attributes?: true, + wordpress_likely?: true, + wordpress_plugin?: true + }, + _url + ) do + %Result{ + ok?: false, + errors: ["A performance optimization plugin seems to have altered our snippet"], + recommendations: [ + {"Please whitelist our script in your performance optimization plugin to stop it from changing our snippet", + "https://plausible.io/wordpress-analytics-plugin "} + ] + } + end + + def interpret( + %__MODULE__{ + plausible_installed?: false, + snippet_unknown_attributes?: true, + wordpress_likely?: true, + wordpress_plugin?: false + }, + _url + ) do + %Result{ + ok?: false, + errors: ["A performance optimization plugin seems to have altered our snippet"], + recommendations: [ + {"Please install and activate our WordPress plugin to avoid the most common plugin conflicts", + "https://plausible.io/wordpress-analytics-plugin "} + ] + } + end + + def interpret( + %__MODULE__{ + plausible_installed?: false, + snippet_unknown_attributes?: true, + wordpress_likely?: false + }, + _url + ) do + %Result{ + ok?: false, + errors: ["Something seems to have altered our snippet"], + recommendations: [ + {"Please manually check your integration to make sure that nothing prevents our script from working", + "https://plausible.io/docs/troubleshoot-integration"} + ] + } + end + + def interpret(rating, url) do + Sentry.capture_message("Unhandled case for site verification: #{url}", + extra: %{ + message: inspect(rating) + } + ) + + %Result{ + ok?: false, + errors: ["Your Plausible integration is not working"], + recommendations: [ + {"Please manually check your integration to make sure that the Plausible snippet has been inserted correctly", + "https://plausible.io/docs/troubleshoot-integration"} + ] + } + end +end diff --git a/lib/plausible/verification/state.ex b/lib/plausible/verification/state.ex new file mode 100644 index 000000000000..7142f25ca8d7 --- /dev/null +++ b/lib/plausible/verification/state.ex @@ -0,0 +1,27 @@ +defmodule Plausible.Verification.State do + @moduledoc """ + The struct and interface describing the state of the site verification process. + Assigns are meant to be used to communicate between checks, while diagnostics + are later on interpreted (translated into user-friendly messages and recommendations) + via `Plausible.Verification.Diagnostics` module. + """ + defstruct url: nil, + data_domain: nil, + report_to: nil, + assigns: %{}, + diagnostics: %Plausible.Verification.Diagnostics{} + + @type t() :: %__MODULE__{} + + def assign(%__MODULE__{} = state, assigns) do + %{state | assigns: Map.merge(state.assigns, Enum.into(assigns, %{}))} + end + + def put_diagnostics(%__MODULE__{} = state, diagnostics) when is_list(diagnostics) do + %{state | diagnostics: struct!(state.diagnostics, diagnostics)} + end + + def put_diagnostics(%__MODULE__{} = state, diagnostics) do + put_diagnostics(state, List.wrap(diagnostics)) + end +end diff --git a/lib/plausible/verification/url.ex b/lib/plausible/verification/url.ex new file mode 100644 index 000000000000..1806c8d3b661 --- /dev/null +++ b/lib/plausible/verification/url.ex @@ -0,0 +1,25 @@ +defmodule Plausible.Verification.URL do + @moduledoc """ + Busting some caches by appending ?plausible_verification=12345 to it. + """ + + def bust_url(url) do + cache_invalidator = abs(:erlang.unique_integer()) + update_url(url, cache_invalidator) + end + + defp update_url(url, invalidator) do + url + |> URI.parse() + |> then(fn uri -> + updated_query = + (uri.query || "") + |> URI.decode_query() + |> Map.put("plausible_verification", invalidator) + |> URI.encode_query() + + struct!(uri, query: updated_query) + end) + |> to_string() + end +end diff --git a/lib/plausible_web/components/generic.ex b/lib/plausible_web/components/generic.ex index ce8c23d4cb3c..ee0f074b6b9e 100644 --- a/lib/plausible_web/components/generic.ex +++ b/lib/plausible_web/components/generic.ex @@ -356,8 +356,34 @@ defmodule PlausibleWeb.Components.Generic do apply(Heroicons, assigns.name, [assigns]) end + attr :width, :integer, default: 100 + attr :height, :integer, default: 100 + attr :id, :string, default: "shuttle" + + def shuttle(assigns) do + ~H""" + + + + + + """ + end + defp icon_class(link_assigns) do - if String.contains?(link_assigns[:class], "text-sm") do + if String.contains?(link_assigns[:class], "text-sm") or + String.contains?(link_assigns[:class], "text-xs") do ["w-3 h-3"] else ["w-4 h-4"] diff --git a/lib/plausible_web/controllers/site_controller.ex b/lib/plausible_web/controllers/site_controller.ex index 738ef40c3b81..887c68d0ebf5 100644 --- a/lib/plausible_web/controllers/site_controller.ex +++ b/lib/plausible_web/controllers/site_controller.ex @@ -129,6 +129,7 @@ defmodule PlausibleWeb.SiteController do |> render("settings_general.html", site: site, changeset: Plausible.Site.changeset(site, %{}), + connect_live_socket: true, dogfood_page_path: "/:dashboard/settings/general", layout: {PlausibleWeb.LayoutView, "site_settings.html"} ) diff --git a/lib/plausible_web/controllers/stats_controller.ex b/lib/plausible_web/controllers/stats_controller.ex index d19864743201..1325fe642a93 100644 --- a/lib/plausible_web/controllers/stats_controller.ex +++ b/lib/plausible_web/controllers/stats_controller.ex @@ -56,9 +56,10 @@ defmodule PlausibleWeb.StatsController do can_see_stats? = not Sites.locked?(site) or conn.assigns[:current_user_role] == :super_admin demo = site.domain == PlausibleWeb.Endpoint.host() dogfood_page_path = if !demo, do: "/:dashboard" + skip_to_dashboard? = conn.params["skip_to_dashboard"] == "true" cond do - stats_start_date && can_see_stats? -> + (stats_start_date && can_see_stats?) || (can_see_stats? && skip_to_dashboard?) -> conn |> put_resp_header("x-robots-tag", "noindex, nofollow") |> render("stats.html", @@ -80,7 +81,8 @@ defmodule PlausibleWeb.StatsController do !stats_start_date && can_see_stats? -> render(conn, "waiting_first_pageview.html", site: site, - dogfood_page_path: dogfood_page_path + dogfood_page_path: dogfood_page_path, + connect_live_socket: true ) Sites.locked?(site) -> diff --git a/lib/plausible_web/live/components/verification.ex b/lib/plausible_web/live/components/verification.ex new file mode 100644 index 000000000000..e483abe6a598 --- /dev/null +++ b/lib/plausible_web/live/components/verification.ex @@ -0,0 +1,145 @@ +defmodule PlausibleWeb.Live.Components.Verification do + @moduledoc """ + This component is responsible for rendering the verification progress + and diagnostics. + """ + use Phoenix.LiveComponent + use Plausible + + import PlausibleWeb.Components.Generic + + attr :domain, :string, required: true + attr :modal?, :boolean, default: false + + attr :message, :string, + default: "We're visiting your site to ensure that everything is working correctly" + + attr :finished?, :boolean, default: false + attr :success?, :boolean, default: false + attr :interpretation, Plausible.Verification.Diagnostics.Result, default: nil + attr :attempts, :integer, default: 0 + + def render(assigns) do + ~H""" +
+

+ <%= if @success? && @finished? do %> + Success! + <% else %> + Verifying your integration + <% end %> +

+

+ <%= if @finished? && @success? do %> + Your integration is working and visitors are being counted accurately + <% else %> + on <%= @domain %> + <% end %> +

+
+
+ +
+ +
+ <.shuttle width={50} height={50} /> +
+
+

+ <%= @message %> + + <%= List.first(@interpretation.errors) %> +

+ <.recommendations interpretation={@interpretation} /> +
+ +

+ Awaiting your first pageview. +

+

+
+ +
+
+
+ <.button_link :if={!@success?} href="#" phx-click="retry" class="text-xs font-bold"> + Verify integration again + + <.button_link + :if={@success?} + href={"/#{URI.encode_www_form(@domain)}?skip_to_dashboard=true"} + class="text-xs font-bold" + > + Go to the dashboard + +
+
+ <%= if ee?() && @finished? && !@success? && @attempts >= 3 do %> + Need further help with your integration? Do + <.styled_link href="https://plausible.io/contact"> + contact us + +
+ <% end %> + <%= if !@modal? && !@success? do %> + Need to see the snippet again? + <.styled_link href={"/#{URI.encode_www_form(@domain)}/snippet"}> + Click here + +
Run verification later and go to Site Settings? + <.styled_link href={"/#{URI.encode_www_form(@domain)}/settings/general"}> + Click here + +
+ <% end %> +
+
+ """ + end + + def recommendations(assigns) do + ~H""" +

+ + <%= recommendation %> + <%= elem(recommendation, 0) %> - + <.styled_link + :if={is_tuple(recommendation)} + href={elem(recommendation, 1)} + new_tab={true} + class="text-xs" + > + Learn more + +
+
+

+ """ + end +end diff --git a/lib/plausible_web/live/verification.ex b/lib/plausible_web/live/verification.ex new file mode 100644 index 000000000000..b3f810995b2d --- /dev/null +++ b/lib/plausible_web/live/verification.ex @@ -0,0 +1,156 @@ +defmodule PlausibleWeb.Live.Verification do + @moduledoc """ + LiveView coordinating the site verification process. + Onboarding new sites, renders a standalone component. + Embedded modal variant is available for general site settings. + """ + use PlausibleWeb, :live_view + use Phoenix.HTML + + alias Plausible.Verification.{Checks, State} + alias PlausibleWeb.Live.Components.Modal + + @component PlausibleWeb.Live.Components.Verification + @slowdown_for_frequent_checking :timer.seconds(5) + + def mount( + :not_mounted_at_router, + %{"domain" => domain} = session, + socket + ) do + socket = + assign(socket, + domain: domain, + modal?: !!session["modal?"], + component: @component, + report_to: session["report_to"] || self(), + delay: session["slowdown"] || 500, + slowdown: session["slowdown"] || 500, + checks_pid: nil, + attempts: 0 + ) + + if connected?(socket) and !session["modal?"] do + launch_delayed(socket) + end + + {:ok, socket} + end + + def render(assigns) do + ~H""" +
+ <.live_component module={Modal} id="verification-modal"> + <.live_component + module={@component} + domain={@domain} + id="verification-within-modal" + modal?={@modal?} + attempts={@attempts} + /> + + + + Verify your integration + +
+ + <.live_component + :if={!@modal?} + module={@component} + domain={@domain} + id="verification-standalone" + attempts={@attempts} + /> + """ + end + + def handle_event("launch-verification", _, socket) do + launch_delayed(socket) + {:noreply, reset_component(socket)} + end + + def handle_event("retry", _, socket) do + launch_delayed(socket) + {:noreply, reset_component(socket)} + end + + def handle_info({:start, report_to}, socket) do + if is_pid(socket.assigns.checks_pid) and Process.alive?(socket.assigns.checks_pid) do + {:noreply, socket} + else + case Plausible.RateLimit.check_rate( + "site_verification_#{socket.assigns.domain}", + :timer.minutes(60), + 3 + ) do + {:allow, _} -> :ok + {:deny, _} -> :timer.sleep(@slowdown_for_frequent_checking) + end + + {:ok, pid} = + Checks.run( + "https://#{socket.assigns.domain}", + socket.assigns.domain, + report_to: report_to, + slowdown: socket.assigns.slowdown + ) + + {:noreply, assign(socket, checks_pid: pid, attempts: socket.assigns.attempts + 1)} + end + end + + def handle_info({:verification_check_start, {check, _state}}, socket) do + update_component(socket, + message: check.friendly_name() + ) + + {:noreply, socket} + end + + def handle_info({:verification_end, %State{} = state}, socket) do + interpretation = Checks.interpret_diagnostics(state) + + update_component(socket, + finished?: true, + success?: interpretation.ok?, + interpretation: interpretation + ) + + {:noreply, assign(socket, checks_pid: nil)} + end + + defp reset_component(socket) do + update_component(socket, + message: "We're visiting your site to ensure that everything is working correctly", + finished?: false, + success?: false, + diagnostics: nil + ) + + socket + end + + defp update_component(socket, updates) do + send_update( + @component, + Keyword.merge(updates, + id: + if(socket.assigns.modal?, + do: "verification-within-modal", + else: "verification-standalone" + ) + ) + ) + end + + defp launch_delayed(socket) do + Process.send_after(self(), {:start, socket.assigns.report_to}, socket.assigns.delay) + end +end diff --git a/lib/plausible_web/templates/site/settings_general.html.heex b/lib/plausible_web/templates/site/settings_general.html.heex index c918df149288..a561d2425414 100644 --- a/lib/plausible_web/templates/site/settings_general.html.heex +++ b/lib/plausible_web/templates/site/settings_general.html.heex @@ -108,5 +108,16 @@ + +
+ <%= live_render(@conn, PlausibleWeb.Live.Verification, + session: %{ + "site_id" => @site.id, + "domain" => @site.domain, + "modal?" => true, + "slowdown" => @conn.private[:verification_slowdown] + } + ) %> +
<% end %> diff --git a/lib/plausible_web/templates/site/snippet.html.heex b/lib/plausible_web/templates/site/snippet.html.heex index de462c0bfcd1..729962354dc7 100644 --- a/lib/plausible_web/templates/site/snippet.html.heex +++ b/lib/plausible_web/templates/site/snippet.html.heex @@ -7,7 +7,11 @@ <%= form_for @conn, "/", [class: "max-w-lg w-full mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8"], fn f -> %>

Add JavaScript snippet

-

+

+ Include this snippet in the <head> + section of your website.
To verify your integration, click the button below to confirm that everything is working correctly. +

+

Paste this snippet in the <head> of your website.

@@ -60,7 +64,13 @@

- <%= link("Start collecting data →", + <% button_label = + if Plausible.Verification.enabled?(@current_user) do + "Verify your integration to start collecting data →" + else + "Start collecting data →" + end %> + <%= link(button_label, class: "button mt-4 w-full", to: "/#{URI.encode_www_form(@site.domain)}" ) %> diff --git a/lib/plausible_web/templates/stats/waiting_first_pageview.html.heex b/lib/plausible_web/templates/stats/waiting_first_pageview.html.heex index 5bc248735e1e..78b0a14fc0bf 100644 --- a/lib/plausible_web/templates/stats/waiting_first_pageview.html.heex +++ b/lib/plausible_web/templates/stats/waiting_first_pageview.html.heex @@ -11,7 +11,6 @@ setInterval(updateStatus, 5000) -
<%= if @site.locked do %>
This dashboard is actually locked. You are viewing it with super-admin access

<% end %> -
+

Waiting for first pageview

on <%= @site.domain %>

@@ -58,4 +60,14 @@

+ + <%= if Plausible.Verification.enabled?(assigns[:current_user]), + do: + live_render(@conn, PlausibleWeb.Live.Verification, + session: %{ + "site_id" => @site.id, + "domain" => @site.domain, + "slowdown" => @conn.private[:verification_slowdown] + } + ) %>
diff --git a/mix.exs b/mix.exs index 241606682636..5e4ccb0a0451 100644 --- a/mix.exs +++ b/mix.exs @@ -84,8 +84,8 @@ defmodule Plausible.MixProject do {:eqrcode, "~> 0.1.10"}, {:ex_machina, "~> 2.3", only: [:dev, :test, :ce_dev, :ce_test]}, {:excoveralls, "~> 0.10", only: :test}, - {:finch, "~> 0.16.0"}, - {:floki, "~> 0.35.0", only: [:dev, :test, :ce_dev, :ce_test]}, + {:finch, "~> 0.17.0"}, + {:floki, "~> 0.35.0"}, {:fun_with_flags, "~> 1.11.0"}, {:fun_with_flags_ui, "~> 1.0"}, {:locus, "~> 2.3"}, @@ -142,7 +142,8 @@ defmodule Plausible.MixProject do {:ex_aws_s3, "~> 2.5"}, {:sweet_xml, "~> 0.7.4"}, {:zstream, "~> 0.6.4"}, - {:con_cache, "~> 1.1.0"} + {:con_cache, "~> 1.1.0"}, + {:req, "~> 0.4.14"} ] end diff --git a/mix.lock b/mix.lock index 17c0d8152ca0..54232150aba6 100644 --- a/mix.lock +++ b/mix.lock @@ -8,7 +8,7 @@ "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.1.0", "0b110a9a6c619b19a7f73fa3004aa11d6e719a67e672d1633dc36b6b2290a0f7", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2ad2acb5a8bc049e8d5aa267802631912bb80d5f4110a178ae7999e69dca1bf7"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, - "castore": {:hex, :castore, "1.0.6", "ffc42f110ebfdafab0ea159cd43d31365fa0af0ce4a02ecebf1707ae619ee727", [:mix], [], "hexpm", "374c6e7ca752296be3d6780a6d5b922854ffcc74123da90f2f328996b962d33a"}, + "castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, "ch": {:hex, :ch, "0.2.5", "b8d70689951bd14c8c8791dc72cdc957ba489ceae723e79cf1a91d95b6b855ae", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "97de104c8f513a23c6d673da37741f68ae743f6cdb654b96a728d382e2fba4de"}, "chatterbox": {:hex, :ts_chatterbox, "0.15.1", "5cac4d15dd7ad61fc3c4415ce4826fc563d4643dee897a558ec4ea0b1c835c9c", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "4f75b91451338bc0da5f52f3480fa6ef6e3a2aeecfc33686d6b3d0a0948f31aa"}, @@ -53,7 +53,7 @@ "excoveralls": {:hex, :excoveralls, "0.18.0", "b92497e69465dc51bc37a6422226ee690ab437e4c06877e836f1c18daeb35da9", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1109bb911f3cb583401760be49c02cbbd16aed66ea9509fc5479335d284da60b"}, "expo": {:hex, :expo, "0.5.1", "249e826a897cac48f591deba863b26c16682b43711dd15ee86b92f25eafd96d9", [:mix], [], "hexpm", "68a4233b0658a3d12ee00d27d37d856b1ba48607e7ce20fd376958d0ba6ce92b"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, - "finch": {:hex, :finch, "0.16.0", "40733f02c89f94a112518071c0a91fe86069560f5dbdb39f9150042f44dcfb1a", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f660174c4d519e5fec629016054d60edd822cdfe2b7270836739ac2f97735ec5"}, + "finch": {:hex, :finch, "0.17.0", "17d06e1d44d891d20dbd437335eebe844e2426a0cd7e3a3e220b461127c73f70", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8d014a661bb6a437263d4b5abf0bcbd3cf0deb26b1e8596f2a271d22e48934c7"}, "floki": {:hex, :floki, "0.35.2", "87f8c75ed8654b9635b311774308b2760b47e9a579dabf2e4d5f1e1d42c39e0b", [:mix], [], "hexpm", "6b05289a8e9eac475f644f09c2e4ba7e19201fd002b89c28c1293e7bd16773d9"}, "fun_with_flags": {:hex, :fun_with_flags, "1.11.0", "a9019d0300e9755c53111cf5b2aba640d7f0de2a8a03a0bd0c593e943c3e9ec5", [:mix], [{:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: true]}, {:redix, "~> 1.0", [hex: :redix, repo: "hexpm", optional: true]}], "hexpm", "448ec640cd1ade4728979ae5b3e7592b0fc8b0f99cf40785d048515c27d09743"}, "fun_with_flags_ui": {:hex, :fun_with_flags_ui, "1.0.0", "d764a4d1cc1233bdbb18dfb416a6ef96d0ecf4a5dc5a0201f7aa0b13cf2e7802", [:mix], [{:cowboy, ">= 2.0.0", [hex: :cowboy, repo: "hexpm", optional: true]}, {:fun_with_flags, "~> 1.11", [hex: :fun_with_flags, repo: "hexpm", optional: false]}, {:plug, "~> 1.12", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 2.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "b0c145894c00d65d5dc20ee5b1f18457985d1fd0b87866f0b41894d5979e55e0"}, @@ -65,7 +65,7 @@ "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [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.4.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", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, "heroicons": {:hex, :heroicons, "0.5.3", "ee8ae8335303df3b18f2cc07f46e1cb6e761ba4cf2c901623fbe9a28c0bc51dd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "a210037e8a09ac17e2a0a0779d729e89c821c944434c3baa7edfc1f5b32f3502"}, "hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"}, - "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, + "hpax": {:hex, :hpax, "0.2.0", "5a58219adcb75977b2edce5eb22051de9362f08236220c9e859a47111c194ff5", [:mix], [], "hexpm", "bea06558cdae85bed075e6c036993d43cd54d447f76d8190a8db0dc5893fa2f1"}, "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, @@ -81,7 +81,7 @@ "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, - "mint": {:hex, :mint, "1.5.2", "4805e059f96028948870d23d7783613b7e6b0e2fb4e98d720383852a760067fd", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "d77d9e9ce4eb35941907f1d3df38d8f750c357865353e21d335bdcdf6d892a02"}, + "mint": {:hex, :mint, "1.6.0", "88a4f91cd690508a04ff1c3e28952f322528934be541844d54e0ceb765f01d5e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "3c5ae85d90a5aca0a49c0d8b67360bbe407f3b54f1030a111047ff988e8fefaa"}, "mjml": {:hex, :mjml, "1.5.0", "20a4ed2490a60c6928d45a69b64fb45ce8d8bdac686ef689315b0adda69c6406", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6.0", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "44dc36c0fccf52eeb8e0afcb26a863ba41a5f9adcb71bb32e084619a13bb4cdf"}, "mjml_eex": {:hex, :mjml_eex, "0.9.1", "102b6b6e57bfd6db01e0feef801b573fcddb1ee34effb884695da8407544a5be", [:mix], [{:erlexec, "~> 2.0", [hex: :erlexec, repo: "hexpm", optional: true]}, {:mjml, "~> 1.5.0", [hex: :mjml, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "310f9364d4f1126170835db6fb8dad87e393b28860b0e710d870812fb0bd7892"}, "mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"}, @@ -91,7 +91,7 @@ "nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"}, "nimble_ownership": {:hex, :nimble_ownership, "0.3.1", "99d5244672fafdfac89bfad3d3ab8f0d367603ce1dc4855f86a1c75008bce56f", [:mix], [], "hexpm", "4bf510adedff0449a1d6e200e43e57a814794c8b5b6439071274d248d272a549"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, - "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "nimble_totp": {:hex, :nimble_totp, "1.0.0", "79753bae6ce59fd7cacdb21501a1dbac249e53a51c4cd22b34fa8438ee067283", [:mix], [], "hexpm", "6ce5e4c068feecdb782e85b18237f86f66541523e6bad123e02ee1adbe48eda9"}, "oban": {:hex, :oban, "2.17.2", "bcd1276473d8635475076b01032c00474f9c7841d3a2ca46ead26e1ec023cdd3", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "de90489c05c039a6d942fa54d8fa13b858db315da2178e4e2c35c82c0a3ab556"}, "observer_cli": {:hex, :observer_cli, "1.7.4", "3c1bfb6d91bf68f6a3d15f46ae20da0f7740d363ee5bc041191ce8722a6c4fae", [:mix, :rebar3], [{:recon, "~> 2.5.1", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm", "50de6d95d814f447458bd5d72666a74624eddb0ef98bdcee61a0153aae0865ff"}, @@ -128,6 +128,7 @@ "recon": {:hex, :recon, "2.5.4", "05dd52a119ee4059fa9daa1ab7ce81bc7a8161a2f12e9d42e9d551ffd2ba901c", [:mix, :rebar3], [], "hexpm", "e9ab01ac7fc8572e41eb59385efeb3fb0ff5bf02103816535bacaedf327d0263"}, "ref_inspector": {:hex, :ref_inspector, "2.0.0", "f3e97e51d9782de4c792f56eed26c80903bc39174c878285392ce76d5e67fe98", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:yamerl, "~> 0.7", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "bf62f3f1a87d6b8b30f457a480668f965373e64f184611282b5e89d8dd81fd33"}, "referrer_blocklist": {:git, "https://github.com/plausible/referrer-blocklist.git", "d6f52c225cccb4f04b80e3a5d588868ec234139d", []}, + "req": {:hex, :req, "0.4.14", "103de133a076a31044e5458e0f850d5681eef23dfabf3ea34af63212e3b902e2", [:mix], [{:aws_signature, "~> 0.3.2", [hex: :aws_signature, repo: "hexpm", optional: true]}, {:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:nimble_ownership, "~> 0.2.0 or ~> 0.3.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "2ddd3d33f9ab714ced8d3c15fd03db40c14dbf129003c4a3eb80fac2cc0b1b08"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.6.2", "d2218ba08a43fa331957f30481d00b666664d7e3861431b02bd3f4f30eec8e5b", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "b9048eaed8d7d14a53f758c91865cc616608a438d2595f621f6a4b32a5511709"}, "scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"}, "scrivener_ecto": {:hex, :scrivener_ecto, "2.7.0", "cf64b8cb8a96cd131cdbcecf64e7fd395e21aaa1cb0236c42a7c2e34b0dca580", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm", "e809f171687806b0031129034352f5ae44849720c48dd839200adeaf0ac3e260"}, diff --git a/priv/verification/verify_plausible_installed.js b/priv/verification/verify_plausible_installed.js new file mode 100644 index 000000000000..f74c0409b46f --- /dev/null +++ b/priv/verification/verify_plausible_installed.js @@ -0,0 +1,42 @@ +export default async function({ page, context }) { + + if (context.debug) { + page.on('console', (msg) => console[msg.type()]('PAGE LOG:', msg.text())); + } + + await page.setUserAgent(context.userAgent); + + await page.goto(context.url); + await page.waitForNetworkIdle({ idleTime: 1000 }); + + const plausibleInstalled = await page.evaluate(() => { + window.__plausible = true; + if (typeof (window.plausible) === "function") { + window.plausible('verification-agent-test', { + callback: function(options) { + window.plausibleCallbackResult = () => options && options.status ? options.status : 1; + } + }); + return true; + } else { + window.plausibleCallbackResult = () => 0; + return false; + } + }); + + await page.waitForFunction('window.plausibleCallbackResult', { timeout: 2000 }); + const callbackStatus = await page.evaluate(() => { + if (typeof (window.plausibleCallbackResult) === "function") { + return window.plausibleCallbackResult(); + } else { + return 0; + } + }); + + return { + data: { + plausibleInstalled, callbackStatus + }, + type: "application/json" + }; +} diff --git a/test/plausible/ingestion/event_test.exs b/test/plausible/ingestion/event_test.exs index b2a903b46a00..e4fb474558d0 100644 --- a/test/plausible/ingestion/event_test.exs +++ b/test/plausible/ingestion/event_test.exs @@ -6,7 +6,7 @@ defmodule Plausible.Ingestion.EventTest do alias Plausible.Ingestion.Request alias Plausible.Ingestion.Event - test "event pipeline processes a request into an event" do + test "processes a request into an event" do site = insert(:site) payload = %{ @@ -20,7 +20,25 @@ defmodule Plausible.Ingestion.EventTest do assert {:ok, %{buffered: [_], dropped: []}} = Event.build_and_buffer(request) end - test "event pipeline drops a request when site does not exists" do + test "drops verification agent" do + site = insert(:site) + + payload = %{ + name: "pageview", + url: "http://#{site.domain}" + } + + conn = + build_conn(:post, "/api/events", payload) + |> Plug.Conn.put_req_header("user-agent", Plausible.Verification.user_agent()) + + assert {:ok, request} = Request.build(conn) + + assert {:ok, %{buffered: [], dropped: [dropped]}} = Event.build_and_buffer(request) + assert dropped.drop_reason == :verification_agent + end + + test "drops a request when site does not exists" do payload = %{ name: "pageview", url: "http://dummy.site" @@ -33,7 +51,7 @@ defmodule Plausible.Ingestion.EventTest do assert dropped.drop_reason == :not_found end - test "event pipeline drops a request when referrer is spam" do + test "drops a request when referrer is spam" do site = insert(:site) payload = %{ @@ -50,7 +68,7 @@ defmodule Plausible.Ingestion.EventTest do assert dropped.drop_reason == :spam_referrer end - test "event pipeline drops a request when referrer is spam for multiple domains" do + test "drops a request when referrer is spam for multiple domains" do site = insert(:site) payload = %{ @@ -67,7 +85,7 @@ defmodule Plausible.Ingestion.EventTest do assert dropped.drop_reason == :spam_referrer end - test "event pipeline selectively drops an event for multiple domains" do + test "selectively drops an event for multiple domains" do site = insert(:site) payload = %{ @@ -83,7 +101,7 @@ defmodule Plausible.Ingestion.EventTest do assert dropped.drop_reason == :not_found end - test "event pipeline selectively drops an event when rate-limited" do + test "selectively drops an event when rate-limited" do site = insert(:site, ingest_rate_limit_threshold: 1) payload = %{ @@ -100,7 +118,7 @@ defmodule Plausible.Ingestion.EventTest do assert dropped.drop_reason == :throttle end - test "event pipeline drops a request when header x-plausible-ip-type is dc_ip" do + test "drops a request when header x-plausible-ip-type is dc_ip" do site = insert(:site) payload = %{ @@ -117,7 +135,7 @@ defmodule Plausible.Ingestion.EventTest do assert dropped.drop_reason == :dc_ip end - test "event pipeline drops a request when ip is on blocklist" do + test "drops a request when ip is on blocklist" do site = insert(:site) payload = %{ @@ -137,7 +155,7 @@ defmodule Plausible.Ingestion.EventTest do assert dropped.drop_reason == :site_ip_blocklist end - test "event pipeline drops a request when country is on blocklist" do + test "drops a request when country is on blocklist" do site = insert(:site) payload = %{ @@ -158,7 +176,7 @@ defmodule Plausible.Ingestion.EventTest do assert dropped.drop_reason == :site_country_blocklist end - test "event pipeline drops a request when page is on blocklist" do + test "drops a request when page is on blocklist" do site = insert(:site) payload = %{ @@ -177,7 +195,7 @@ defmodule Plausible.Ingestion.EventTest do assert dropped.drop_reason == :site_page_blocklist end - test "event pipeline drops a request when hostname allowlist is defined and hostname is not on the list" do + test "drops a request when hostname allowlist is defined and hostname is not on the list" do site = insert(:site) payload = %{ @@ -196,7 +214,7 @@ defmodule Plausible.Ingestion.EventTest do assert dropped.drop_reason == :site_hostname_allowlist end - test "event pipeline passes a request when hostname allowlist is defined and hostname is on the list" do + test "passes a request when hostname allowlist is defined and hostname is on the list" do site = insert(:site) payload = %{ @@ -214,7 +232,7 @@ defmodule Plausible.Ingestion.EventTest do assert {:ok, %{buffered: [_], dropped: []}} = Event.build_and_buffer(request) end - test "event pipeline drops events for site with accept_trafic_until in the past" do + test "drops events for site with accept_trafic_until in the past" do yesterday = Date.add(Date.utc_today(), -1) site = diff --git a/test/plausible/site/verification/checks/csp_test.exs b/test/plausible/site/verification/checks/csp_test.exs new file mode 100644 index 000000000000..3f250bf5c82c --- /dev/null +++ b/test/plausible/site/verification/checks/csp_test.exs @@ -0,0 +1,39 @@ +defmodule Plausible.Verification.Checks.CSPTest do + use Plausible.DataCase, async: true + + alias Plausible.Verification.State + + @check Plausible.Verification.Checks.CSP + + test "skips no headers" do + state = %State{} + assert ^state = @check.perform(state) + end + + test "skips no headers 2" do + state = %State{} |> State.assign(headers: %{}) + assert ^state = @check.perform(state) + end + + test "disallowed" do + headers = %{"content-security-policy" => ["default-src 'self' foo.local; example.com"]} + + state = + %State{} + |> State.assign(headers: headers) + |> @check.perform() + + assert state.diagnostics.disallowed_via_csp? + end + + test "allowed" do + headers = %{"content-security-policy" => ["default-src 'self' example.com; localhost"]} + + state = + %State{} + |> State.assign(headers: headers) + |> @check.perform() + + refute state.diagnostics.disallowed_via_csp? + end +end diff --git a/test/plausible/site/verification/checks/fetch_body_test.exs b/test/plausible/site/verification/checks/fetch_body_test.exs new file mode 100644 index 000000000000..0e579ccc054e --- /dev/null +++ b/test/plausible/site/verification/checks/fetch_body_test.exs @@ -0,0 +1,64 @@ +defmodule Plausible.Verification.Checks.FetchBodyTest do + use Plausible.DataCase, async: true + + import Plug.Conn + + @check Plausible.Verification.Checks.FetchBody + + @normal_body """ + + + + + Hello + + """ + + setup do + {:ok, + state: %Plausible.Verification.State{ + url: "https://example.com" + }} + end + + test "extracts document", %{state: state} do + stub() + state = @check.perform(state) + + assert state.assigns.raw_body == @normal_body + assert state.assigns.document == Floki.parse_document!(@normal_body) + assert state.assigns.headers["content-type"] == ["text/html; charset=utf-8"] + + assert state.diagnostics.body_fetched? + end + + test "doesn't extract on non-2xx", %{state: state} do + stub(400) + state = @check.perform(state) + + assert map_size(state.assigns) == 0 + + refute state.diagnostics.body_fetched? + end + + test "doesn't extract non-HTML", %{state: state} do + stub(200, @normal_body, "text/plain") + state = @check.perform(state) + + assert map_size(state.assigns) == 0 + + refute state.diagnostics.body_fetched? + end + + defp stub(f) when is_function(f, 1) do + Req.Test.stub(@check, f) + end + + defp stub(status \\ 200, body \\ @normal_body, content_type \\ "text/html") do + stub(fn conn -> + conn + |> put_resp_content_type(content_type) + |> send_resp(status, body) + end) + end +end diff --git a/test/plausible/site/verification/checks/scan_body_test.exs b/test/plausible/site/verification/checks/scan_body_test.exs new file mode 100644 index 000000000000..169ab8925180 --- /dev/null +++ b/test/plausible/site/verification/checks/scan_body_test.exs @@ -0,0 +1,70 @@ +defmodule Plausible.Verification.Checks.ScanBodyTest do + use Plausible.DataCase, async: true + + alias Plausible.Verification.State + + @check Plausible.Verification.Checks.ScanBody + + test "skips on no raw body" do + state = %State{} + assert ^state = @check.perform(state) + end + + test "detects nothing" do + state = + %State{} + |> State.assign(raw_body: "...") + |> @check.perform() + + refute state.diagnostics.gtm_likely? + refute state.diagnostics.wordpress_likely? + end + + test "detects GTM" do + state = + %State{} + |> State.assign(raw_body: "...googletagmanager.com/gtm.js...") + |> @check.perform() + + assert state.diagnostics.gtm_likely? + refute state.diagnostics.wordpress_likely? + end + + for signature <- ["wp-content", "wp-includes", "wp-json"] do + test "detects WordPress: #{signature}" do + state = + %State{} + |> State.assign(raw_body: "...#{unquote(signature)}...") + |> @check.perform() + + refute state.diagnostics.gtm_likely? + assert state.diagnostics.wordpress_likely? + refute state.diagnostics.wordpress_plugin? + end + end + + test "detects GTM and WordPress" do + state = + %State{} + |> State.assign(raw_body: "...googletagmanager.com/gtm.js....wp-content...") + |> @check.perform() + + assert state.diagnostics.gtm_likely? + assert state.diagnostics.wordpress_likely? + refute state.diagnostics.wordpress_plugin? + end + + @d """ + + """ + + test "detects official plugin" do + state = + %State{} + |> State.assign(raw_body: @d, document: Floki.parse_document!(@d)) + |> @check.perform() + + assert state.diagnostics.wordpress_likely? + assert state.diagnostics.wordpress_plugin? + end +end diff --git a/test/plausible/site/verification/checks/snippet_test.exs b/test/plausible/site/verification/checks/snippet_test.exs new file mode 100644 index 000000000000..5a5b8f4335b1 --- /dev/null +++ b/test/plausible/site/verification/checks/snippet_test.exs @@ -0,0 +1,143 @@ +defmodule Plausible.Verification.Checks.SnippetTest do + use Plausible.DataCase, async: true + + alias Plausible.Verification.State + + @check Plausible.Verification.Checks.Snippet + + test "skips when there's no document" do + state = %State{} + assert ^state = @check.perform(state) + end + + @well_placed """ + + + + """ + + test "figures out well placed snippet" do + state = + @well_placed + |> new_state() + |> @check.perform() + + assert state.diagnostics.snippets_found_in_head == 1 + assert state.diagnostics.snippets_found_in_body == 0 + refute state.diagnostics.data_domain_mismatch? + refute state.diagnostics.snippet_unknown_attributes? + refute state.diagnostics.proxy_likely? + end + + @multi_domain """ + + + + """ + + test "figures out well placed snippet in a multi-domain setup" do + state = + @multi_domain + |> new_state() + |> @check.perform() + + assert state.diagnostics.snippets_found_in_head == 1 + assert state.diagnostics.snippets_found_in_body == 0 + refute state.diagnostics.data_domain_mismatch? + refute state.diagnostics.snippet_unknown_attributes? + refute state.diagnostics.proxy_likely? + end + + @crazy """ + + + + + + + + + + """ + test "counts snippets" do + state = + @crazy + |> new_state() + |> @check.perform() + + assert state.diagnostics.snippets_found_in_head == 2 + assert state.diagnostics.snippets_found_in_body == 3 + end + + test "figures out data-domain mismatch" do + state = + @well_placed + |> new_state(data_domain: "example.typo") + |> @check.perform() + + assert state.diagnostics.snippets_found_in_head == 1 + assert state.diagnostics.snippets_found_in_body == 0 + assert state.diagnostics.data_domain_mismatch? + refute state.diagnostics.snippet_unknown_attributes? + refute state.diagnostics.proxy_likely? + end + + @proxy_likely """ + + + + """ + + test "figures out proxy likely" do + state = + @proxy_likely + |> new_state() + |> @check.perform() + + assert state.diagnostics.snippets_found_in_head == 1 + assert state.diagnostics.snippets_found_in_body == 0 + refute state.diagnostics.data_domain_mismatch? + refute state.diagnostics.snippet_unknown_attributes? + assert state.diagnostics.proxy_likely? + end + + @unknown_attributes """ + + + + """ + + @valid_attributes """ + + + + """ + + test "figures out unknown attributes" do + state = + @valid_attributes + |> new_state() + |> @check.perform() + + refute state.diagnostics.snippet_unknown_attributes? + + state = + @unknown_attributes + |> new_state() + |> @check.perform() + + assert state.diagnostics.snippet_unknown_attributes? + end + + defp new_state(html, opts \\ []) do + doc = Floki.parse_document!(html) + + opts = + [data_domain: "example.com"] + |> Keyword.merge(opts) + + State + |> struct!(opts) + |> State.assign(document: doc) + end +end diff --git a/test/plausible/site/verification/checks_test.exs b/test/plausible/site/verification/checks_test.exs new file mode 100644 index 000000000000..15577c9223b3 --- /dev/null +++ b/test/plausible/site/verification/checks_test.exs @@ -0,0 +1,790 @@ +defmodule Plausible.Verification.ChecksTest do + use Plausible.DataCase, async: true + + alias Plausible.Verification.Checks + alias Plausible.Verification.State + + import ExUnit.CaptureLog + import Plug.Conn + + @normal_body """ + + + + + Hello + + """ + + describe "running checks" do + test "success" do + stub_fetch_body(200, @normal_body) + stub_installation() + + result = run_checks() + + interpretation = Checks.interpret_diagnostics(result) + assert interpretation.ok? + assert interpretation.errors == [] + assert interpretation.recommendations == [] + end + + test "service error - 400" do + stub_fetch_body(200, @normal_body) + stub_installation(400, %{}) + + result = run_checks() + + interpretation = Checks.interpret_diagnostics(result) + + refute interpretation.ok? + + assert interpretation.errors == [ + "We encountered a temporary problem verifying your website" + ] + + assert interpretation.recommendations == [ + {"Please try again in a few minutes or manually check your integration", + "https://plausible.io/docs/troubleshoot-integration"} + ] + end + + @tag :slow + test "can't fetch body but headless reports ok" do + stub_fetch_body(500, "") + stub_installation() + + {result, log} = + with_log(fn -> + run_checks() + end) + + assert log =~ "3 attempts left" + assert log =~ "2 attempts left" + assert log =~ "1 attempt left" + + interpretation = Checks.interpret_diagnostics(result) + refute interpretation.ok? + assert interpretation.errors == ["We couldn't reach https://example.com. Is your site up?"] + + assert interpretation.recommendations == [ + {"If your site is running at a different location, please manually check your integration", + "https://plausible.io/docs/troubleshoot-integration"} + ] + end + + test "fetching will follow 2 redirects" do + ref = :counters.new(1, [:atomics]) + test = self() + + Req.Test.stub(Plausible.Verification.Checks.FetchBody, fn conn -> + if :counters.get(ref, 1) < 2 do + :counters.add(ref, 1, 1) + send(test, :redirect_sent) + + conn + |> put_resp_header("location", "https://example.com") + |> send_resp(302, "redirecting to https://example.com") + else + conn + |> put_resp_header("content-type", "text/html") + |> send_resp(200, @normal_body) + end + end) + + stub_installation() + + result = run_checks() + assert_receive :redirect_sent + assert_receive :redirect_sent + refute_receive _ + + interpretation = Checks.interpret_diagnostics(result) + assert interpretation.ok? + assert interpretation.errors == [] + assert interpretation.recommendations == [] + end + + test "fetching will not follow more than 2 redirect" do + test = self() + + stub_fetch_body(fn conn -> + send(test, :redirect_sent) + + conn + |> put_resp_header("location", "https://example.com") + |> send_resp(302, "redirecting to https://example.com") + end) + + stub_installation() + + result = run_checks() + + assert_receive :redirect_sent + assert_receive :redirect_sent + assert_receive :redirect_sent + refute_receive _ + + interpretation = Checks.interpret_diagnostics(result) + refute interpretation.ok? + assert interpretation.errors == ["We couldn't reach https://example.com. Is your site up?"] + + assert interpretation.recommendations == [ + {"If your site is running at a different location, please manually check your integration", + "https://plausible.io/docs/troubleshoot-integration"} + ] + end + + test "fetching body fails at non-2xx status, but installation is ok" do + stub_fetch_body(599, "boo") + stub_installation() + + result = run_checks() + + interpretation = Checks.interpret_diagnostics(result) + refute interpretation.ok? + assert interpretation.errors == ["We couldn't reach https://example.com. Is your site up?"] + + assert interpretation.recommendations == [ + {"If your site is running at a different location, please manually check your integration", + "https://plausible.io/docs/troubleshoot-integration"} + ] + end + + @snippet_in_body """ + + + + + Hello + + + + """ + + test "detecting snippet in body" do + stub_fetch_body(200, @snippet_in_body) + stub_installation() + + result = run_checks() + interpretation = Checks.interpret_diagnostics(result) + + refute interpretation.ok? + assert interpretation.errors == ["Plausible snippet is placed in the body of your site"] + + assert interpretation.recommendations == [ + {"Please relocate the snippet to the header of your site", + "https://plausible.io/docs/troubleshoot-integration"} + ] + end + + @many_snippets """ + + + + + + + Hello + + + + + + + """ + + test "detecting many snippets" do + stub_fetch_body(200, @many_snippets) + stub_installation() + + result = run_checks() + interpretation = Checks.interpret_diagnostics(result) + + refute interpretation.ok? + assert interpretation.errors == ["We've found multiple Plausible snippets on your site."] + + assert interpretation.recommendations == [ + {"Please ensure that only one snippet is used", + "https://plausible.io/docs/troubleshoot-integration"} + ] + end + + @body_no_snippet """ + + + + + Hello + + + """ + + test "detecting snippet after busting cache" do + stub_fetch_body(fn conn -> + conn = fetch_query_params(conn) + + if conn.query_params["plausible_verification"] do + conn + |> put_resp_content_type("text/html") + |> send_resp(200, @normal_body) + else + conn + |> put_resp_content_type("text/html") + |> send_resp(200, @body_no_snippet) + end + end) + + stub_installation(fn conn -> + {:ok, body, _} = read_body(conn) + + if String.contains?(body, "?plausible_verification") do + conn + |> put_resp_content_type("application/json") + |> send_resp(200, Jason.encode!(plausible_installed())) + else + raise "Should not get here even" + end + end) + + result = run_checks() + + interpretation = Checks.interpret_diagnostics(result) + refute interpretation.ok? + assert interpretation.errors == ["We encountered an issue with your site cache"] + + assert interpretation.recommendations == [ + {"Please clear your cache (or wait for your provider to clear it) to ensure that the latest version of your site is being displayed to all your visitors", + "https://plausible.io/docs/troubleshoot-integration"} + ] + end + + @normal_body_wordpress """ + + + + + + Hello + + """ + + test "detecting snippet after busting WordPress cache - no official plugin" do + stub_fetch_body(fn conn -> + conn = fetch_query_params(conn) + + if conn.query_params["plausible_verification"] do + conn + |> put_resp_content_type("text/html") + |> send_resp(200, @normal_body_wordpress) + else + conn + |> put_resp_content_type("text/html") + |> send_resp(200, @body_no_snippet) + end + end) + + stub_installation(fn conn -> + {:ok, body, _} = read_body(conn) + + if String.contains?(body, "?plausible_verification") do + conn + |> put_resp_content_type("application/json") + |> send_resp(200, Jason.encode!(plausible_installed())) + else + raise "Should not get here even" + end + end) + + result = run_checks() + + interpretation = Checks.interpret_diagnostics(result) + refute interpretation.ok? + assert interpretation.errors == ["We encountered an issue with your site cache"] + + assert interpretation.recommendations == [ + {"Please install and activate our WordPress plugin to start counting your visitors", + "https://plausible.io/wordpress-analytics-plugin"} + ] + end + + @normal_body_wordpress_official_plugin """ + + + + + + + Hello + + """ + + test "detecting snippet after busting WordPress cache - official plugin" do + stub_fetch_body(fn conn -> + conn = fetch_query_params(conn) + + if conn.query_params["plausible_verification"] do + conn + |> put_resp_content_type("text/html") + |> send_resp(200, @normal_body_wordpress_official_plugin) + else + conn + |> put_resp_content_type("text/html") + |> send_resp(200, @body_no_snippet) + end + end) + + stub_installation(fn conn -> + {:ok, body, _} = read_body(conn) + + if String.contains?(body, "?plausible_verification") do + conn + |> put_resp_content_type("application/json") + |> send_resp(200, Jason.encode!(plausible_installed())) + else + raise "Should not get here even" + end + end) + + result = run_checks() + + interpretation = Checks.interpret_diagnostics(result) + refute interpretation.ok? + assert interpretation.errors == ["We encountered an issue with your site cache"] + + assert interpretation.recommendations == [ + {"Please clear your WordPress cache to ensure that the latest version of your site is being displayed to all your visitors", + "https://plausible.io/wordpress-analytics-plugin"} + ] + end + + test "detecting no snippet" do + stub_fetch_body(200, @body_no_snippet) + stub_installation(200, plausible_installed(false)) + + result = run_checks() + interpretation = Checks.interpret_diagnostics(result) + + refute interpretation.ok? + assert interpretation.errors == ["We couldn't find the Plausible snippet on your site"] + + assert interpretation.recommendations == [ + {"Please insert the snippet into your site", + "https://plausible.io/docs/plausible-script"} + ] + end + + test "a check that raises" do + defmodule FaultyCheckRaise do + use Plausible.Verification.Check + + @impl true + def friendly_name, do: "Faulty check" + + @impl true + def perform(_), do: raise("boom") + end + + {result, log} = + with_log(fn -> + run_checks(checks: [FaultyCheckRaise]) + end) + + assert log =~ + ~s|Error running check Plausible.Verification.ChecksTest.FaultyCheckRaise on https://example.com: %RuntimeError{message: "boom"}| + + interpretation = Checks.interpret_diagnostics(result) + + refute interpretation.ok? + assert interpretation.errors == ["We couldn't reach https://example.com. Is your site up?"] + + assert interpretation.recommendations == [ + {"If your site is running at a different location, please manually check your integration", + "https://plausible.io/docs/troubleshoot-integration"} + ] + end + + test "a check that throws" do + defmodule FaultyCheckThrow do + use Plausible.Verification.Check + + @impl true + def friendly_name, do: "Faulty check" + + @impl true + def perform(_), do: :erlang.throw(:boom) + end + + {result, log} = + with_log(fn -> + run_checks(checks: [FaultyCheckThrow]) + end) + + assert log =~ + ~s|Error running check Plausible.Verification.ChecksTest.FaultyCheckThrow on https://example.com: :boom| + + interpretation = Checks.interpret_diagnostics(result) + refute interpretation.ok? + assert interpretation.errors == ["We couldn't reach https://example.com. Is your site up?"] + + assert interpretation.recommendations == [ + {"If your site is running at a different location, please manually check your integration", + "https://plausible.io/docs/troubleshoot-integration"} + ] + end + + test "disallowed via content-security-policy" do + stub_fetch_body(fn conn -> + conn + |> put_resp_header("content-security-policy", "default-src 'self' foo.local") + |> put_resp_content_type("text/html") + |> send_resp(200, @normal_body) + end) + + stub_installation(200, plausible_installed(false)) + + result = run_checks() + interpretation = Checks.interpret_diagnostics(result) + + refute interpretation.ok? + + assert interpretation.errors == ["We encountered an issue with your site's CSP"] + + assert interpretation.recommendations == [ + { + "Please add plausible.io domain specifically to the allowed list of domains in your Content Security Policy (CSP)", + "https://plausible.io/docs/troubleshoot-integration" + } + ] + end + + test "disallowed via content-security-policy with no snippet should make the latter a priority" do + stub_fetch_body(fn conn -> + conn + |> put_resp_header("content-security-policy", "default-src 'self' foo.local") + |> put_resp_content_type("text/html") + |> send_resp(200, @body_no_snippet) + end) + + stub_installation(200, plausible_installed(false)) + + result = run_checks() + interpretation = Checks.interpret_diagnostics(result) + + refute interpretation.ok? + + assert interpretation.errors == ["We couldn't find the Plausible snippet on your site"] + end + + test "allowed via content-security-policy" do + stub_fetch_body(fn conn -> + conn + |> put_resp_header( + "content-security-policy", + Enum.random([ + "default-src 'self'; script-src plausible.io; connect-src #{PlausibleWeb.Endpoint.host()}", + "default-src 'self' *.#{PlausibleWeb.Endpoint.host()}" + ]) + ) + |> put_resp_content_type("text/html") + |> send_resp(200, @normal_body) + end) + + stub_installation() + result = run_checks() + + interpretation = Checks.interpret_diagnostics(result) + + assert interpretation.ok? + assert interpretation.errors == [] + assert interpretation.recommendations == [] + end + + test "running checks sends progress messages" do + stub_fetch_body(200, @normal_body) + stub_installation() + + final_state = run_checks(report_to: self()) + + assert_receive {:verification_check_start, {Checks.FetchBody, %State{}}} + assert_receive {:verification_check_start, {Checks.CSP, %State{}}} + assert_receive {:verification_check_start, {Checks.ScanBody, %State{}}} + assert_receive {:verification_check_start, {Checks.Snippet, %State{}}} + assert_receive {:verification_check_start, {Checks.SnippetCacheBust, %State{}}} + assert_receive {:verification_check_start, {Checks.Installation, %State{}}} + assert_receive {:verification_end, %State{} = ^final_state} + refute_receive _ + end + + @gtm_body """ + + + + + + + + Hello + + + """ + + test "detecting gtm" do + stub_fetch_body(200, @gtm_body) + stub_installation(200, plausible_installed(false)) + + result = run_checks() + interpretation = Checks.interpret_diagnostics(result) + + refute interpretation.ok? + assert interpretation.errors == ["We encountered an issue with your Plausible integration"] + + assert interpretation.recommendations == [ + {"As you're using Google Tag Manager, you'll need to use a GTM-specific Plausible snippet", + "https://plausible.io/docs/google-tag-manager"} + ] + end + + test "non-html body" do + stub_fetch_body(fn conn -> + conn + |> put_resp_content_type("image/png") + |> send_resp(200, :binary.copy(<<0>>, 100)) + end) + + stub_installation(200, plausible_installed(false)) + + result = run_checks() + + interpretation = Checks.interpret_diagnostics(result) + refute interpretation.ok? + assert interpretation.errors == ["We couldn't reach https://example.com. Is your site up?"] + + assert interpretation.recommendations == [ + {"If your site is running at a different location, please manually check your integration", + "https://plausible.io/docs/troubleshoot-integration"} + ] + end + + @proxied_script_body """ + + + + + Hello + + """ + + test "proxied setup working OK" do + stub_fetch_body(200, @proxied_script_body) + stub_installation() + + result = run_checks() + + interpretation = Checks.interpret_diagnostics(result) + assert interpretation.ok? + assert interpretation.errors == [] + assert interpretation.recommendations == [] + end + + test "proxied setup, function defined but callback won't fire" do + stub_fetch_body(200, @proxied_script_body) + stub_installation(200, plausible_installed(true, 0)) + + result = run_checks() + interpretation = Checks.interpret_diagnostics(result) + + refute interpretation.ok? + assert interpretation.errors == ["We encountered an error with your Plausible proxy"] + + assert interpretation.recommendations == [ + {"Please check whether you've configured the /event route correctly", + "https://plausible.io/docs/proxy/introduction"} + ] + end + + @proxied_script_body_wordpress """ + + + + + + Hello + + """ + + test "proxied WordPress setup, function undefined, callback won't fire" do + stub_fetch_body(200, @proxied_script_body_wordpress) + stub_installation(200, plausible_installed(false, 0)) + + result = run_checks() + interpretation = Checks.interpret_diagnostics(result) + + refute interpretation.ok? + assert interpretation.errors == ["We encountered an error with your Plausible proxy"] + + assert interpretation.recommendations == + [ + {"Please re-enable the proxy in our WordPress plugin to start counting your visitors", + "https://plausible.io/wordpress-analytics-plugin"} + ] + end + + test "proxied setup, function undefined, callback won't fire" do + stub_fetch_body(200, @proxied_script_body) + stub_installation(200, plausible_installed(false, 0)) + + result = run_checks() + interpretation = Checks.interpret_diagnostics(result) + + refute interpretation.ok? + assert interpretation.errors == ["We encountered an error with your Plausible proxy"] + + assert interpretation.recommendations == + [ + {"Please check your proxy configuration to make sure it's set up correctly", + "https://plausible.io/docs/proxy/introduction"} + ] + end + + test "non-proxied setup, but callback fails to fire" do + stub_fetch_body(200, @normal_body) + stub_installation(200, plausible_installed(true, 0)) + + result = run_checks() + interpretation = Checks.interpret_diagnostics(result) + + refute interpretation.ok? + assert interpretation.errors == ["Your Plausible integration is not working"] + + assert interpretation.recommendations == [ + {"Please manually check your integration to make sure that the Plausible snippet has been inserted correctly", + "https://plausible.io/docs/troubleshoot-integration"} + ] + end + + @body_unknown_attributes """ + + + + + Hello + + """ + + test "unknown attributes" do + stub_fetch_body(200, @body_unknown_attributes) + stub_installation(200, plausible_installed(false, 0)) + + result = run_checks() + interpretation = Checks.interpret_diagnostics(result) + + refute interpretation.ok? + assert interpretation.errors == ["Something seems to have altered our snippet"] + + assert interpretation.recommendations == [ + {"Please manually check your integration to make sure that nothing prevents our script from working", + "https://plausible.io/docs/troubleshoot-integration"} + ] + end + + @body_unknown_attributes_wordpress """ + + + + + + Hello + + """ + + test "unknown attributes for WordPress installation" do + stub_fetch_body(200, @body_unknown_attributes_wordpress) + stub_installation(200, plausible_installed(false, 0)) + + result = run_checks() + interpretation = Checks.interpret_diagnostics(result) + + refute interpretation.ok? + + assert interpretation.errors == [ + "A performance optimization plugin seems to have altered our snippet" + ] + + assert interpretation.recommendations == [ + {"Please install and activate our WordPress plugin to avoid the most common plugin conflicts", + "https://plausible.io/wordpress-analytics-plugin "} + ] + end + + @body_unknown_attributes_wordpress_official_plugin """ + + + + + + + Hello + + """ + + test "unknown attributes for WordPress installation - official plugin" do + stub_fetch_body(200, @body_unknown_attributes_wordpress_official_plugin) + stub_installation(200, plausible_installed(false, 0)) + + result = run_checks() + interpretation = Checks.interpret_diagnostics(result) + + refute interpretation.ok? + + assert interpretation.errors == [ + "A performance optimization plugin seems to have altered our snippet" + ] + + assert interpretation.recommendations == [ + {"Please whitelist our script in your performance optimization plugin to stop it from changing our snippet", + "https://plausible.io/wordpress-analytics-plugin "} + ] + end + end + + defp run_checks(extra_opts \\ []) do + Checks.run( + "https://example.com", + "example.com", + Keyword.merge([async?: false, report_to: nil, slowdown: 0], extra_opts) + ) + end + + defp stub_fetch_body(f) when is_function(f, 1) do + Req.Test.stub(Plausible.Verification.Checks.FetchBody, f) + end + + defp stub_installation(f) when is_function(f, 1) do + Req.Test.stub(Plausible.Verification.Checks.Installation, f) + end + + defp stub_fetch_body(status, body) do + stub_fetch_body(fn conn -> + conn + |> put_resp_content_type("text/html") + |> send_resp(status, body) + end) + end + + defp stub_installation(status \\ 200, json \\ plausible_installed()) do + stub_installation(fn conn -> + conn + |> put_resp_content_type("application/json") + |> send_resp(status, Jason.encode!(json)) + end) + end + + defp plausible_installed(bool \\ true, callback_status \\ 202) do + %{"data" => %{"plausibleInstalled" => bool, "callbackStatus" => callback_status}} + end +end diff --git a/test/plausible_web/controllers/stats_controller_test.exs b/test/plausible_web/controllers/stats_controller_test.exs index 89821c521807..5d090e97b71e 100644 --- a/test/plausible_web/controllers/stats_controller_test.exs +++ b/test/plausible_web/controllers/stats_controller_test.exs @@ -77,6 +77,17 @@ defmodule PlausibleWeb.StatsControllerTest do assert text_of_attr(resp, @react_container, "data-logged-in") == "true" end + test "can view stats of a website I've created, enforcing pageviews check skip", %{ + conn: conn, + site: site + } do + resp = conn |> get("/" <> site.domain) |> html_response(200) + refute text_of_attr(resp, @react_container, "data-logged-in") == "true" + + resp = conn |> get("/" <> site.domain <> "?skip_to_dashboard=true") |> html_response(200) + assert text_of_attr(resp, @react_container, "data-logged-in") == "true" + end + test "shows locked page if page is locked", %{conn: conn, user: user} do locked_site = insert(:site, locked: true, members: [user]) conn = get(conn, "/" <> locked_site.domain) diff --git a/test/plausible_web/live/components/verification_test.exs b/test/plausible_web/live/components/verification_test.exs new file mode 100644 index 000000000000..68b0d9490947 --- /dev/null +++ b/test/plausible_web/live/components/verification_test.exs @@ -0,0 +1,87 @@ +defmodule PlausibleWeb.Live.Components.VerificationTest do + use PlausibleWeb.ConnCase, async: true + import Phoenix.LiveViewTest + import Plausible.Test.Support.HTML + + @component PlausibleWeb.Live.Components.Verification + @progress ~s|div#progress| + + @pulsating_circle ~s|div#progress-indicator div.pulsating-circle| + @check_circle ~s|div#progress-indicator #check-circle| + @shuttle ~s|div#progress-indicator svg#shuttle| + @recommendations ~s|div#recommendations .recommendation| + + test "renders initial state" do + html = render_component(@component, domain: "example.com") + assert element_exists?(html, @progress) + + assert text_of_element(html, @progress) == + "We're visiting your site to ensure that everything is working correctly" + + assert element_exists?(html, ~s|a[href="/example.com/snippet"]|) + assert element_exists?(html, ~s|a[href="/example.com/settings/general"]|) + assert element_exists?(html, @pulsating_circle) + refute class_of_element(html, @pulsating_circle) =~ "hidden" + refute element_exists?(html, @recommendations) + refute element_exists?(html, @check_circle) + end + + test "renders shuttle on error" do + html = render_component(@component, domain: "example.com", success?: false, finished?: true) + refute element_exists?(html, @pulsating_circle) + refute element_exists?(html, @check_circle) + refute element_exists?(html, @recommendations) + assert element_exists?(html, @shuttle) + end + + test "renders diagnostic interpretation" do + interpretation = + Plausible.Verification.Checks.interpret_diagnostics(%Plausible.Verification.State{ + url: "example.com" + }) + + html = + render_component(@component, + domain: "example.com", + success?: false, + finished?: true, + interpretation: interpretation + ) + + recommendations = html |> find(@recommendations) |> Enum.map(&text/1) + + assert recommendations == [ + "If your site is running at a different location, please manually check your integration - Learn more" + ] + end + + test "hides pulsating circle when finished in a modal, shows check circle" do + html = + render_component(@component, + domain: "example.com", + modal?: true, + success?: true, + finished?: true + ) + + assert class_of_element(html, @pulsating_circle) =~ "hidden" + assert element_exists?(html, @check_circle) + end + + test "renders a progress message" do + html = render_component(@component, domain: "example.com", message: "Arbitrary message") + + assert text_of_element(html, @progress) == "Arbitrary message" + end + + @tag :ee_only + test "renders contact link on >3 attempts" do + html = render_component(@component, domain: "example.com", attempts: 2, finished?: true) + refute html =~ "Need further help with your integration?" + refute element_exists?(html, ~s|a[href="https://plausible.io/contact"]|) + + html = render_component(@component, domain: "example.com", attempts: 3, finished?: true) + assert html =~ "Need further help with your integration?" + assert element_exists?(html, ~s|a[href="https://plausible.io/contact"]|) + end +end diff --git a/test/plausible_web/live/shields/hostnames_test.exs b/test/plausible_web/live/shields/hostnames_test.exs index cc305001890a..e08b290f71e4 100644 --- a/test/plausible_web/live/shields/hostnames_test.exs +++ b/test/plausible_web/live/shields/hostnames_test.exs @@ -84,8 +84,7 @@ defmodule PlausibleWeb.Live.Shields.HostnamesTest do html = render(lv) assert text(html) =~ - "NB: Once added, we will start rejecting traffic from non-matching hostnames within a few minutes. -" + "NB: Once added, we will start rejecting traffic from non-matching hostnames within a few minutes." refute text(html) =~ "we will start accepting" end diff --git a/test/plausible_web/live/verification_test.exs b/test/plausible_web/live/verification_test.exs new file mode 100644 index 000000000000..b8bead83c7a7 --- /dev/null +++ b/test/plausible_web/live/verification_test.exs @@ -0,0 +1,238 @@ +defmodule PlausibleWeb.Live.VerificationTest do + use PlausibleWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Plausible.Test.Support.HTML + + setup [:create_user, :log_in, :create_site] + + @verify_button ~s|button#launch-verification-button[phx-click="launch-verification"]| + @verification_modal ~s|div#verification-modal| + @retry_button ~s|a[phx-click="retry"]| + @go_to_dashboard_button ~s|a[href$="?skip_to_dashboard=true"]| + @progress ~s|div#progress| + + describe "GET /:domain" do + test "static verification screen renders", %{conn: conn, site: site} do + resp = conn |> no_slowdown() |> get("/#{site.domain}") |> html_response(200) + + assert text_of_element(resp, @progress) =~ + "We're visiting your site to ensure that everything is working correctly" + + assert resp =~ "Verifying your integration" + assert resp =~ "on #{site.domain}" + assert resp =~ "Need to see the snippet again?" + assert resp =~ "Run verification later and go to Site Settings?" + refute resp =~ "modal" + refute element_exists?(resp, @verification_modal) + end + end + + describe "GET /settings/general" do + test "verification elements render under the snippet", %{conn: conn, site: site} do + resp = + conn |> no_slowdown() |> get("/#{site.domain}/settings/general") |> html_response(200) + + assert element_exists?(resp, @verify_button) + assert element_exists?(resp, @verification_modal) + end + end + + describe "LiveView: standalone" do + test "LiveView mounts", %{conn: conn, site: site} do + stub_fetch_body(200, "") + stub_installation() + + {_, html} = get_lv_standalone(conn, site) + + assert html =~ "Verifying your integration" + assert html =~ "on #{site.domain}" + + assert text_of_element(html, @progress) =~ + "We're visiting your site to ensure that everything is working correctly" + end + + test "eventually verifies installation", %{conn: conn, site: site} do + stub_fetch_body(200, source(site.domain)) + stub_installation() + + {:ok, lv} = kick_off_live_verification_standalone(conn, site) + + assert eventually(fn -> + html = render(lv) + + { + text_of_element(html, @progress) =~ + "Awaiting your first pageview", + html + } + end) + + html = render(lv) + assert html =~ "Success!" + assert html =~ "Your integration is working and visitors are being counted accurately" + end + + test "eventually fails to verify installation", %{conn: conn, site: site} do + stub_fetch_body(200, "") + stub_installation(200, plausible_installed(false)) + + {:ok, lv} = kick_off_live_verification_standalone(conn, site) + + assert html = + eventually(fn -> + html = render(lv) + {html =~ "", html} + + { + text_of_element(html, @progress) =~ + "We couldn't find the Plausible snippet on your site", + html + } + end) + + refute element_exists?(html, @verification_modal) + assert element_exists?(html, @retry_button) + + assert html =~ "Please insert the snippet into your site" + end + end + + describe "LiveView: modal" do + test "LiveView mounts", %{conn: conn, site: site} do + stub_fetch_body(200, "") + stub_installation() + + {_, html} = get_lv_modal(conn, site) + + text = text(html) + + refute text =~ "Need to see the snippet again?" + refute text =~ "Run verification later and go to Site Settings?" + assert element_exists?(html, @verification_modal) + end + + test "Clicking the Verify modal launches verification", %{conn: conn, site: site} do + stub_fetch_body(200, source(site.domain)) + stub_installation() + + {lv, html} = get_lv_modal(conn, site) + + assert element_exists?(html, @verification_modal) + assert element_exists?(html, @verify_button) + assert text_of_attr(html, @verify_button, "x-on:click") =~ "open-modal" + + assert text_of_element(html, @progress) =~ + "We're visiting your site to ensure that everything is working correctly" + + lv |> element(@verify_button) |> render_click() + + assert html = + eventually(fn -> + html = render(lv) + + { + html =~ "Success!", + html + } + end) + + refute html =~ "Awaiting your first pageview" + assert element_exists?(html, @go_to_dashboard_button) + end + + test "failed verification can be retried", %{conn: conn, site: site} do + stub_fetch_body(200, "") + stub_installation(200, plausible_installed(false)) + + {lv, _html} = get_lv_modal(conn, site) + + lv |> element(@verify_button) |> render_click() + + assert html = + eventually(fn -> + html = render(lv) + + {text_of_element(html, @progress) =~ + "We couldn't find the Plausible snippet on your site", html} + end) + + assert element_exists?(html, @retry_button) + + stub_fetch_body(200, source(site.domain)) + stub_installation() + + lv |> element(@retry_button) |> render_click() + + assert eventually(fn -> + html = render(lv) + {html =~ "Success!", html} + end) + end + end + + defp get_lv_standalone(conn, site) do + conn = conn |> no_slowdown() |> assign(:live_module, PlausibleWeb.Live.Verification) + {:ok, lv, html} = live(conn, "/#{site.domain}") + {lv, html} + end + + defp get_lv_modal(conn, site) do + conn = conn |> no_slowdown() |> assign(:live_module, PlausibleWeb.Live.Verification) + {:ok, lv, html} = live(no_slowdown(conn), "/#{site.domain}/settings/general") + {lv, html} + end + + defp kick_off_live_verification_standalone(conn, site) do + {:ok, lv, _} = + live_isolated(conn, PlausibleWeb.Live.Verification, + session: %{ + "domain" => site.domain, + "delay" => 0, + "slowdown" => 0 + } + ) + + {:ok, lv} + end + + defp no_slowdown(conn) do + Plug.Conn.put_private(conn, :verification_slowdown, 0) + end + + defp stub_fetch_body(f) when is_function(f, 1) do + Req.Test.stub(Plausible.Verification.Checks.FetchBody, f) + end + + defp stub_installation(f) when is_function(f, 1) do + Req.Test.stub(Plausible.Verification.Checks.Installation, f) + end + + defp stub_fetch_body(status, body) do + stub_fetch_body(fn conn -> + conn + |> put_resp_content_type("text/html") + |> send_resp(status, body) + end) + end + + defp stub_installation(status \\ 200, json \\ plausible_installed()) do + stub_installation(fn conn -> + conn + |> put_resp_content_type("application/json") + |> send_resp(status, Jason.encode!(json)) + end) + end + + defp plausible_installed(bool \\ true, callback_status \\ 202) do + %{"data" => %{"plausibleInstalled" => bool, "callbackStatus" => callback_status}} + end + + defp source(domain) do + """ + + + + """ + end +end diff --git a/test/support/html.ex b/test/support/html.ex index 7d6eda93d9c3..041b450c21c5 100644 --- a/test/support/html.ex +++ b/test/support/html.ex @@ -38,6 +38,7 @@ defmodule Plausible.Test.Support.HTML do element |> Floki.text() |> String.trim() + |> String.replace(~r/\s+/, " ") end def class_of_element(html, element) do diff --git a/test/test_helper.exs b/test/test_helper.exs index 9b1b07f7a8f7..d9ec1e60a151 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,3 +1,5 @@ +FunWithFlags.enable(:verification) + if not Enum.empty?(Path.wildcard("lib/**/*_test.exs")) do raise "Oops, test(s) found in `lib/` directory. Move them to `test/`." end diff --git a/tracker/src/plausible.js b/tracker/src/plausible.js index 68c85a840160..7701c5d43d11 100644 --- a/tracker/src/plausible.js +++ b/tracker/src/plausible.js @@ -33,7 +33,7 @@ if (/^localhost$|^127(\.[0-9]+){0,2}\.[0-9]+$|^\[::1?\]$/.test(location.hostname) || location.protocol === 'file:') { return onIgnoredEvent('localhost', options) } - if (window._phantom || window.__nightmare || window.navigator.webdriver || window.Cypress) { + if ((window._phantom || window.__nightmare || window.navigator.webdriver || window.Cypress) && !window.__plausible) { return onIgnoredEvent(null, options) } {{/unless}} @@ -115,7 +115,7 @@ request.onreadystatechange = function() { if (request.readyState === 4) { - options && options.callback && options.callback() + options && options.callback && options.callback({status: request.status}) } } }