Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow domain change #2803

Merged
merged 20 commits into from
Apr 4, 2023
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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 = [
Expand All @@ -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 = [
Expand Down
65 changes: 55 additions & 10 deletions lib/plausible/site.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, [
Expand Down Expand Up @@ -173,19 +195,17 @@ defmodule Plausible.Site do
|> Timex.to_date()
end

defp clean_domain(changeset) do
defp clean_domain(changeset, field \\ :domain) do
aerosol marked this conversation as resolved.
Show resolved Hide resolved
clean_domain =
(get_field(changeset, :domain) || "")
(get_field(changeset, field) || "")
|> String.trim()
|> String.replace_leading("http://", "")
|> String.replace_leading("https://", "")
|> String.replace_leading("www.", "")
|> String.replace_trailing("/", "")
|> String.downcase()

change(changeset, %{
domain: clean_domain
})
change(changeset, %{field => clean_domain})
end

# https://tools.ietf.org/html/rfc3986#section-2.2
Expand All @@ -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
18 changes: 18 additions & 0 deletions lib/plausible/site/cache.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
}

Expand All @@ -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}
}

Expand All @@ -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)

Expand Down Expand Up @@ -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 changes: 62 additions & 0 deletions lib/plausible/site/domain.ex
Original file line number Diff line number Diff line change
@@ -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 \\ []) when is_binary(new_domain) do
changeset = Site.update_changeset(site, %{domain: new_domain}, opts)

changeset =
if Enum.empty?(changeset.changes) do
Ecto.Changeset.add_error(
changeset,
:domain,
"New domain must be different than your current one."
)
else
changeset
end

Repo.update(changeset)
end
end
2 changes: 1 addition & 1 deletion lib/plausible/sites.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 55 additions & 5 deletions lib/plausible_web/controllers/site_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
5 changes: 4 additions & 1 deletion lib/plausible_web/plugs/authorize_stats_api.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions lib/plausible_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,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
Expand Down
Loading