mirror of
https://github.com/plausible/analytics.git
synced 2024-12-22 17:11:36 +03:00
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:
parent
5ca53a70be
commit
1d01328287
@ -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
|
||||
|
@ -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 = [
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
62
lib/plausible/site/domain.ex
Normal file
62
lib/plausible/site/domain.ex
Normal 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
|
@ -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
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
29
lib/plausible_web/templates/site/change_domain.html.eex
Normal file
29
lib/plausible_web/templates/site/change_domain.html.eex
Normal 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>
|
@ -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" %>
|
||||
|
@ -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><head></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>
|
22
lib/workers/expire_domain_change_transitions.ex
Normal file
22
lib/workers/expire_domain_change_transitions.ex
Normal 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
|
@ -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)
|
||||
|
@ -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)
|
||||
|
96
test/plausible/site/domain_test.exs
Normal file
96
test/plausible/site/domain_test.exs
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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."
|
||||
)
|
||||
|
43
test/workers/expire_domain_change_transitions_test.exs
Normal file
43
test/workers/expire_domain_change_transitions_test.exs
Normal 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
|
Loading…
Reference in New Issue
Block a user