Add public search discovery metadata

This commit is contained in:
SimpleTest 2026-08-09 08:29:02 +03:00
parent 5990a1935e
commit d8177f38bd
16 changed files with 275 additions and 31 deletions

View File

@ -1,4 +1,5 @@
x-app-environment: &app-environment x-app-environment: &app-environment
DEPLOYMENT_ENV: ${DEPLOYMENT_ENV:?Set DEPLOYMENT_ENV in .env}
APP_ROLE: web APP_ROLE: web
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env} DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env}
DATABASE_SOCKET_DIR: ${DATABASE_SOCKET_DIR:-} DATABASE_SOCKET_DIR: ${DATABASE_SOCKET_DIR:-}

View File

@ -27,6 +27,7 @@ config :who_need_help, :scopes,
] ]
config :who_need_help, config :who_need_help,
deployment_env: :development,
ecto_repos: [WhoNeedHelp.Repo], ecto_repos: [WhoNeedHelp.Repo],
generators: [timestamp_type: :utc_datetime, binary_id: true], generators: [timestamp_type: :utc_datetime, binary_id: true],
app_role: :web, app_role: :web,

View File

@ -1,5 +1,20 @@
import Config import Config
deployment_env =
case System.get_env("DEPLOYMENT_ENV", "development") do
"development" ->
:development
"test" ->
:test
"production" ->
:production
other ->
raise "DEPLOYMENT_ENV must be development, test, or production; got #{inspect(other)}"
end
app_role = app_role =
case System.get_env("APP_ROLE", "web") do case System.get_env("APP_ROLE", "web") do
"web" -> :web "web" -> :web
@ -46,6 +61,7 @@ rate_limit_policies =
end end
config :who_need_help, config :who_need_help,
deployment_env: deployment_env,
app_role: app_role, app_role: app_role,
codex_session_id: System.get_env("CODEX_SESSION_ID", "not-configured"), codex_session_id: System.get_env("CODEX_SESSION_ID", "not-configured"),
map_tile_url: map_tile_url:

View File

@ -1,6 +1,7 @@
import Config import Config
config :who_need_help, :handover_secret, "isolated-test-handover-secret" config :who_need_help, :handover_secret, "isolated-test-handover-secret"
config :who_need_help, :deployment_env, :test
config :who_need_help, :tracking_presence_cleanup_grace_ms, 100 config :who_need_help, :tracking_presence_cleanup_grace_ms, 100
# Unit tests opt in to individual policies inside the relevant test. This keeps # Unit tests opt in to individual policies inside the relevant test. This keeps
# unrelated examples independent from shared counters and mirrors the explicit # unrelated examples independent from shared counters and mirrors the explicit

View File

@ -2322,3 +2322,33 @@ promoted.
`cf7bacdf61ffb171ebac85e32dfcf12bc6972d59`; its application, database, and `cf7bacdf61ffb171ebac85e32dfcf12bc6972d59`; its application, database, and
Mailpit remained healthy, and public readiness remained `ready`. The remote Mailpit remained healthy, and public readiness remained `ready`. The remote
repository and hackathon-test deployment were not mutated. repository and hackathon-test deployment were not mutated.
# 2026-08-09 local public search-discovery verification
- The public home, Privacy Policy, Safety rules, and Terms of Service pages now
expose locale-aware canonical and reciprocal `en`/`uk`/`ru`/`x-default`
alternate links. Every other route defaults to `noindex, nofollow`, so
authenticated, support, legal, moderation, request, activity, profile, and
staff surfaces are not presented as public search results.
- `/sitemap.xml` contains exactly twelve absolute entries: the four public
pages in each of the three supported locales. It contains no account,
request, activity, support, legal, moderation, notification, or staff URL.
- `/robots.txt` is environment-aware. Production permits the public pages,
lists the private route families as disallowed, and advertises the absolute
sitemap URL. Development and test configurations return `Disallow: /`.
- Nineteen focused controller tests passed against an isolated temporary
PostgreSQL database. The complete isolated quality/security gate then passed
455 ExUnit tests, all fourteen browser map-clustering tests, and every
configured compiler, formatting, xref, Credo, Sobelow, Dialyzer,
dependency, image, Compose, Helm, migration, rollback, and observability
check. The Debian 13.6 runtime-image scan reported zero detected
vulnerabilities.
- The isolated quality unit
`codex-heavy-wnh-quality-seo-rerun-20260809-20260809-082004-916736.service`
exited with status `0` after 4 minutes 47 seconds and reported a 210.9 MiB
memory peak. Its run-scoped images, containers, networks, and volumes were
removed. Two older quality database volumes remain referenced by their own
stopped containers and were not changed by this verification.
- This verification changed only the local checkout. The public Git remote,
production deployment, and frozen hackathon test deployment were not
changed.

View File

@ -18,7 +18,7 @@ defmodule WhoNeedHelpWeb do
""" """
def static_paths, def static_paths,
do: ~w(assets fonts images favicon.ico manifest.webmanifest offline.html robots.txt sw.js) do: ~w(assets fonts images favicon.ico manifest.webmanifest offline.html sw.js)
def router do def router do
quote do quote do

View File

@ -16,6 +16,7 @@
<meta name="csrf-token" content={get_csrf_token()} /> <meta name="csrf-token" content={get_csrf_token()} />
<meta name="theme-color" content="#047b68" /> <meta name="theme-color" content="#047b68" />
<meta name="color-scheme" content="light dark" /> <meta name="color-scheme" content="light dark" />
<meta name="robots" content={assigns[:page_robots] || "noindex, nofollow"} />
<meta <meta
name="description" name="description"
content={ content={
@ -25,6 +26,12 @@
) )
} }
/> />
<%= if canonical_url = assigns[:canonical_url] do %>
<link rel="canonical" href={canonical_url} />
<% end %>
<%= for {language, url} <- assigns[:language_alternates] || [] do %>
<link rel="alternate" hreflang={language} href={url} />
<% end %>
<.live_title <.live_title
default="Who Need Help" default="Who Need Help"
suffix={ suffix={

View File

@ -1,42 +1,60 @@
defmodule WhoNeedHelpWeb.PageController do defmodule WhoNeedHelpWeb.PageController do
use WhoNeedHelpWeb, :controller use WhoNeedHelpWeb, :controller
alias WhoNeedHelpWeb.SearchMetadata
def home(conn, _params) do def home(conn, _params) do
render(conn, :home, assigns =
page_description: SearchMetadata.public_page_assigns("/")
|> Keyword.put(
:page_description,
gettext( gettext(
"Ask nearby volunteers for free medicine pickup or safe practical help, coordinate privately, and verify the handover." "Ask nearby volunteers for free medicine pickup or safe practical help, coordinate privately, and verify the handover."
) )
) )
render(conn, :home, assigns)
end end
def privacy(conn, _params) do def privacy(conn, _params) do
render(conn, :privacy, assigns =
page_title: gettext("Privacy Policy"), SearchMetadata.public_page_assigns("/privacy")
page_description: |> Keyword.put(:page_title, gettext("Privacy Policy"))
|> Keyword.put(
:page_description,
gettext( gettext(
"How Who Need Help processes account, Google sign-in, location, communication, and safety data." "How Who Need Help processes account, Google sign-in, location, communication, and safety data."
) )
) )
render(conn, :privacy, assigns)
end end
def safety(conn, _params) do def safety(conn, _params) do
render(conn, :safety, assigns =
page_title: gettext("Safety rules"), SearchMetadata.public_page_assigns("/safety")
page_description: |> Keyword.put(:page_title, gettext("Safety rules"))
|> Keyword.put(
:page_description,
gettext( gettext(
"Safety guidance for voluntary help, medicine pickup, roadside requests, location sharing, and in-person activities." "Safety guidance for voluntary help, medicine pickup, roadside requests, location sharing, and in-person activities."
) )
) )
render(conn, :safety, assigns)
end end
def terms(conn, _params) do def terms(conn, _params) do
render(conn, :terms, assigns =
page_title: gettext("Terms of Service"), SearchMetadata.public_page_assigns("/terms")
page_description: |> Keyword.put(:page_title, gettext("Terms of Service"))
|> Keyword.put(
:page_description,
gettext( gettext(
"The conditions for using Who Need Help to coordinate voluntary help and social activities." "The conditions for using Who Need Help to coordinate voluntary help and social activities."
) )
) )
render(conn, :terms, assigns)
end end
end end

View File

@ -0,0 +1,89 @@
defmodule WhoNeedHelpWeb.SearchDocumentController do
use WhoNeedHelpWeb, :controller
alias WhoNeedHelpWeb.SearchMetadata
@public_paths ["/", "/privacy", "/safety", "/terms"]
@locales ~w(en uk ru)
@private_prefixes ~w(
/account/
/activities
/admin
/analytics
/auth
/categories/proposals
/legal
/mobile
/moderation
/notifications
/people
/profile
/reports
/requests
/support
/users
)
def robots(conn, _params) do
body =
case Application.fetch_env!(:who_need_help, :deployment_env) do
:production -> production_robots()
_non_production -> "User-agent: *\nDisallow: /\n"
end
conn
|> put_resp_content_type("text/plain", "utf-8")
|> put_resp_header("cache-control", "public, max-age=3600")
|> send_resp(:ok, body)
end
def sitemap(conn, _params) do
entries =
for path <- @public_paths,
locale <- @locales do
sitemap_entry(path, locale)
end
body =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" <>
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" " <>
"xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">\n" <>
Enum.join(entries, "") <>
"</urlset>\n"
conn
|> put_resp_content_type("application/xml", "utf-8")
|> put_resp_header("cache-control", "public, max-age=3600")
|> send_resp(:ok, body)
end
defp production_robots do
disallowed = Enum.map_join(@private_prefixes, "", &"Disallow: #{&1}\n")
"User-agent: *\nAllow: /\n" <>
disallowed <>
"Sitemap: #{SearchMetadata.absolute_url("/sitemap.xml")}\n"
end
defp sitemap_entry(path, locale) do
alternates =
Enum.map_join(@locales, "", fn alternate_locale ->
~s( <xhtml:link rel="alternate" hreflang="#{alternate_locale}" href="#{xml_escape(SearchMetadata.localized_url(path, alternate_locale))}" />\n)
end) <>
~s( <xhtml:link rel="alternate" hreflang="x-default" href="#{xml_escape(SearchMetadata.absolute_url(path))}" />\n)
" <url>\n" <>
" <loc>#{xml_escape(SearchMetadata.localized_url(path, locale))}</loc>\n" <>
alternates <>
" </url>\n"
end
defp xml_escape(value) do
value
|> String.replace("&", "&amp;")
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
|> String.replace("\"", "&quot;")
|> String.replace("'", "&apos;")
end
end

View File

@ -26,7 +26,7 @@ defmodule WhoNeedHelpWeb.Endpoint do
from: :who_need_help, from: :who_need_help,
gzip: not code_reloading?, gzip: not code_reloading?,
only: WhoNeedHelpWeb.static_paths(), only: WhoNeedHelpWeb.static_paths(),
only_matching: ~w(favicon manifest offline robots sw), only_matching: ~w(favicon manifest offline sw),
raise_on_missing_only: code_reloading? raise_on_missing_only: code_reloading?
# Code reloading can be explicitly enabled under the # Code reloading can be explicitly enabled under the

View File

@ -79,6 +79,8 @@ defmodule WhoNeedHelpWeb.Router do
scope "/", WhoNeedHelpWeb do scope "/", WhoNeedHelpWeb do
pipe_through :browser pipe_through :browser
get "/robots.txt", SearchDocumentController, :robots
get "/sitemap.xml", SearchDocumentController, :sitemap
get "/", PageController, :home get "/", PageController, :home
get "/feedback", FeedbackController, :show get "/feedback", FeedbackController, :show
get "/privacy", PageController, :privacy get "/privacy", PageController, :privacy

View File

@ -0,0 +1,29 @@
defmodule WhoNeedHelpWeb.SearchMetadata do
@moduledoc false
@locales ~w(en uk ru)
def public_page_assigns(path) when is_binary(path) do
locale = Gettext.get_locale(WhoNeedHelpWeb.Gettext)
[
page_robots: "index, follow",
canonical_url: localized_url(path, locale),
language_alternates:
Enum.map(@locales, &{&1, localized_url(path, &1)}) ++
[{"x-default", absolute_url(path)}]
]
end
def localized_url(path, "en"), do: absolute_url(path)
def localized_url(path, locale) when locale in @locales do
absolute_url(path) <> "?" <> URI.encode_query(%{"locale" => locale})
end
def localized_url(path, _unknown_locale), do: absolute_url(path)
def absolute_url(path) do
String.trim_trailing(WhoNeedHelpWeb.Endpoint.url(), "/") <> path
end
end

View File

@ -1610,7 +1610,7 @@ msgid "Safety policy, category approval, account moderation, public launch, and
msgstr "" msgstr ""
#: lib/who_need_help_web/components/layouts.ex:191 #: lib/who_need_help_web/components/layouts.ex:191
#: lib/who_need_help_web/controllers/page_controller.ex:25 #: lib/who_need_help_web/controllers/page_controller.ex:36
#: lib/who_need_help_web/controllers/page_html/safety.html.heex:6 #: lib/who_need_help_web/controllers/page_html/safety.html.heex:6
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Safety rules" msgid "Safety rules"
@ -1685,7 +1685,7 @@ msgstr ""
msgid "Signal updated." msgid "Signal updated."
msgstr "" msgstr ""
#: lib/who_need_help_web/components/layouts/root.html.heex:48 #: lib/who_need_help_web/components/layouts/root.html.heex:55
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Skip to main content" msgid "Skip to main content"
msgstr "" msgstr ""
@ -3395,7 +3395,7 @@ msgid "Privacy"
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/google_auth_html/complete.html.heex:107 #: lib/who_need_help_web/controllers/google_auth_html/complete.html.heex:107
#: lib/who_need_help_web/controllers/page_controller.ex:15 #: lib/who_need_help_web/controllers/page_controller.ex:22
#: lib/who_need_help_web/controllers/page_html/privacy.html.heex:4 #: lib/who_need_help_web/controllers/page_html/privacy.html.heex:4
#: lib/who_need_help_web/controllers/page_html/terms.html.heex:75 #: lib/who_need_help_web/controllers/page_html/terms.html.heex:75
#: lib/who_need_help_web/controllers/user_registration_html/new.html.heex:79 #: lib/who_need_help_web/controllers/user_registration_html/new.html.heex:79
@ -5442,7 +5442,7 @@ msgstr ""
msgid "Restore request" msgid "Restore request"
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/page_controller.ex:27 #: lib/who_need_help_web/controllers/page_controller.ex:39
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Safety guidance for voluntary help, medicine pickup, roadside requests, location sharing, and in-person activities." msgid "Safety guidance for voluntary help, medicine pickup, roadside requests, location sharing, and in-person activities."
msgstr "" msgstr ""
@ -5926,7 +5926,7 @@ msgstr ""
msgid "Account settings control profile, location visibility, direct messages, and connected Google sign-in. Live tracking is optional and can be stopped. You may submit a" msgid "Account settings control profile, location visibility, direct messages, and connected Google sign-in. Live tracking is optional and can be stopped. You may submit a"
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/page_controller.ex:7 #: lib/who_need_help_web/controllers/page_controller.ex:11
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Ask nearby volunteers for free medicine pickup or safe practical help, coordinate privately, and verify the handover." msgid "Ask nearby volunteers for free medicine pickup or safe practical help, coordinate privately, and verify the handover."
msgstr "" msgstr ""
@ -6001,7 +6001,7 @@ msgstr ""
msgid "Hosting, database, backup, transactional-email, and map-tile providers process the minimum data needed to provide their component of the service. External social-profile and thank-you links are controlled by their own operators and policies." msgid "Hosting, database, backup, transactional-email, and map-tile providers process the minimum data needed to provide their component of the service. External social-profile and thank-you links are controlled by their own operators and policies."
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/page_controller.ex:17 #: lib/who_need_help_web/controllers/page_controller.ex:25
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "How Who Need Help processes account, Google sign-in, location, communication, and safety data." msgid "How Who Need Help processes account, Google sign-in, location, communication, and safety data."
msgstr "" msgstr ""
@ -6071,13 +6071,13 @@ msgstr ""
msgid "Some safety, fraud, support, completed-help, backup, or legal records may need to remain, as explained in the Privacy Policy. Updated terms will be published here with a new effective date; material changes should be reviewed before continuing to use the service." msgid "Some safety, fraud, support, completed-help, backup, or legal records may need to remain, as explained in the Privacy Policy. Updated terms will be published here with a new effective date; material changes should be reviewed before continuing to use the service."
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/page_controller.ex:35 #: lib/who_need_help_web/controllers/page_controller.ex:50
#: lib/who_need_help_web/controllers/page_html/terms.html.heex:4 #: lib/who_need_help_web/controllers/page_html/terms.html.heex:4
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Terms of Service" msgid "Terms of Service"
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/page_controller.ex:37 #: lib/who_need_help_web/controllers/page_controller.ex:53
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "The conditions for using Who Need Help to coordinate voluntary help and social activities." msgid "The conditions for using Who Need Help to coordinate voluntary help and social activities."
msgstr "" msgstr ""
@ -6432,7 +6432,7 @@ msgstr ""
msgid "Waiting for Android notification permission and token…" msgid "Waiting for Android notification permission and token…"
msgstr "" msgstr ""
#: lib/who_need_help_web/components/layouts/root.html.heex:23 #: lib/who_need_help_web/components/layouts/root.html.heex:24
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Who Need Help connects adults who need urgent local help with nearby volunteers." msgid "Who Need Help connects adults who need urgent local help with nearby volunteers."
msgstr "" msgstr ""

View File

@ -1,5 +0,0 @@
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#
# To ban all spiders from the entire site uncomment the next two lines:
# User-agent: *
# Disallow: /

View File

@ -5,6 +5,7 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
conn = get(conn, ~p"/") conn = get(conn, ~p"/")
html = html_response(conn, 200) html = html_response(conn, 200)
document = LazyHTML.from_document(html) document = LazyHTML.from_document(html)
endpoint_url = WhoNeedHelpWeb.Endpoint.url()
[content_security_policy] = get_resp_header(conn, "content-security-policy") [content_security_policy] = get_resp_header(conn, "content-security-policy")
assert html =~ "Need help nearby? Ask the community." assert html =~ "Need help nearby? Ask the community."
@ -18,6 +19,13 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
assert html =~ "Before you create a request" assert html =~ "Before you create a request"
assert html =~ ~r/<title[^>]*>Who Need Help<\/title>/ assert html =~ ~r/<title[^>]*>Who Need Help<\/title>/
refute html =~ "Who Need Help · Who Need Help" refute html =~ "Who Need Help · Who Need Help"
assert html =~ ~s(<meta name="robots" content="index, follow")
assert html =~ ~s(<link rel="canonical" href="#{endpoint_url}/")
for locale <- ~w(en uk ru x-default) do
assert html =~ ~s(rel="alternate" hreflang="#{locale}")
end
assert content_security_policy =~ "default-src 'self'" assert content_security_policy =~ "default-src 'self'"
assert content_security_policy =~ "script-src 'self'" assert content_security_policy =~ "script-src 'self'"
refute content_security_policy =~ "script-src 'self' 'unsafe-inline'" refute content_security_policy =~ "script-src 'self' 'unsafe-inline'"
@ -98,6 +106,13 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
) == 1 ) == 1
end end
test "private and authentication pages default to noindex", %{conn: conn} do
html = conn |> get(~p"/users/log-in") |> html_response(200)
assert html =~ ~s(<meta name="robots" content="noindex, nofollow")
refute html =~ ~s(<link rel="canonical")
end
test "HTTPS content security policy permits only secure WebSockets", %{conn: conn} do test "HTTPS content security policy permits only secure WebSockets", %{conn: conn} do
conn = conn =
conn conn

View File

@ -0,0 +1,40 @@
defmodule WhoNeedHelpWeb.SearchDocumentControllerTest do
use WhoNeedHelpWeb.ConnCase, async: false
test "sitemap contains only absolute localized public URLs", %{conn: conn} do
conn = get(conn, ~p"/sitemap.xml")
body = response(conn, 200)
endpoint_url = WhoNeedHelpWeb.Endpoint.url()
assert get_resp_header(conn, "content-type") == ["application/xml; charset=utf-8"]
assert length(Regex.scan(~r/<url>/, body)) == 12
assert body =~ "<loc>#{endpoint_url}/</loc>"
assert body =~ "<loc>#{endpoint_url}/privacy?locale=uk</loc>"
assert body =~ "<loc>#{endpoint_url}/safety?locale=ru</loc>"
assert body =~ ~s(hreflang="x-default" href="#{endpoint_url}/terms")
refute body =~ "/users/"
refute body =~ "/requests"
refute body =~ "/support"
end
test "non-production robots blocks crawling", %{conn: conn} do
conn = get(conn, ~p"/robots.txt")
assert response(conn, 200) == "User-agent: *\nDisallow: /\n"
assert get_resp_header(conn, "content-type") == ["text/plain; charset=utf-8"]
end
test "production robots advertises sitemap and avoids private route families", %{conn: conn} do
previous = Application.fetch_env!(:who_need_help, :deployment_env)
Application.put_env(:who_need_help, :deployment_env, :production)
on_exit(fn -> Application.put_env(:who_need_help, :deployment_env, previous) end)
body = conn |> get(~p"/robots.txt") |> response(200)
assert body =~ "Allow: /\n"
assert body =~ "Disallow: /admin\n"
assert body =~ "Disallow: /requests\n"
assert body =~ "Disallow: /support\n"
assert body =~ "Sitemap: #{WhoNeedHelpWeb.Endpoint.url()}/sitemap.xml\n"
end
end