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
DEPLOYMENT_ENV: ${DEPLOYMENT_ENV:?Set DEPLOYMENT_ENV in .env}
APP_ROLE: web
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env}
DATABASE_SOCKET_DIR: ${DATABASE_SOCKET_DIR:-}

View File

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

View File

@ -1,5 +1,20 @@
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 =
case System.get_env("APP_ROLE", "web") do
"web" -> :web
@ -46,6 +61,7 @@ rate_limit_policies =
end
config :who_need_help,
deployment_env: deployment_env,
app_role: app_role,
codex_session_id: System.get_env("CODEX_SESSION_ID", "not-configured"),
map_tile_url:

View File

@ -1,6 +1,7 @@
import Config
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
# Unit tests opt in to individual policies inside the relevant test. This keeps
# unrelated examples independent from shared counters and mirrors the explicit

View File

@ -2322,3 +2322,33 @@ promoted.
`cf7bacdf61ffb171ebac85e32dfcf12bc6972d59`; its application, database, and
Mailpit remained healthy, and public readiness remained `ready`. The remote
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,
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
quote do

View File

@ -16,6 +16,7 @@
<meta name="csrf-token" content={get_csrf_token()} />
<meta name="theme-color" content="#047b68" />
<meta name="color-scheme" content="light dark" />
<meta name="robots" content={assigns[:page_robots] || "noindex, nofollow"} />
<meta
name="description"
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
default="Who Need Help"
suffix={

View File

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

View File

@ -79,6 +79,8 @@ defmodule WhoNeedHelpWeb.Router do
scope "/", WhoNeedHelpWeb do
pipe_through :browser
get "/robots.txt", SearchDocumentController, :robots
get "/sitemap.xml", SearchDocumentController, :sitemap
get "/", PageController, :home
get "/feedback", FeedbackController, :show
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 ""
#: 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
#, elixir-autogen, elixir-format
msgid "Safety rules"
@ -1685,7 +1685,7 @@ msgstr ""
msgid "Signal updated."
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
msgid "Skip to main content"
msgstr ""
@ -3395,7 +3395,7 @@ msgid "Privacy"
msgstr ""
#: 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/terms.html.heex:75
#: lib/who_need_help_web/controllers/user_registration_html/new.html.heex:79
@ -5442,7 +5442,7 @@ msgstr ""
msgid "Restore request"
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
msgid "Safety guidance for voluntary help, medicine pickup, roadside requests, location sharing, and in-person activities."
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"
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
msgid "Ask nearby volunteers for free medicine pickup or safe practical help, coordinate privately, and verify the handover."
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."
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
msgid "How Who Need Help processes account, Google sign-in, location, communication, and safety data."
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."
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
#, elixir-autogen, elixir-format
msgid "Terms of Service"
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
msgid "The conditions for using Who Need Help to coordinate voluntary help and social activities."
msgstr ""
@ -6432,7 +6432,7 @@ msgstr ""
msgid "Waiting for Android notification permission and token…"
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
msgid "Who Need Help connects adults who need urgent local help with nearby volunteers."
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"/")
html = html_response(conn, 200)
document = LazyHTML.from_document(html)
endpoint_url = WhoNeedHelpWeb.Endpoint.url()
[content_security_policy] = get_resp_header(conn, "content-security-policy")
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 =~ ~r/<title[^>]*>Who Need Help<\/title>/
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 =~ "script-src 'self'"
refute content_security_policy =~ "script-src 'self' 'unsafe-inline'"
@ -98,6 +106,13 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
) == 1
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
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