feat: prevent join requests with equal mail
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Simon 2026-03-13 11:18:34 +01:00
parent 40a4461d23
commit 086ecdcb1b
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
22 changed files with 534 additions and 11 deletions

View file

@ -29,7 +29,11 @@ defmodule Mv.Membership do
require Ash.Query
import Ash.Expr
alias Ash.Error.Query.NotFound, as: NotFoundError
alias Mv.Helpers.SystemActor
alias Mv.Membership.JoinRequest
alias Mv.Membership.Member
alias MvWeb.Emails.JoinAlreadyMemberEmail
alias MvWeb.Emails.JoinAlreadyPendingEmail
alias MvWeb.Emails.JoinConfirmationEmail
require Logger
@ -365,15 +369,130 @@ defmodule Mv.Membership do
## Returns
- `{:ok, request}` - Created JoinRequest in status pending_confirmation, email sent
- `{:ok, :notified_already_member}` - Email already a member; notice sent by email only (no request created)
- `{:ok, :notified_already_pending}` - Email already has pending/submitted request; notice or resend sent by email only
- `{:error, :email_delivery_failed}` - Request created but confirmation email could not be sent (logged)
- `{:error, error}` - Validation or authorization error
"""
def submit_join_request(attrs, opts \\ []) do
actor = Keyword.get(opts, :actor)
token = Map.get(attrs, :confirmation_token) || generate_confirmation_token()
email = normalize_submit_email(attrs)
# Raw token is passed to the submit action; JoinRequest.Changes.SetConfirmationToken
# hashes it before persist. Only the hash is stored; the raw token is sent in the email link.
pending =
if email != nil and email != "", do: pending_join_request_with_email(email), else: nil
cond do
email != nil and email != "" and member_exists_with_email?(email) ->
send_already_member_and_return(email)
pending != nil ->
handle_already_pending(email, pending)
true ->
do_create_join_request(attrs, actor)
end
end
defp normalize_submit_email(attrs) do
raw = attrs["email"] || attrs[:email]
if is_binary(raw), do: String.trim(raw), else: nil
end
defp member_exists_with_email?(email) when is_binary(email) do
system_actor = SystemActor.get_system_actor()
opts = [actor: system_actor, domain: __MODULE__]
case Ash.get(Member, %{email: email}, opts) do
{:ok, _member} -> true
_ -> false
end
end
defp member_exists_with_email?(_), do: false
defp pending_join_request_with_email(email) when is_binary(email) do
system_actor = SystemActor.get_system_actor()
query =
JoinRequest
|> Ash.Query.filter(expr(email == ^email and status in [:pending_confirmation, :submitted]))
|> Ash.Query.sort(inserted_at: :desc)
|> Ash.Query.limit(1)
case Ash.read_one(query, actor: system_actor, domain: __MODULE__) do
{:ok, request} -> request
_ -> nil
end
end
defp pending_join_request_with_email(_), do: nil
defp apply_anti_enumeration_delay do
Process.sleep(100 + :rand.uniform(200))
end
defp send_already_member_and_return(email) do
case JoinAlreadyMemberEmail.send(email) do
{:ok, _} ->
:ok
{:error, reason} ->
Logger.error("Join already-member email failed for #{email}: #{inspect(reason)}")
end
apply_anti_enumeration_delay()
{:ok, :notified_already_member}
end
defp handle_already_pending(email, existing) do
if existing.status == :pending_confirmation do
resend_confirmation_to_pending(email, existing)
else
send_already_pending_and_return(email)
end
end
defp resend_confirmation_to_pending(email, request) do
new_token = generate_confirmation_token()
case request
|> Ash.Changeset.for_update(:regenerate_confirmation_token, %{
confirmation_token: new_token
})
|> Ash.update(domain: __MODULE__, authorize?: false) do
{:ok, _updated} ->
case JoinConfirmationEmail.send(email, new_token, resend: true) do
{:ok, _} ->
:ok
{:error, reason} ->
Logger.error("Join resend confirmation email failed for #{email}: #{inspect(reason)}")
end
apply_anti_enumeration_delay()
{:ok, :notified_already_pending}
{:error, _} ->
# Fallback: do not create duplicate; send generic pending email
send_already_pending_and_return(email)
end
end
defp send_already_pending_and_return(email) do
case JoinAlreadyPendingEmail.send(email) do
{:ok, _} ->
:ok
{:error, reason} ->
Logger.error("Join already-pending email failed for #{email}: #{inspect(reason)}")
end
apply_anti_enumeration_delay()
{:ok, :notified_already_pending}
end
defp do_create_join_request(attrs, actor) do
token = Map.get(attrs, :confirmation_token) || generate_confirmation_token()
attrs_with_token = Map.put(attrs, :confirmation_token, token)
case Ash.create(JoinRequest, attrs_with_token,
@ -384,6 +503,7 @@ defmodule Mv.Membership do
{:ok, request} ->
case JoinConfirmationEmail.send(request.email, token) do
{:ok, _email} ->
apply_anti_enumeration_delay()
{:ok, request}
{:error, reason} ->