Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ All notable changes to this project will be documented in this file.
- Update custom range datepicker styles
- Improved site transfer UI
- Redesigned authentication pages (register, sign in, 2FA, password reset, account activation)
- Replaced HCaptcha with Friendly Captcha

### Fixed

Expand Down
7 changes: 7 additions & 0 deletions assets/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions assets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"generate-types": "json2ts ../priv/json-schemas/query-api-schema.json ../assets/js/types/query-api.d.ts --bannerComment '/* Autogenerated, recreate with `npm run --prefix assets generate-types` */'"
},
"dependencies": {
"@friendlycaptcha/sdk": "1.0.2",
"@headlessui/react": "^1.7.19",
"@heroicons/react": "^2.2.0",
"@jsonurl/jsonurl": "^1.1.7",
Expand Down
4 changes: 2 additions & 2 deletions config/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ ENVIRONMENT=test
MAILER_ADAPTER=Bamboo.TestAdapter
ENABLE_EMAIL_VERIFICATION=true
SELFHOST=false
HCAPTCHA_SITEKEY=test
HCAPTCHA_SECRET=scottiger
FRIENDLY_CAPTCHA_SITEKEY=test
FRIENDLY_CAPTCHA_API_KEY=scottiger
IP_GEOLOCATION_DB=test/priv/GeoLite2-City-Test.mmdb
SITE_DEFAULT_INGEST_THRESHOLD=1000000
GOOGLE_CLIENT_ID=fake_client_id
Expand Down
6 changes: 6 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ config :esbuild,
~w(js/app.js js/dashboard.tsx js/embed.host.js js/embed.content.js --bundle --target=es2017 --loader:.js=jsx --outdir=../priv/static/js --define:BUILD_EXTRA=true),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
],
# https://developer.friendlycaptcha.com/docs/v2/getting-started/install#using-the-scripts-without-a-cdn-ie-self-hosting
friendly_captcha: [
args:
~w(node_modules/@friendlycaptcha/sdk/site.min.js node_modules/@friendlycaptcha/sdk/site.compat.min.js --loader:.js=copy --outbase=node_modules/@friendlycaptcha/sdk --outdir=../priv/static/js/friendly-captcha),
cd: Path.expand("../assets", __DIR__)
]

config :tailwind,
Expand Down
2 changes: 2 additions & 0 deletions config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ config :plausible, PlausibleWeb.Endpoint,
check_origin: false,
watchers: [
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
# Not watched: these are vendored files that only change on npm install.
esbuild: {Esbuild, :install_and_run, [:friendly_captcha, []]},
tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]},
npm: ["--prefix", "assets", "run", "typecheck", "--", "--watch", "--preserveWatchOutput"],
npm: [
Expand Down
10 changes: 5 additions & 5 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -312,8 +312,8 @@ if disable_registration not in [true, false, :invite_only] do
raise "DISABLE_REGISTRATION must be one of `true`, `false`, or `invite_only`. See https://github.com/plausible/community-edition/wiki/configuration#disable_registration"
end

hcaptcha_sitekey = get_var_from_path_or_env(config_dir, "HCAPTCHA_SITEKEY")
hcaptcha_secret = get_var_from_path_or_env(config_dir, "HCAPTCHA_SECRET")
friendly_captcha_sitekey = get_var_from_path_or_env(config_dir, "FRIENDLY_CAPTCHA_SITEKEY")
friendly_captcha_api_key = get_var_from_path_or_env(config_dir, "FRIENDLY_CAPTCHA_API_KEY")

custom_script_name =
config_dir
Expand Down Expand Up @@ -899,9 +899,9 @@ else
queues: queues
end

config :plausible, :hcaptcha,
sitekey: hcaptcha_sitekey,
secret: hcaptcha_secret
config :plausible, :friendly_captcha,
sitekey: friendly_captcha_sitekey,
api_key: friendly_captcha_api_key

nolt_sso_secret = get_var_from_path_or_env(config_dir, "NOLT_SSO_SECRET")
config :joken, default_signer: nolt_sso_secret
Expand Down
16 changes: 10 additions & 6 deletions lib/plausible_web/captcha.ex
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
defmodule PlausibleWeb.Captcha do
@moduledoc """
Integration with Friendly Captcha
"""

alias Plausible.HTTPClient

@verify_endpoint "https://hcaptcha.com/siteverify"
@verify_endpoint "https://global.frcapi.com/api/v2/captcha/siteverify"

def enabled? do
is_binary(sitekey())
end

def sitekey() do
Application.get_env(:plausible, :hcaptcha, [])[:sitekey]
Application.get_env(:plausible, :friendly_captcha, [])[:sitekey]
end

def verify(token) do
if enabled?() do
res =
HTTPClient.impl().post(
@verify_endpoint,
[{"content-type", "application/x-www-form-urlencoded"}],
[{"content-type", "application/json"}, {"x-api-key", api_key()}],
%{
response: token,
secret: secret()
sitekey: sitekey()
}
)

Expand All @@ -35,7 +39,7 @@ defmodule PlausibleWeb.Captcha do
end
end

defp secret() do
Application.get_env(:plausible, :hcaptcha, [])[:secret]
defp api_key() do
Application.get_env(:plausible, :friendly_captcha, [])[:api_key]
end
end
115 changes: 115 additions & 0 deletions lib/plausible_web/components/captcha.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
defmodule PlausibleWeb.Components.Captcha do
@moduledoc """
Friendly Captcha widget shared between the registration and password-reset forms.

Renders the (invisible) widget placeholder, the SDK script tags, and the reveal
script that:

* matches the widget to the app's resolved light/dark theme,
* reveals the widget only when the user must interact (or on error/slow solve),
* dispatches `frc-captcha-ready` / `frc-captcha-reset` window events so the
submit button can gate on a valid solution.

Pass `live?={true}` from a LiveView so the widget and scripts carry
`phx-update="ignore"` and survive DOM patching.
"""
use Phoenix.Component, global_prefixes: ~w(x-)

attr :live?, :boolean, default: false
attr :error, :string, default: nil

def widget(assigns) do
~H"""
<div>
<div
phx-update={if @live?, do: "ignore"}
id="frc-captcha-placeholder"
class="frc-captcha hidden mb-2"
data-sitekey={PlausibleWeb.Captcha.sitekey()}
data-start="auto"
style="width: 100%"
>
</div>
<p :if={@error} class="text-xs text-red-500 mt-2">
{@error}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
This site is protected by
<PlausibleWeb.Components.Generic.styled_link href="https://friendlycaptcha.com" new_tab={true}>
Friendly Captcha
</PlausibleWeb.Components.Generic.styled_link>
</p>
<script
phx-update={if @live?, do: "ignore"}
id="frc-captcha-script"
type="module"
src={
PlausibleWeb.Router.Helpers.static_path(
PlausibleWeb.Endpoint,
"/js/friendly-captcha/site.min.js"
)
}
async
defer
>
</script>
<script
phx-update={if @live?, do: "ignore"}
id="frc-captcha-script-compat"
nomodule
src={
PlausibleWeb.Router.Helpers.static_path(
PlausibleWeb.Endpoint,
"/js/friendly-captcha/site.compat.min.js"
)
}
async
defer
>
</script>
<script phx-update={if @live?, do: "ignore"} id="frc-captcha-reveal">
(function () {
var SHOW_AFTER_LONG_WAIT_MS = 5000;
var el = document.getElementById("frc-captcha-placeholder");
if (!el) return;

// Match the widget to the app's resolved light/dark theme. This runs
// before the (deferred) SDK initializes, so the widget picks up the
// right theme from the start, and the observer keeps it in sync.
function applyTheme() {
el.dataset.theme =
document.documentElement.classList.contains("dark") ? "dark" : "light";
}
applyTheme();
new MutationObserver(applyTheme).observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"]
});

function show() { el.classList.remove("hidden"); }
var timeout;
// Friendly Captcha carries the event payload on `e.detail` (not `e`).
el.addEventListener("frc:widget.statechange", function (e) {
var d = e.detail || {};
// Interactive mode means the user must click to solve: reveal the widget.
if (d.mode === "interactive") { show(); }
// Reveal if solving takes unusually long, then stop waiting once done.
if (d.state === "requesting") {
clearTimeout(timeout);
timeout = setTimeout(show, SHOW_AFTER_LONG_WAIT_MS);
} else if (d.state === "completed") {
clearTimeout(timeout);
}
// Reveal on error or expiry so the user can recover.
if (d.state === "error" || d.state === "expired") { show(); }
// Enable the submit button only once we hold a valid solution.
window.dispatchEvent(new Event(
d.state === "completed" ? "frc-captcha-ready" : "frc-captcha-reset"
));
});
})();
</script>
</div>
"""
end
end
3 changes: 0 additions & 3 deletions lib/plausible_web/components/layout.ex
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,13 @@ defmodule PlausibleWeb.Components.Layout do
function reapplyTheme() {
var darkMediaPref = window.matchMedia('(prefers-color-scheme: dark)').matches;
var htmlRef = document.querySelector('html');
var hcaptchaRefs = Array.from(document.getElementsByClassName('h-captcha'));

var isDark = themePref === 'dark' || (themePref === 'system' && darkMediaPref);

if (isDark) {
htmlRef.classList.add('dark')
hcaptchaRefs.forEach(function(ref) { ref.dataset.theme = "dark"; });
} else {
htmlRef.classList.remove('dark');
hcaptchaRefs.forEach(function(ref) { ref.dataset.theme = "light"; });
}
}

Expand Down
2 changes: 1 addition & 1 deletion lib/plausible_web/controllers/auth_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ defmodule PlausibleWeb.AuthController do
end

def password_reset_request(conn, %{"email" => email} = params) do
if PlausibleWeb.Captcha.verify(params["h-captcha-response"]) do
if PlausibleWeb.Captcha.verify(params["frc-captcha-response"]) do
case Auth.lookup(email) do
{:ok, _user} ->
token = Auth.Token.sign_password_reset(email)
Expand Down
41 changes: 13 additions & 28 deletions lib/plausible_web/live/register_form.ex
Original file line number Diff line number Diff line change
Expand Up @@ -131,31 +131,7 @@ defmodule PlausibleWeb.Live.RegisterForm do
</div>

<%= if PlausibleWeb.Captcha.enabled?() do %>
<div>
<div
phx-update="ignore"
id="hcaptcha-placeholder"
class="h-captcha"
data-sitekey={PlausibleWeb.Captcha.sitekey()}
>
</div>
<p
:if={@captcha_error}
class="text-xs text-red-500 mt-2"
x-data
x-init="hcaptcha.reset()"
>
{@captcha_error}
</p>
<script
phx-update="ignore"
id="hcaptcha-script"
src="https://hcaptcha.com/1/api.js"
async
defer
>
</script>
</div>
<PlausibleWeb.Components.Captcha.widget live?={true} error={@captcha_error} />
<% end %>

<div class="flex flex-col gap-y-4">
Expand All @@ -165,7 +141,16 @@ defmodule PlausibleWeb.Live.RegisterForm do
else
"Start my free trial"
end %>
<.button id="register" disabled={@disable_submit} type="submit" class="w-full" mt?={false}>
<.button
id="register"
type="submit"
class="w-full"
mt?={false}
x-data={"{ captchaReady: #{not PlausibleWeb.Captcha.enabled?()} }"}
x-on:frc-captcha-ready.window="captchaReady = true"
x-on:frc-captcha-reset.window="captchaReady = false"
x-bind:disabled={"!captchaReady || #{@disable_submit}"}
>
{submit_text}
</.button>

Expand Down Expand Up @@ -259,7 +244,7 @@ defmodule PlausibleWeb.Live.RegisterForm do
%{assigns: %{invitation: %{} = invitation}} = socket
) do
if not PlausibleWeb.Captcha.enabled?() or
PlausibleWeb.Captcha.verify(params["h-captcha-response"]) do
PlausibleWeb.Captcha.verify(params["frc-captcha-response"]) do
user =
params["user"]
|> Map.put("email", invitation.email)
Expand All @@ -275,7 +260,7 @@ defmodule PlausibleWeb.Live.RegisterForm do

def handle_event("register", %{"user" => _} = params, socket) do
if not PlausibleWeb.Captcha.enabled?() or
PlausibleWeb.Captcha.verify(params["h-captcha-response"]) do
PlausibleWeb.Captcha.verify(params["frc-captcha-response"]) do
user = Auth.User.new(params["user"])

add_user(socket, user)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,21 @@
</div>

<%= if PlausibleWeb.Captcha.enabled?() do %>
<div>
<div class="h-captcha" data-sitekey={PlausibleWeb.Captcha.sitekey()}></div>
<p :if={assigns[:captcha_error]} class="text-xs text-red-500 mt-2">
{@captcha_error}
</p>
<script src="https://hcaptcha.com/1/api.js" async defer>
</script>
</div>
<PlausibleWeb.Components.Captcha.widget error={assigns[:captcha_error]} />
<% end %>

<div class="flex flex-col gap-y-4">
<.button class="w-full" type="submit" mt?={false}>Send reset link</.button>
<.button
class="w-full"
type="submit"
mt?={false}
x-data={"{ captchaReady: #{not PlausibleWeb.Captcha.enabled?()} }"}
x-on:frc-captcha-ready.window="captchaReady = true"
x-on:frc-captcha-reset.window="captchaReady = false"
x-bind:disabled="!captchaReady"
>
Send reset link
</.button>

<p class="text-sm text-center text-gray-500 dark:text-gray-400">
<.styled_link href="/login">Back to sign in</.styled_link>
Expand Down
Loading
Loading