Allow domain change (#2803)

* Migration (PR: https://github.com/plausible/analytics/pull/2802)

* Implement Site.Domain interface allowing change and expiry

* Fixup seeds so they work with V2_MIGRATION_DONE=1

* Update Sites.Cache so it's capable of multi-keyed lookups

* Implement worker handling domain change expiration

* Implement domain change UI

* Implement transition period for public APIs

* Exclude v2 tests in primary test run

* Update lib/plausible_web/controllers/site_controller.ex

Co-authored-by: Vini Brasil <vini@hey.com>

* Update lib/plausible_web/controllers/site_controller.ex

Co-authored-by: Vini Brasil <vini@hey.com>

* Update moduledoc

* Update changelog

* Remove remnant from previous implementation attempt

* !fixup

* !fixup

* Implement domain change via Sites API

cc @ukutaht

* Update CHANGELOG

* Credo

* !fixup commit missing tests

* Allow continuous domain change within the same site

---------

Co-authored-by: Vini Brasil <vini@hey.com>
This commit is contained in:
hq1 2023-04-04 10:55:12 +02:00 committed by GitHub
parent 5ca53a70be
commit 1d01328287
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 832 additions and 49 deletions

View File

@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
### Added
- 'Last updated X seconds ago' info to 'current visitors' tooltips
- Add support for more Bamboo adapters, i.e. `Bamboo.MailgunAdapter`, `Bamboo.MandrillAdapter`, `Bamboo.SendGridAdapter` plausible/analytics#2649
- Ability to change domain for existing site (requires numeric IDs data migration, instructions will be provided separately) UI + API (`PUT /api/v1/sites`)
### Fixed
- Make goal-filtered CSV export return only unique_conversions timeseries in the 'visitors.csv' file

View File

@ -363,7 +363,7 @@ end
base_cron = [
# Daily at midnight
{"0 0 * * *", Plausible.Workers.RotateSalts},
#  hourly
# hourly
{"0 * * * *", Plausible.Workers.ScheduleEmailReports},
# hourly
{"0 * * * *", Plausible.Workers.SendSiteSetupEmails},
@ -374,7 +374,9 @@ base_cron = [
# Every day at midnight
{"0 0 * * *", Plausible.Workers.CleanEmailVerificationCodes},
# Every day at 1am
{"0 1 * * *", Plausible.Workers.CleanInvitations}
{"0 1 * * *", Plausible.Workers.CleanInvitations},
# Every 2 hours
{"0 */2 * * *", Plausible.Workers.ExpireDomainChangeTransitions}
]
cloud_cron = [
@ -399,7 +401,8 @@ base_queues = [
site_setup_emails: 1,
clean_email_verification_codes: 1,
clean_invitations: 1,
google_analytics_imports: 1
google_analytics_imports: 1,
domain_change_transition: 1
]
cloud_queues = [

View File

@ -21,6 +21,9 @@ defmodule Plausible.Site do
field :ingest_rate_limit_scale_seconds, :integer, default: 60
field :ingest_rate_limit_threshold, :integer
field :domain_changed_from, :string
field :domain_changed_at, :naive_datetime
embeds_one :imported_data, Plausible.Site.ImportedData, on_replace: :update
many_to_many :members, User, join_through: Plausible.Site.Membership
@ -40,21 +43,40 @@ defmodule Plausible.Site do
timestamps()
end
@domain_unique_error """
This domain cannot be registered. Perhaps one of your colleagues registered it? If that's not the case, please contact support@plausible.io
"""
def changeset(site, attrs \\ %{}) do
site
|> cast(attrs, [:domain, :timezone])
|> clean_domain()
|> validate_required([:domain, :timezone])
|> validate_format(:domain, ~r/^[-\.\\\/:\p{L}\d]*$/u,
message: "only letters, numbers, slashes and period allowed"
)
|> validate_domain_format()
|> validate_domain_reserved_characters()
|> unique_constraint(:domain,
message:
"This domain cannot be registered. Perhaps one of your colleagues registered it? If that's not the case, please contact support@plausible.io"
message: @domain_unique_error
)
end
def update_changeset(site, attrs \\ %{}, opts \\ []) do
at =
opts
|> Keyword.get(:at, NaiveDateTime.utc_now())
|> NaiveDateTime.truncate(:second)
attrs =
if Plausible.v2?() do
attrs
else
Map.delete(attrs, :domain)
end
site
|> changeset(attrs)
|> handle_domain_change(at)
end
def crm_changeset(site, attrs) do
site
|> cast(attrs, [
@ -183,9 +205,7 @@ defmodule Plausible.Site do
|> String.replace_trailing("/", "")
|> String.downcase()
change(changeset, %{
domain: clean_domain
})
change(changeset, %{domain: clean_domain})
end
# https://tools.ietf.org/html/rfc3986#section-2.2
@ -203,4 +223,29 @@ defmodule Plausible.Site do
changeset
end
end
defp validate_domain_format(changeset) do
validate_format(changeset, :domain, ~r/^[-\.\\\/:\p{L}\d]*$/u,
message: "only letters, numbers, slashes and period allowed"
)
end
defp handle_domain_change(changeset, at) do
new_domain = get_change(changeset, :domain)
if new_domain do
changeset
|> put_change(:domain_changed_from, changeset.data.domain)
|> put_change(:domain_changed_at, at)
|> unique_constraint(:domain,
name: "domain_change_disallowed",
message: @domain_unique_error
)
|> unique_constraint(:domain_changed_from,
message: @domain_unique_error
)
else
changeset
end
end
end

View File

@ -9,6 +9,10 @@ defmodule Plausible.Site.Cache do
during tests via the `:sites_by_domain_cache_enabled` application env key.
This can be overridden on case by case basis, using the child specs options.
NOTE: the cache allows lookups by both `domain` and `domain_changed_from`
fields - this is to allow traffic from sites whose domains changed within a certain
grace period (see: `Plausible.Site.Transfer`).
When Cache is disabled via application env, the `get/1` function
falls back to pure database lookups. This should help with introducing
cached lookups in existing code, so that no existing tests should break.
@ -49,6 +53,7 @@ defmodule Plausible.Site.Cache do
@cached_schema_fields ~w(
id
domain
domain_changed_from
ingest_rate_limit_scale_seconds
ingest_rate_limit_threshold
)a
@ -91,6 +96,7 @@ defmodule Plausible.Site.Cache do
from s in Site,
select: {
s.domain,
s.domain_changed_from,
%{struct(s, ^@cached_schema_fields) | from_cache?: true}
}
@ -109,6 +115,7 @@ defmodule Plausible.Site.Cache do
where: s.updated_at > ago(^15, "minute"),
select: {
s.domain,
s.domain_changed_from,
%{struct(s, ^@cached_schema_fields) | from_cache?: true}
}
@ -124,6 +131,7 @@ defmodule Plausible.Site.Cache do
def merge([], _), do: :ok
def merge(new_items, opts) do
new_items = unwrap_cache_keys(new_items)
cache_name = Keyword.get(opts, :cache_name, @cache_name)
true = Cachex.put_many!(cache_name, new_items)
@ -221,4 +229,14 @@ defmodule Plausible.Site.Cache do
stop = System.monotonic_time()
{stop - start, result}
end
defp unwrap_cache_keys(items) do
Enum.reduce(items, [], fn
{domain, nil, object}, acc ->
[{domain, object} | acc]
{domain, domain_changed_from, object}, acc ->
[{domain, object}, {domain_changed_from, object} | acc]
end)
end
end

View File

@ -0,0 +1,62 @@
defmodule Plausible.Site.Domain do
@expire_threshold_hours 72
@moduledoc """
Basic interface for domain changes.
Once `Plausible.DataMigration.NumericIDs` schema migration is ready,
domain change operation will be enabled, accessible to the users.
We will set a transition period of #{@expire_threshold_hours} hours
during which, both old and new domains, will be accepted as traffic
identifiers to the same site.
A periodic worker will call the `expire/0` function to end it where applicable.
See: `Plausible.Workers.ExpireDomainChangeTransitions`.
The underlying changeset for domain change (see: `Plausible.Site`) relies
on database trigger installed via `Plausible.Repo.Migrations.AllowDomainChange`
Postgres migration. The trigger checks if either `domain` or `domain_changed_from`
exist to ensure unicity.
"""
alias Plausible.Site
alias Plausible.Repo
import Ecto.Query
@spec expire_change_transitions(integer()) :: {:ok, non_neg_integer()}
def expire_change_transitions(expire_threshold_hours \\ @expire_threshold_hours) do
{updated, _} =
Repo.update_all(
from(s in Site,
where: s.domain_changed_at < ago(^expire_threshold_hours, "hour")
),
set: [
domain_changed_from: nil,
domain_changed_at: nil
]
)
{:ok, updated}
end
@spec change(Site.t(), String.t(), Keyword.t()) ::
{:ok, Site.t()} | {:error, Ecto.Changeset.t()}
def change(%Site{} = site, new_domain, opts \\ []) do
changeset = Site.update_changeset(site, %{domain: new_domain}, opts)
changeset =
if Enum.empty?(changeset.changes) and is_nil(changeset.errors[:domain]) do
Ecto.Changeset.add_error(
changeset,
:domain,
"New domain must be different than the current one"
)
else
changeset
end
Repo.update(changeset)
end
end

View File

@ -107,7 +107,7 @@ defmodule Plausible.Sites do
on: sm.site_id == s.id,
where: sm.user_id == ^user_id,
where: sm.role in ^roles,
where: s.domain == ^domain,
where: s.domain == ^domain or s.domain_changed_from == ^domain,
select: s
)
end

View File

@ -49,6 +49,25 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end
end
def update_site(conn, %{"site_id" => site_id} = params) do
# for now this only allows to change the domain
site = Sites.get_for_user(conn.assigns[:current_user].id, site_id, [:owner, :admin])
if site && Plausible.v2?() do
case Plausible.Site.Domain.change(site, params["domain"]) do
{:ok, site} ->
json(conn, site)
{:error, changeset} ->
conn
|> put_status(400)
|> json(serialize_errors(changeset))
end
else
H.not_found(conn, "Site could not be found")
end
end
defp expect_param_key(params, key) do
case Map.fetch(params, key) do
:error -> {:missing, key}

View File

@ -329,11 +329,10 @@ defmodule PlausibleWeb.SiteController do
end
def update_settings(conn, %{"site" => site_params}) do
site = conn.assigns[:site]
changeset = site |> Plausible.Site.changeset(site_params)
res = changeset |> Repo.update()
site = conn.assigns[:site] |> Repo.preload(:custom_domain)
changeset = Plausible.Site.update_changeset(site, site_params)
case res do
case Repo.update(changeset) do
{:ok, site} ->
site_session_key = "authorized_site__" <> site.domain
@ -343,7 +342,13 @@ defmodule PlausibleWeb.SiteController do
|> redirect(to: Routes.site_path(conn, :settings_general, site.domain))
{:error, changeset} ->
render(conn, "settings_general.html", site: site, changeset: changeset)
conn
|> put_flash(:error, "Could not update your site settings")
|> render("settings_general.html",
site: site,
changeset: changeset,
layout: {PlausibleWeb.LayoutView, "site_settings.html"}
)
end
end
@ -867,4 +872,49 @@ defmodule PlausibleWeb.SiteController do
|> redirect(to: Routes.site_path(conn, :settings_general, site.domain))
end
end
def change_domain(conn, _params) do
if Plausible.v2?() do
changeset = Plausible.Site.update_changeset(conn.assigns.site)
render(conn, "change_domain.html",
changeset: changeset,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
else
render_error(conn, 404)
end
end
def change_domain_submit(conn, %{"site" => %{"domain" => new_domain}}) do
if Plausible.v2?() do
case Plausible.Site.Domain.change(conn.assigns.site, new_domain) do
{:ok, updated_site} ->
conn
|> put_flash(:success, "Website domain changed successfully")
|> redirect(
to: Routes.site_path(conn, :add_snippet_after_domain_change, updated_site.domain)
)
{:error, changeset} ->
render(conn, "change_domain.html",
changeset: changeset,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
end
else
render_error(conn, 404)
end
end
def add_snippet_after_domain_change(conn, _params) do
site = conn.assigns[:site] |> Repo.preload(:custom_domain)
conn
|> assign(:skip_plausible_tracking, true)
|> render("snippet_after_domain_change.html",
site: site,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
end
end

View File

@ -52,7 +52,10 @@ defmodule PlausibleWeb.AuthorizeStatsApiPlug do
defp verify_access(_api_key, nil), do: {:error, :missing_site_id}
defp verify_access(api_key, site_id) do
case Repo.get_by(Plausible.Site, domain: site_id) do
domain_based_search =
from s in Plausible.Site, where: s.domain == ^site_id or s.domain_changed_from == ^site_id
case Repo.one(domain_based_search) do
%Plausible.Site{} = site ->
is_member? = Sites.is_member?(api_key.user_id, site)
is_super_admin? = Plausible.Auth.is_super_admin?(api_key.user_id)

View File

@ -98,11 +98,12 @@ defmodule PlausibleWeb.Router do
pipe_through [:public_api, PlausibleWeb.AuthorizeSitesApiPlug]
post "/", ExternalSitesController, :create_site
get "/:site_id", ExternalSitesController, :get_site
delete "/:site_id", ExternalSitesController, :delete_site
put "/shared-links", ExternalSitesController, :find_or_create_shared_link
put "/goals", ExternalSitesController, :find_or_create_goal
delete "/goals/:goal_id", ExternalSitesController, :delete_goal
get "/:site_id", ExternalSitesController, :get_site
put "/:site_id", ExternalSitesController, :update_site
delete "/:site_id", ExternalSitesController, :delete_site
end
scope "/api", PlausibleWeb do
@ -175,6 +176,9 @@ defmodule PlausibleWeb.Router do
get "/sites", SiteController, :index
get "/sites/new", SiteController, :new
post "/sites", SiteController, :create_site
get "/sites/:website/change-domain", SiteController, :change_domain
put "/sites/:website/change-domain", SiteController, :change_domain_submit
get "/:website/change-domain-snippet", SiteController, :add_snippet_after_domain_change
post "/sites/:website/make-public", SiteController, :make_public
post "/sites/:website/make-private", SiteController, :make_private
post "/sites/:website/weekly-report/enable", SiteController, :enable_weekly_report

View File

@ -0,0 +1,29 @@
<div class="w-full max-w-3xl mt-4 mx-auto flex">
<%= form_for @changeset, Routes.site_path(@conn, :change_domain_submit, @site.domain), [class: "max-w-lg w-full mx-auto bg-white dark:bg-gray-800 shadow-lg rounded px-8 pt-6 pb-8 mb-4 mt-8"], fn f -> %>
<h2 class="text-xl font-black dark:text-gray-100">Change your website domain</h2>
<div class="my-6">
<%= label f, :domain, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<p class="text-gray-500 dark:text-gray-400 text-xs mt-1">Just the naked domain or subdomain without 'www'</p>
<div class="mt-2 flex rounded-md shadow-sm">
<span class="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 dark:border-gray-500 bg-gray-50 dark:bg-gray-850 text-gray-500 dark:text-gray-400 sm:text-sm">
https://
</span>
<%= text_input f, :domain, class: "focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 flex-1 block w-full px-3 py-2 rounded-none rounded-r-md sm:text-sm border-gray-300 dark:border-gray-500 dark:bg-gray-900 dark:text-gray-300", placeholder: "example.com" %>
</div>
<%= error_tag f, :domain %>
</div>
<p class="text-sm sm:text-sm text-gray-700 dark:text-gray-300">
<span class="font-bold dark:text-gray-100">Once you change your domain, you must update the JavaScript snippet on your site within 72 hours to guarantee continuous tracking</span>. If you're using the API, please also make sure to update your API credentials.</p>
<p class="text-sm sm:text-sm text-gray-700 dark:text-gray-300 mt-4">
Visit our <a target="_blank" href="https://plausible.io/docs/change-domain-name/" class="text-indigo-500">documentation</a> for details.
</p>
<%= submit "Change domain and add new snippet →", class: "button mt-4 w-full" %>
<div class="text-center mt-8">
<%= link "Back to site settings", to: Routes.site_path(@conn, :settings_general, @site.domain), class: "text-indigo-500 w-full text-center" %>
</div>
<% end %>
</div>

View File

@ -1,8 +1,27 @@
<%= if Plausible.v2?() do %>
<div class="shadow sm:rounded-md sm:overflow-hidden">
<div class="bg-white dark:bg-gray-800 py-6 px-4 space-y-6 sm:p-6">
<header class="relative">
<h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">Site domain</h2>
<p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-200">Moving your site to a different domain? We got you!</p>
<%= link(to: "https://plausible.io/docs/change-domain-name/", target: "_blank", rel: "noreferrer") do %>
<svg class="w-6 h-6 absolute top-0 right-0 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path></svg>
<% end %>
</header>
</div>
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-850 text-right sm:px-6">
<span class="inline-flex rounded-md shadow-sm">
<%= link "Change domain", to: Routes.site_path(@conn, :change_domain, @site.domain), class: "button" %>
</span>
</div>
</div>
<% end %>
<%= form_for @changeset, "/#{URI.encode_www_form(@site.domain)}/settings", fn f -> %>
<div class="shadow sm:rounded-md sm:overflow-hidden">
<div class="bg-white dark:bg-gray-800 py-6 px-4 space-y-6 sm:p-6">
<header class="relative">
<h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">General information</h2>
<h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">Site timezone</h2>
<p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-200">Update your reporting timezone.</p>
<%= link(to: "https://plausible.io/docs/general/", target: "_blank", rel: "noreferrer") do %>
<svg class="w-6 h-6 absolute top-0 right-0 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path></svg>
@ -10,8 +29,6 @@
</header>
<div class="grid grid-cols-4 gap-6">
<div class="col-span-4 sm:col-span-2"> <%= label f, :domain, class: "block text-sm font-medium leading-5 text-gray-700 dark:text-gray-300" %> <%= text_input f, :domain, class: "dark:bg-gray-900 mt-1 block w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:max-w-xs sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-100", disabled: "disabled" %>
</div>
<div class="col-span-4 sm:col-span-2">
<%= label f, :timezone, "Reporting Timezone", class: "block text-sm font-medium leading-5 text-gray-700 dark:text-gray-300" %>

View File

@ -0,0 +1,23 @@
<div class="w-full max-w-3xl mt-4 mx-auto flex">
<%= 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 -> %>
<h2 class="text-xl font-bold dark:text-gray-100">Change JavaScript snippet</h2>
<div class="my-4">
<p class="dark:text-gray-100">Replace your snippet in the <code>&lt;head&gt;</code> of your website.</p>
<div class="relative">
<%= textarea f, :domain, id: "snippet_code", class: "transition overflow-hidden bg-gray-100 dark:bg-gray-900 appearance-none border border-transparent rounded w-full p-2 pr-6 text-gray-700 dark:text-gray-300 leading-normal appearance-none focus:outline-none focus:bg-white dark:focus:bg-gray-800 focus:border-gray-400 dark:focus:border-gray-500 text-xs mt-4 resize-none", value: snippet(@site), rows: 3, readonly: "readonly" %>
<a onclick="var textarea = document.getElementById('snippet_code'); textarea.focus(); textarea.select(); document.execCommand('copy');" href="javascript:void(0)" class="no-underline text-indigo-500 text-sm hover:underline">
<svg class="absolute text-indigo-500" style="top: 24px; right: 12px;" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
</a>
</div>
</div>
<p class="text-sm sm:text-sm text-gray-700 dark:text-gray-300">
<span class="font-bold dark:text-gray-100">Your domain has been changed. You must update the JavaScript snippet on your site within 72 hours to guarantee continuous tracking</span>. If you're using the API, please also make sure to update your API credentials.</p>
<p class="text-sm sm:text-sm text-gray-700 dark:text-gray-300 mt-4">
Visit our <a target="_blank" href="https://plausible.io/docs/change-domain-name/" class="text-indigo-500">documentation</a> for details.
</p>
<%= link("I understand, I'll change my snippet →", class: "button mt-4 w-full", to: "/#{URI.encode_www_form(@site.domain)}") %>
<% end %>
</div>

View File

@ -0,0 +1,22 @@
defmodule Plausible.Workers.ExpireDomainChangeTransitions do
@moduledoc """
Periodic worker that expires domain change transition period.
Old domains are frozen for a given time, so users can still access them
before redeploying their scripts and integrations.
"""
use Plausible.Repo
use Oban.Worker, queue: :domain_change_transition
require Logger
@impl Oban.Worker
def perform(_job) do
{:ok, n} = Plausible.Site.Domain.expire_change_transitions()
if n > 0 do
Logger.warning("Expired #{n} from the domain change transition period.")
end
:ok
end
end

View File

@ -84,17 +84,31 @@ Enum.flat_map(-720..0, fn day_index ->
Enum.map(number_of_events, fn _ ->
geolocation = Enum.random(geolocations)
[
domain: site.domain,
hostname: site.domain,
timestamp: put_random_time.(date, day_index),
referrer_source: Enum.random(["", "Facebook", "Twitter", "DuckDuckGo", "Google"]),
browser: Enum.random(["Edge", "Chrome", "Safari", "Firefox", "Vivaldi"]),
browser_version: to_string(Enum.random(0..50)),
screen_size: Enum.random(["Mobile", "Tablet", "Desktop", "Laptop"]),
operating_system: Enum.random(["Windows", "macOS", "Linux"]),
operating_system_version: to_string(Enum.random(0..15))
]
if Plausible.v2?() do
[
site_id: site.id,
hostname: site.domain,
timestamp: put_random_time.(date, day_index),
referrer_source: Enum.random(["", "Facebook", "Twitter", "DuckDuckGo", "Google"]),
browser: Enum.random(["Edge", "Chrome", "Safari", "Firefox", "Vivaldi"]),
browser_version: to_string(Enum.random(0..50)),
screen_size: Enum.random(["Mobile", "Tablet", "Desktop", "Laptop"]),
operating_system: Enum.random(["Windows", "macOS", "Linux"]),
operating_system_version: to_string(Enum.random(0..15))
]
else
[
domain: site.domain,
hostname: site.domain,
timestamp: put_random_time.(date, day_index),
referrer_source: Enum.random(["", "Facebook", "Twitter", "DuckDuckGo", "Google"]),
browser: Enum.random(["Edge", "Chrome", "Safari", "Firefox", "Vivaldi"]),
browser_version: to_string(Enum.random(0..50)),
screen_size: Enum.random(["Mobile", "Tablet", "Desktop", "Laptop"]),
operating_system: Enum.random(["Windows", "macOS", "Linux"]),
operating_system_version: to_string(Enum.random(0..15))
]
end
|> Keyword.merge(geolocation)
|> then(&Plausible.Factory.build(:pageview, &1))
end)

View File

@ -71,6 +71,15 @@ defmodule Plausible.Site.CacheTest do
assert Cache.ready?(test)
end
test "cache allows lookups for sites with changed domain", %{test: test} do
{:ok, _} = start_test_cache(test)
insert(:site, domain: "new.example.com", domain_changed_from: "old.example.com")
:ok = Cache.refresh_all(cache_name: test)
assert Cache.get("old.example.com", force?: true, cache_name: test)
assert Cache.get("new.example.com", force?: true, cache_name: test)
end
test "cache exposes hit rate", %{test: test} do
{:ok, _} = start_test_cache(test)
@ -106,6 +115,46 @@ defmodule Plausible.Site.CacheTest do
assert %Site{domain: ^domain2} = Cache.get(domain2, cache_opts)
end
@tag :v2_only
test "sites with recently changed domains are refreshed", %{test: test} do
{:ok, _} = start_test_cache(test)
cache_opts = [cache_name: test, force?: true]
domain1 = "first.example.com"
domain2 = "second.example.com"
site = insert(:site, domain: domain1)
assert :ok = Cache.refresh_updated_recently(cache_opts)
assert item = Cache.get(domain1, cache_opts)
refute item.domain_changed_from
# change domain1 to domain2
{:ok, _site} = Site.Domain.change(site, domain2)
# small refresh keeps both items in cache
assert :ok = Cache.refresh_updated_recently(cache_opts)
assert item_by_domain1 = Cache.get(domain1, cache_opts)
assert item_by_domain2 = Cache.get(domain2, cache_opts)
assert item_by_domain1 == item_by_domain2
assert item_by_domain1.domain == domain2
assert item_by_domain1.domain_changed_from == domain1
# domain_changed_from gets no longer tracked
{:ok, _} = Site.Domain.expire_change_transitions(-1)
# full refresh removes the stale entry
assert :ok = Cache.refresh_all(cache_opts)
refute Cache.get(domain1, cache_opts)
assert item = Cache.get(domain2, cache_opts)
refute item.domain_changed_from
end
test "refreshing all sites sends a telemetry event",
%{
test: test
@ -205,14 +254,14 @@ defmodule Plausible.Site.CacheTest do
test "merging adds new items", %{test: test} do
{:ok, _} = start_test_cache(test)
:ok = Cache.merge([{"item1", :item1}], cache_name: test)
:ok = Cache.merge([{"item1", nil, :item1}], cache_name: test)
assert :item1 == Cache.get("item1", cache_name: test, force?: true)
end
test "merging no new items leaves the old cache intact", %{test: test} do
{:ok, _} = start_test_cache(test)
:ok = Cache.merge([{"item1", :item1}], cache_name: test)
:ok = Cache.merge([{"item1", nil, :item1}], cache_name: test)
:ok = Cache.merge([], cache_name: test)
assert :item1 == Cache.get("item1", cache_name: test, force?: true)
end
@ -220,8 +269,8 @@ defmodule Plausible.Site.CacheTest do
test "merging removes stale items", %{test: test} do
{:ok, _} = start_test_cache(test)
:ok = Cache.merge([{"item1", :item1}], cache_name: test)
:ok = Cache.merge([{"item2", :item2}], cache_name: test)
:ok = Cache.merge([{"item1", nil, :item1}], cache_name: test)
:ok = Cache.merge([{"item2", nil, :item2}], cache_name: test)
refute Cache.get("item1", cache_name: test, force?: true)
assert Cache.get("item2", cache_name: test, force?: true)
@ -230,8 +279,8 @@ defmodule Plausible.Site.CacheTest do
test "merging optionally leaves stale items intact", %{test: test} do
{:ok, _} = start_test_cache(test)
:ok = Cache.merge([{"item1", :item1}], cache_name: test)
:ok = Cache.merge([{"item2", :item2}], cache_name: test, delete_stale_items?: false)
:ok = Cache.merge([{"item1", nil, :item1}], cache_name: test)
:ok = Cache.merge([{"item2", nil, :item2}], cache_name: test, delete_stale_items?: false)
assert Cache.get("item1", cache_name: test, force?: true)
assert Cache.get("item2", cache_name: test, force?: true)
@ -240,15 +289,24 @@ defmodule Plausible.Site.CacheTest do
test "merging updates changed items", %{test: test} do
{:ok, _} = start_test_cache(test)
:ok = Cache.merge([{"item1", :item1}, {"item2", :item2}], cache_name: test)
:ok = Cache.merge([{"item1", :changed}, {"item2", :item2}], cache_name: test)
:ok = Cache.merge([{"item1", nil, :item1}, {"item2", nil, :item2}], cache_name: test)
:ok = Cache.merge([{"item1", nil, :changed}, {"item2", nil, :item2}], cache_name: test)
assert :changed == Cache.get("item1", cache_name: test, force?: true)
assert :item2 == Cache.get("item2", cache_name: test, force?: true)
end
@items1 for i <- 1..200_000, do: {i, :batch1}
@items2 for _ <- 1..200_000, do: {Enum.random(1..400_000), :batch2}
test "merging keeps secondary keys", %{test: test} do
{:ok, _} = start_test_cache(test)
:ok = Cache.merge([{"item1", nil, :item1}], cache_name: test)
:ok = Cache.merge([{"item2", "item1", :updated}], cache_name: test)
assert :updated == Cache.get("item1", cache_name: test, force?: true)
assert :updated == Cache.get("item2", cache_name: test, force?: true)
end
@items1 for i <- 1..200_000, do: {i, nil, :batch1}
@items2 for _ <- 1..200_000, do: {Enum.random(1..400_000), nil, :batch2}
@max_seconds 2
test "merging large sets is expected to be under #{@max_seconds} seconds", %{test: test} do
{:ok, _} = start_test_cache(test)

View File

@ -0,0 +1,96 @@
defmodule Plausible.Site.DomainTest do
alias Plausible.Site
alias Plausible.Site.Domain
use Plausible.DataCase, async: true
@moduletag :v2_only
test "successful change" do
site = insert(:site)
assert {:ok, updated} = Domain.change(site, "new-domain.example.com")
assert updated.domain_changed_from == site.domain
assert updated.domain == "new-domain.example.com"
assert updated.domain_changed_at
end
test "domain_changed_from is kept unique, so no double change is possible" do
site1 = insert(:site)
assert {:ok, _} = Domain.change(site1, "new-domain.example.com")
site2 = insert(:site)
assert {:error, changeset} = Domain.change(site2, "new-domain.example.com")
assert {error_message, _} = changeset.errors[:domain]
assert error_message =~ "This domain cannot be registered"
end
test "domain is also guaranteed unique against existing domain_changed_from entries" do
site1 =
insert(:site, domain: "site1.example.com", domain_changed_from: "oldsite1.example.com")
site2 = insert(:site, domain: "site2.example.com")
assert {:error, %{errors: [{:domain, {error, _}}]}} = Domain.change(site2, site1.domain)
assert {:error, %{errors: [{:domain, {^error, _}}]}} =
Domain.change(site2, site1.domain_changed_from)
assert error =~ "This domain cannot be registered"
end
test "a single site's domain can be changed back and forth" do
site1 = insert(:site, domain: "foo.example.com")
site2 = insert(:site, domain: "baz.example.com")
assert {:ok, _} = Domain.change(site1, "bar.example.com")
assert {:error, _} = Domain.change(site2, "bar.example.com")
assert {:error, _} = Domain.change(site2, "foo.example.com")
assert {:ok, _} = Domain.change(Repo.reload!(site1), "foo.example.com")
assert {:ok, _} = Domain.change(Repo.reload!(site1), "bar.example.com")
end
test "change info is cleared when the grace period expires" do
site = insert(:site)
assert {:ok, site} = Domain.change(site, "new-domain.example.com")
assert site.domain_changed_from
assert site.domain_changed_at
assert {:ok, _} = Domain.expire_change_transitions(-1)
refute Repo.reload!(site).domain_changed_from
refute Repo.reload!(site).domain_changed_at
end
test "expire changes overdue" do
now = NaiveDateTime.utc_now()
yesterday = now |> NaiveDateTime.add(-60 * 60 * 24, :second)
three_days_ago = now |> NaiveDateTime.add(-60 * 60 * 72, :second)
{:ok, s1} = insert(:site) |> Domain.change("new-domain1.example.com")
{:ok, s2} = insert(:site) |> Domain.change("new-domain2.example.com", at: yesterday)
{:ok, s3} = insert(:site) |> Domain.change("new-domain3.example.com", at: three_days_ago)
assert {:ok, 1} = Domain.expire_change_transitions()
assert is_nil(Repo.reload!(s3).domain_changed_from)
assert is_nil(Repo.reload!(s3).domain_changed_at)
assert {:ok, 1} = Domain.expire_change_transitions(24)
assert is_nil(Repo.reload!(s2).domain_changed_at)
assert {:ok, 0} = Domain.expire_change_transitions()
assert Repo.reload!(s1).domain_changed_at
end
test "new domain gets validated" do
site = build(:site)
changeset = Site.update_changeset(site, %{domain: " "})
assert {"can't be blank", _} = changeset.errors[:domain]
changeset = Site.update_changeset(site, %{domain: "?#[]"})
assert {"must not contain URI reserved characters" <> _, _} = changeset.errors[:domain]
end
end

View File

@ -97,12 +97,24 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
describe "DELETE /api/v1/sites/:site_id" do
setup :create_new_site
test "delete a site by it's domain", %{conn: conn, site: site} do
test "delete a site by its domain", %{conn: conn, site: site} do
conn = delete(conn, "/api/v1/sites/" <> site.domain)
assert json_response(conn, 200) == %{"deleted" => true}
end
@tag :v2_only
test "delete a site by its old domain after domain change", %{conn: conn, site: site} do
old_domain = site.domain
new_domain = "new.example.com"
Plausible.Site.Domain.change(site, new_domain)
conn = delete(conn, "/api/v1/sites/" <> old_domain)
assert json_response(conn, 200) == %{"deleted" => true}
end
test "is 404 when site cannot be found", %{conn: conn} do
conn = delete(conn, "/api/v1/sites/foobar.baz")
@ -147,6 +159,27 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
assert String.starts_with?(res["url"], "http://")
end
@tag :v2_only
test "can add a shared link to a site using the old site id after domain change", %{
conn: conn,
site: site
} do
old_domain = site.domain
new_domain = "new.example.com"
Plausible.Site.Domain.change(site, new_domain)
conn =
put(conn, "/api/v1/sites/shared-links", %{
site_id: old_domain,
name: "Wordpress"
})
res = json_response(conn, 200)
assert res["name"] == "Wordpress"
assert String.starts_with?(res["url"], "http://")
end
test "is idempotent find or create op", %{conn: conn, site: site} do
conn =
put(conn, "/api/v1/sites/shared-links", %{
@ -238,6 +271,25 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
assert res["page_path"] == "/signup"
end
@tag :v2_only
test "can add a goal using old site_id after domain change", %{conn: conn, site: site} do
old_domain = site.domain
new_domain = "new.example.com"
Plausible.Site.Domain.change(site, new_domain)
conn =
put(conn, "/api/v1/sites/goals", %{
site_id: old_domain,
goal_type: "event",
event_name: "Signup"
})
res = json_response(conn, 200)
assert res["goal_type"] == "event"
assert res["event_name"] == "Signup"
end
test "is idempotent find or create op", %{conn: conn, site: site} do
conn =
put(conn, "/api/v1/sites/goals", %{
@ -341,7 +393,7 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
describe "DELETE /api/v1/sites/goals/:goal_id" do
setup :create_new_site
test "delete a goal by it's id", %{conn: conn, site: site} do
test "delete a goal by its id", %{conn: conn, site: site} do
conn =
put(conn, "/api/v1/sites/goals", %{
site_id: site.domain,
@ -359,6 +411,30 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
assert json_response(conn, 200) == %{"deleted" => true}
end
@tag :v2_only
test "delete a goal using old site_id after domain change", %{conn: conn, site: site} do
old_domain = site.domain
new_domain = "new.example.com"
Plausible.Site.Domain.change(site, new_domain)
conn =
put(conn, "/api/v1/sites/goals", %{
site_id: new_domain,
goal_type: "event",
event_name: "Signup"
})
%{"id" => goal_id} = json_response(conn, 200)
conn =
delete(conn, "/api/v1/sites/goals/#{goal_id}", %{
site_id: old_domain
})
assert json_response(conn, 200) == %{"deleted" => true}
end
test "is 404 when goal cannot be found", %{conn: conn, site: site} do
conn =
delete(conn, "/api/v1/sites/goals/0", %{
@ -405,16 +481,74 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
describe "GET /api/v1/sites/:site_id" do
setup :create_new_site
test "get a site by it's domain", %{conn: conn, site: site} do
test "get a site by its domain", %{conn: conn, site: site} do
conn = get(conn, "/api/v1/sites/" <> site.domain)
assert json_response(conn, 200) == %{"domain" => site.domain, "timezone" => site.timezone}
end
@tag :v2_only
test "get a site by old site_id after domain change", %{conn: conn, site: site} do
old_domain = site.domain
new_domain = "new.example.com"
Plausible.Site.Domain.change(site, new_domain)
conn = get(conn, "/api/v1/sites/" <> old_domain)
assert json_response(conn, 200) == %{"domain" => new_domain, "timezone" => site.timezone}
end
test "is 404 when site cannot be found", %{conn: conn} do
conn = get(conn, "/api/v1/sites/foobar.baz")
assert json_response(conn, 404) == %{"error" => "Site could not be found"}
end
end
describe "PUT /api/v1/sites/:site_id" do
setup :create_new_site
@tag :v2_only
test "can change domain name", %{conn: conn, site: site} do
old_domain = site.domain
assert old_domain != "new.example.com"
conn =
put(conn, "/api/v1/sites/#{old_domain}", %{
"domain" => "new.example.com"
})
assert json_response(conn, 200) == %{
"domain" => "new.example.com",
"timezone" => "UTC"
}
site = Repo.reload!(site)
assert site.domain == "new.example.com"
assert site.domain_changed_from == old_domain
end
@tag :v2_only
test "can't make a no-op change", %{conn: conn, site: site} do
conn =
put(conn, "/api/v1/sites/#{site.domain}", %{
"domain" => site.domain
})
assert json_response(conn, 400) == %{
"error" => "domain: New domain must be different than the current one"
}
end
@tag :v2_only
test "domain parameter is required", %{conn: conn, site: site} do
conn = put(conn, "/api/v1/sites/#{site.domain}", %{})
assert json_response(conn, 400) == %{
"error" => "domain: can't be blank"
}
end
end
end

View File

@ -123,6 +123,33 @@ defmodule PlausibleWeb.Api.ExternalStatsController.AuthTest do
)
end
@tag :v2_only
test "can access with either site_id after domain change", %{
conn: conn,
user: user,
api_key: api_key
} do
old_domain = "old.example.com"
new_domain = "new.example.com"
site = insert(:site, domain: old_domain, members: [user])
Plausible.Site.Domain.change(site, new_domain)
conn
|> with_api_key(api_key)
|> get("/api/v1/stats/aggregate", %{"site_id" => new_domain, "metrics" => "pageviews"})
|> assert_ok(%{
"results" => %{"pageviews" => %{"value" => 0}}
})
conn
|> with_api_key(api_key)
|> get("/api/v1/stats/aggregate", %{"site_id" => old_domain, "metrics" => "pageviews"})
|> assert_ok(%{
"results" => %{"pageviews" => %{"value" => 0}}
})
end
defp with_api_key(conn, api_key) do
Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key}")
end

View File

@ -305,7 +305,7 @@ defmodule PlausibleWeb.SiteControllerTest do
conn = get(conn, "/#{site.domain}/settings/general")
resp = html_response(conn, 200)
assert resp =~ "General information"
assert resp =~ "Site timezone"
assert resp =~ "Data Import from Google Analytics"
assert resp =~ "https://accounts.google.com/o/oauth2/v2/auth?"
assert resp =~ "analytics.readonly"
@ -1145,4 +1145,116 @@ defmodule PlausibleWeb.SiteControllerTest do
assert Repo.reload(job).state == "cancelled"
end
end
describe "domain change" do
setup [:create_user, :log_in, :create_site]
@tag :v2_only
test "shows domain change in the settings form", %{conn: conn, site: site} do
conn = get(conn, Routes.site_path(conn, :settings_general, site.domain))
resp = html_response(conn, 200)
assert resp =~ "Site domain"
assert resp =~ "Change domain"
assert resp =~ Routes.site_path(conn, :change_domain, site.domain)
end
@tag :v2_only
test "domain change form renders", %{conn: conn, site: site} do
conn = get(conn, Routes.site_path(conn, :change_domain, site.domain))
resp = html_response(conn, 200)
assert resp =~ Routes.site_path(conn, :change_domain_submit, site.domain)
assert resp =~
"Once you change your domain, you must update the JavaScript snippet on your site within 72 hours"
end
@tag :v2_only
test "domain change form submission when no change is made", %{conn: conn, site: site} do
conn =
put(conn, Routes.site_path(conn, :change_domain_submit, site.domain), %{
"site" => %{"domain" => site.domain}
})
resp = html_response(conn, 200)
assert resp =~ "New domain must be different than the current one"
end
@tag :v2_only
test "domain change form submission to an existing domain", %{conn: conn, site: site} do
another_site = insert(:site)
conn =
put(conn, Routes.site_path(conn, :change_domain_submit, site.domain), %{
"site" => %{"domain" => another_site.domain}
})
resp = html_response(conn, 200)
assert resp =~ "This domain cannot be registered"
site = Repo.reload!(site)
assert site.domain != another_site.domain
assert is_nil(site.domain_changed_from)
end
@tag :v2_only
test "domain change form submission to a domain in transition period", %{
conn: conn,
site: site
} do
another_site = insert(:site, domain_changed_from: "foo.example.com")
conn =
put(conn, Routes.site_path(conn, :change_domain_submit, site.domain), %{
"site" => %{"domain" => "foo.example.com"}
})
resp = html_response(conn, 200)
assert resp =~ "This domain cannot be registered"
site = Repo.reload!(site)
assert site.domain != another_site.domain
assert is_nil(site.domain_changed_from)
end
@tag :v2_only
test "domain change succcessful form submission redirects to snippet change info", %{
conn: conn,
site: site
} do
original_domain = site.domain
conn =
put(conn, Routes.site_path(conn, :change_domain_submit, site.domain), %{
"site" => %{"domain" => "foo.example.com"}
})
assert redirected_to(conn) ==
Routes.site_path(conn, :add_snippet_after_domain_change, "foo.example.com")
site = Repo.reload!(site)
assert site.domain == "foo.example.com"
assert site.domain_changed_from == original_domain
end
@tag :v2_only
test "snippet info after domain change", %{
conn: conn,
site: site
} do
put(conn, Routes.site_path(conn, :change_domain_submit, site.domain), %{
"site" => %{"domain" => "foo.example.com"}
})
resp =
conn
|> get(Routes.site_path(conn, :add_snippet_after_domain_change, "foo.example.com"))
|> html_response(200)
|> Floki.parse_document!()
|> Floki.text()
assert resp =~
"Your domain has been changed. You must update the JavaScript snippet on your site within 72 hours"
end
end
end

View File

@ -1,13 +1,16 @@
{:ok, _} = Application.ensure_all_started(:ex_machina)
Mox.defmock(Plausible.HTTPClient.Mock, for: Plausible.HTTPClient.Interface)
FunWithFlags.enable(:visits_metric)
ExUnit.start(exclude: :slow)
Application.ensure_all_started(:double)
Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, :manual)
if Plausible.v2?() do
ExUnit.configure(exclude: [:slow])
IO.puts("Running tests against v2 schema")
else
ExUnit.configure(exclude: [:v2_only, :slow])
IO.puts(
"Running tests against v1 schema. Use: `V2_MIGRATION_DONE=1 mix test` for secondary run."
)

View File

@ -0,0 +1,43 @@
defmodule Plausible.Workers.ExpireDomainChangeTransitionsTest do
use Plausible.DataCase, async: true
alias Plausible.Workers.ExpireDomainChangeTransitions
alias Plausible.Site
alias Plausible.Sites
import ExUnit.CaptureLog
@moduletag :v2_only
test "doesn't log when there is nothing to do" do
log =
capture_log(fn ->
assert :ok = ExpireDomainChangeTransitions.perform(nil)
end)
assert log == ""
end
test "expires domains selectively after change and logs the result" do
now = NaiveDateTime.utc_now()
yesterday = now |> NaiveDateTime.add(-60 * 60 * 24, :second)
three_days_ago = now |> NaiveDateTime.add(-60 * 60 * 72, :second)
long_time_ago = now |> NaiveDateTime.add(-60 * 60 * 24 * 365, :second)
insert(:site) |> Site.Domain.change("site1.example.com")
insert(:site) |> Site.Domain.change("site2.example.com", at: yesterday)
insert(:site) |> Site.Domain.change("site3.example.com", at: three_days_ago)
insert(:site) |> Site.Domain.change("site4.example.com", at: long_time_ago)
log =
capture_log(fn ->
assert :ok = ExpireDomainChangeTransitions.perform(nil)
end)
assert log =~ "Expired 2 from the domain change transition period"
assert Sites.get_by_domain("site1.example.com").domain_changed_from
assert Sites.get_by_domain("site2.example.com").domain_changed_from
refute Sites.get_by_domain("site3.example.com").domain_changed_from
refute Sites.get_by_domain("site4.example.com").domain_changed_from
end
end