From 119b9514b257e1743b0e4966ccb37c0136c3d908 Mon Sep 17 00:00:00 2001 From: Uku Taht Date: Tue, 4 May 2021 15:37:58 +0300 Subject: [PATCH] Add limit of 20 sites --- assets/css/app.css | 4 ++ config/runtime.exs | 2 + lib/plausible/billing/billing.ex | 14 +++++++ lib/plausible/sites.ex | 42 +++++++++++++------ .../api/external_sites_controller.ex | 6 +-- .../controllers/site_controller.ex | 35 +++++++--------- .../plugs/authorize_sites_api.ex | 3 +- lib/plausible_web/templates/site/new.html.eex | 27 ++++++++++-- .../controllers/site_controller_test.exs | 35 ++++++++++++++++ 9 files changed, 127 insertions(+), 41 deletions(-) diff --git a/assets/css/app.css b/assets/css/app.css index 100e4aa72..11a7168f3 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -11,6 +11,10 @@ @apply inline-flex justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md leading-5 transition hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500; } +.button[disabled] { + @apply bg-gray-400 dark:bg-gray-600 +} + .button-outline { @apply text-indigo-600 bg-transparent border border-indigo-600; } diff --git a/config/runtime.exs b/config/runtime.exs index 836e7c934..78821990f 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -80,6 +80,7 @@ hcaptcha_secret = System.get_env("HCAPTCHA_SECRET") log_level = String.to_existing_atom(System.get_env("LOG_LEVEL", "warn")) log_format = System.get_env("LOG_FORMAT", "elixir") is_selfhost = String.to_existing_atom(System.get_env("SELFHOST", "true")) +{site_limit, ""} = Integer.parse(System.get_env("SITE_LIMIT", "20")) disable_cron = String.to_existing_atom(System.get_env("DISABLE_CRON", "false")) {user_agent_cache_limit, ""} = Integer.parse(System.get_env("USER_AGENT_CACHE_LIMIT", "1000")) @@ -94,6 +95,7 @@ config :plausible, environment: env, mailer_email: mailer_email, admin_emails: admin_emails, + site_limit: site_limit, is_selfhost: is_selfhost config :plausible, :selfhost, diff --git a/lib/plausible/billing/billing.ex b/lib/plausible/billing/billing.ex index c9059d68f..f24d581ee 100644 --- a/lib/plausible/billing/billing.ex +++ b/lib/plausible/billing/billing.ex @@ -188,6 +188,20 @@ defmodule Plausible.Billing do end) end + @doc """ + Returns the number of sites that an account is allowed to have. Accounts for + grandfathering old accounts to unlimited websites and ignores site limit on self-hosted + installations. + """ + @limit_accounts_after ~D[2018-05-04] + def sites_limit(user) do + cond do + Timex.before?(user.inserted_at, @limit_accounts_after) -> nil + Application.get_env(:plausible, :is_selfhost) -> nil + true -> Application.get_env(:plausible, :site_limit) + end + end + defp format_subscription(params) do %{ paddle_subscription_id: params["subscription_id"], diff --git a/lib/plausible/sites.ex b/lib/plausible/sites.ex index ffdf8b7f9..353d8e33c 100644 --- a/lib/plausible/sites.ex +++ b/lib/plausible/sites.ex @@ -2,21 +2,28 @@ defmodule Plausible.Sites do use Plausible.Repo alias Plausible.Site.{CustomDomain, SharedLink} - def create(user_id, params) do - site_changeset = Plausible.Site.changeset(%Plausible.Site{}, params) + def create(user, params) do + count = count_for(user) + limit = Plausible.Billing.sites_limit(user) - Ecto.Multi.new() - |> Ecto.Multi.insert(:site, site_changeset) - |> Ecto.Multi.run(:site_membership, fn repo, %{site: site} -> - membership_changeset = - Plausible.Site.Membership.changeset(%Plausible.Site.Membership{}, %{ - site_id: site.id, - user_id: user_id - }) + if count >= limit do + {:error, :limit, limit} + else + site_changeset = Plausible.Site.changeset(%Plausible.Site{}, params) - repo.insert(membership_changeset) - end) - |> Repo.transaction() + Ecto.Multi.new() + |> Ecto.Multi.insert(:site, site_changeset) + |> Ecto.Multi.run(:site_membership, fn repo, %{site: site} -> + membership_changeset = + Plausible.Site.Membership.changeset(%Plausible.Site.Membership{}, %{ + site_id: site.id, + user_id: user.id + }) + + repo.insert(membership_changeset) + end) + |> Repo.transaction() + end end def create_shared_link(site, name, password \\ nil) do @@ -51,6 +58,15 @@ defmodule Plausible.Sites do ) end + def count_for(user) do + Repo.aggregate( + from(sm in Plausible.Site.Membership, + where: sm.user_id == ^user.id + ), + :count + ) + end + def has_goals?(site) do Repo.exists?( from g in Plausible.Goal, diff --git a/lib/plausible_web/controllers/api/external_sites_controller.ex b/lib/plausible_web/controllers/api/external_sites_controller.ex index befa1d6b9..0ed2a6a23 100644 --- a/lib/plausible_web/controllers/api/external_sites_controller.ex +++ b/lib/plausible_web/controllers/api/external_sites_controller.ex @@ -6,9 +6,9 @@ defmodule PlausibleWeb.Api.ExternalSitesController do alias PlausibleWeb.Api.Helpers, as: H def create_site(conn, params) do - user_id = conn.assigns[:current_user_id] + user = conn.assigns[:current_user] - case Sites.create(user_id, params) do + case Sites.create(user, params) do {:ok, %{site: site}} -> json(conn, site) @@ -29,7 +29,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do def find_or_create_shared_link(conn, params) do with {:ok, site_id} <- expect_param_key(params, "site_id"), {:ok, link_name} <- expect_param_key(params, "name"), - site when not is_nil(site) <- Sites.get_for_user(conn.assigns[:current_user_id], site_id) do + site when not is_nil(site) <- Sites.get_for_user(conn.assigns[:current_user].id, site_id) do shared_link = Repo.get_by(Plausible.Site.SharedLink, site_id: site.id, name: link_name) shared_link = diff --git a/lib/plausible_web/controllers/site_controller.ex b/lib/plausible_web/controllers/site_controller.ex index 633967c77..13252eebb 100644 --- a/lib/plausible_web/controllers/site_controller.ex +++ b/lib/plausible_web/controllers/site_controller.ex @@ -23,36 +23,31 @@ defmodule PlausibleWeb.SiteController do def new(conn, _params) do current_user = conn.assigns[:current_user] - changeset = Plausible.Site.changeset(%Plausible.Site{}) + site_count = Plausible.Sites.count_for(current_user) + site_limit = Plausible.Billing.sites_limit(current_user) + is_at_limit = site_limit && site_count >= site_limit + is_first_site = site_count == 0 - is_first_site = - !Repo.exists?( - from sm in Plausible.Site.Membership, - where: sm.user_id == ^current_user.id - ) + changeset = Plausible.Site.changeset(%Plausible.Site{}) render(conn, "new.html", changeset: changeset, is_first_site: is_first_site, + is_at_limit: is_at_limit, + site_limit: site_limit, layout: {PlausibleWeb.LayoutView, "focus.html"} ) end def create_site(conn, %{"site" => site_params}) do user = conn.assigns[:current_user] + site_count = Plausible.Sites.count_for(user) + is_first_site = site_count == 0 - case Sites.create(user.id, site_params) do + case Sites.create(user, site_params) do {:ok, %{site: site}} -> Plausible.Slack.notify("#{user.name} created #{site.domain} [email=#{user.email}]") - is_first_site = - !Repo.exists?( - from sm in Plausible.Site.Membership, - where: - sm.user_id == ^user.id and - sm.site_id != ^site.id - ) - if is_first_site do PlausibleWeb.Email.welcome_email(user) |> Plausible.Mailer.send_email() @@ -63,17 +58,15 @@ defmodule PlausibleWeb.SiteController do |> redirect(to: "/#{URI.encode_www_form(site.domain)}/snippet") {:error, :site, changeset, _} -> - is_first_site = - !Repo.exists?( - from sm in Plausible.Site.Membership, - where: sm.user_id == ^user.id - ) - render(conn, "new.html", changeset: changeset, is_first_site: is_first_site, + is_at_limit: false, layout: {PlausibleWeb.LayoutView, "focus.html"} ) + + {:error, :limit, _limit} -> + send_resp(conn, 400, "Site limit reached") end end diff --git a/lib/plausible_web/plugs/authorize_sites_api.ex b/lib/plausible_web/plugs/authorize_sites_api.ex index 125f7b153..b55796e3d 100644 --- a/lib/plausible_web/plugs/authorize_sites_api.ex +++ b/lib/plausible_web/plugs/authorize_sites_api.ex @@ -11,7 +11,8 @@ defmodule PlausibleWeb.AuthorizeSitesApiPlug do def call(conn, _opts) do with {:ok, raw_api_key} <- get_bearer_token(conn), {:ok, api_key} <- verify_access(raw_api_key) do - assign(conn, :current_user_id, api_key.user_id) + user = Repo.get_by(Plausible.Auth.User, id: api_key.user_id) + assign(conn, :current_user, user) else {:error, :missing_api_key} -> H.unauthorized( diff --git a/lib/plausible_web/templates/site/new.html.eex b/lib/plausible_web/templates/site/new.html.eex index 670ce0610..d3c6bca5a 100644 --- a/lib/plausible_web/templates/site/new.html.eex +++ b/lib/plausible_web/templates/site/new.html.eex @@ -1,6 +1,27 @@
<%= form_for @changeset, "/sites", [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 -> %>

Your website details

+ <%= if @is_at_limit do %> +
+
+
+ +
+
+

+ Upgrade required +

+
+

+ Your account is limited to <%= @site_limit %> sites. Please contact hello@plausible.io to add more sites. +

+
+
+
+
+ <% end %>
<%= label f, :domain, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>

Just the naked domain or subdomain without 'www'

@@ -8,7 +29,7 @@ https:// - <%= 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" %> + <%= 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", disabled: @is_at_limit %>
<%= error_tag f, :domain %>
@@ -17,7 +38,7 @@

To make sure we agree on what 'today' means

- <%= select f, :timezone, Plausible.Timezones.options(), id: "tz-select", selected: "Etc/Greenwich", class: "mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 dark:border-gray-500 dark:bg-gray-900 dark:text-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md" %> + <%= select f, :timezone, Plausible.Timezones.options(), id: "tz-select", selected: "Etc/Greenwich", class: "mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 dark:border-gray-500 dark:bg-gray-900 dark:text-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", disabled: @is_at_limit %>
- <%= submit "Add snippet →", class: "button mt-4 w-full" %> + <%= submit "Add snippet →", class: "button mt-4 w-full", disabled: @is_at_limit %> <% end %> <%= if @is_first_site do %> diff --git a/test/plausible_web/controllers/site_controller_test.exs b/test/plausible_web/controllers/site_controller_test.exs index 81ce569cf..b25114d31 100644 --- a/test/plausible_web/controllers/site_controller_test.exs +++ b/test/plausible_web/controllers/site_controller_test.exs @@ -9,8 +9,23 @@ defmodule PlausibleWeb.SiteControllerTest do test "shows the site form", %{conn: conn} do conn = get(conn, "/sites/new") + assert html_response(conn, 200) =~ "Your website details" end + + test "shows onboarding steps if it's the first site for the user", %{conn: conn} do + conn = get(conn, "/sites/new") + + assert html_response(conn, 200) =~ "Add site info" + end + + test "does not show onboarding steps if user has a site already", %{conn: conn, user: user} do + insert(:site, members: [user], domain: "test-site.com") + + conn = get(conn, "/sites/new") + + refute html_response(conn, 200) =~ "Add site info" + end end describe "GET /sites" do @@ -73,6 +88,26 @@ defmodule PlausibleWeb.SiteControllerTest do assert_no_emails_delivered() end + test "does not allow site creation when the user is at their site limit", %{ + conn: conn, + user: user + } do + Application.put_env(:plausible, :site_limit, 3) + insert(:site, members: [user]) + insert(:site, members: [user]) + insert(:site, members: [user]) + + conn = + post(conn, "/sites", %{ + "site" => %{ + "domain" => "example.com", + "timezone" => "Europe/London" + } + }) + + assert conn.status == 400 + end + test "cleans up the url", %{conn: conn} do conn = post(conn, "/sites", %{