Harden public support verification workflow
This commit is contained in:
parent
d28a71bc6b
commit
a540165da4
|
|
@ -230,4 +230,7 @@ CODEX_SESSION_ID=copy-the-main-local-codex-session-id
|
||||||
# Authentication delivery uses paired email/IP actions:
|
# Authentication delivery uses paired email/IP actions:
|
||||||
# registration_email + registration_ip, magic_link_email + magic_link_ip,
|
# registration_email + registration_ip, magic_link_email + magic_link_ip,
|
||||||
# password_login_email + password_login_ip, email_change_email + email_change_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={}
|
RATE_LIMIT_POLICIES_JSON={}
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,8 @@ local Codex CLI authenticated with their ChatGPT subscription.
|
||||||
conversation linked by a report.
|
conversation linked by a report.
|
||||||
- Separate public support and content-removal intake, including moderation
|
- Separate public support and content-removal intake, including moderation
|
||||||
appeals, account deletion/data requests, a URL-only TAKE IT DOWN form,
|
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
|
Authenticated users can download an allow-listed JSON data export that omits
|
||||||
password/session/push credentials and counterpart message bodies. A
|
password/session/push credentials and counterpart message bodies. A
|
||||||
moderator-only deletion preflight reports active workflows without performing
|
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
|
and magic-link login are unusable for real
|
||||||
recipients until the selected provider and its accepted sender are configured. Set
|
recipients until the selected provider and its accepted sender are configured. Set
|
||||||
the optional `SUPPORT_INBOX_ADDRESS` to a monitored mailbox to receive
|
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
|
metadata-only alerts for authenticated or email-verified support cases and make
|
||||||
database queues continue to work when it is empty. See
|
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).
|
[the support and content-removal runbook](docs/support-and-content-removal.md).
|
||||||
Then validate the file structure and the production Compose render:
|
Then validate the file structure and the production Compose render:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -377,8 +377,11 @@ Configure the transactional SMTP relay and a sender accepted by it using
|
||||||
accepts the corresponding `PRODUCTION_EMAIL_DELIVERY_PROVIDER` and
|
accepts the corresponding `PRODUCTION_EMAIL_DELIVERY_PROVIDER` and
|
||||||
`PRODUCTION_SMTP_*` inputs.
|
`PRODUCTION_SMTP_*` inputs.
|
||||||
Set optional `SUPPORT_INBOX_ADDRESS` to
|
Set optional `SUPPORT_INBOX_ADDRESS` to
|
||||||
a monitored address for support/removal queue alerts and email `Reply-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
|
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
|
Google registration/sign-in is
|
||||||
enabled, also set both Google Web client credentials and register
|
enabled, also set both Google Web client credentials and register
|
||||||
`https://YOUR_PHX_HOST/auth/google/callback` as the exact authorized redirect
|
`https://YOUR_PHX_HOST/auth/google/callback` as the exact authorized redirect
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,20 @@ at most 20 locations in one notice.
|
||||||
|
|
||||||
## Contact verification and status access
|
## Contact verification and status access
|
||||||
|
|
||||||
Public submissions display a reference but never expose the signed status token
|
Public support submissions display a reference but never expose the signed
|
||||||
in the redirect. The private status link is sent to the contact email. Opening
|
status token in the redirect. They are stored as `pending_verification`, are not
|
||||||
it marks that contact address as verified. An authenticated submission uses the
|
visible in the operator queue, and do not produce an operator alert. The private
|
||||||
confirmed account email and is verified immediately.
|
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
|
Email-link verification establishes access to the mailbox, not government
|
||||||
identity, authority to act for another person, or the truth of the claim.
|
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`;
|
- outgoing support/removal messages use it as `Reply-To`;
|
||||||
- it receives a metadata-only alert containing the reference, queue type, and
|
- 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
|
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,
|
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
|
## Abuse controls and operational limits
|
||||||
|
|
||||||
`support_request` and `content_removal_notice` are supported names in the shared
|
`support_request`, `support_request_ip`, and `content_removal_notice` are
|
||||||
PostgreSQL rate limiter. As with the other actions, no numeric policy is enabled
|
supported names in the shared PostgreSQL rate limiter. Public support intake
|
||||||
unless the operator supplies measured values through
|
checks both its normalized account/email scope and the trusted client-IP scope
|
||||||
`RATE_LIMIT_POLICIES_JSON`. CSRF protection, validation, exact URL limits,
|
in one database statement. As with the other actions, no numeric policy is
|
||||||
contact verification, staff authorization, and audit events apply regardless.
|
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
|
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
|
prioritized in the queue, while every public safety screen continues to direct
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
||||||
|
|
|
||||||
|
|
@ -25,44 +25,55 @@ defmodule WhoNeedHelp.Support do
|
||||||
SupportRequest.submission_changeset(request, attrs)
|
SupportRequest.submission_changeset(request, attrs)
|
||||||
end
|
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)
|
user = scope_user(scope)
|
||||||
attrs = normalize_keys(attrs)
|
attrs = normalize_keys(attrs)
|
||||||
contact_email = if user, do: user.email, else: attrs["contact_email"]
|
contact_email = if user, do: user.email, else: attrs["contact_email"]
|
||||||
attrs = Map.put(attrs, "contact_email", 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} <-
|
||||||
Repo.transact(fn ->
|
RateLimiter.check_many(rate_scopes(user, contact_email, client_scope)) do
|
||||||
with {:ok, request} <-
|
result =
|
||||||
%SupportRequest{
|
Repo.transact(fn ->
|
||||||
reference: unique_reference("SUP"),
|
with {:ok, request} <-
|
||||||
requester_id: user && user.id,
|
%SupportRequest{
|
||||||
contact_verified_at: user && DateTime.utc_now(:second)
|
reference: unique_reference("SUP"),
|
||||||
}
|
requester_id: user && user.id,
|
||||||
|> SupportRequest.submission_changeset(attrs)
|
contact_verified_at: user && DateTime.utc_now(:second),
|
||||||
|> Repo.insert(),
|
public_submission_fingerprint: fingerprint,
|
||||||
{:ok, _audit} <-
|
status: status
|
||||||
Trust.audit(
|
|
||||||
user && user.id,
|
|
||||||
"support_request.created",
|
|
||||||
"support_request",
|
|
||||||
request.id,
|
|
||||||
%{
|
|
||||||
"kind" => to_string(request.kind)
|
|
||||||
}
|
}
|
||||||
),
|
|> SupportRequest.submission_changeset(attrs)
|
||||||
{:ok, _event} <-
|
|> Repo.insert(),
|
||||||
record_status_event(
|
{:ok, _audit} <-
|
||||||
request,
|
Trust.audit(
|
||||||
nil,
|
user && user.id,
|
||||||
request.status,
|
"support_request.created",
|
||||||
user && user.id,
|
"support_request",
|
||||||
:requester
|
request.id,
|
||||||
) do
|
%{
|
||||||
{:ok, request}
|
"kind" => to_string(request.kind)
|
||||||
end
|
}
|
||||||
end)
|
),
|
||||||
|> notify_received()
|
{: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()
|
|> broadcast_request_update()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -135,6 +146,7 @@ defmodule WhoNeedHelp.Support do
|
||||||
cursor = Pagination.cursor(options)
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
SupportRequest
|
SupportRequest
|
||||||
|
|> where([request], not is_nil(request.contact_verified_at))
|
||||||
|> maybe_kind(Keyword.get(options, :kind))
|
|> maybe_kind(Keyword.get(options, :kind))
|
||||||
|> before(cursor)
|
|> before(cursor)
|
||||||
|> order_by([request], desc: request.inserted_at, desc: request.id)
|
|> order_by([request], desc: request.inserted_at, desc: request.id)
|
||||||
|
|
@ -170,7 +182,7 @@ defmodule WhoNeedHelp.Support do
|
||||||
Repo.transact(fn ->
|
Repo.transact(fn ->
|
||||||
request =
|
request =
|
||||||
SupportRequest
|
SupportRequest
|
||||||
|> where([request], request.id == ^id)
|
|> where([request], request.id == ^id and not is_nil(request.contact_verified_at))
|
||||||
|> lock("FOR UPDATE")
|
|> lock("FOR UPDATE")
|
||||||
|> Repo.one()
|
|> Repo.one()
|
||||||
|
|
||||||
|
|
@ -333,7 +345,11 @@ defmodule WhoNeedHelp.Support do
|
||||||
def deletion_assessment(%Scope{user: moderator} = scope, id) do
|
def deletion_assessment(%Scope{user: moderator} = scope, id) do
|
||||||
with true <- Accounts.moderator_authorized?(moderator),
|
with true <- Accounts.moderator_authorized?(moderator),
|
||||||
{:ok, id} <- Ecto.UUID.cast(id),
|
{: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)
|
DataLifecycle.deletion_assessment(scope, request)
|
||||||
else
|
else
|
||||||
false -> {:error, :forbidden}
|
false -> {:error, :forbidden}
|
||||||
|
|
@ -341,13 +357,19 @@ defmodule WhoNeedHelp.Support do
|
||||||
end
|
end
|
||||||
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_received(request, status_url(request))
|
||||||
_ = Notifier.deliver_operator_alert(request)
|
_ = Notifier.deliver_operator_alert(request)
|
||||||
{:ok, request}
|
{:ok, request}
|
||||||
end
|
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}),
|
defp notify_decision({:ok, %SupportRequest{contact_verified_at: nil} = request}),
|
||||||
do: {:ok, request}
|
do: {:ok, request}
|
||||||
|
|
@ -386,7 +408,10 @@ defmodule WhoNeedHelp.Support do
|
||||||
event = {:support_request_updated, request.id}
|
event = {:support_request_updated, request.id}
|
||||||
|
|
||||||
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, request_topic(request.id), event)
|
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
|
result
|
||||||
end
|
end
|
||||||
|
|
@ -396,13 +421,69 @@ defmodule WhoNeedHelp.Support do
|
||||||
defp request_topic(id), do: "support:request:#{id}"
|
defp request_topic(id), do: "support:request:#{id}"
|
||||||
|
|
||||||
defp verify_contact(%SupportRequest{contact_verified_at: nil} = request) do
|
defp verify_contact(%SupportRequest{contact_verified_at: nil} = request) do
|
||||||
request
|
Repo.transact(fn ->
|
||||||
|> Ecto.Changeset.change(contact_verified_at: DateTime.utc_now(:second))
|
current =
|
||||||
|> Repo.update()
|
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
|
end
|
||||||
|
|
||||||
defp verify_contact(%SupportRequest{} = request), do: {:ok, request}
|
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
|
defp preload_conversation(%SupportRequest{} = request) do
|
||||||
Repo.preload(
|
Repo.preload(
|
||||||
request,
|
request,
|
||||||
|
|
@ -486,6 +567,17 @@ defmodule WhoNeedHelp.Support do
|
||||||
defp maybe_kind(query, nil), do: query
|
defp maybe_kind(query, nil), do: query
|
||||||
defp maybe_kind(query, kind), do: where(query, [request], request.kind == ^kind)
|
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(%User{id: id}, _email), do: "user:#{id}"
|
||||||
|
|
||||||
defp rate_scope(nil, email) when is_binary(email),
|
defp rate_scope(nil, email) when is_binary(email),
|
||||||
|
|
@ -501,5 +593,51 @@ defmodule WhoNeedHelp.Support do
|
||||||
"#{prefix}-#{suffix}"
|
"#{prefix}-#{suffix}"
|
||||||
end
|
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)
|
defp normalize_keys(attrs), do: Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,21 @@ defmodule WhoNeedHelp.Support.Notifier do
|
||||||
alias WhoNeedHelp.Mailer
|
alias WhoNeedHelp.Mailer
|
||||||
alias WhoNeedHelp.Support.SupportRequest
|
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
|
def deliver_received(%SupportRequest{} = request, status_url) do
|
||||||
deliver(
|
deliver(
|
||||||
request.contact_email,
|
request.contact_email,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ defmodule WhoNeedHelp.Support.StatusEvent do
|
||||||
@primary_key {:id, :binary_id, autogenerate: true}
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
@foreign_key_type :binary_id
|
@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
|
schema "support_status_events" do
|
||||||
field :actor_role, Ecto.Enum, values: [:requester, :staff, :system]
|
field :actor_role, Ecto.Enum, values: [:requester, :staff, :system]
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,21 @@ defmodule WhoNeedHelp.Support.SupportRequest do
|
||||||
]
|
]
|
||||||
|
|
||||||
field :status, Ecto.Enum,
|
field :status, Ecto.Enum,
|
||||||
values: [:open, :reviewing, :waiting_for_requester, :resolved, :closed],
|
values: [
|
||||||
|
:pending_verification,
|
||||||
|
:open,
|
||||||
|
:reviewing,
|
||||||
|
:waiting_for_requester,
|
||||||
|
:resolved,
|
||||||
|
:closed
|
||||||
|
],
|
||||||
default: :open
|
default: :open
|
||||||
|
|
||||||
field :contact_email, :string
|
field :contact_email, :string
|
||||||
field :subject, :string
|
field :subject, :string
|
||||||
field :details, :string
|
field :details, :string
|
||||||
field :contact_verified_at, :utc_datetime
|
field :contact_verified_at, :utc_datetime
|
||||||
|
field :public_submission_fingerprint, :string
|
||||||
field :resolution_note, :string
|
field :resolution_note, :string
|
||||||
field :reviewed_at, :utc_datetime
|
field :reviewed_at, :utc_datetime
|
||||||
field :response_sent_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(:subject, min: 3, max: 160)
|
||||||
|> validate_length(:details, min: 10, max: 5_000)
|
|> validate_length(:details, min: 10, max: 5_000)
|
||||||
|> unique_constraint(:reference)
|
|> unique_constraint(:reference)
|
||||||
|
|> unique_constraint(:public_submission_fingerprint)
|
||||||
end
|
end
|
||||||
|
|
||||||
def moderation_changeset(request, attrs) do
|
def moderation_changeset(request, attrs) do
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ defmodule WhoNeedHelpWeb.SupportController do
|
||||||
|
|
||||||
alias WhoNeedHelp.Support
|
alias WhoNeedHelp.Support
|
||||||
alias WhoNeedHelp.Support.SupportRequest
|
alias WhoNeedHelp.Support.SupportRequest
|
||||||
|
alias WhoNeedHelpWeb.ClientIp
|
||||||
|
|
||||||
def index(conn, _params) do
|
def index(conn, _params) do
|
||||||
requests =
|
requests =
|
||||||
|
|
@ -22,7 +23,11 @@ defmodule WhoNeedHelpWeb.SupportController do
|
||||||
end
|
end
|
||||||
|
|
||||||
def create(conn, %{"support_request" => params}) when is_map(params) do
|
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} ->
|
{:ok, request} ->
|
||||||
redirect(conn, to: ~p"/support/received?reference=#{request.reference}")
|
redirect(conn, to: ~p"/support/received?reference=#{request.reference}")
|
||||||
|
|
||||||
|
|
@ -60,7 +65,11 @@ defmodule WhoNeedHelpWeb.SupportController do
|
||||||
|> Map.put("kind", "account_deletion")
|
|> Map.put("kind", "account_deletion")
|
||||||
|> Map.put("subject", gettext("Delete my Who Need Help account"))
|
|> 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} ->
|
{:ok, request} ->
|
||||||
redirect(conn, to: ~p"/support/received?reference=#{request.reference}")
|
redirect(conn, to: ~p"/support/received?reference=#{request.reference}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ defmodule WhoNeedHelpWeb.SupportHTML do
|
||||||
def kind_label(:other), do: gettext("Other")
|
def kind_label(:other), do: gettext("Other")
|
||||||
def kind_label(value), do: to_string(value)
|
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(:open), do: gettext("Open")
|
||||||
def status_label(:reviewing), do: gettext("Reviewing")
|
def status_label(:reviewing), do: gettext("Reviewing")
|
||||||
def status_label(:waiting_for_requester), do: gettext("Waiting for your response")
|
def status_label(:waiting_for_requester), do: gettext("Waiting for your response")
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,11 @@
|
||||||
page_align={:center}
|
page_align={:center}
|
||||||
>
|
>
|
||||||
<div class="text-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">
|
<h1 class="mt-2 text-4xl font-black">
|
||||||
{if @current_scope,
|
{if @current_scope,
|
||||||
do: gettext("Support request created"),
|
do: gettext("Support request created"),
|
||||||
|
|
@ -19,7 +23,7 @@
|
||||||
</p>
|
</p>
|
||||||
<p :if={is_nil(@current_scope)} class="mt-4 text-base-content/65">
|
<p :if={is_nil(@current_scope)} class="mt-4 text-base-content/65">
|
||||||
{gettext(
|
{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
|
reference: @reference
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -6808,3 +6808,23 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Updating results for the visible map area…"
|
msgid "Updating results for the visible map area…"
|
||||||
msgstr ""
|
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 ""
|
||||||
|
|
|
||||||
|
|
@ -6808,3 +6808,23 @@ msgstr "Update results as map moves"
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Updating results for the visible map area…"
|
msgid "Updating results for the visible map area…"
|
||||||
msgstr "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."
|
||||||
|
|
|
||||||
|
|
@ -6951,3 +6951,23 @@ msgstr "Обновлять результаты при перемещении к
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Updating results for the visible map area…"
|
msgid "Updating results for the visible map area…"
|
||||||
msgstr "Обновляем результаты для видимой области карты…"
|
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}. Она ещё не попала в очередь поддержки. Откройте приватную ссылку из письма, чтобы подтвердить адрес и отправить заявку на рассмотрение."
|
||||||
|
|
|
||||||
|
|
@ -6945,3 +6945,23 @@ msgstr "Оновлювати результати під час переміще
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Updating results for the visible map area…"
|
msgid "Updating results for the visible map area…"
|
||||||
msgstr "Оновлюємо результати для видимої області карти…"
|
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}, що очікує підтвердження. Він ще не потрапив до черги підтримки. Відкрийте приватне посилання з листа, щоб підтвердити адресу та надіслати запит на розгляд."
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,4 @@
|
||||||
20260722190604 forward_only assignment history permits rows the old single-assignment model cannot interpret safely
|
20260722190604 forward_only assignment history permits rows the old single-assignment model cannot interpret safely
|
||||||
20260723015032 application_safe additive request-helper lookup index
|
20260723015032 application_safe additive request-helper lookup index
|
||||||
20260724161628 application_safe additive support conversation and status-history tables
|
20260724161628 application_safe additive support conversation and status-history tables
|
||||||
|
20260731230828 forward_only pending support verification status is not understood by older releases
|
||||||
|
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
@ -20,6 +20,8 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
||||||
|
|
||||||
assert request.reference =~ "SUP-"
|
assert request.reference =~ "SUP-"
|
||||||
assert request.contact_email == "person@example.com"
|
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)
|
assert is_nil(request.contact_verified_at)
|
||||||
|
|
||||||
test_pid = self()
|
test_pid = self()
|
||||||
|
|
@ -50,7 +52,13 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
||||||
Support.get_by_access_token(request.id, emailed_token)
|
Support.get_by_access_token(request.id, emailed_token)
|
||||||
|
|
||||||
assert verified.contact_verified_at
|
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?(
|
assert Repo.exists?(
|
||||||
from event in AuditEvent,
|
from event in AuditEvent,
|
||||||
|
|
@ -60,7 +68,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
||||||
)
|
)
|
||||||
end
|
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)
|
previous = Application.get_env(:who_need_help, :support_inbox_address)
|
||||||
Application.put_env(:who_need_help, :support_inbox_address, "support@example.com")
|
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)
|
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 ->
|
assert_email_sent(fn email ->
|
||||||
email.to == [{"", "appeal@example.com"}] and
|
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)
|
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 ->
|
assert_email_sent(fn email ->
|
||||||
email.to == [{"", "support@example.com"}] and email.subject =~ request.reference and
|
email.to == [{"", "support@example.com"}] and email.subject =~ request.reference and
|
||||||
email.text_body =~ "/support/operations" and
|
email.text_body =~ "/support/operations" and
|
||||||
not String.contains?(email.text_body, request.details)
|
not String.contains?(email.text_body, request.details)
|
||||||
end)
|
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
|
end
|
||||||
|
|
||||||
test "authenticated support uses the account email and staff moderation is audited" do
|
test "authenticated support uses the account email and staff moderation is audited" do
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,54 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
||||||
location = redirected_to(conn)
|
location = redirected_to(conn)
|
||||||
assert location =~ "/support/received?reference=SUP-"
|
assert location =~ "/support/received?reference=SUP-"
|
||||||
refute location =~ "token="
|
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
|
end
|
||||||
|
|
||||||
test "creates urgent TAKE IT DOWN notice without accepting a media upload", %{conn: conn} do
|
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)
|
|> Ecto.Changeset.change(role: :moderator)
|
||||||
|> WhoNeedHelp.Repo.update!()
|
|> WhoNeedHelp.Repo.update!()
|
||||||
|
|
||||||
{:ok, _support_request} =
|
{:ok, support_request} =
|
||||||
WhoNeedHelp.Support.create_request(nil, %{
|
WhoNeedHelp.Support.create_request(nil, %{
|
||||||
"kind" => "technical_issue",
|
"kind" => "technical_issue",
|
||||||
"contact_email" => "support-queue@example.com",
|
"contact_email" => "support-queue@example.com",
|
||||||
|
|
@ -279,6 +326,8 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
||||||
"details" => "This request verifies that an open status remains selected."
|
"details" => "This request verifies that an open status remains selected."
|
||||||
})
|
})
|
||||||
|
|
||||||
|
assert support_request.status == :pending_verification
|
||||||
|
|
||||||
{:ok, _removal_notice} =
|
{:ok, _removal_notice} =
|
||||||
WhoNeedHelp.ContentRemoval.create_notice(nil, :general, %{
|
WhoNeedHelp.ContentRemoval.create_notice(nil, :general, %{
|
||||||
"category" => "illegal_content",
|
"category" => "illegal_content",
|
||||||
|
|
@ -292,6 +341,12 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
||||||
"accurate_complete" => "true"
|
"accurate_complete" => "true"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
assert {:ok, _verified_support_request} =
|
||||||
|
WhoNeedHelp.Support.get_by_access_token(
|
||||||
|
support_request.id,
|
||||||
|
WhoNeedHelp.Support.access_token(support_request)
|
||||||
|
)
|
||||||
|
|
||||||
response =
|
response =
|
||||||
conn
|
conn
|
||||||
|> log_in_user(moderator)
|
|> log_in_user(moderator)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user