diff --git a/.env.example b/.env.example index 83b1cd9..77c1ace 100644 --- a/.env.example +++ b/.env.example @@ -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={} diff --git a/README.md b/README.md index d014007..d1e382b 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/operations.md b/docs/operations.md index c7e575e..e5a8fa5 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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 diff --git a/docs/support-and-content-removal.md b/docs/support-and-content-removal.md index 916309c..92aec8f 100644 --- a/docs/support-and-content-removal.md +++ b/docs/support-and-content-removal.md @@ -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 diff --git a/docs/verification.md b/docs/verification.md index a512c54..14d6bcf 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -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. | diff --git a/lib/who_need_help/support.ex b/lib/who_need_help/support.ex index ad74c86..2cc0577 100644 --- a/lib/who_need_help/support.ex +++ b/lib/who_need_help/support.ex @@ -25,44 +25,55 @@ 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 - Repo.transact(fn -> - with {:ok, request} <- - %SupportRequest{ - reference: unique_reference("SUP"), - requester_id: user && user.id, - contact_verified_at: user && DateTime.utc_now(:second) - } - |> SupportRequest.submission_changeset(attrs) - |> Repo.insert(), - {:ok, _audit} <- - Trust.audit( - user && user.id, - "support_request.created", - "support_request", - request.id, - %{ - "kind" => to_string(request.kind) + 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), + public_submission_fingerprint: fingerprint, + status: status } - ), - {:ok, _event} <- - record_status_event( - request, - nil, - request.status, - user && user.id, - :requester - ) do - {:ok, request} - end - end) - |> notify_received() + |> SupportRequest.submission_changeset(attrs) + |> Repo.insert(), + {:ok, _audit} <- + Trust.audit( + user && user.id, + "support_request.created", + "support_request", + request.id, + %{ + "kind" => to_string(request.kind) + } + ), + {:ok, _event} <- + record_status_event( + request, + nil, + request.status, + user && user.id, + :requester + ) do + {:ok, request} + end + end) + + 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) - Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @staff_topic, 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 diff --git a/lib/who_need_help/support/notifier.ex b/lib/who_need_help/support/notifier.ex index 046ec31..6aaf66c 100644 --- a/lib/who_need_help/support/notifier.ex +++ b/lib/who_need_help/support/notifier.ex @@ -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, diff --git a/lib/who_need_help/support/status_event.ex b/lib/who_need_help/support/status_event.ex index c8cf7aa..89a77f8 100644 --- a/lib/who_need_help/support/status_event.ex +++ b/lib/who_need_help/support/status_event.ex @@ -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] diff --git a/lib/who_need_help/support/support_request.ex b/lib/who_need_help/support/support_request.ex index 1033eb4..88b06ba 100644 --- a/lib/who_need_help/support/support_request.ex +++ b/lib/who_need_help/support/support_request.ex @@ -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 diff --git a/lib/who_need_help_web/controllers/support_controller.ex b/lib/who_need_help_web/controllers/support_controller.ex index 995760c..9670e97 100644 --- a/lib/who_need_help_web/controllers/support_controller.ex +++ b/lib/who_need_help_web/controllers/support_controller.ex @@ -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}") diff --git a/lib/who_need_help_web/controllers/support_html.ex b/lib/who_need_help_web/controllers/support_html.ex index 5cbc6d3..ab53c51 100644 --- a/lib/who_need_help_web/controllers/support_html.ex +++ b/lib/who_need_help_web/controllers/support_html.ex @@ -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") diff --git a/lib/who_need_help_web/controllers/support_html/received.html.heex b/lib/who_need_help_web/controllers/support_html/received.html.heex index fda4c36..57f15ae 100644 --- a/lib/who_need_help_web/controllers/support_html/received.html.heex +++ b/lib/who_need_help_web/controllers/support_html/received.html.heex @@ -5,7 +5,11 @@ page_align={:center} >
{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 )}
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 89da8ca..50a89a8 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -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 "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 6c13f8b..772d3cc 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -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." diff --git a/priv/gettext/ru/LC_MESSAGES/default.po b/priv/gettext/ru/LC_MESSAGES/default.po index 12a7c77..431aadd 100644 --- a/priv/gettext/ru/LC_MESSAGES/default.po +++ b/priv/gettext/ru/LC_MESSAGES/default.po @@ -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}. Она ещё не попала в очередь поддержки. Откройте приватную ссылку из письма, чтобы подтвердить адрес и отправить заявку на рассмотрение." diff --git a/priv/gettext/uk/LC_MESSAGES/default.po b/priv/gettext/uk/LC_MESSAGES/default.po index 1de12b5..2341a4c 100644 --- a/priv/gettext/uk/LC_MESSAGES/default.po +++ b/priv/gettext/uk/LC_MESSAGES/default.po @@ -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}, що очікує підтвердження. Він ще не потрапив до черги підтримки. Відкрийте приватне посилання з листа, щоб підтвердити адресу та надіслати запит на розгляд." diff --git a/priv/repo/migration_application_compatibility.tsv b/priv/repo/migration_application_compatibility.tsv index 577cb83..3448c74 100644 --- a/priv/repo/migration_application_compatibility.tsv +++ b/priv/repo/migration_application_compatibility.tsv @@ -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 diff --git a/priv/repo/migrations/20260731230828_harden_public_support_verification.exs b/priv/repo/migrations/20260731230828_harden_public_support_verification.exs new file mode 100644 index 0000000..ae4e4eb --- /dev/null +++ b/priv/repo/migrations/20260731230828_harden_public_support_verification.exs @@ -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 diff --git a/test/who_need_help/support_and_content_removal_test.exs b/test/who_need_help/support_and_content_removal_test.exs index fea02cc..9fb9827 100644 --- a/test/who_need_help/support_and_content_removal_test.exs +++ b/test/who_need_help/support_and_content_removal_test.exs @@ -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 diff --git a/test/who_need_help_web/controllers/support_controller_test.exs b/test/who_need_help_web/controllers/support_controller_test.exs index 00516b5..8b28c56 100644 --- a/test/who_need_help_web/controllers/support_controller_test.exs +++ b/test/who_need_help_web/controllers/support_controller_test.exs @@ -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)