Custom domains (#34)

* UI to create custom domains

* Only call ssh once per domain

* Update copy for custom domain setup

* Use correct user for ssh
This commit is contained in:
Uku Taht 2020-02-26 10:54:21 +02:00 committed by GitHub
parent da58d8d87e
commit 79b9f72b52
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 283 additions and 10 deletions

View File

@ -0,0 +1,30 @@
defmodule Mix.Tasks.ObtainSslCertificates do
use Plausible.Repo
def run(_args) do
Application.ensure_all_started(:plausible)
execute()
end
def execute(system \\ System) do
recent_custom_domains = Repo.all(
from cd in Plausible.Site.CustomDomain,
where: cd.updated_at > fragment("now() - '3 days'::interval"),
where: not cd.has_ssl_certificate
)
for domain <- recent_custom_domains do
system.cmd("ssh", ["-t", "ubuntu@custom.plausible.io", "sudo certbot certonly --nginx -n -d #{domain.domain}"])
|> report_result(domain)
end
end
defp report_result({_, 0}, domain) do
Ecto.Changeset.change(domain, has_ssl_certificate: true) |> Repo.update!
Plausible.Slack.notify("Obtained SSL cert for #{domain.domain}")
end
defp report_result({error_msg, error_code}, domain) do
Sentry.capture_message("Error obtaining SSL certificate", extra: %{error_msg: error_msg, error_code: error_code, domain: domain.domain})
end
end

View File

@ -0,0 +1,20 @@
defmodule Plausible.Site.CustomDomain do
use Ecto.Schema
import Ecto.Changeset
@domain_name_regex ~r/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/
schema "custom_domains" do
field :domain, :string
field :has_ssl_certificate, :boolean
belongs_to :site, Plausible.Site
timestamps()
end
def changeset(custom_domain, attrs) do
custom_domain
|> cast(attrs, [:domain, :site_id])
|> validate_required([:domain, :site_id])
|> validate_format(:domain, @domain_name_regex, message: "please enter a valid domain name")
end
end

View File

@ -13,6 +13,7 @@ defmodule Plausible.Site do
has_one :google_auth, GoogleAuth
has_one :weekly_report, Plausible.Site.WeeklyReport
has_one :monthly_report, Plausible.Site.MonthlyReport
has_one :custom_domain, Plausible.Site.CustomDomain
timestamps()
end

View File

@ -1,5 +1,6 @@
defmodule Plausible.Sites do
use Plausible.Repo
alias Plausible.Site.CustomDomain
def get_for_user!(user_id, domain) do
Repo.one!(
@ -31,4 +32,11 @@ defmodule Plausible.Sites do
where: sm.user_id == ^user_id and sm.site_id == ^site.id
)
end
def add_custom_domain(site, custom_domain) do
CustomDomain.changeset(%CustomDomain{}, %{
site_id: site.id,
domain: custom_domain
}) |> Repo.insert
end
end

View File

@ -1,6 +1,7 @@
defmodule Plausible.Slack do
@app_env System.get_env("APP_ENV") || "dev"
@feed_channel_url "https://hooks.slack.com/services/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"
@feed_channel_url "https://hooks.slack.com/services/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"
require Logger
def notify(text) do
Task.start(fn ->
@ -8,7 +9,7 @@ defmodule Plausible.Slack do
"prod" ->
HTTPoison.post!(@feed_channel_url, Poison.encode!(%{text: text}))
_ ->
nil
Logger.debug(text)
end
end)
end

View File

@ -75,6 +75,7 @@ defmodule PlausibleWeb.SiteController do
def settings(conn, %{"website" => website}) do
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
|> Repo.preload(:google_auth)
|> Repo.preload(:custom_domain)
search_console_domains = if site.google_auth do
Plausible.Google.Api.fetch_verified_properties(site.google_auth)
@ -290,6 +291,38 @@ defmodule PlausibleWeb.SiteController do
redirect(conn, to: "/#{URI.encode_www_form(site.domain)}/settings#visibility")
end
def new_custom_domain(conn, %{"website" => website}) do
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
changeset = Plausible.Site.CustomDomain.changeset(%Plausible.Site.CustomDomain{}, %{})
render(conn, "new_custom_domain.html", site: site, changeset: changeset, layout: {PlausibleWeb.LayoutView, "focus.html"})
end
def custom_domain_dns_setup(conn, %{"website" => website}) do
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
|> Repo.preload(:custom_domain)
render(conn, "custom_domain_dns_setup.html", site: site, layout: {PlausibleWeb.LayoutView, "focus.html"})
end
def custom_domain_snippet(conn, %{"website" => website}) do
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
|> Repo.preload(:custom_domain)
render(conn, "custom_domain_snippet.html", site: site, layout: {PlausibleWeb.LayoutView, "focus.html"})
end
def add_custom_domain(conn, %{"website" => website, "custom_domain" => domain}) do
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
case Sites.add_custom_domain(site, domain["domain"]) do
{:ok, _custom_domain} ->
redirect(conn, to: "/sites/#{URI.encode_www_form(site.domain)}/custom-domains/dns-setup")
{:error, changeset} ->
render(conn, "new_custom_domain.html", site: site, changeset: changeset, layout: {PlausibleWeb.LayoutView, "focus.html"})
end
end
defp insert_site(user_id, params) do
site_changeset = Plausible.Site.changeset(%Plausible.Site{}, params)

View File

@ -113,6 +113,11 @@ defmodule PlausibleWeb.Router do
post "/sites/:website/shared-links", SiteController, :create_shared_link
delete "/sites/:website/shared-links/:slug", SiteController, :delete_shared_link
get "/sites/:website/custom-domains/new", SiteController, :new_custom_domain
get "/sites/:website/custom-domains/dns-setup", SiteController, :custom_domain_dns_setup
get "/sites/:website/custom-domains/snippet", SiteController, :custom_domain_snippet
post "/sites/:website/custom-domains", SiteController, :add_custom_domain
get "/sites/:website/weekly-report/unsubscribe", UnsubscribeController, :weekly_report
get "/sites/:website/monthly-report/unsubscribe", UnsubscribeController, :monthly_report

View File

@ -0,0 +1,10 @@
<div class="max-w-sm w-full mx-auto bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8">
<h2>DNS for <%= @site.custom_domain.domain %></h2>
<ol class="my-6">
<li>Go to your DNS providers website</li>
<li class="mt-4">Create a new CNAME record for <code><%= @site.custom_domain.domain %></code></li>
<li class="mt-4">Point the record to <code>custom.plausible.io.</code> (including the dot)</li>
</ol>
<%= link("Done ->", to: "/sites/#{URI.encode_www_form(@site.domain)}/custom-domains/snippet", class: "button w-full") %>
</div>

View File

@ -0,0 +1,27 @@
<%= form_for @conn, "/", [class: "max-w-sm w-full mx-auto bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8"], fn f -> %>
<div class="flex items-center justify-between">
<h2>Update javascript snippet</h2>
</div>
<div class="my-4">
<p>
Allow up to 4 hours for DNS changes to propagate and for us to obtain an SSL certificate for <code><%= @site.custom_domain.domain %></code>
</p>
<p class="mt-4">
The setup is working when <a href="//<%= @site.custom_domain.domain %>/js/plausible.js" target="_blank" class="text-indigo"><%= @site.custom_domain.domain %>/js/plausible.js</a> loads the javascript tracker.
</p>
<p class="mt-4">
To finish your setup, please update the tracking snippet on your site with your custom domain.
</p>
<div class="relative">
<%= textarea f, :domain, id: "snippet_code", class: "transition overflow-hidden bg-grey-lighter appearance-none border border-transparent rounded w-full p-2 pr-6 text-grey-darker leading-normal appearance-none focus:outline-none focus:bg-white focus:border-grey-light text-xs mt-4 resize-none", value: snippet(@site), rows: 2, 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 text-sm hover:underline">
<svg class="absolute text-indigo" 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" class="feather feather-copy"><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>
<%= link("Back to settings →", class: "button mt-4 w-full", to: "/#{URI.encode_www_form(@site.domain)}/settings#custom-domain") %>
<p class="mt-4 text-grey-dark text-sm">
Problems? <%= link("Get help via email", to: "mailto:uku@plausible.io", class: "text-indigo-darker underline") %>
</p>
<% end %>

View File

@ -0,0 +1,15 @@
<%= form_for @changeset, "/sites/#{URI.encode_www_form(@site.domain)}/custom-domains", [class: "max-w-sm w-full mx-auto bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8"], fn f -> %>
<h2>Setup custom domain</h2>
<div class="my-6">
We recommend using a subdomain of the website you're running Plausible on.
If your site is on <code>example.com</code> you can use <code>stats.example.com</code>.
<br /><br /> The name of the subdomain can be anything, it doesn't have to be <code>stats</code>.
</div>
<div class="my-6">
<%= label f, :domain, class: "block text-sm font-bold" %>
<%= text_input f, :domain, class: "transition mt-3 bg-grey-lighter appearance-none border border-transparent rounded w-full p-2 text-grey-darker leading-normal appearance-none focus:outline-none focus:bg-white focus:border-grey-light", placeholder: "stats.[yourdomain].com" %>
<%= error_tag f, :domain %>
</div>
<%= submit "DNS setup ->", class: "button mt-4 w-full" %>
<% end %>

View File

@ -207,10 +207,22 @@
<% end %>
</div>
<%= form_for @conn, "/", [class: "bg-white max-w-md mx-auto shadow-md rounded rounded-t-none border-t-2 border-indigo-lightest px-8 pt-6 pb-8 mb-4 mt-16"], fn f -> %>
<div class="flex items-center justify-between">
<h2>Javascript snippet</h2>
<div class="bg-white max-w-md mx-auto shadow-md rounded rounded-t-none border-t-2 border-indigo-lightest px-8 pt-6 pb-8 mb-4 mt-16" id="custom-domain">
<h2>Custom domain</h2>
<div class="my-4">
Some browsers and extensions block analytics services. To get around that, Plausible offers a quick and easy way to serve the script from your own custom domain.
</div>
<%= if @site.custom_domain do %>
Configured domain: <b><%= @site.custom_domain.domain %></b>
<% else %>
<div class="mt-4">
<%= link("Add custom domain", to: "/sites/#{URI.encode_www_form(@site.domain)}/custom-domains/new", class: "button") %>
</div>
<% end %>
</div>
<%= form_for @conn, "/", [class: "bg-white max-w-md mx-auto shadow-md rounded rounded-t-none border-t-2 border-indigo-lightest px-8 pt-6 pb-8 mb-4 mt-16"], fn f -> %>
<h2>Javascript snippet</h2>
<div class="my-4">
<p>Include this snippet in the <code>&lt;head&gt;</code> of your website.</p>
<div class="relative">

View File

@ -6,7 +6,7 @@
<p>Paste this 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-grey-lighter appearance-none border border-transparent rounded w-full p-2 pr-6 text-grey-darker leading-normal appearance-none focus:outline-none focus:bg-white focus:border-grey-light text-xs mt-4 resize-none", value: snippet(@site), rows: 2 %>
<%= textarea f, :domain, id: "snippet_code", class: "transition overflow-hidden bg-grey-lighter appearance-none border border-transparent rounded w-full p-2 pr-6 text-grey-darker leading-normal appearance-none focus:outline-none focus:bg-white focus:border-grey-light text-xs mt-4 resize-none", value: snippet(@site), rows: 2, 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 text-sm hover:underline">
<svg class="absolute text-indigo" 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" class="feather feather-copy"><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>

View File

@ -14,8 +14,10 @@ defmodule PlausibleWeb.SiteView do
end
def snippet(site) do
tracker_domain = if site.custom_domain, do: site.custom_domain.domain, else: "plausible.io"
"""
<script async defer data-domain="#{site.domain}" src="https://plausible.io/js/plausible.js"></script>
<script async defer data-domain="#{site.domain}" src="https://#{tracker_domain}/js/plausible.js"></script>
"""
end
end

View File

@ -33,7 +33,7 @@ defmodule Plausible.MixProject do
# Type `mix help deps` for examples and options.
defp deps do
[
{:browser, "~> 0.4.3"},
{:browser, "~> 0.4.3"}, # remove
{:bcrypt_elixir, "~> 2.0"},
{:cors_plug, "~> 1.5"},
{:ecto_sql, "~> 3.0"},
@ -47,7 +47,7 @@ defmodule Plausible.MixProject do
{:phoenix_pubsub, "~> 1.1"},
{:plug_cowboy, "~> 2.0"},
{:postgrex, ">= 0.0.0"},
{:poison, "~> 3.1"}, # Used in paddle_api
{:poison, "~> 3.1"}, # Used in paddle_api, can remove
{:ref_inspector, "~> 1.3"},
{:timex, "~> 3.6"},
{:ua_inspector, "~> 0.18"},
@ -57,6 +57,7 @@ defmodule Plausible.MixProject do
{:httpoison, "~> 1.4"},
{:ex_machina, "~> 2.3", only: :test},
{:excoveralls, "~> 0.10", only: :test},
{:double, "~> 0.7.0", only: :test},
{:joken, "~> 2.0"},
{:php_serializer, "~> 0.9.0"},
{:csv, "~> 2.3"},

View File

@ -14,6 +14,7 @@
"csv": {:hex, :csv, "2.3.1", "9ce11eff5a74a07baf3787b2b19dd798724d29a9c3a492a41df39f6af686da0e", [:mix], [{:parallel_stream, "~> 1.0.4", [hex: :parallel_stream, repo: "hexpm", optional: false]}], "hexpm"},
"db_connection": {:hex, :db_connection, "2.0.6", "bde2f85d047969c5b5800cb8f4b3ed6316c8cb11487afedac4aa5f93fd39abfa", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"},
"decimal": {:hex, :decimal, "1.7.0", "30d6b52c88541f9a66637359ddf85016df9eb266170d53105f02e4a67e00c5aa", [:mix], [], "hexpm"},
"double": {:hex, :double, "0.7.0", "a7ee4c3488a0acc6d2ad9b69b6c7d3ddf3da2b54488d0f7c2d6ceb3a995887ca", [:mix], [], "hexpm"},
"ecto": {:hex, :ecto, "3.0.8", "9eb6a1fcfc593e6619d45ef51afe607f1554c21ca188a1cd48eecc27223567f1", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"},
"ecto_sql": {:hex, :ecto_sql, "3.0.5", "7e44172b4f7aca4469f38d7f6a3da394dbf43a1bcf0ca975e958cb957becd74e", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0.6", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.3.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
"elixir_make": {:hex, :elixir_make, "0.5.2", "96a28c79f5b8d34879cd95ebc04d2a0d678cfbbd3e74c43cb63a76adf0ee8054", [:mix], [], "hexpm"},

View File

@ -0,0 +1,15 @@
defmodule Plausible.Repo.Migrations.CreateCustomDomains do
use Ecto.Migration
def change do
create table(:custom_domains) do
add :domain, :text, null: false
add :site_id, references(:sites), null: false
add :has_ssl_certificate, :boolean, null: false, default: false
timestamps()
end
create unique_index(:custom_domains, :site_id)
end
end

View File

@ -0,0 +1,37 @@
defmodule Mix.Tasks.ObtainSslCertificatesTest do
use Plausible.DataCase
alias Mix.Tasks.ObtainSslCertificates
import Double
test "makes ssh call to certbot" do
site = insert(:site)
insert(:custom_domain, site: site, domain: "custom-site.com")
system_stub = stub(System, :cmd, fn(_cmd, _args) -> {"", 0} end)
ObtainSslCertificates.execute(system_stub)
assert_receive({System, :cmd, ["ssh", ["-t", "ubuntu@custom.plausible.io", "sudo certbot certonly --nginx -n -d custom-site.com"]]})
end
test "sets has_ssl_certficate=true if the ssh command is succesful" do
site = insert(:site)
insert(:custom_domain, site: site, domain: "custom-site.com")
system_stub = stub(System, :cmd, fn(_cmd, _args) -> {"", 0} end)
ObtainSslCertificates.execute(system_stub)
domain = Repo.get_by(Plausible.Site.CustomDomain, site_id: site.id)
assert domain.has_ssl_certificate
end
test "does not set has_ssl_certficate=true if the ssh command fails" do
site = insert(:site)
insert(:custom_domain, site: site, domain: "custom-site.com")
failing_system_stub = stub(System, :cmd, fn(_cmd, _args) -> {"", 1} end)
ObtainSslCertificates.execute(failing_system_stub)
domain = Repo.get_by(Plausible.Site.CustomDomain, site_id: site.id)
refute domain.has_ssl_certificate
end
end

View File

@ -296,7 +296,7 @@ defmodule PlausibleWeb.SiteControllerTest do
end
end
describe "POSt /sites/:website/shared-links" do
describe "POST /sites/:website/shared-links" do
setup [:create_user, :log_in, :create_site]
test "creates shared link without password", %{conn: conn, site: site} do
@ -332,4 +332,52 @@ defmodule PlausibleWeb.SiteControllerTest do
assert redirected_to(conn, 302) =~ "/#{site.domain}/settings"
end
end
describe "GET /sites/:website/custom-domains/new" do
setup [:create_user, :log_in, :create_site]
test "shows form for new custom domain", %{conn: conn, site: site} do
conn = get(conn, "/sites/#{site.domain}/custom-domains/new")
assert html_response(conn, 200) =~ "Setup custom domain"
end
end
describe "POST /sites/:website/custom-domains" do
setup [:create_user, :log_in, :create_site]
test "creates a custom domain", %{conn: conn, site: site} do
conn = post(conn, "/sites/#{site.domain}/custom-domains", %{"custom_domain" => %{"domain" => "plausible.example.com"}})
domain = Repo.one(Plausible.Site.CustomDomain)
assert redirected_to(conn, 302) =~ "/sites/#{site.domain}/custom-domains/dns-setup"
assert domain.domain == "plausible.example.com"
end
test "validates presence of domain name", %{conn: conn, site: site} do
conn = post(conn, "/sites/#{site.domain}/custom-domains", %{"custom_domain" => %{"domain" => ""}})
refute Repo.one(Plausible.Site.CustomDomain)
assert html_response(conn, 200) =~ "Setup custom domain"
end
test "validates format of domain name", %{conn: conn, site: site} do
conn = post(conn, "/sites/#{site.domain}/custom-domains", %{"custom_domain" => %{"domain" => "ASD?/not-domain"}})
refute Repo.one(Plausible.Site.CustomDomain)
assert html_response(conn, 200) =~ "Setup custom domain"
end
end
describe "GET /sites/:website/custom-domains/dns-setup" do
setup [:create_user, :log_in, :create_site]
test "shows instructions to set up dns", %{conn: conn, site: site} do
domain = insert(:custom_domain, site: site)
conn = get(conn, "/sites/#{site.domain}/custom-domains/dns-setup")
assert html_response(conn, 200) =~ "DNS for #{domain.domain}"
end
end
end

View File

@ -81,6 +81,12 @@ defmodule Plausible.Factory do
}
end
def custom_domain_factory do
%Plausible.Site.CustomDomain{
domain: sequence(:custom_domain, &"domain-#{&1}.com")
}
end
def tweet_factory do
%Plausible.Twitter.Tweet{
tweet_id: UUID.uuid4(),

View File

@ -1,3 +1,4 @@
{:ok, _} = Application.ensure_all_started(:ex_machina)
ExUnit.start()
Application.ensure_all_started(:double)
Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, :manual)