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
+
- {@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")