diff --git a/assets/css/app.css b/assets/css/app.css index 28ea24b..e3c6e83 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -99,6 +99,19 @@ /* Make LiveView wrapper divs transparent for layout */ [data-phx-session] { display: contents } +/* Honeypot: off-screen and minimal size so bots fill it, humans never see it (best practice) */ +.join-form-helper { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; +} +.join-form-helper .join-form-helper-input { + position: absolute; + left: -9999px; +} + /* WCAG 1.4.12 Text Spacing: allow user stylesheets to adjust text spacing in popovers. Popover content (e.g. from DaisyUI dropdown) must not rely on non-overridable inline spacing; use inherited values so custom stylesheets can override. */ diff --git a/config/config.exs b/config/config.exs index 323f5cd..ab55f2a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -93,6 +93,9 @@ config :mv, Mv.Mailer, adapter: Swoosh.Adapters.Local # user confirmation, password reset). Override in config/runtime.exs from ENV. config :mv, :mail_from, {"Mila", "noreply@example.com"} +# Join form rate limiting (Hammer). scale_ms: window in ms, limit: max submits per window per IP. +config :mv, :join_rate_limit, scale_ms: 60_000, limit: 10 + # Configure esbuild (the version is required) config :esbuild, version: "0.17.11", diff --git a/config/test.exs b/config/test.exs index 864222f..84ccd70 100644 --- a/config/test.exs +++ b/config/test.exs @@ -55,3 +55,6 @@ config :mv, :default_locale, "en" # Enable SQL Sandbox for async LiveView tests # This flag controls sync vs async behavior in CycleGenerator after_action hooks config :mv, :sql_sandbox, true + +# Join form rate limit: low limit so tests can trigger rate limiting (e.g. 2 per minute) +config :mv, :join_rate_limit, scale_ms: 60_000, limit: 2 diff --git a/lib/mv/application.ex b/lib/mv/application.ex index 835652f..6b4a10b 100644 --- a/lib/mv/application.ex +++ b/lib/mv/application.ex @@ -9,6 +9,7 @@ defmodule Mv.Application do alias Mv.Repo alias Mv.Vereinfacht.SyncFlash alias MvWeb.Endpoint + alias MvWeb.JoinRateLimit alias MvWeb.Telemetry @impl true @@ -18,6 +19,7 @@ defmodule Mv.Application do children = [ Telemetry, Repo, + {JoinRateLimit, [clean_period: :timer.minutes(1)]}, {Task.Supervisor, name: Mv.TaskSupervisor}, {DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: Mv.PubSub}, diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index 79983c5..17fca11 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -82,7 +82,25 @@ defmodule MvWeb.Layouts do <% else %> - + +
+ Mila Logo + + {@club_name} + +
+ + +
+
{render_slot(@inner_block)} diff --git a/lib/mv_web/endpoint.ex b/lib/mv_web/endpoint.ex index d1b4247..dfa01ad 100644 --- a/lib/mv_web/endpoint.ex +++ b/lib/mv_web/endpoint.ex @@ -12,8 +12,8 @@ defmodule MvWeb.Endpoint do ] socket "/live", Phoenix.LiveView.Socket, - websocket: [connect_info: [session: @session_options]], - longpoll: [connect_info: [session: @session_options]] + websocket: [connect_info: [:peer_data, session: @session_options]], + longpoll: [connect_info: [:peer_data, session: @session_options]] # Serve at "/" the static files from "priv/static" directory. # diff --git a/lib/mv_web/join_rate_limit.ex b/lib/mv_web/join_rate_limit.ex new file mode 100644 index 0000000..6bb1198 --- /dev/null +++ b/lib/mv_web/join_rate_limit.ex @@ -0,0 +1,27 @@ +defmodule MvWeb.JoinRateLimit do + @moduledoc """ + Rate limiting for the public join form (submit action). + + Uses Hammer with ETS backend. Key is derived from client IP so each IP + is limited independently. Config from :mv :join_rate_limit (scale_ms, limit). + """ + use Hammer, backend: :ets + + @doc """ + Checks if the given key (e.g. client IP) is within rate limit for join form submit. + + Returns: + - `:allow` - submission allowed + - `{:deny, _retry_after_ms}` - rate limit exceeded + """ + def check(key) when is_binary(key) do + config = Application.get_env(:mv, :join_rate_limit, []) + scale_ms = Keyword.get(config, :scale_ms, 60_000) + limit = Keyword.get(config, :limit, 10) + + case hit(key, scale_ms, limit) do + {:allow, _count} -> :allow + {:deny, retry_after} -> {:deny, retry_after} + end + end +end diff --git a/lib/mv_web/live/join_live.ex b/lib/mv_web/live/join_live.ex new file mode 100644 index 0000000..fc7038a --- /dev/null +++ b/lib/mv_web/live/join_live.ex @@ -0,0 +1,239 @@ +defmodule MvWeb.JoinLive do + @moduledoc """ + Public join page (unauthenticated). Renders form from allowlist, handles submit + with honeypot and rate limiting; shows success copy after submit. + """ + use MvWeb, :live_view + + alias Mv.Membership + alias MvWeb.JoinRateLimit + alias MvWeb.Translations.MemberFields + + # Honeypot field name (legitimate-sounding to avoid bot detection) + @honeypot_field "website" + + @impl true + def mount(_params, _session, socket) do + allowlist = Membership.get_join_form_allowlist() + join_fields = build_join_fields_with_labels(allowlist) + client_ip = client_ip_from_socket(socket) + + socket = + socket + |> assign(:join_fields, join_fields) + |> assign(:submitted, false) + |> assign(:rate_limit_error, nil) + |> assign(:client_ip, client_ip) + |> assign(:honeypot_field, @honeypot_field) + |> assign(:form, to_form(initial_form_params(join_fields))) + + {:ok, socket} + end + + @impl true + def render(assigns) do + ~H""" + +
+ <.header> + {gettext("Become a member")} + + +

+ {gettext("Please enter your details for the membership application here.")} +

+ + <%= if @submitted do %> +
+

+ {gettext( + "We have saved your details. To complete your request, please click the link we sent to your email." + )} +

+
+ <% else %> + <.form + for={@form} + id="join-form" + phx-submit="submit" + class="space-y-4" + > + <%= if @rate_limit_error do %> +
+ {@rate_limit_error} +
+ <% end %> + + <%= for field <- @join_fields do %> +
+ + +
+ <% end %> + + <%!-- + Honeypot (best practice): legit field name "website", type="text", no inline CSS, + hidden via class in app.css (off-screen + 1px). tabindex=-1, autocomplete=off, + aria-hidden so screen readers skip. If filled → silent failure (same success UI). + --%> + + +

+ {gettext( + "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed." + )} +

+ +

+ {gettext( + "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary." + )} +

+ +
+ +
+ + <% end %> +
+
+ """ + end + + @impl true + def handle_event("submit", params, socket) do + # Honeypot: if filled, treat as bot – show same success UI, do not create (silent failure) + honeypot_value = String.trim(params[@honeypot_field] || "") + + if honeypot_value != "" do + {:noreply, assign(socket, :submitted, true)} + else + key = "join:#{socket.assigns.client_ip}" + + case JoinRateLimit.check(key) do + :allow -> do_submit(socket, params) + {:deny, _retry_after} -> rate_limited_reply(socket, params) + end + end + end + + defp do_submit(socket, params) do + case build_submit_attrs(params, socket.assigns.join_fields) do + {:ok, attrs} -> + case Membership.submit_join_request(attrs, actor: nil) do + {:ok, _} -> {:noreply, assign(socket, :submitted, true)} + {:error, _} -> validation_error_reply(socket, params) + end + + {:error, message} -> + {:noreply, + socket + |> put_flash(:error, message) + |> assign(:form, to_form(params, as: "join"))} + end + end + + defp validation_error_reply(socket, params) do + {:noreply, + socket + |> put_flash(:error, gettext("Please check your entries. Email is required.")) + |> assign(:form, to_form(params, as: "join"))} + end + + defp rate_limited_reply(socket, params) do + {:noreply, + socket + |> assign(:rate_limit_error, gettext("Too many requests. Please try again later.")) + |> assign(:form, to_form(params, as: "join"))} + end + + defp build_join_fields_with_labels(allowlist) do + member_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + + Enum.map(allowlist, fn %{id: id, required: required} -> + label = + if id in member_field_strings do + MemberFields.label(String.to_existing_atom(id)) + else + gettext("Field") + end + + %{id: id, label: label, required: required} + end) + end + + defp initial_form_params(join_fields) do + join_fields + |> Enum.map(fn f -> {f.id, ""} end) + |> Map.new() + |> Map.put(@honeypot_field, "") + end + + defp input_type("email"), do: "email" + defp input_type(_), do: "text" + + defp build_submit_attrs(params, join_fields) do + allowlist_ids = MapSet.new(Enum.map(join_fields, & &1.id)) + typed = ["email", "first_name", "last_name"] + + email = String.trim(params["email"] || "") + + if email == "" do + {:error, gettext("Email is required.")} + else + attrs = %{ + email: email, + first_name: optional_param(params, "first_name"), + last_name: optional_param(params, "last_name"), + form_data: %{}, + schema_version: 1 + } + + form_data = + params + |> Enum.filter(fn {key, _} -> key in allowlist_ids and key not in typed end) + |> Map.new() + |> Enum.map(fn {k, v} -> {k, String.trim(to_string(v))} end) + |> Map.new() + + attrs = %{attrs | form_data: form_data} + {:ok, attrs} + end + end + + defp optional_param(params, key) do + v = params[key] + if is_binary(v), do: String.trim(v), else: nil + end + + defp client_ip_from_socket(socket) do + case get_connect_info(socket, :peer_data) do + %{address: address} when is_tuple(address) -> + address |> Tuple.to_list() |> Enum.join(".") + + _ -> + "unknown" + end + end +end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index e2e037d..c8f32a0 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -107,6 +107,9 @@ defmodule MvWeb.MemberLive.Index do {:error, _} -> %{member_field_visibility: %{}} end + # Ensure nested module is loaded (can be missing after code reload in dev if load order changes) + Code.ensure_loaded!(FieldSelection) + # Load user field selection from session session_selection = FieldSelection.get_from_session(session) diff --git a/lib/mv_web/plugs/check_page_permission.ex b/lib/mv_web/plugs/check_page_permission.ex index 3aa6561..ff6d47d 100644 --- a/lib/mv_web/plugs/check_page_permission.ex +++ b/lib/mv_web/plugs/check_page_permission.ex @@ -80,7 +80,7 @@ defmodule MvWeb.Plugs.CheckPagePermission do Used by LiveView hook to skip redirect on sign-in etc. """ def public_path?(path) when is_binary(path) do - path in ["/register", "/reset", "/set_locale", "/sign-in", "/sign-out"] or + path in ["/register", "/reset", "/set_locale", "/sign-in", "/sign-out", "/join"] or String.starts_with?(path, "/auth") or String.starts_with?(path, "/confirm") or String.starts_with?(path, "/password-reset") diff --git a/lib/mv_web/plugs/join_form_enabled.ex b/lib/mv_web/plugs/join_form_enabled.ex new file mode 100644 index 0000000..f6865c4 --- /dev/null +++ b/lib/mv_web/plugs/join_form_enabled.ex @@ -0,0 +1,33 @@ +defmodule MvWeb.Plugs.JoinFormEnabled do + @moduledoc """ + For GET /join: returns 404 when the join form is disabled in settings. + No-op for other paths. + """ + import Plug.Conn + + alias Mv.Membership + + def init(opts), do: opts + + def call(conn, _opts) do + if join_path?(conn), do: maybe_404(conn), else: conn + end + + defp join_path?(conn) do + conn.request_path == "/join" and conn.method == "GET" + end + + defp maybe_404(conn) do + case Membership.get_settings() do + {:ok, %{join_form_enabled: true}} -> conn + _ -> send_404(conn) + end + end + + defp send_404(conn) do + conn + |> put_resp_content_type("text/html") + |> send_resp(404, "Not Found") + |> halt() + end +end diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index 3ab264f..74fcd22 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -15,6 +15,7 @@ defmodule MvWeb.Router do plug :load_from_session plug :set_locale plug MvWeb.Plugs.CheckPagePermission + plug MvWeb.Plugs.JoinFormEnabled end pipeline :api do @@ -126,6 +127,12 @@ defmodule MvWeb.Router do overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.DaisyUI], gettext_backend: {MvWeb.Gettext, "auth"} + # Public join page (no auth required) + live_session :public_join, + on_mount: [{MvWeb.LiveUserAuth, :live_user_optional}] do + live "/join", JoinLive, :index + end + # Public join confirmation (double opt-in); /confirm* is already public in CheckPagePermission get "/confirm_join/:token", JoinConfirmController, :confirm diff --git a/mix.exs b/mix.exs index 0d1d4f1..56e7dde 100644 --- a/mix.exs +++ b/mix.exs @@ -82,7 +82,8 @@ defmodule Mv.MixProject do {:ecto_commons, "~> 0.3"}, {:slugify, "~> 1.3"}, {:nimble_csv, "~> 1.0"}, - {:imprintor, "~> 0.5.0"} + {:imprintor, "~> 0.5.0"}, + {:hammer, "~> 7.0"} ] end diff --git a/mix.lock b/mix.lock index 849dfd5..8ac995a 100644 --- a/mix.lock +++ b/mix.lock @@ -37,6 +37,7 @@ "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, + "hammer": {:hex, :hammer, "7.2.0", "73113eca87f0fd20a6d3679c1182e8c4c1778266f61de4e9dc8c589dee156c30", [:mix], [], "hexpm", "c50fa865ddfe7b3d4f8a6941f56940679e02a9a1465b00668a95d140b101d828"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 90bedc5..15ade4b 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -1792,6 +1792,7 @@ msgid "Email is invalid." msgstr "E-Mail ist ungültig." #: lib/mv/membership/import/member_csv.ex +#: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Email is required." msgstr "E-Mail ist erforderlich." @@ -3348,6 +3349,7 @@ msgid "Could not save join form settings." msgstr "Beitrittsformular-Einstellungen konnten nicht gespeichert werden." #: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Field" msgstr "Feld" @@ -3401,3 +3403,48 @@ msgstr "Umordnen" #, elixir-autogen, elixir-format msgid "The order of rows determines the field order in the join form." msgstr "Die Reihenfolge der Zeilen bestimmt die Reihenfolge der Felder im Beitrittsformular." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Become a member" +msgstr "Mitglied werden" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Please check your entries. Email is required." +msgstr "Bitte prüfe deine Angaben. E-Mail ist erforderlich." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Submit request" +msgstr "Antrag absenden" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Too many requests. Please try again later." +msgstr "Zu viele Anfragen. Bitte versuche es später erneut." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "We have saved your details. To complete your request, please click the link we sent to your email." +msgstr "Wir haben deine Angaben gespeichert. Um deinen Antrag abzuschließen, klicke bitte auf den Link in der E-Mail, die wir dir geschickt haben." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Website" +msgstr "Webseite" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed." +msgstr "Mit Absenden deines Antrags erhältst du eine Mail mit einem Bestätigungslink. Sobald du deine Mail-Adresse bestätigt hast, wird dein Antrag geprüft." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Please enter your details for the membership application here." +msgstr "Bitte gib hier die Daten für deinen Mitgliedsantrag an." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary." +msgstr "Deine Angaben werden nur zur Bearbeitung deines Mitgliedsantrags und zur Kontaktaufnahme genutzt. Zur Absicherung gegen Missbrauch verarbeiten wir zusätzlich technische Daten (z. B. IP-Adresse) nur im dafür nötigen Umfang." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 70bf233..913d5cc 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -514,6 +514,7 @@ msgstr "" msgid "Back to users list" msgstr "" +#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Select language" @@ -1793,6 +1794,7 @@ msgid "Email is invalid." msgstr "" #: lib/mv/membership/import/member_csv.ex +#: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Email is required." msgstr "" @@ -3348,6 +3350,7 @@ msgid "Could not save join form settings." msgstr "" #: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Field" msgstr "" @@ -3401,3 +3404,48 @@ msgstr "" #, elixir-autogen, elixir-format msgid "The order of rows determines the field order in the join form." msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Become a member" +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Please check your entries. Email is required." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Submit request" +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Too many requests. Please try again later." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "We have saved your details. To complete your request, please click the link we sent to your email." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Website (leave empty)" +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Please enter your details for the membership application here." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 02160f9..e91e3be 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -1793,6 +1793,7 @@ msgid "Email is invalid." msgstr "" #: lib/mv/membership/import/member_csv.ex +#: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Email is required." msgstr "" @@ -3348,6 +3349,7 @@ msgid "Could not save join form settings." msgstr "" #: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Field" msgstr "" @@ -3401,3 +3403,48 @@ msgstr "Reorder" #, elixir-autogen, elixir-format msgid "The order of rows determines the field order in the join form." msgstr "The order of rows determines the field order in the join form." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Become a member" +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Please check your entries. Email is required." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Submit request" +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Too many requests. Please try again later." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "We have saved your details. To complete your request, please click the link we sent to your email." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Website (leave empty)" +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Please enter your details for the membership application here." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary." +msgstr "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary." diff --git a/test/membership/join_request_test.exs b/test/membership/join_request_test.exs index 6c39d4e..ea509fa 100644 --- a/test/membership/join_request_test.exs +++ b/test/membership/join_request_test.exs @@ -125,6 +125,7 @@ defmodule Mv.Membership.JoinRequestTest do test "submit with non-allowlisted form_data keys does not persist those keys" do # Allowlist restricts which fields are accepted; extra keys must not be stored. {:ok, settings} = Membership.get_settings() + Mv.Membership.update_settings(settings, %{ join_form_enabled: true, join_form_field_ids: ["email", "first_name"], diff --git a/test/mv_web/live/join_live_test.exs b/test/mv_web/live/join_live_test.exs index cc4e5db..bd133cd 100644 --- a/test/mv_web/live/join_live_test.exs +++ b/test/mv_web/live/join_live_test.exs @@ -5,6 +5,9 @@ defmodule MvWeb.JoinLiveTest do Covers: public path /join (unauthenticated 200), 404 when join disabled, submit creates JoinRequest and shows success copy, honeypot prevents create, rate limiting rejects excess submits. Uses unauthenticated conn; no User/Member. + + Honeypot: form param `"website"` (legit-sounding name per best practice; not "honeypot"). + Field is hidden via CSS class in app.css (off-screen, no inline styles), type="text". """ use MvWeb.ConnCase, async: true import Phoenix.LiveViewTest @@ -44,7 +47,7 @@ defmodule MvWeb.JoinLiveTest do "email" => "newuser#{System.unique_integer([:positive])}@example.com", "first_name" => "Jane", "last_name" => "Doe", - "honeypot" => "" + "website" => "" }) |> render_submit() @@ -66,7 +69,7 @@ defmodule MvWeb.JoinLiveTest do "email" => "bot#{System.unique_integer([:positive])}@example.com", "first_name" => "Bot", "last_name" => "User", - "honeypot" => "filled-by-bot" + "website" => "filled-by-bot" }) |> render_submit() @@ -79,38 +82,66 @@ defmodule MvWeb.JoinLiveTest do test "after rate limit exceeded submit returns 429 or error and no new JoinRequest", %{ conn: conn } do + # Reset rate limit state so this test is independent of others (same key in test) + try do + :ets.delete_all_objects(MvWeb.JoinRateLimit) + rescue + ArgumentError -> :ok + end + enable_join_form(true) + # Set allowlist so form has email, first_name, last_name + {:ok, settings} = Membership.get_settings() + + Membership.update_settings(settings, %{ + join_form_field_ids: ["email", "first_name", "last_name"], + join_form_field_required: %{"email" => true, "first_name" => false, "last_name" => false} + }) + # Rely on test config: join rate limit low (e.g. 2 per window) base_email = "ratelimit#{System.unique_integer([:positive])}@example.com" count_before = count_join_requests() + sandbox = conn.private[:ecto_sandbox] - {:ok, view, _html} = live(conn, "/join") - - # Exhaust limit with valid submits + # Exhaust limit with 2 valid submits (each needs a fresh session because form disappears after submit) for i <- 0..1 do + c = + build_conn() + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_private(:ecto_sandbox, sandbox) + + {:ok, view, _} = live(c, "/join") + view |> form("#join-form", %{ "email" => "#{i}-#{base_email}", "first_name" => "User", "last_name" => "Test", - "honeypot" => "" + "website" => "" }) |> render_submit() end - # Next submit should be rate limited + # Next submit (new session) should be rate limited + c = + build_conn() + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_private(:ecto_sandbox, sandbox) + + {:ok, view, _} = live(c, "/join") + result = view |> form("#join-form", %{ "email" => "third-#{base_email}", "first_name" => "Third", "last_name" => "User", - "honeypot" => "" + "website" => "" }) |> render_submit() assert count_join_requests() == count_before + 2 - assert result =~ "rate limit" or result =~ "too many" or result =~ "429" + assert result =~ "rate limit" or String.downcase(result) =~ "too many" or result =~ "429" end end @@ -120,7 +151,15 @@ defmodule MvWeb.JoinLiveTest do end defp enable_join_form_for_test(_context) do - enable_join_form(true) + {:ok, settings} = Membership.get_settings() + + {:ok, _} = + Membership.update_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", "first_name", "last_name"], + join_form_field_required: %{"email" => true, "first_name" => false, "last_name" => false} + }) + :ok end