Skip to content

Commit

Permalink
Implement basic HelpScout integration
Browse files Browse the repository at this point in the history
  • Loading branch information
zoldar committed Jul 11, 2024
1 parent 4c21091 commit 1bae368
Show file tree
Hide file tree
Showing 13 changed files with 897 additions and 0 deletions.
5 changes: 5 additions & 0 deletions config/.env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,9 @@ S3_ENDPOINT=http://localhost:10000
S3_EXPORTS_BUCKET=dev-exports
S3_IMPORTS_BUCKET=dev-imports

HELP_SCOUT_APP_ID=fake_app_id
HELP_SCOUT_APP_SECRET=fake_app_secret
HELP_SCOUT_SIGNATURE_KEY=fake_signature_key
HELP_SCOUT_VAULT_KEY=ym9ZQg0KPNGCH3C2eD5y6KpL0tFzUqAhwxQO6uEv/ZM=

VERIFICATION_ENABLED=true
4 changes: 4 additions & 0 deletions config/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ IP_GEOLOCATION_DB=test/priv/GeoLite2-City-Test.mmdb
SITE_DEFAULT_INGEST_THRESHOLD=1000000
GOOGLE_CLIENT_ID=fake_client_id
GOOGLE_CLIENT_SECRET=fake_client_secret
HELP_SCOUT_APP_ID=fake_app_id
HELP_SCOUT_APP_SECRET=fake_app_secret
HELP_SCOUT_SIGNATURE_KEY=fake_signature_key
HELP_SCOUT_VAULT_KEY=ym9ZQg0KPNGCH3C2eD5y6KpL0tFzUqAhwxQO6uEv/ZM=

S3_DISABLED=false
S3_ACCESS_KEY_ID=minioadmin
Expand Down
10 changes: 10 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ paddle_vendor_id = get_var_from_path_or_env(config_dir, "PADDLE_VENDOR_ID")
google_cid = get_var_from_path_or_env(config_dir, "GOOGLE_CLIENT_ID")
google_secret = get_var_from_path_or_env(config_dir, "GOOGLE_CLIENT_SECRET")
postmark_api_key = get_var_from_path_or_env(config_dir, "POSTMARK_API_KEY")
help_scout_app_id = get_var_from_path_or_env(config_dir, "HELP_SCOUT_APP_ID")
help_scout_app_secret = get_var_from_path_or_env(config_dir, "HELP_SCOUT_APP_SECRET")
help_scout_signature_key = get_var_from_path_or_env(config_dir, "HELP_SCOUT_SIGNATURE_KEY")
help_scout_vault_key = get_var_from_path_or_env(config_dir, "HELP_SCOUT_VAULT_KEY")

{otel_sampler_ratio, ""} =
config_dir
Expand Down Expand Up @@ -372,6 +376,12 @@ config :plausible, :google,
api_url: "https://www.googleapis.com",
reporting_api_url: "https://analyticsreporting.googleapis.com"

config :plausible, Plausible.HelpScout,
app_id: help_scout_app_id,
app_secret: help_scout_app_secret,
signature_key: help_scout_signature_key,
vault_key: help_scout_vault_key

config :plausible, :imported,
max_buffer_size: get_int_from_path_or_env(config_dir, "IMPORTED_MAX_BUFFER_SIZE", 10_000)

Expand Down
5 changes: 5 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,8 @@ config :plausible, Plausible.Verification.Checks.Installation,
req_opts: [
plug: {Req.Test, Plausible.Verification.Checks.Installation}
]

config :plausible, Plausible.HelpScout,
req_opts: [
plug: {Req.Test, Plausible.HelpScout}
]
259 changes: 259 additions & 0 deletions extra/lib/plausible/help_scout.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
defmodule Plausible.HelpScout do
@moduledoc """
HelpScout callback API logic.
"""

import Ecto.Query

alias Plausible.Billing
alias Plausible.Billing.Subscription
alias Plausible.HelpScout.Vault
alias Plausible.Repo

require Plausible.Billing.Subscription.Status

@base_api_url "https://api.helpscout.net"
@signature_field "X-HelpScout-Signature"

@doc """
Validates signature against secret key configured for the
HelpScout application.
NOTE: HelpScout signature generation procedure at
https://developer.helpscout.com/apps/guides/signature-validation/
fails to mention that it's implicitly dependent on request params
order getting preserved. PHP arrays are ordered maps, so they provide
this guarantee. Here, on the other hand, we have to determine the original
order of the keys directly from the query string and serialize
params to JSON using wrapper struct, informing Jason to put the values
in the serialized object in this particular order matching query string.
"""
@spec validate_signature(Plug.Conn.t()) :: :ok | {:error, :missing_signature | :bad_signature}
def validate_signature(conn) do
params = conn.params

keys =
conn.query_string
|> String.split("&")
|> Enum.map(fn part ->
part |> String.split("=") |> List.first()
end)
|> Enum.reject(&(&1 == @signature_field))

signature = params[@signature_field]

if is_binary(signature) do
signature_key = Keyword.fetch!(config(), :signature_key)

ordered_data = Enum.map(keys, fn key -> {key, params[key]} end)
data = Jason.encode!(%Jason.OrderedObject{values: ordered_data})

calculated =
:hmac
|> :crypto.mac(:sha, signature_key, data)
|> Base.encode64()

if signature == calculated do
:ok
else
{:error, :bad_signature}
end
else
{:error, :missing_signature}
end
end

@spec get_customer_details(String.t()) :: {:ok, map()} | {:error, any()}
def get_customer_details(customer_id) do
with {:ok, emails} <- get_customer_emails(customer_id),
{:ok, user} <- get_user(emails) do
user = Plausible.Users.with_subscription(user.id)
plan = Billing.Plans.get_subscription_plan(user.subscription)

{:ok,
%{
status_label: status_label(user),
status_link: PlausibleWeb.Endpoint.url() <> "/crm/auth/user/#{user.id}",
plan_label: plan_label(user.subscription, plan),
plan_link: plan_link(user.subscription)
}}
end
end

defp plan_link(nil), do: "#"

defp plan_link(%{paddle_subscription_id: paddle_id}) do
Billing.PaddleApi.vendors_domain() <>
"/subscriptions/customers/manage/" <> paddle_id
end

defp status_label(user) do
subscription_active? = Billing.Subscriptions.active?(user.subscription)
trial? = Plausible.Users.on_trial?(user)

cond do
not subscription_active? and not trial? and user.trial_expiry_date == nil ->
"None"

user.subscription == nil and not trial? ->
"Expired trial"

trial? ->
"Trial"

user.subscription.status == Subscription.Status.deleted() ->
if subscription_active? do
"Pending cancellation"
else
"Canceled"
end

user.subscription.status == Subscription.Status.paused() ->
"Paused"

Plausible.Sites.owned_sites_locked?(user) ->
"Dashboard locked"

subscription_active? ->
"Paid"
end
end

defp plan_label(_, nil) do
"None"
end

defp plan_label(_, :free_10k) do
"Free 10k"
end

defp plan_label(subscription, %Billing.Plan{} = plan) do
[plan] = Billing.Plans.with_prices([plan], "127.0.0.1")
interval = Billing.Plans.subscription_interval(subscription)
quota = PlausibleWeb.AuthView.subscription_quota(subscription, [])

price =
cond do
interval == "monthly" && plan.monthly_cost ->
Billing.format_price(plan.monthly_cost)

interval == "yearly" && plan.yearly_cost ->
Billing.format_price(plan.yearly_cost)

true ->
"N/A"
end

"#{quota} Plan (#{price} #{interval})"
end

defp plan_label(subscription, %Billing.EnterprisePlan{} = plan) do
quota = PlausibleWeb.AuthView.subscription_quota(subscription, [])
price_amount = Billing.Plans.get_price_for(plan, "127.0.0.1")

price =
if price_amount do
Billing.format_price(price_amount)
else
"N/A"
end

"#{quota} Enterprise Plan (#{price} #{plan.billing_interval})"
end

defp get_user(emails) do
user =
from(u in Plausible.Auth.User, where: u.email in ^emails, limit: 1)
|> Repo.one()

if user do
{:ok, user}
else
{:error, :not_found}
end
end

defp get_customer_emails(customer_id, opts \\ []) do
refresh? = Keyword.get(opts, :refresh?, true)
token = get_token!()

url = @base_api_url <> "/v2/customers/" <> customer_id

extra_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || []
opts = Keyword.merge([auth: {:bearer, token}], extra_opts)

case Req.get(url, opts) do
{:ok, %{body: %{"_embedded" => %{"emails" => [_ | _] = emails}}}} ->
{:ok, Enum.map(emails, & &1["value"])}

{:ok, %{status: 200}} ->
{:error, :no_emails}

{:ok, %{status: 404}} ->
{:error, :not_found}

{:ok, %{status: 401}} ->
if refresh? do
refresh_token!()
get_customer_emails(customer_id, refresh?: false)
else
{:error, :auth_failed}
end

error ->
Sentry.capture_message("Failed to obtain customer data from HelpScout API",
extra: %{error: inspect(error)}
)

{:error, :unknown}
end
end

defp get_token!() do
token =
"SELECT access_token FROM help_scout_credentials ORDER BY id DESC LIMIT 1"
|> Repo.query!()
|> Map.get(:rows)
|> List.first()

case token do
[token] when is_binary(token) ->
Vault.decrypt!(token)

_ ->
refresh_token!()
end
end

defp refresh_token!() do
url = @base_api_url <> "/v2/oauth2/token"
credentials = config()

params = [
grant_type: "client_credentials",
client_id: Keyword.fetch!(credentials, :app_id),
client_secret: Keyword.fetch!(credentials, :app_secret)
]

extra_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || []
opts = Keyword.merge([form: params], extra_opts)

token =
url
|> Req.post!(opts)
|> Map.fetch!(:body)
|> Map.fetch!("access_token")

now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)

Repo.insert_all("help_scout_credentials", [
[access_token: Vault.encrypt!(token), inserted_at: now, updated_at: now]
])

token
end

defp config() do
Application.fetch_env!(:plausible, __MODULE__)
end
end
19 changes: 19 additions & 0 deletions extra/lib/plausible/help_scout/vault.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
defmodule Plausible.HelpScout.Vault do
@moduledoc """
Provides a vault that will be used to encrypt/decrypt the stored HelpScout API access tokens.
"""

use Cloak.Vault, otp_app: :plausible

@impl GenServer
def init(config) do
{key, config} = Keyword.pop!(config, :key)

config =
Keyword.put(config, :ciphers,
default: {Cloak.Ciphers.AES.GCM, tag: "AES.GCM.V1", iv_length: 12, key: key}
)

{:ok, config}
end
end
20 changes: 20 additions & 0 deletions extra/lib/plausible_web/controllers/help_scout_controller.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
defmodule PlausibleWeb.HelpScoutController do
use PlausibleWeb, :controller

alias Plausible.HelpScout

def callback(conn, %{"customer-id" => customer_id}) do
conn =
conn
|> delete_resp_header("x-frame-options")
|> put_layout(false)

with :ok <- HelpScout.validate_signature(conn),
{:ok, details} <- HelpScout.get_customer_details(customer_id) do
render(conn, "callback.html", details)
else
{:error, error} ->
render(conn, "callback.html", error: inspect(error))
end
end
end
Loading

0 comments on commit 1bae368

Please sign in to comment.