diff --git a/CHANGELOG.md b/CHANGELOG.md index 488e03507b2d..f6d9d95e190c 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 ### Fixed diff --git a/assets/package-lock.json b/assets/package-lock.json index b32a3b16d491..3bb1795be306 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", @@ -820,6 +821,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 559481596eca..2082e44a36c4 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..d19738a4fac5 --- /dev/null +++ b/lib/plausible_web/components/captcha.ex @@ -0,0 +1,140 @@ +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, + * resets the widget when the server pushes the `reset-frc-captcha` event. + + Pass `live?={true}` from a LiveView so the widget and scripts carry + `phx-update="ignore"` and survive DOM patching. A captcha solution is + single-use, so a LiveView must call `reset/1` whenever a submit fails and the + form stays on screen - otherwise the next submit fails on a spent solution. + """ + use Phoenix.Component, global_prefixes: ~w(x-) + + @reset_event "reset-frc-captcha" + + @doc """ + Tells the client to discard the current captcha solution and solve a new one. + """ + def reset(socket) do + if PlausibleWeb.Captcha.enabled?() do + Phoenix.LiveView.push_event(socket, @reset_event, %{}) + else + socket + end + end + + attr :live?, :boolean, default: false + attr :error, :string, default: nil + + def widget(assigns) do + assigns = assign(assigns, :reset_event, @reset_event) + + ~H""" +
+ {@error} +
+
+ This site is protected by
+
- {@captcha_error} -
- -- {@captcha_error} -
- -<.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") diff --git a/test/plausible_web/live/register_form_test.exs b/test/plausible_web/live/register_form_test.exs index 7563b9ab76ca..eab969c08b97 100644 --- a/test/plausible_web/live/register_form_test.exs +++ b/test/plausible_web/live/register_form_test.exs @@ -91,9 +91,27 @@ defmodule PlausibleWeb.Live.RegisterFormTest do html = lv |> element("form") |> render_submit() assert html =~ "Please complete the captcha to register" + assert_push_event(lv, "reset-frc-captcha", %{}) refute Repo.one(User) end + + test "resets the captcha when registration fails", %{conn: conn} do + mock_captcha_success() + + new_user(email: "mary.sue@plausible.test") + + lv = get_liveview(conn, "/register") + + type_into_input(lv, "user[name]", "Mary Sue") + type_into_input(lv, "user[email]", "mary.sue@plausible.test") + type_into_input(lv, "user[password]", "very-long-and-very-secret-123") + + html = lv |> element("form") |> render_submit() + + assert html =~ "has already been taken" + assert_push_event(lv, "reset-frc-captcha", %{}) + end end describe "/register/invitation/:invitation_id" do @@ -242,6 +260,7 @@ defmodule PlausibleWeb.Live.RegisterFormTest do html = lv |> element("form") |> render_submit() assert html =~ "Please complete the captcha to register" + assert_push_event(lv, "reset-frc-captcha", %{}) refute Repo.get_by(User, email: "user@email.co") end