Skip to content

Commit

Permalink
Allow domain change
Browse files Browse the repository at this point in the history
  • Loading branch information
aerosol committed Mar 29, 2023
1 parent 70f1c65 commit dc84f79
Show file tree
Hide file tree
Showing 21 changed files with 797 additions and 49 deletions.
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
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
55 changes: 55 additions & 0 deletions lib/plausible/site/domain.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
defmodule Plausible.Site.Domain do
@expire_threshold_hours 72

@moduledoc """
Basic interface for domain changes.
Once V2 schema migration is ready, domain change operation
will be enabled, accessible to the users.
We will set a grace period of #{@expire_threshold_hours} hours
during which both old and new domains will redirect events traffic
to the same site. A periodic worker will call the `expire/0`
function to end it where applicable.
"""

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
64 changes: 59 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,53 @@ 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
site = conn.assigns[:site]

changeset = Plausible.Site.update_changeset(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
site = conn.assigns[:site]

case Plausible.Site.Domain.change(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
29 changes: 29 additions & 0 deletions lib/plausible_web/templates/site/change_domain.html.eex
Original file line number Diff line number Diff line change
@@ -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>
Loading

0 comments on commit dc84f79

Please sign in to comment.