Harden public support verification workflow

This commit is contained in:
SimpleTest 2026-08-01 02:51:39 +03:00
parent d28a71bc6b
commit a540165da4
20 changed files with 515 additions and 67 deletions

View File

@ -230,4 +230,7 @@ CODEX_SESSION_ID=copy-the-main-local-codex-session-id
# Authentication delivery uses paired email/IP actions:
# registration_email + registration_ip, magic_link_email + magic_link_ip,
# password_login_email + password_login_ip, email_change_email + email_change_ip.
# Public support intake uses support_request (account/email scope) together with
# support_request_ip (trusted client-IP scope). Choose limits from measured traffic;
# the application does not invent a universal threshold.
RATE_LIMIT_POLICIES_JSON={}

View File

@ -69,7 +69,8 @@ local Codex CLI authenticated with their ChatGPT subscription.
conversation linked by a report.
- Separate public support and content-removal intake, including moderation
appeals, account deletion/data requests, a URL-only TAKE IT DOWN form,
verified-contact status links, operator alerts, and audited staff queues.
verified-contact status links, verification-gated support alerts, and audited
staff queues.
Authenticated users can download an allow-listed JSON data export that omits
password/session/push credentials and counterpart message bodies. A
moderator-only deletion preflight reports active workflows without performing
@ -265,8 +266,9 @@ SMTP adapter and requires the relay's `SMTP_*` credentials. Email registration
and magic-link login are unusable for real
recipients until the selected provider and its accepted sender are configured. Set
the optional `SUPPORT_INBOX_ADDRESS` to a monitored mailbox to receive
metadata-only new-case alerts and make replies return to the support team; the
database queues continue to work when it is empty. See
metadata-only alerts for authenticated or email-verified support cases and make
replies return to the support team; unverified public support stays outside the
operator queue. The database queues continue to work when it is empty. See
[the support and content-removal runbook](docs/support-and-content-removal.md).
Then validate the file structure and the production Compose render:

View File

@ -377,8 +377,11 @@ Configure the transactional SMTP relay and a sender accepted by it using
accepts the corresponding `PRODUCTION_EMAIL_DELIVERY_PROVIDER` and
`PRODUCTION_SMTP_*` inputs.
Set optional `SUPPORT_INBOX_ADDRESS` to
a monitored address for support/removal queue alerts and email `Reply-To`;
leaving it empty disables operator email alerts, not the protected queues. If
a monitored address for support/removal queue alerts and email `Reply-To`.
Public support creates an alert only after email verification; authenticated
support is already verified, while content-removal notification rules remain
separate. Leaving the value empty disables operator email alerts, not the
protected queues. If
Google registration/sign-in is
enabled, also set both Google Web client credentials and register
`https://YOUR_PHX_HOST/auth/google/callback` as the exact authorized redirect

View File

@ -36,10 +36,20 @@ at most 20 locations in one notice.
## Contact verification and status access
Public submissions display a reference but never expose the signed status token
in the redirect. The private status link is sent to the contact email. Opening
it marks that contact address as verified. An authenticated submission uses the
confirmed account email and is verified immediately.
Public support submissions display a reference but never expose the signed
status token in the redirect. They are stored as `pending_verification`, are not
visible in the operator queue, and do not produce an operator alert. The private
status link is sent to the contact email. Opening it atomically verifies the
address, changes the request to `open`, makes it visible to authorised staff,
and sends one metadata-only operator alert. Opening the same link again does not
send another alert. An authenticated submission uses the confirmed account
email, is verified immediately, and enters the queue without this extra step.
Exact repeats of the same unverified public support submission are represented
by one pending row. Its temporary fingerprint is a SHA-256 digest of normalized
form fields; it is cleared on verification and does not replace the original
record or its audit history. Existing unverified rows are quarantined rather
than deleted because no retention policy has been approved.
Email-link verification establishes access to the mailbox, not government
identity, authority to act for another person, or the truth of the claim.
@ -95,7 +105,9 @@ specific review.
- outgoing support/removal messages use it as `Reply-To`;
- it receives a metadata-only alert containing the reference, queue type, and
protected operator URL when a case is created.
protected operator URL when an authenticated support case is created or a
public support contact is verified. Content-removal notification behavior is
kept separate because those notices can require prompt legal or safety review.
The alert intentionally excludes the free-text report and reported URLs. The
full record remains in the protected database queue. If the variable is empty,
@ -136,11 +148,14 @@ resource when an app allows account creation:
## Abuse controls and operational limits
`support_request` and `content_removal_notice` are supported names in the shared
PostgreSQL rate limiter. As with the other actions, no numeric policy is enabled
unless the operator supplies measured values through
`RATE_LIMIT_POLICIES_JSON`. CSRF protection, validation, exact URL limits,
contact verification, staff authorization, and audit events apply regardless.
`support_request`, `support_request_ip`, and `content_removal_notice` are
supported names in the shared PostgreSQL rate limiter. Public support intake
checks both its normalized account/email scope and the trusted client-IP scope
in one database statement. As with the other actions, no numeric policy is
enabled unless the operator supplies values justified by measured traffic
through `RATE_LIMIT_POLICIES_JSON`. CSRF protection, validation, exact URL
limits, contact verification, staff authorization, and audit events apply
regardless.
The software does not provide emergency response. Threats to life or safety are
prioritized in the queue, while every public safety screen continues to direct

View File

@ -631,7 +631,7 @@ this audit.
| Account registration and sign-in | Implemented and browser-verified | Email registration is a single passwordless flow: it records the display name and acceptance once, sends a confirmation link, and does not duplicate a user on subsequent sign-in. Confirmed users can keep using magic links or add a password in settings. Google OpenID Connect registration, sign-in, link, unlink, replay prevention, verified-email enforcement, and account-ownership rules are covered by the 353-test suite. Real headed Chrome on the development origin exercised the Google callback, existing-account ownership email, one-time identity connection, and subsequent one-click Google login without creating a duplicate user. The application-generated authentication email was observed in Gmail from the development sender. | Production SMTP delivery and the production Google callback remain unverified. |
| Notifications and nearby alerts | Implemented and browser/physical-device verified in development | Users can configure push/email preferences, quiet hours, category/urgency/day/time filters, a private matching center, and 1/3/5/10/25 km radii. Durable inbox notifications and Oban delivery jobs are tested; public notification payloads omit chat text, exact coordinates, and the private saved-area label. The focused Chromium replay completed subscription, matching request, inbox delivery, navigation, and export. Real development Web Push delivery completed without a recorded error, and a real private FCM notification reached the physical Android development app. | Production Web Push and production Android FCM delivery remain unverified and require isolated production credentials. |
| Social profiles | Manual links implemented; optional GitHub verification implemented and automated-tested | Manual links cannot set verification fields. The optional GitHub flow uses state, PKCE, a user-bound one-time session, unique provider ownership, and an audit record. The local protocol drill also performs real HTTP token/user exchanges without returning an access token to the application. | GitHub OAuth credentials are intentionally absent and are not required for registration or the help flow. The real external provider redirect/callback remains disabled and unverified; other providers remain manual/unverified. |
| Support and content removal | Implemented and browser-verified | Public support, account deletion, general removal, and TAKE IT DOWN forms create separate audited queues; private email status links verify public contacts; authenticated submissions use the account email; moderator-only operations can update status and notify verified contacts. TAKE IT DOWN accepts URLs/text only and records a 48-hour review due time. Authenticated users can download an allowlisted JSON export, and moderators can run a read-only deletion relationship preflight. | The current product hosts no user media and does not claim TAKE IT DOWN coverage. Staffing, jurisdiction-specific legal classification, final retention rules, destructive account erasure/anonymisation, and identical-media-copy handling remain operational/legal work. |
| Support and content removal | Implemented and browser-verified | Public support, account deletion, general removal, and TAKE IT DOWN forms use separate audited workflows; public support remains pending and outside the staff queue until its private email link verifies the contact, while authenticated submissions use the account email immediately. Exact pending repeats are deduplicated, email/IP intake limits are independently configurable, and moderator-only operations can update verified cases. TAKE IT DOWN accepts URLs/text only and records a 48-hour review due time. Authenticated users can download an allowlisted JSON export, and moderators can run a read-only deletion relationship preflight. | The current product hosts no user media and does not claim TAKE IT DOWN coverage. Staffing, measured rate-limit thresholds, jurisdiction-specific legal classification, final retention rules, destructive account erasure/anonymisation, and identical-media-copy handling remain operational/legal work. |
| Voluntary thanks | Implemented as an external optional link | A helper can expose an optional link after completion; the UI states that the platform does not process the payment. | The platform does not provide payments, escrow, refunds, tax reporting, or payment guarantees. |
| Android client | Local, development, test/staging, and production build identities implemented | The native packages `org.whoneedhelp.mobile.debug`, `org.whoneedhelp.mobile.development`, `org.whoneedhelp.mobile.staging`, and `org.whoneedhelp.mobile` are separated by build type and signing identity. Lifecycle, permission, deep-link, foreground tracking, recoverable main-page failure, notification-Stop, and Activity-destruction tests remain in place. Ephemeral signed development and production pipelines verify package IDs, certificates, unit tests, lint, APKs and instrumentation artifacts; production also verifies the signed AAB with Bundletool. The API 37 smoke verified the development App Link and WebView boundaries. A physical development device then passed magic-link login, bidirectional browser chat, real FCM delivery, foreground location sampling, Stop cleanup, and exact fixture cleanup. Evidence is `output/android-physical-development-e2e/physical-20260724-191948-1352510`. | Play registration/App Signing, production-device FCM delivery, unattended/background-permission tracking, and iOS are not complete. |
| Multiple web/worker instances | Implemented and locally failure/rollout-verified | The final isolated Compose drill passed BEAM crashes and sequential replacement with 3 web/2 worker replicas: all five nodes joined, PubSub passed, and 744/744 readiness requests succeeded. The project-owned kind cluster replaced all 2 web/2 worker pod UIDs under `maxUnavailable=0`; all four replacement pods joined, PubSub passed, and 363/363 samples ultimately succeeded. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. |

View File

@ -25,19 +25,27 @@ defmodule WhoNeedHelp.Support do
SupportRequest.submission_changeset(request, attrs)
end
def create_request(scope, attrs) when is_map(attrs) do
def create_request(scope, attrs) when is_map(attrs), do: create_request(scope, attrs, nil)
def create_request(scope, attrs, client_scope) when is_map(attrs) do
user = scope_user(scope)
attrs = normalize_keys(attrs)
contact_email = if user, do: user.email, else: attrs["contact_email"]
attrs = Map.put(attrs, "contact_email", contact_email)
fingerprint = if user, do: nil, else: public_submission_fingerprint(attrs)
status = if user, do: :open, else: :pending_verification
with {:ok, _limit} <- RateLimiter.check(:support_request, rate_scope(user, contact_email)) do
with {:ok, _limits} <-
RateLimiter.check_many(rate_scopes(user, contact_email, client_scope)) do
result =
Repo.transact(fn ->
with {:ok, request} <-
%SupportRequest{
reference: unique_reference("SUP"),
requester_id: user && user.id,
contact_verified_at: user && DateTime.utc_now(:second)
contact_verified_at: user && DateTime.utc_now(:second),
public_submission_fingerprint: fingerprint,
status: status
}
|> SupportRequest.submission_changeset(attrs)
|> Repo.insert(),
@ -62,7 +70,10 @@ defmodule WhoNeedHelp.Support do
{:ok, request}
end
end)
|> notify_received()
result
|> resolve_duplicate_submission(fingerprint)
|> notify_created()
|> broadcast_request_update()
end
end
@ -135,6 +146,7 @@ defmodule WhoNeedHelp.Support do
cursor = Pagination.cursor(options)
SupportRequest
|> where([request], not is_nil(request.contact_verified_at))
|> maybe_kind(Keyword.get(options, :kind))
|> before(cursor)
|> order_by([request], desc: request.inserted_at, desc: request.id)
@ -170,7 +182,7 @@ defmodule WhoNeedHelp.Support do
Repo.transact(fn ->
request =
SupportRequest
|> where([request], request.id == ^id)
|> where([request], request.id == ^id and not is_nil(request.contact_verified_at))
|> lock("FOR UPDATE")
|> Repo.one()
@ -333,7 +345,11 @@ defmodule WhoNeedHelp.Support do
def deletion_assessment(%Scope{user: moderator} = scope, id) do
with true <- Accounts.moderator_authorized?(moderator),
{:ok, id} <- Ecto.UUID.cast(id),
%SupportRequest{} = request <- Repo.get(SupportRequest, id) do
%SupportRequest{} = request <-
Repo.one(
from request in SupportRequest,
where: request.id == ^id and not is_nil(request.contact_verified_at)
) do
DataLifecycle.deletion_assessment(scope, request)
else
false -> {:error, :forbidden}
@ -341,13 +357,19 @@ defmodule WhoNeedHelp.Support do
end
end
defp notify_received({:ok, request}) do
defp notify_created({:ok, {request, :pending_verification}}) do
_ = Notifier.deliver_confirmation(request, status_url(request))
{:ok, request}
end
defp notify_created({:ok, {request, :verified}}) do
_ = Notifier.deliver_received(request, status_url(request))
_ = Notifier.deliver_operator_alert(request)
{:ok, request}
end
defp notify_received(result), do: result
defp notify_created({:ok, {request, :duplicate}}), do: {:ok, request}
defp notify_created(result), do: result
defp notify_decision({:ok, %SupportRequest{contact_verified_at: nil} = request}),
do: {:ok, request}
@ -386,7 +408,10 @@ defmodule WhoNeedHelp.Support do
event = {:support_request_updated, request.id}
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, request_topic(request.id), event)
if request.contact_verified_at do
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @staff_topic, event)
end
result
end
@ -396,13 +421,69 @@ defmodule WhoNeedHelp.Support do
defp request_topic(id), do: "support:request:#{id}"
defp verify_contact(%SupportRequest{contact_verified_at: nil} = request) do
request
|> Ecto.Changeset.change(contact_verified_at: DateTime.utc_now(:second))
|> Repo.update()
Repo.transact(fn ->
current =
SupportRequest
|> where([record], record.id == ^request.id)
|> lock("FOR UPDATE")
|> Repo.one()
cond do
is_nil(current) ->
{:error, :not_found}
current.contact_verified_at ->
{:ok, {current, :already_verified}}
true ->
previous_status = current.status
verified_at = DateTime.utc_now(:second)
with {:ok, verified} <-
current
|> Ecto.Changeset.change(
contact_verified_at: verified_at,
public_submission_fingerprint: nil,
status: :open
)
|> Repo.update(),
{:ok, _event} <-
maybe_record_status_event(
verified,
previous_status,
:open,
nil,
:requester
),
{:ok, _audit} <-
Trust.audit(
nil,
"support_request.contact_verified",
"support_request",
verified.id
) do
{:ok, {verified, :newly_verified}}
end
end
end)
|> notify_verified_request()
end
defp verify_contact(%SupportRequest{} = request), do: {:ok, request}
defp notify_verified_request({:ok, {request, :newly_verified}}) do
_ = Notifier.deliver_operator_alert(request)
event = {:support_request_updated, request.id}
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, request_topic(request.id), event)
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @staff_topic, event)
{:ok, request}
end
defp notify_verified_request({:ok, {request, :already_verified}}), do: {:ok, request}
defp notify_verified_request(result), do: result
defp preload_conversation(%SupportRequest{} = request) do
Repo.preload(
request,
@ -486,6 +567,17 @@ defmodule WhoNeedHelp.Support do
defp maybe_kind(query, nil), do: query
defp maybe_kind(query, kind), do: where(query, [request], request.kind == ^kind)
defp rate_scopes(user, contact_email, client_scope) do
[{:support_request, rate_scope(user, contact_email)}]
|> maybe_add_client_rate_scope(client_scope)
end
defp maybe_add_client_rate_scope(scopes, client_scope)
when is_binary(client_scope) and client_scope != "",
do: [{:support_request_ip, client_scope} | scopes]
defp maybe_add_client_rate_scope(scopes, _client_scope), do: scopes
defp rate_scope(%User{id: id}, _email), do: "user:#{id}"
defp rate_scope(nil, email) when is_binary(email),
@ -501,5 +593,51 @@ defmodule WhoNeedHelp.Support do
"#{prefix}-#{suffix}"
end
defp public_submission_fingerprint(attrs) do
["contact_email", "kind", "subject", "details"]
|> Enum.map_join("\u0000", fn key ->
attrs |> Map.get(key, "") |> normalize_fingerprint_part()
end)
|> then(&:crypto.hash(:sha256, &1))
|> Base.encode16(case: :lower)
end
defp normalize_fingerprint_part(value) do
value
|> to_string()
|> String.trim()
|> String.downcase()
|> String.replace(~r/\s+/u, " ")
end
defp resolve_duplicate_submission({:ok, request}, _fingerprint) do
outcome = if request.contact_verified_at, do: :verified, else: :pending_verification
{:ok, {request, outcome}}
end
defp resolve_duplicate_submission({:error, %Ecto.Changeset{} = changeset} = error, fingerprint)
when is_binary(fingerprint) do
if duplicate_fingerprint_error?(changeset) do
case Repo.get_by(SupportRequest, public_submission_fingerprint: fingerprint) do
%SupportRequest{} = request -> {:ok, {request, :duplicate}}
nil -> error
end
else
error
end
end
defp resolve_duplicate_submission(result, _fingerprint), do: result
defp duplicate_fingerprint_error?(changeset) do
Enum.any?(changeset.errors, fn
{:public_submission_fingerprint, {_message, metadata}} ->
metadata[:constraint] == :unique
_other ->
false
end)
end
defp normalize_keys(attrs), do: Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
end

View File

@ -4,6 +4,21 @@ defmodule WhoNeedHelp.Support.Notifier do
alias WhoNeedHelp.Mailer
alias WhoNeedHelp.Support.SupportRequest
def deliver_confirmation(%SupportRequest{} = request, status_url) do
deliver(
request.contact_email,
"Confirm Who Need Help support request #{request.reference}",
"""
Confirm the email address for support request #{request.reference}.
Your request has not been sent to the support queue yet. Open this private link to verify the address and submit it for review:
#{status_url}
If you did not make this request, ignore this email. It will not reach the support queue.
"""
)
end
def deliver_received(%SupportRequest{} = request, status_url) do
deliver(
request.contact_email,

View File

@ -5,7 +5,7 @@ defmodule WhoNeedHelp.Support.StatusEvent do
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@statuses [:open, :reviewing, :waiting_for_requester, :resolved, :closed]
@statuses [:pending_verification, :open, :reviewing, :waiting_for_requester, :resolved, :closed]
schema "support_status_events" do
field :actor_role, Ecto.Enum, values: [:requester, :staff, :system]

View File

@ -21,13 +21,21 @@ defmodule WhoNeedHelp.Support.SupportRequest do
]
field :status, Ecto.Enum,
values: [:open, :reviewing, :waiting_for_requester, :resolved, :closed],
values: [
:pending_verification,
:open,
:reviewing,
:waiting_for_requester,
:resolved,
:closed
],
default: :open
field :contact_email, :string
field :subject, :string
field :details, :string
field :contact_verified_at, :utc_datetime
field :public_submission_fingerprint, :string
field :resolution_note, :string
field :reviewed_at, :utc_datetime
field :response_sent_at, :utc_datetime
@ -55,6 +63,7 @@ defmodule WhoNeedHelp.Support.SupportRequest do
|> validate_length(:subject, min: 3, max: 160)
|> validate_length(:details, min: 10, max: 5_000)
|> unique_constraint(:reference)
|> unique_constraint(:public_submission_fingerprint)
end
def moderation_changeset(request, attrs) do

View File

@ -3,6 +3,7 @@ defmodule WhoNeedHelpWeb.SupportController do
alias WhoNeedHelp.Support
alias WhoNeedHelp.Support.SupportRequest
alias WhoNeedHelpWeb.ClientIp
def index(conn, _params) do
requests =
@ -22,7 +23,11 @@ defmodule WhoNeedHelpWeb.SupportController do
end
def create(conn, %{"support_request" => params}) when is_map(params) do
case Support.create_request(conn.assigns.current_scope, params) do
case Support.create_request(
conn.assigns.current_scope,
params,
ClientIp.rate_limit_scope(conn)
) do
{:ok, request} ->
redirect(conn, to: ~p"/support/received?reference=#{request.reference}")
@ -60,7 +65,11 @@ defmodule WhoNeedHelpWeb.SupportController do
|> Map.put("kind", "account_deletion")
|> Map.put("subject", gettext("Delete my Who Need Help account"))
case Support.create_request(conn.assigns.current_scope, params) do
case Support.create_request(
conn.assigns.current_scope,
params,
ClientIp.rate_limit_scope(conn)
) do
{:ok, request} ->
redirect(conn, to: ~p"/support/received?reference=#{request.reference}")

View File

@ -13,6 +13,7 @@ defmodule WhoNeedHelpWeb.SupportHTML do
def kind_label(:other), do: gettext("Other")
def kind_label(value), do: to_string(value)
def status_label(:pending_verification), do: gettext("Pending email verification")
def status_label(:open), do: gettext("Open")
def status_label(:reviewing), do: gettext("Reviewing")
def status_label(:waiting_for_requester), do: gettext("Waiting for your response")

View File

@ -5,7 +5,11 @@
page_align={:center}
>
<div class="text-center">
<div class="text-sm font-semibold text-success">{gettext("REQUEST RECEIVED")}</div>
<div class="text-sm font-semibold text-success">
{if @current_scope,
do: gettext("REQUEST RECEIVED"),
else: gettext("EMAIL CONFIRMATION REQUIRED")}
</div>
<h1 class="mt-2 text-4xl font-black">
{if @current_scope,
do: gettext("Support request created"),
@ -19,7 +23,7 @@
</p>
<p :if={is_nil(@current_scope)} class="mt-4 text-base-content/65">
{gettext(
"We created support request %{reference}. A private status link has been sent to the contact email when delivery is configured. Opening that link verifies the address for public requests.",
"We saved pending request %{reference}. It is not in the support queue yet. Open the private link sent to the contact email to verify the address and submit the request for review.",
reference: @reference
)}
</p>

View File

@ -6808,3 +6808,23 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Updating results for the visible map area…"
msgstr ""
#: lib/who_need_help_web/controllers/support_html/received.html.heex:11
#, elixir-autogen, elixir-format
msgid "EMAIL CONFIRMATION REQUIRED"
msgstr ""
#: lib/who_need_help_web/live/request_live/show.ex:305
#, elixir-autogen, elixir-format
msgid "Location sharing could not start."
msgstr ""
#: lib/who_need_help_web/controllers/support_html.ex:16
#, elixir-autogen, elixir-format
msgid "Pending email verification"
msgstr ""
#: lib/who_need_help_web/controllers/support_html/received.html.heex:25
#, elixir-autogen, elixir-format
msgid "We saved pending request %{reference}. It is not in the support queue yet. Open the private link sent to the contact email to verify the address and submit the request for review."
msgstr ""

View File

@ -6808,3 +6808,23 @@ msgstr "Update results as map moves"
#, elixir-autogen, elixir-format
msgid "Updating results for the visible map area…"
msgstr "Updating results for the visible map area…"
#: lib/who_need_help_web/controllers/support_html/received.html.heex:11
#, elixir-autogen, elixir-format
msgid "EMAIL CONFIRMATION REQUIRED"
msgstr "EMAIL CONFIRMATION REQUIRED"
#: lib/who_need_help_web/live/request_live/show.ex:305
#, elixir-autogen, elixir-format
msgid "Location sharing could not start."
msgstr "Location sharing could not start."
#: lib/who_need_help_web/controllers/support_html.ex:16
#, elixir-autogen, elixir-format
msgid "Pending email verification"
msgstr "Pending email verification"
#: lib/who_need_help_web/controllers/support_html/received.html.heex:25
#, elixir-autogen, elixir-format
msgid "We saved pending request %{reference}. It is not in the support queue yet. Open the private link sent to the contact email to verify the address and submit the request for review."
msgstr "We saved pending request %{reference}. It is not in the support queue yet. Open the private link sent to the contact email to verify the address and submit the request for review."

View File

@ -6951,3 +6951,23 @@ msgstr "Обновлять результаты при перемещении к
#, elixir-autogen, elixir-format
msgid "Updating results for the visible map area…"
msgstr "Обновляем результаты для видимой области карты…"
#: lib/who_need_help_web/controllers/support_html/received.html.heex:11
#, elixir-autogen, elixir-format
msgid "EMAIL CONFIRMATION REQUIRED"
msgstr "ТРЕБУЕТСЯ ПОДТВЕРЖДЕНИЕ EMAIL"
#: lib/who_need_help_web/live/request_live/show.ex:305
#, elixir-autogen, elixir-format
msgid "Location sharing could not start."
msgstr "Не удалось включить передачу геопозиции."
#: lib/who_need_help_web/controllers/support_html.ex:16
#, elixir-autogen, elixir-format
msgid "Pending email verification"
msgstr "Ожидает подтверждения email"
#: lib/who_need_help_web/controllers/support_html/received.html.heex:25
#, elixir-autogen, elixir-format
msgid "We saved pending request %{reference}. It is not in the support queue yet. Open the private link sent to the contact email to verify the address and submit the request for review."
msgstr "Мы сохранили ожидающую заявку %{reference}. Она ещё не попала в очередь поддержки. Откройте приватную ссылку из письма, чтобы подтвердить адрес и отправить заявку на рассмотрение."

View File

@ -6945,3 +6945,23 @@ msgstr "Оновлювати результати під час переміще
#, elixir-autogen, elixir-format
msgid "Updating results for the visible map area…"
msgstr "Оновлюємо результати для видимої області карти…"
#: lib/who_need_help_web/controllers/support_html/received.html.heex:11
#, elixir-autogen, elixir-format
msgid "EMAIL CONFIRMATION REQUIRED"
msgstr "ПОТРІБНЕ ПІДТВЕРДЖЕННЯ EMAIL"
#: lib/who_need_help_web/live/request_live/show.ex:305
#, elixir-autogen, elixir-format
msgid "Location sharing could not start."
msgstr "Не вдалося ввімкнути передавання геопозиції."
#: lib/who_need_help_web/controllers/support_html.ex:16
#, elixir-autogen, elixir-format
msgid "Pending email verification"
msgstr "Очікує підтвердження email"
#: lib/who_need_help_web/controllers/support_html/received.html.heex:25
#, elixir-autogen, elixir-format
msgid "We saved pending request %{reference}. It is not in the support queue yet. Open the private link sent to the contact email to verify the address and submit the request for review."
msgstr "Ми зберегли запит %{reference}, що очікує підтвердження. Він ще не потрапив до черги підтримки. Відкрийте приватне посилання з листа, щоб підтвердити адресу та надіслати запит на розгляд."

View File

@ -4,3 +4,4 @@
20260722190604 forward_only assignment history permits rows the old single-assignment model cannot interpret safely
20260723015032 application_safe additive request-helper lookup index
20260724161628 application_safe additive support conversation and status-history tables
20260731230828 forward_only pending support verification status is not understood by older releases

1 # migration_version application_rollback_policy reason
4 20260722190604 forward_only assignment history permits rows the old single-assignment model cannot interpret safely
5 20260723015032 application_safe additive request-helper lookup index
6 20260724161628 application_safe additive support conversation and status-history tables
7 20260731230828 forward_only pending support verification status is not understood by older releases

View File

@ -0,0 +1,44 @@
defmodule WhoNeedHelp.Repo.Migrations.HardenPublicSupportVerification do
use Ecto.Migration
def up do
alter table(:support_requests) do
add :public_submission_fingerprint, :string
end
create unique_index(:support_requests, [:public_submission_fingerprint])
create index(:support_requests, [:inserted_at, :id],
where: "contact_verified_at IS NOT NULL",
name: :support_requests_verified_queue_index
)
execute("""
UPDATE support_requests
SET status = 'pending_verification', updated_at = NOW()
WHERE requester_id IS NULL
AND contact_verified_at IS NULL
AND status = 'open'
""")
end
def down do
execute("""
UPDATE support_requests
SET status = 'open', updated_at = NOW()
WHERE requester_id IS NULL
AND contact_verified_at IS NULL
AND status = 'pending_verification'
""")
drop index(:support_requests, [:inserted_at, :id],
name: :support_requests_verified_queue_index
)
drop unique_index(:support_requests, [:public_submission_fingerprint])
alter table(:support_requests) do
remove :public_submission_fingerprint
end
end
end

View File

@ -20,6 +20,8 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert request.reference =~ "SUP-"
assert request.contact_email == "person@example.com"
assert request.status == :pending_verification
assert is_binary(request.public_submission_fingerprint)
assert is_nil(request.contact_verified_at)
test_pid = self()
@ -50,7 +52,13 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
Support.get_by_access_token(request.id, emailed_token)
assert verified.contact_verified_at
assert [%StatusEvent{from_status: nil, to_status: :open}] = verified.status_events
assert verified.status == :open
assert is_nil(verified.public_submission_fingerprint)
assert [
%StatusEvent{from_status: nil, to_status: :pending_verification},
%StatusEvent{from_status: :pending_verification, to_status: :open}
] = verified.status_events
assert Repo.exists?(
from event in AuditEvent,
@ -60,7 +68,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
)
end
test "configured support inbox receives a metadata-only operator alert and Reply-To" do
test "operator alert is sent only after public contact verification" do
previous = Application.get_env(:who_need_help, :support_inbox_address)
Application.put_env(:who_need_help, :support_inbox_address, "support@example.com")
on_exit(fn -> Application.put_env(:who_need_help, :support_inbox_address, previous) end)
@ -76,14 +84,75 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert_email_sent(fn email ->
email.to == [{"", "appeal@example.com"}] and
email.reply_to == {"", "support@example.com"}
email.reply_to == {"", "support@example.com"} and
email.subject =~ "Confirm Who Need Help support request"
end)
refute_receive {:email, _operator_alert}, 50
assert {:ok, verified} =
Support.get_by_access_token(request.id, Support.access_token(request))
assert verified.status == :open
assert_email_sent(fn email ->
email.to == [{"", "support@example.com"}] and email.subject =~ request.reference and
email.text_body =~ "/support/operations" and
not String.contains?(email.text_body, request.details)
end)
assert {:ok, _same_request} =
Support.get_by_access_token(request.id, Support.access_token(request))
refute_receive {:email, _duplicate_operator_alert}, 50
end
test "identical unverified public submissions are deduplicated without another email" do
attrs = %{
"kind" => "technical_issue",
"contact_email" => "duplicate@example.com",
"subject" => "Repeated public support request",
"details" => "The same unverified request must not create another queue record."
}
assert {:ok, first} = Support.create_request(nil, attrs)
assert_email_sent()
assert {:ok, duplicate} =
Support.create_request(nil, %{
attrs
| "contact_email" => " DUPLICATE@example.com ",
"subject" => " Repeated public support request "
})
assert duplicate.id == first.id
assert Repo.aggregate(SupportRequest, :count) == 1
refute_receive {:email, _duplicate_confirmation}, 50
end
test "unverified public support is absent from staff access until confirmation" do
moderator_scope = moderator_scope()
assert {:ok, pending} =
Support.create_request(nil, %{
"kind" => "account_access",
"contact_email" => "pending@example.com",
"subject" => "Pending account access request",
"details" => "This public request must remain outside the operator queue."
})
assert Support.paginate_for_staff(moderator_scope).entries == []
assert {:error, :not_found} =
Support.moderate(moderator_scope, pending.id, %{
"status" => "reviewing",
"response" => "This must not be recorded before verification."
})
assert {:ok, verified} =
Support.get_by_access_token(pending.id, Support.access_token(pending))
assert Enum.map(Support.paginate_for_staff(moderator_scope).entries, & &1.id) == [verified.id]
end
test "authenticated support uses the account email and staff moderation is audited" do

View File

@ -45,7 +45,54 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
location = redirected_to(conn)
assert location =~ "/support/received?reference=SUP-"
refute location =~ "token="
assert_email_sent()
assert_email_sent(fn email ->
email.to == [{"", "person@example.com"}] and
email.subject =~ "Confirm Who Need Help support request"
end)
page = conn |> recycle() |> get(location) |> html_response(200)
assert page =~ "EMAIL CONFIRMATION REQUIRED"
assert page =~ "It is not in the support queue yet"
end
test "support intake enforces independent email and client IP limits", %{conn: conn} do
previous = Application.get_env(:who_need_help, :rate_limit_policies)
Application.put_env(:who_need_help, :rate_limit_policies, %{
"support_request" => %{"limit" => 1, "window_seconds" => 60},
"support_request_ip" => %{"limit" => 2, "window_seconds" => 60}
})
on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, previous) end)
attrs = fn email, suffix ->
%{
"support_request" => %{
"kind" => "technical_issue",
"contact_email" => email,
"subject" => "Support rate limit #{suffix}",
"details" => "This valid request exercises both independent support intake limits."
}
}
end
assert conn
|> post(~p"/support", attrs.("first@example.com", "one"))
|> redirected_to() =~ "/support/received"
assert conn
|> Map.put(:remote_ip, {198, 51, 100, 22})
|> post(~p"/support", attrs.("first@example.com", "two"))
|> response(429)
assert conn
|> post(~p"/support", attrs.("second@example.com", "three"))
|> redirected_to() =~ "/support/received"
assert conn
|> post(~p"/support", attrs.("third@example.com", "four"))
|> response(429)
end
test "creates urgent TAKE IT DOWN notice without accepting a media upload", %{conn: conn} do
@ -271,7 +318,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|> Ecto.Changeset.change(role: :moderator)
|> WhoNeedHelp.Repo.update!()
{:ok, _support_request} =
{:ok, support_request} =
WhoNeedHelp.Support.create_request(nil, %{
"kind" => "technical_issue",
"contact_email" => "support-queue@example.com",
@ -279,6 +326,8 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
"details" => "This request verifies that an open status remains selected."
})
assert support_request.status == :pending_verification
{:ok, _removal_notice} =
WhoNeedHelp.ContentRemoval.create_notice(nil, :general, %{
"category" => "illegal_content",
@ -292,6 +341,12 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
"accurate_complete" => "true"
})
assert {:ok, _verified_support_request} =
WhoNeedHelp.Support.get_by_access_token(
support_request.id,
WhoNeedHelp.Support.access_token(support_request)
)
response =
conn
|> log_in_user(moderator)