diff --git a/assets/js/app.js b/assets/js/app.js index bbdf758..76a5bfa 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -25,6 +25,7 @@ import {LiveSocket} from "phoenix_live_view" import {hooks as colocatedHooks} from "phoenix-colocated/who_need_help" import topbar from "../vendor/topbar" import {Hooks, mountStaticAidMaps} from "./hooks" +import {isProtectedFragmentTokenForForm} from "./protected_token_fragment.mjs" const systemTheme = () => matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" @@ -58,13 +59,6 @@ matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const activateProtectedTokenFragment = () => { - if (!window.location.hash.startsWith("#token=")) return - - const token = new URLSearchParams(window.location.hash.slice(1)).get("token") - window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`) - - if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return - const candidates = [ { form: document.getElementById("magic-link-fragment-form"), @@ -77,11 +71,13 @@ const activateProtectedTokenFragment = () => { }, { form: document.getElementById("support-confirmation-fragment-form"), - input: document.getElementById("support-confirmation-fragment-token") + input: document.getElementById("support-confirmation-fragment-token"), + invalidMessage: document.getElementById("support-confirmation-fragment-invalid") }, { form: document.getElementById("content-removal-confirmation-fragment-form"), - input: document.getElementById("content-removal-confirmation-fragment-token") + input: document.getElementById("content-removal-confirmation-fragment-token"), + invalidMessage: document.getElementById("content-removal-confirmation-fragment-invalid") } ] @@ -91,8 +87,26 @@ const activateProtectedTokenFragment = () => { if (!candidate) return + const hasTokenFragment = window.location.hash.startsWith("#token=") + const token = hasTokenFragment + ? new URLSearchParams(window.location.hash.slice(1)).get("token") + : null + + if (hasTokenFragment) { + window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`) + } + + if (!isProtectedFragmentTokenForForm(candidate.form.id, token)) { + if (candidate.invalidMessage instanceof HTMLElement) { + candidate.invalidMessage.hidden = false + } + + return + } + candidate.input.value = token candidate.form.hidden = false + if (candidate.invalidMessage instanceof HTMLElement) candidate.invalidMessage.hidden = true if (candidate.options instanceof HTMLElement) candidate.options.hidden = true candidate.form.querySelector("button")?.focus() } diff --git a/assets/js/protected_token_fragment.mjs b/assets/js/protected_token_fragment.mjs new file mode 100644 index 0000000..89428ae --- /dev/null +++ b/assets/js/protected_token_fragment.mjs @@ -0,0 +1,26 @@ +const rawTokenPattern = /^[A-Za-z0-9_-]{43}$/ +const phoenixSignedTokenPattern = /^SFMyNTY\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$/ + +const phoenixSignedTokenForms = new Set([ + "support-confirmation-fragment-form", + "content-removal-confirmation-fragment-form" +]) + +const rawTokenForms = new Set([ + "magic-link-fragment-form", + "email-change-fragment-form" +]) + +export const isProtectedFragmentTokenForForm = (formID, token) => { + if (typeof token !== "string") return false + + if (phoenixSignedTokenForms.has(formID)) { + return phoenixSignedTokenPattern.test(token) + } + + if (rawTokenForms.has(formID)) { + return rawTokenPattern.test(token) + } + + return false +} diff --git a/assets/js/protected_token_fragment.test.mjs b/assets/js/protected_token_fragment.test.mjs new file mode 100644 index 0000000..cecc602 --- /dev/null +++ b/assets/js/protected_token_fragment.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import {isProtectedFragmentTokenForForm} from "./protected_token_fragment.mjs" + +const rawToken = "a".repeat(43) +const signedToken = `SFMyNTY.${"b".repeat(64)}.${"c".repeat(43)}` + +test("raw account tokens remain limited to one 43-character segment", () => { + assert.equal( + isProtectedFragmentTokenForForm("magic-link-fragment-form", rawToken), + true + ) + assert.equal( + isProtectedFragmentTokenForForm("email-change-fragment-form", rawToken), + true + ) + assert.equal( + isProtectedFragmentTokenForForm("magic-link-fragment-form", signedToken), + false + ) +}) + +test("support and removal forms accept Phoenix SHA-256 signed tokens", () => { + assert.equal( + isProtectedFragmentTokenForForm("support-confirmation-fragment-form", signedToken), + true + ) + assert.equal( + isProtectedFragmentTokenForForm( + "content-removal-confirmation-fragment-form", + signedToken + ), + true + ) + assert.equal( + isProtectedFragmentTokenForForm("support-confirmation-fragment-form", rawToken), + false + ) +}) + +test("malformed signed tokens are rejected", () => { + const candidates = [ + `SFMyNTY.${"b".repeat(64)}.${"c".repeat(42)}`, + `SFMyNTY.${"b".repeat(64)}.${"c".repeat(44)}`, + `SFMyNTY.${"b".repeat(64)}.${"c".repeat(42)}+`, + `SFMyNTY..${"c".repeat(43)}`, + `SFMyNTY.${"b".repeat(64)}.${"c".repeat(43)}?extra=1`, + `token=${signedToken}` + ] + + for (const candidate of candidates) { + assert.equal( + isProtectedFragmentTokenForForm( + "support-confirmation-fragment-form", + candidate + ), + false + ) + } +}) + +test("unknown forms do not inherit a token format", () => { + assert.equal(isProtectedFragmentTokenForForm("unknown-form", rawToken), false) + assert.equal(isProtectedFragmentTokenForForm("unknown-form", signedToken), false) +}) diff --git a/docs/public-launch-checklist.md b/docs/public-launch-checklist.md index 9334512..f0b23d7 100644 --- a/docs/public-launch-checklist.md +++ b/docs/public-launch-checklist.md @@ -73,6 +73,25 @@ volume, temporary image, or quality state. These changes are recorded in local commits only; public Git, the frozen hackathon test deployment, shared Caddy, and production were not modified by this quality rerun. +The subsequent public-contact hardening candidate passed an initial complete +isolated quality pipeline with 491 ExUnit tests and 26 JavaScript asset tests. A +later focused correction made token formats explicit per form and added a +visible invalid-link state when the protected fragment is missing. The final +complete quality rerun passed 491 ExUnit and 27 JavaScript asset tests, every +configured quality/security gate, and the production-image scan with zero +detected vulnerabilities. Its isolated unit completed in 4 minutes 25.214 +seconds with a measured 242.6 MiB memory peak and zero swap. The focused +support/legal browser replay passed all +15 checks across Chromium, Firefox, and WebKit: missing fragments produced an +actionable error without flashing on valid links; anonymous support and +content-removal submissions both used query-free signed fragments, required a +visible POST confirmation, and opened the corresponding private case; and the +authenticated staff-queue scenario passed. The final browser unit measured a +58.8 MiB memory peak and zero swap. Exact cleanup left no run-owned container, +network, volume, or temporary image. The candidate remains local only because +production still has unexpired confirmation messages generated with the +preceding URL contract. + ## 2. Verify production configuration without exposing secrets Run both checks against the single ignored production `.env`. The first reports diff --git a/docs/verification.md b/docs/verification.md index 2b69b25..0d8413b 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -3,6 +3,57 @@ Observed through 2026-08-26 in the local workspace. This report separates observed results from product limits and unknown production properties. +## Protected support and content-removal email confirmation on 2026-08-26 + +- Browser inspection found that the shared fragment bootstrap accepted only raw + 43-character account tokens, while anonymous support and content-removal + confirmation messages use a Phoenix signed token. The browser therefore hid + the explicit confirmation form even when the email contained a valid fragment. + The candidate now validates raw account tokens and Phoenix signed public-contact + tokens against separate, form-specific contracts. +- The anonymous support and content-removal browser scenarios submitted each + public form, obtained its generated message from the isolated Mailpit instance, + verified that the action URL contained no query token and did contain a + `#token=SFMyNTY...` fragment, confirmed through the visible POST form, and + opened the corresponding private case. Together with the authenticated staff + queue scenario, the initial support/legal file passed in Chromium, Firefox, + and WebKit: nine checks in total. A later focused correction rejected token + formats for unknown forms and added an explicit invalid-link state for a + missing protected fragment. The final browser replay passed all 15 checks in + the same three engines, including verifying that a valid fragment does not + flash the invalid state. The JavaScript asset suite separately passed all 27 + tests. The final browser unit + `codex-heavy-e2e-public-contact-no-flash-20260826-051539-1512049.service` + measured a 58.8 MiB memory peak and zero swap. Exact cleanup found no + container, network, volume, or temporary image owned by run + `20260826021539-1512057`. +- Public support and content-removal access now requires an already verified + contact. A GET carrying an older access token no longer verifies or mutates an + unverified record; focused context tests cover both record types. +- The complete isolated quality unit + `codex-heavy-quality-protected-email-links-20260826-044358-690415.service` + exited successfully after 4 minutes 9.899 seconds with a measured 312.6 MiB + memory peak and no swap. ExUnit reported 491 passing tests; all configured + quality and security gates passed; and the final production-image scan reported + zero detected vulnerabilities. Exact cleanup left no run-owned container, + network, volume, or temporary image. +- After the missing-fragment UX and explicit per-form token contracts were + added, the complete isolated quality unit + `codex-heavy-wnh-public-contact-final-quality-r2-20260826-052517-1775390.service` + passed 491 ExUnit tests, 27 JavaScript asset tests, every configured + quality/security gate, release and recovery drills, and all image scans. The + final production-image scan reported zero detected vulnerabilities. The unit + completed in 4 minutes 25.214 seconds with a measured 242.6 MiB memory peak + and zero swap. Exact cleanup found no Compose container, network, volume, + temporary image, or quality state owned by run + `20260826022517-1775400`. +- A read-only production count observed 30 unverified support records and no + content-removal notices. Their current confirmation messages use the older + query-token link format and remain valid for up to 24 hours. The newest observed + support record was created at 2026-08-26 00:06 UTC. The candidate was therefore + not deployed: production, the frozen hackathon test, shared Caddy, Google Play, + and public Git were not changed by this verification. + ## Local candidate and operational recheck on 2026-08-26 - The uncommitted production Web Push verifier hardening and service-worker diff --git a/e2e/tests/support-legal.spec.ts b/e2e/tests/support-legal.spec.ts index 832ef23..aa0fc4e 100644 --- a/e2e/tests/support-legal.spec.ts +++ b/e2e/tests/support-legal.spec.ts @@ -5,8 +5,10 @@ import { latestMessageID, loginWithMagicLink, loginWithPassword, + newIsolatedContext, projectEmail, registerAndConfirm, + waitForApplicationEmailLink, } from "./helpers"; async function waitForNewEmail( @@ -66,6 +68,204 @@ async function submitAuthenticatedSupportRequest( ).toBeVisible(); } +test("support confirmation explains a missing protected fragment", async ({ + browser, +}) => { + const context = await newIsolatedContext(browser); + const page = await context.newPage(); + const assertBrowserClean = captureBrowserFailures(page); + + await page.goto( + "/support/cases/00000000-0000-4000-8000-000000000001/verify", + ); + + await expect(page.locator("#support-confirmation-fragment-form")).toBeHidden(); + await expect( + page.locator("#support-confirmation-fragment-invalid"), + ).toBeVisible(); + await expect( + page.getByText("The link is invalid or it has expired."), + ).toBeVisible(); + await expect(page.getByRole("link", { name: "Back to support" })).toHaveAttribute( + "href", + "/support", + ); + + assertBrowserClean(); + await context.close(); +}); + +test("content-removal confirmation explains a missing protected fragment", async ({ + browser, +}) => { + const context = await newIsolatedContext(browser); + const page = await context.newPage(); + const assertBrowserClean = captureBrowserFailures(page); + + await page.goto( + "/legal/content-removal/00000000-0000-4000-8000-000000000002/verify", + ); + + await expect( + page.locator("#content-removal-confirmation-fragment-form"), + ).toBeHidden(); + await expect( + page.locator("#content-removal-confirmation-fragment-invalid"), + ).toBeVisible(); + await expect( + page.getByText("The link is invalid or it has expired."), + ).toBeVisible(); + await expect( + page + .locator("#content-removal-confirmation-fragment-invalid") + .getByRole("link", { name: "Report content" }), + ).toHaveAttribute("href", "/legal/content-removal"); + + assertBrowserClean(); + await context.close(); +}); + +test("anonymous support confirmation opens from the protected email fragment", async ({ + browser, + request, +}, testInfo) => { + const email = projectEmail("anonymous-support", testInfo.project.name); + const subject = `Anonymous support confirmation [${testInfo.project.name}]`; + const context = await newIsolatedContext(browser); + const page = await context.newPage(); + const assertBrowserClean = captureBrowserFailures(page); + const previousMessageID = await latestMessageID(request, email); + + await page.goto("/support"); + await page + .getByLabel("What do you need help with?") + .selectOption("technical_issue"); + await page.getByLabel("Contact email").fill(email); + await page.getByLabel("Subject").fill(subject); + await page + .getByLabel("Describe the problem") + .fill("Browser E2E verifies the protected Phoenix.Token fragment before support sees the request."); + await page.getByRole("button", { name: "Send support request" }).click(); + + await expect(page).toHaveURL(/\/support\/received\?reference=SUP-/); + await expect(page.getByRole("heading", { name: "Check your email" })).toBeVisible(); + + const confirmationLink = await waitForApplicationEmailLink( + request, + email, + "/support/cases/", + previousMessageID, + ); + const confirmationURL = new URL(confirmationLink); + expect(confirmationURL.pathname).toMatch(/\/support\/cases\/[0-9a-f-]+\/verify$/); + expect(confirmationURL.search).toBe(""); + expect(confirmationURL.hash).toMatch(/^#token=SFMyNTY\./); + + await page.goto(confirmationLink); + const confirmationForm = page.locator("#support-confirmation-fragment-form"); + await expect(confirmationForm).toBeVisible(); + await expect( + page.locator("#support-confirmation-fragment-invalid"), + ).toBeHidden(); + await expect(page.locator("#support-confirmation-fragment-token")).toHaveValue( + /^SFMyNTY\./, + ); + await confirmationForm + .getByRole("button", { name: "Confirm support request" }) + .click(); + + await expect(page).toHaveURL(/\/support\/cases\/[0-9a-f-]+\?token=/); + await expect( + page.getByText("Your email was confirmed and the request was sent to support."), + ).toBeVisible(); + await expect(page.getByRole("heading", { name: subject })).toBeVisible(); + + assertBrowserClean(); + await context.close(); +}); + +test("anonymous content-removal confirmation opens from the protected email fragment", async ({ + browser, + request, +}, testInfo) => { + const email = projectEmail("anonymous-removal", testInfo.project.name); + const context = await newIsolatedContext(browser); + const page = await context.newPage(); + const assertBrowserClean = captureBrowserFailures(page); + const previousMessageID = await latestMessageID(request, email); + + await page.goto("/legal/content-removal"); + await page.getByLabel("Reason").selectOption("privacy_violation"); + await page + .getByLabel("Your name or organisation") + .fill("Anonymous removal E2E"); + await page.getByLabel("Contact email").fill(email); + await page + .getByLabel("Your relationship to the affected person or rights holder") + .selectOption("self"); + await page + .getByLabel("Exact content URLs — one per line") + .fill(`${process.env.BASE_URL}/requests/anonymous-removal-e2e`); + await page + .getByLabel("Why do you believe this content should be removed?") + .fill("Browser E2E verifies explicit confirmation before legal review."); + await page + .getByLabel("Electronic signature (type your full name)") + .fill("Anonymous removal E2E"); + await page + .getByLabel(/I believe in good faith that the identified content/) + .check(); + await page + .getByLabel(/I confirm that this notice is accurate and complete/) + .check(); + await page.getByRole("button", { name: "Submit removal notice" }).click(); + + await expect(page).toHaveURL( + /\/legal\/content-removal\/received\?reference=REM-/, + ); + + const confirmationLink = await waitForApplicationEmailLink( + request, + email, + "/legal/content-removal/", + previousMessageID, + ); + const confirmationURL = new URL(confirmationLink); + expect(confirmationURL.pathname).toMatch( + /\/legal\/content-removal\/[0-9a-f-]+\/verify$/, + ); + expect(confirmationURL.search).toBe(""); + expect(confirmationURL.hash).toMatch(/^#token=SFMyNTY\./); + + await page.goto(confirmationLink); + const confirmationForm = page.locator( + "#content-removal-confirmation-fragment-form", + ); + await expect(confirmationForm).toBeVisible(); + await expect( + page.locator("#content-removal-confirmation-fragment-invalid"), + ).toBeHidden(); + await expect( + page.locator("#content-removal-confirmation-fragment-token"), + ).toHaveValue(/^SFMyNTY\./); + await confirmationForm + .getByRole("button", { name: "Confirm content-removal notice" }) + .click(); + + await expect(page).toHaveURL( + /\/legal\/content-removal\/[0-9a-f-]+\?token=/, + ); + await expect( + page.getByText("Your email was confirmed and the notice was sent for review."), + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Content-removal notice" }), + ).toBeVisible(); + + assertBrowserClean(); + await context.close(); +}); + test("authenticated support and legal notices reach the scoped staff queues", async ({ browser, request, diff --git a/lib/who_need_help/content_removal.ex b/lib/who_need_help/content_removal.ex index f9c59c6..efae421 100644 --- a/lib/who_need_help/content_removal.ex +++ b/lib/who_need_help/content_removal.ex @@ -92,12 +92,9 @@ defmodule WhoNeedHelp.ContentRemoval do with {:ok, id} <- Ecto.UUID.cast(id), {:ok, ^id} <- PublicAccess.verify(@access_salt, token, max_age: case_access_max_age_seconds()), - %Notice{} = notice <- Repo.get(Notice, id) do - if notice.contact_verified_at do - {:ok, notice} - else - verify_legacy_confirmation(notice, token) - end + %Notice{contact_verified_at: verified_at} = notice when not is_nil(verified_at) <- + Repo.get(Notice, id) do + {:ok, notice} else _ -> {:error, :not_found} end @@ -272,13 +269,6 @@ defmodule WhoNeedHelp.ContentRemoval do defp notify_verified_notice({:ok, {notice, :already_verified}}), do: {:ok, notice} defp notify_verified_notice(result), do: result - defp verify_legacy_confirmation(%Notice{id: id} = notice, token) do - case PublicAccess.verify(@access_salt, token, max_age: contact_verification_max_age_seconds()) do - {:ok, ^id} -> verify_contact(notice) - _error -> {:error, :not_found} - end - end - defp contact_verification_max_age_seconds do Application.fetch_env!(:who_need_help, :public_contact_verification_max_age_seconds) end diff --git a/lib/who_need_help/support.ex b/lib/who_need_help/support.ex index 8c52e8e..cbec73e 100644 --- a/lib/who_need_help/support.ex +++ b/lib/who_need_help/support.ex @@ -142,12 +142,9 @@ defmodule WhoNeedHelp.Support do with {:ok, id} <- Ecto.UUID.cast(id), {:ok, ^id} <- PublicAccess.verify(@access_salt, token, max_age: case_access_max_age_seconds()), - %SupportRequest{} = request <- Repo.get(SupportRequest, id) do - if request.contact_verified_at do - {:ok, preload_conversation(request)} - else - verify_legacy_confirmation(request, token) - end + %SupportRequest{contact_verified_at: verified_at} = request + when not is_nil(verified_at) <- Repo.get(SupportRequest, id) do + {:ok, preload_conversation(request)} else _ -> {:error, :not_found} end @@ -583,18 +580,6 @@ defmodule WhoNeedHelp.Support do end end - defp verify_legacy_confirmation(%SupportRequest{id: id} = request, token) do - case PublicAccess.verify(@access_salt, token, max_age: contact_verification_max_age_seconds()) do - {:ok, ^id} -> - with {:ok, verified} <- verify_contact(request) do - {:ok, preload_conversation(verified)} - end - - _error -> - {:error, :not_found} - end - end - defp contact_verification_max_age_seconds do Application.fetch_env!(:who_need_help, :public_contact_verification_max_age_seconds) end diff --git a/lib/who_need_help_web/controllers/content_removal_html/verify.html.heex b/lib/who_need_help_web/controllers/content_removal_html/verify.html.heex index 3776acd..5ac32fa 100644 --- a/lib/who_need_help_web/controllers/content_removal_html/verify.html.heex +++ b/lib/who_need_help_web/controllers/content_removal_html/verify.html.heex @@ -29,6 +29,13 @@ + +

{gettext( "If you did not submit this notice, close this page. Nothing will be sent for review." diff --git a/lib/who_need_help_web/controllers/support_html/verify.html.heex b/lib/who_need_help_web/controllers/support_html/verify.html.heex index 91b42ba..f04844e 100644 --- a/lib/who_need_help_web/controllers/support_html/verify.html.heex +++ b/lib/who_need_help_web/controllers/support_html/verify.html.heex @@ -24,6 +24,13 @@ +

+

{gettext( "If you did not submit this request, close this page. Nothing will be sent to support." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 83b5f7b..1d9095b 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -1836,6 +1836,8 @@ msgstr "" msgid "The approved group has reached its capacity." msgstr "" +#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:33 +#: lib/who_need_help_web/controllers/support_html/verify.html.heex:28 #: lib/who_need_help_web/controllers/user_session_controller.ex:50 #, elixir-autogen, elixir-format msgid "The link is invalid or it has expired." @@ -2522,8 +2524,8 @@ msgstr "" msgid "Could not unblock this user. Please try again." msgstr "" -#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:40 -#: lib/who_need_help_web/controllers/support_html/verify.html.heex:35 +#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:47 +#: lib/who_need_help_web/controllers/support_html/verify.html.heex:42 #: lib/who_need_help_web/controllers/user_settings_html/confirm_email.html.heex:27 #, elixir-autogen, elixir-format msgid "JavaScript is required to confirm this protected email link." @@ -2846,6 +2848,7 @@ msgid "Back to removal form" msgstr "" #: lib/who_need_help_web/controllers/support_html/received.html.heex:34 +#: lib/who_need_help_web/controllers/support_html/verify.html.heex:30 #, elixir-autogen, elixir-format msgid "Back to support" msgstr "" @@ -3120,6 +3123,7 @@ msgstr "" #: lib/who_need_help_web/components/layouts.ex:201 #: lib/who_need_help_web/controllers/content_removal_controller.ex:151 +#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:35 #: lib/who_need_help_web/controllers/support_html/new.html.heex:16 #, elixir-autogen, elixir-format msgid "Report content" @@ -7901,12 +7905,12 @@ msgstr "" msgid "Confirm that you submitted this request before it is sent to support." msgstr "" -#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:33 +#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:40 #, elixir-autogen, elixir-format msgid "If you did not submit this notice, close this page. Nothing will be sent for review." msgstr "" -#: lib/who_need_help_web/controllers/support_html/verify.html.heex:28 +#: lib/who_need_help_web/controllers/support_html/verify.html.heex:35 #, elixir-autogen, elixir-format msgid "If you did not submit this request, close this page. Nothing will be sent to support." msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index c723310..f284aa8 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -1836,6 +1836,8 @@ msgstr "That verification provider is not supported." msgid "The approved group has reached its capacity." msgstr "The approved group has reached its capacity." +#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:33 +#: lib/who_need_help_web/controllers/support_html/verify.html.heex:28 #: lib/who_need_help_web/controllers/user_session_controller.ex:50 #, elixir-autogen, elixir-format msgid "The link is invalid or it has expired." @@ -2522,8 +2524,8 @@ msgstr "Could not publish the request. Please try again." msgid "Could not unblock this user. Please try again." msgstr "Could not unblock this user. Please try again." -#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:40 -#: lib/who_need_help_web/controllers/support_html/verify.html.heex:35 +#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:47 +#: lib/who_need_help_web/controllers/support_html/verify.html.heex:42 #: lib/who_need_help_web/controllers/user_settings_html/confirm_email.html.heex:27 #, elixir-autogen, elixir-format msgid "JavaScript is required to confirm this protected email link." @@ -2846,6 +2848,7 @@ msgid "Back to removal form" msgstr "Back to removal form" #: lib/who_need_help_web/controllers/support_html/received.html.heex:34 +#: lib/who_need_help_web/controllers/support_html/verify.html.heex:30 #, elixir-autogen, elixir-format msgid "Back to support" msgstr "Back to support" @@ -3120,6 +3123,7 @@ msgstr "Removal notice updated." #: lib/who_need_help_web/components/layouts.ex:201 #: lib/who_need_help_web/controllers/content_removal_controller.ex:151 +#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:35 #: lib/who_need_help_web/controllers/support_html/new.html.heex:16 #, elixir-autogen, elixir-format msgid "Report content" @@ -7901,12 +7905,12 @@ msgstr "Confirm that you submitted this notice before it is sent for review." msgid "Confirm that you submitted this request before it is sent to support." msgstr "Confirm that you submitted this request before it is sent to support." -#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:33 +#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:40 #, elixir-autogen, elixir-format msgid "If you did not submit this notice, close this page. Nothing will be sent for review." msgstr "If you did not submit this notice, close this page. Nothing will be sent for review." -#: lib/who_need_help_web/controllers/support_html/verify.html.heex:28 +#: lib/who_need_help_web/controllers/support_html/verify.html.heex:35 #, elixir-autogen, elixir-format msgid "If you did not submit this request, close this page. Nothing will be sent to support." msgstr "If you did not submit this request, close this page. Nothing will be sent to support." diff --git a/priv/gettext/ru/LC_MESSAGES/default.po b/priv/gettext/ru/LC_MESSAGES/default.po index 403dd88..5c9f911 100644 --- a/priv/gettext/ru/LC_MESSAGES/default.po +++ b/priv/gettext/ru/LC_MESSAGES/default.po @@ -1925,6 +1925,8 @@ msgstr "Этот провайдер проверки не поддерживае msgid "The approved group has reached its capacity." msgstr "Одобренная группа уже заполнена." +#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:33 +#: lib/who_need_help_web/controllers/support_html/verify.html.heex:28 #: lib/who_need_help_web/controllers/user_session_controller.ex:50 #, elixir-autogen, elixir-format msgid "The link is invalid or it has expired." @@ -2638,8 +2640,8 @@ msgstr "Не удалось опубликовать заявку. Попроб msgid "Could not unblock this user. Please try again." msgstr "Не удалось разблокировать пользователя. Попробуйте ещё раз." -#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:40 -#: lib/who_need_help_web/controllers/support_html/verify.html.heex:35 +#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:47 +#: lib/who_need_help_web/controllers/support_html/verify.html.heex:42 #: lib/who_need_help_web/controllers/user_settings_html/confirm_email.html.heex:27 #, elixir-autogen, elixir-format msgid "JavaScript is required to confirm this protected email link." @@ -2962,6 +2964,7 @@ msgid "Back to removal form" msgstr "Вернуться к форме удаления" #: lib/who_need_help_web/controllers/support_html/received.html.heex:34 +#: lib/who_need_help_web/controllers/support_html/verify.html.heex:30 #, elixir-autogen, elixir-format msgid "Back to support" msgstr "Вернуться в поддержку" @@ -3236,6 +3239,7 @@ msgstr "Уведомление об удалении обновлено." #: lib/who_need_help_web/components/layouts.ex:201 #: lib/who_need_help_web/controllers/content_removal_controller.ex:151 +#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:35 #: lib/who_need_help_web/controllers/support_html/new.html.heex:16 #, elixir-autogen, elixir-format msgid "Report content" @@ -8021,12 +8025,12 @@ msgstr "Подтвердите, что это уведомление отпра msgid "Confirm that you submitted this request before it is sent to support." msgstr "Подтвердите, что это обращение отправили вы, прежде чем оно поступит в поддержку." -#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:33 +#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:40 #, elixir-autogen, elixir-format msgid "If you did not submit this notice, close this page. Nothing will be sent for review." msgstr "Если вы не отправляли это уведомление, закройте страницу. На рассмотрение ничего не поступит." -#: lib/who_need_help_web/controllers/support_html/verify.html.heex:28 +#: lib/who_need_help_web/controllers/support_html/verify.html.heex:35 #, elixir-autogen, elixir-format msgid "If you did not submit this request, close this page. Nothing will be sent to support." msgstr "Если вы не отправляли это обращение, закройте страницу. В поддержку ничего не поступит." diff --git a/priv/gettext/uk/LC_MESSAGES/default.po b/priv/gettext/uk/LC_MESSAGES/default.po index 17fea4c..3e1aa07 100644 --- a/priv/gettext/uk/LC_MESSAGES/default.po +++ b/priv/gettext/uk/LC_MESSAGES/default.po @@ -1922,6 +1922,8 @@ msgstr "Цей постачальник перевірки не підтриму msgid "The approved group has reached its capacity." msgstr "Схвалена група вже заповнена." +#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:33 +#: lib/who_need_help_web/controllers/support_html/verify.html.heex:28 #: lib/who_need_help_web/controllers/user_session_controller.ex:50 #, elixir-autogen, elixir-format msgid "The link is invalid or it has expired." @@ -2632,8 +2634,8 @@ msgstr "Не вдалося опублікувати заявку. Спробу msgid "Could not unblock this user. Please try again." msgstr "Не вдалося розблокувати користувача. Спробуйте ще раз." -#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:40 -#: lib/who_need_help_web/controllers/support_html/verify.html.heex:35 +#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:47 +#: lib/who_need_help_web/controllers/support_html/verify.html.heex:42 #: lib/who_need_help_web/controllers/user_settings_html/confirm_email.html.heex:27 #, elixir-autogen, elixir-format msgid "JavaScript is required to confirm this protected email link." @@ -2956,6 +2958,7 @@ msgid "Back to removal form" msgstr "Повернутися до форми видалення" #: lib/who_need_help_web/controllers/support_html/received.html.heex:34 +#: lib/who_need_help_web/controllers/support_html/verify.html.heex:30 #, elixir-autogen, elixir-format msgid "Back to support" msgstr "Повернутися до підтримки" @@ -3230,6 +3233,7 @@ msgstr "Повідомлення про видалення оновлено." #: lib/who_need_help_web/components/layouts.ex:201 #: lib/who_need_help_web/controllers/content_removal_controller.ex:151 +#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:35 #: lib/who_need_help_web/controllers/support_html/new.html.heex:16 #, elixir-autogen, elixir-format msgid "Report content" @@ -8015,12 +8019,12 @@ msgstr "Підтвердьте, що це повідомлення надісл msgid "Confirm that you submitted this request before it is sent to support." msgstr "Підтвердьте, що це звернення надіслали ви, перш ніж воно надійде до підтримки." -#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:33 +#: lib/who_need_help_web/controllers/content_removal_html/verify.html.heex:40 #, elixir-autogen, elixir-format msgid "If you did not submit this notice, close this page. Nothing will be sent for review." msgstr "Якщо ви не надсилали це повідомлення, закрийте сторінку. На розгляд нічого не надійде." -#: lib/who_need_help_web/controllers/support_html/verify.html.heex:28 +#: lib/who_need_help_web/controllers/support_html/verify.html.heex:35 #, elixir-autogen, elixir-format msgid "If you did not submit this request, close this page. Nothing will be sent to support." msgstr "Якщо ви не надсилали це звернення, закрийте сторінку. До підтримки нічого не надійде." 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 7a39124..2e0dbc5 100644 --- a/test/who_need_help/support_and_content_removal_test.exs +++ b/test/who_need_help/support_and_content_removal_test.exs @@ -72,6 +72,9 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do assert {:error, :not_found} = Support.get_by_access_token(request.id, "invalid") assert {:error, :not_found} = Support.get_by_access_token(request.id, confirmation_token) + assert {:error, :not_found} = + Support.get_by_access_token(request.id, Support.access_token(request)) + assert {:error, :not_found} = Support.verify_by_confirmation_token(request.id, Support.access_token(request)) @@ -666,6 +669,12 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do moderator_scope = moderator_scope() assert ContentRemoval.paginate_for_staff(moderator_scope).entries == [] + assert {:error, :not_found} = + ContentRemoval.get_by_access_token( + notice.id, + ContentRemoval.access_token(notice) + ) + assert {:error, :not_found} = ContentRemoval.moderate(moderator_scope, notice.id, %{ "status" => "reviewing",