diff --git a/CHANGELOG.md b/CHANGELOG.md index fbc3e1820877..e64477e65a14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - Removed limitation that blocked users without owned sites from starting a subscription ### Fixed diff --git a/assets/package-lock.json b/assets/package-lock.json index d735d5b54489..3e62b33100e8 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -9,6 +9,7 @@ "version": "1.4.0", "license": "AGPL-3.0-or-later", "dependencies": { + "@friendlycaptcha/sdk": "1.0.2", "@headlessui/react": "^1.7.19", "@heroicons/react": "^2.2.0", "@jsonurl/jsonurl": "^1.1.7", @@ -810,6 +811,12 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@friendlycaptcha/sdk": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@friendlycaptcha/sdk/-/sdk-1.0.2.tgz", + "integrity": "sha512-NW1mXrqRjQdg53fWKz369PqkxoNPqNIgBRW5vYIMvPHjIqHieinya1Nr9TjSVD5yoLzZ5AoWx2MfryWMm+dGOg==", + "license": "MPL-2.0" + }, "node_modules/@headlessui/react": { "version": "1.7.19", "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.19.tgz", diff --git a/assets/package.json b/assets/package.json index c7fce70b07e0..f5789913cdc6 100644 --- a/assets/package.json +++ b/assets/package.json @@ -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", diff --git a/config/.env.test b/config/.env.test index 42a1e8552634..354c0bffdb91 100644 --- a/config/.env.test +++ b/config/.env.test @@ -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 diff --git a/config/config.exs b/config/config.exs index 0b6efb948791..811f97ff4278 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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, diff --git a/config/dev.exs b/config/dev.exs index 7201157eea5b..65cb9eef7b29 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -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: [ diff --git a/config/runtime.exs b/config/runtime.exs index 91f056353d27..bd52c95b2520 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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 @@ -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 diff --git a/lib/plausible_web/captcha.ex b/lib/plausible_web/captcha.ex index aff593d88eba..10567db8b6c8 100644 --- a/lib/plausible_web/captcha.ex +++ b/lib/plausible_web/captcha.ex @@ -1,14 +1,18 @@ 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 @@ -16,10 +20,10 @@ defmodule PlausibleWeb.Captcha 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() } ) @@ -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 diff --git a/lib/plausible_web/components/captcha.ex b/lib/plausible_web/components/captcha.ex new file mode 100644 index 000000000000..92c20a619e5d --- /dev/null +++ b/lib/plausible_web/components/captcha.ex @@ -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""" +
+ +

+ {@error} +

+

+ This site is protected by + + Friendly Captcha + +

+ + + +
+ """ + end +end diff --git a/lib/plausible_web/components/layout.ex b/lib/plausible_web/components/layout.ex index 9fc63ab11c5b..068433cb2c92 100644 --- a/lib/plausible_web/components/layout.ex +++ b/lib/plausible_web/components/layout.ex @@ -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"; }); } } diff --git a/lib/plausible_web/controllers/auth_controller.ex b/lib/plausible_web/controllers/auth_controller.ex index 13ae747473d4..f142a04d6b20 100644 --- a/lib/plausible_web/controllers/auth_controller.ex +++ b/lib/plausible_web/controllers/auth_controller.ex @@ -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) diff --git a/lib/plausible_web/live/register_form.ex b/lib/plausible_web/live/register_form.ex index 3ee6c1333470..cff42e4cbee1 100644 --- a/lib/plausible_web/live/register_form.ex +++ b/lib/plausible_web/live/register_form.ex @@ -131,31 +131,7 @@ defmodule PlausibleWeb.Live.RegisterForm do <%= if PlausibleWeb.Captcha.enabled?() do %> -
-
-
-

- {@captcha_error} -

- -
+ <% end %>
@@ -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} @@ -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) @@ -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) diff --git a/lib/plausible_web/templates/auth/password_reset_request_form.html.heex b/lib/plausible_web/templates/auth/password_reset_request_form.html.heex index 806bfebdb6a1..14d7d22b091b 100644 --- a/lib/plausible_web/templates/auth/password_reset_request_form.html.heex +++ b/lib/plausible_web/templates/auth/password_reset_request_form.html.heex @@ -25,18 +25,21 @@
<%= if PlausibleWeb.Captcha.enabled?() do %> -
-
-

- {@captcha_error} -

- -
+ <% end %>
- <.button class="w-full" type="submit" mt?={false}>Send reset link + <.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 +

<.styled_link href="/login">Back to sign in diff --git a/mix.exs b/mix.exs index 63aa67da359e..bc757995dd02 100644 --- a/mix.exs +++ b/mix.exs @@ -199,6 +199,7 @@ defmodule Plausible.MixProject do # mix test.e2e --debug segments.spec.ts "test.e2e": [ "esbuild default", + "esbuild friendly_captcha", "ecto.create --quiet", "ecto.migrate", "clean_postgres", @@ -209,11 +210,14 @@ defmodule Plausible.MixProject do "assets.typecheck": ["cmd npm --prefix assets run typecheck"], "assets.build": [ "tailwind default", - "esbuild default" + "esbuild default", + "esbuild friendly_captcha" ], "assets.deploy": [ "tailwind default --minify", "esbuild default --minify", + # already minified upstream, so no --minify here + "esbuild friendly_captcha", "phx.digest" ] ] diff --git a/test/plausible_web/captcha_test.exs b/test/plausible_web/captcha_test.exs index 7308283bf3a4..141e7510dd47 100644 --- a/test/plausible_web/captcha_test.exs +++ b/test/plausible_web/captcha_test.exs @@ -7,16 +7,18 @@ defmodule PlausibleWeb.CaptchaTest do alias PlausibleWeb.Captcha describe "mocked payloads" do - @failure Jason.decode!(~s/{"success":false,"error-codes":["invalid-input-response"]}/) + @failure Jason.decode!( + ~s/{"success":false,"error":{"error_code":"response_invalid","detail":"the response was invalid"}}/ + ) @success Jason.decode!(~s/{"success":true}/) test "returns false for non-success response" do expect( Plausible.HTTPClient.Mock, :post, - fn "https://hcaptcha.com/siteverify", - [{"content-type", "application/x-www-form-urlencoded"}], - %{response: "bad", secret: "scottiger"} -> + fn "https://global.frcapi.com/api/v2/captcha/siteverify", + [{"content-type", "application/json"}, {"x-api-key", "scottiger"}], + %{response: "bad", sitekey: "test"} -> {:ok, %Finch.Response{ status: 200, @@ -33,9 +35,9 @@ defmodule PlausibleWeb.CaptchaTest do expect( Plausible.HTTPClient.Mock, :post, - fn "https://hcaptcha.com/siteverify", - [{"content-type", "application/x-www-form-urlencoded"}], - %{response: "good", secret: "scottiger"} -> + fn "https://global.frcapi.com/api/v2/captcha/siteverify", + [{"content-type", "application/json"}, {"x-api-key", "scottiger"}], + %{response: "good", sitekey: "test"} -> {:ok, %Finch.Response{ status: 200, @@ -50,7 +52,7 @@ defmodule PlausibleWeb.CaptchaTest do end describe "with patched application env" do - setup_patch_env(:hcaptcha, sitekey: nil) + setup_patch_env(:friendly_captcha, sitekey: nil) test "returns true when disabled" do assert Captcha.verify("disabled")