diff --git a/README.md b/README.md index ad71f22..80ffa3f 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ In `prod`, these environment variables are used: - `DATABASE_URL` (**required**), e.g. `ecto://USER:PASS@HOST/parrhesia_prod` - `POOL_SIZE` (optional, default `32`) - `PORT` (optional, default `4413`) -- `PARRHESIA_*` runtime overrides for relay config, identity, sync, ACL, limits, policies, listeners, retention, and features +- `PARRHESIA_*` runtime overrides for relay config, metadata, identity, sync, ACL, limits, policies, listeners, retention, and features - `PARRHESIA_EXTRA_CONFIG` (optional path to an extra runtime config file) `config/runtime.exs` reads these values at runtime in production releases. @@ -145,6 +145,7 @@ In `prod`, these environment variables are used: For runtime overrides, use the `PARRHESIA_...` prefix: - `PARRHESIA_RELAY_URL` +- `PARRHESIA_METADATA_HIDE_VERSION` - `PARRHESIA_IDENTITY_*` - `PARRHESIA_SYNC_*` - `PARRHESIA_ACL_*` @@ -181,6 +182,7 @@ CSV env vars use comma-separated values. Boolean env vars accept `1/0`, `true/fa | Atom key | ENV | Default | Notes | | --- | --- | --- | --- | | `:relay_url` | `PARRHESIA_RELAY_URL` | `ws://localhost:4413/relay` | Advertised relay URL and auth relay tag target | +| `:metadata.hide_version?` | `PARRHESIA_METADATA_HIDE_VERSION` | `true` | Hides the relay version from outbound `User-Agent` and NIP-11 when enabled | | `:acl.protected_filters` | `PARRHESIA_ACL_PROTECTED_FILTERS` | `[]` | JSON-encoded protected filter list for sync ACL checks | | `:identity.path` | `PARRHESIA_IDENTITY_PATH` | `nil` | Optional path for persisted relay identity material | | `:identity.private_key` | `PARRHESIA_IDENTITY_PRIVATE_KEY` | `nil` | Optional inline relay private key | diff --git a/config/config.exs b/config/config.exs index 5d93cae..3c82952 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,8 +1,19 @@ import Config +project_version = + case Mix.Project.config()[:version] do + version when is_binary(version) -> version + version -> to_string(version) + end + config :postgrex, :json_library, JSON config :parrhesia, + metadata: [ + name: "Parrhesia", + version: project_version, + hide_version?: true + ], database: [ separate_read_pool?: config_env() != :test ], diff --git a/config/runtime.exs b/config/runtime.exs index 7932fcb..63372ef 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -132,6 +132,7 @@ if config_env() == :prod do repo_defaults = Application.get_env(:parrhesia, Parrhesia.Repo, []) read_repo_defaults = Application.get_env(:parrhesia, Parrhesia.ReadRepo, []) relay_url_default = Application.get_env(:parrhesia, :relay_url) + metadata_defaults = Application.get_env(:parrhesia, :metadata, []) moderation_cache_enabled_default = Application.get_env(:parrhesia, :moderation_cache_enabled, true) @@ -646,6 +647,15 @@ if config_env() == :prod do config :parrhesia, relay_url: string_env.("PARRHESIA_RELAY_URL", relay_url_default), + metadata: [ + name: Keyword.get(metadata_defaults, :name, "Parrhesia"), + version: Keyword.get(metadata_defaults, :version, "0.0.0"), + hide_version?: + bool_env.( + "PARRHESIA_METADATA_HIDE_VERSION", + Keyword.get(metadata_defaults, :hide_version?, true) + ) + ], acl: [ protected_filters: json_env.( diff --git a/lib/parrhesia/http.ex b/lib/parrhesia/http.ex new file mode 100644 index 0000000..a8dcdd7 --- /dev/null +++ b/lib/parrhesia/http.ex @@ -0,0 +1,48 @@ +defmodule Parrhesia.HTTP do + @moduledoc false + + alias Parrhesia.Metadata + + @default_headers [{"user-agent", Metadata.user_agent()}] + + @spec default_headers() :: [{String.t(), String.t()}] + def default_headers, do: @default_headers + + @spec get(Keyword.t()) :: {:ok, Req.Response.t()} | {:error, Exception.t()} + def get(options) when is_list(options) do + Req.get(put_default_headers(options)) + end + + @spec post(Keyword.t()) :: {:ok, Req.Response.t()} | {:error, Exception.t()} + def post(options) when is_list(options) do + Req.post(put_default_headers(options)) + end + + @spec put_default_headers(Keyword.t()) :: Keyword.t() + def put_default_headers(options) when is_list(options) do + Keyword.update(options, :headers, @default_headers, &merge_headers(&1, @default_headers)) + end + + defp merge_headers(headers, defaults) do + existing_names = + headers + |> List.wrap() + |> Enum.reduce(MapSet.new(), fn + {name, _value}, acc -> MapSet.put(acc, normalize_header_name(name)) + _other, acc -> acc + end) + + headers ++ + Enum.reject(defaults, fn {name, _value} -> + MapSet.member?(existing_names, normalize_header_name(name)) + end) + end + + defp normalize_header_name(name) when is_atom(name) do + name + |> Atom.to_string() + |> String.downcase() + end + + defp normalize_header_name(name) when is_binary(name), do: String.downcase(name) +end diff --git a/lib/parrhesia/metadata.ex b/lib/parrhesia/metadata.ex new file mode 100644 index 0000000..4e66d7a --- /dev/null +++ b/lib/parrhesia/metadata.ex @@ -0,0 +1,29 @@ +defmodule Parrhesia.Metadata do + @moduledoc false + + @metadata Application.compile_env(:parrhesia, :metadata, []) + @name Keyword.get(@metadata, :name, "Parrhesia") + @version Keyword.get(@metadata, :version, "0.0.0") + @hide_version? Keyword.get(@metadata, :hide_version?, true) + + @spec name() :: String.t() + def name, do: @name + + @spec version() :: String.t() + def version, do: @version + + @spec hide_version?() :: boolean() + def hide_version?, do: @hide_version? + + @spec name_and_version() :: String.t() + def name_and_version, do: "#{@name}/#{@version}" + + @spec user_agent() :: String.t() + def user_agent do + if hide_version?() do + name() + else + name_and_version() + end + end +end diff --git a/lib/parrhesia/nip66/probe.ex b/lib/parrhesia/nip66/probe.ex index 5435ad4..65b1df7 100644 --- a/lib/parrhesia/nip66/probe.ex +++ b/lib/parrhesia/nip66/probe.ex @@ -1,6 +1,7 @@ defmodule Parrhesia.NIP66.Probe do @moduledoc false + alias Parrhesia.HTTP alias Parrhesia.Sync.Transport.WebSockexClient @type result :: %{ @@ -145,7 +146,7 @@ defmodule Parrhesia.NIP66.Probe do defp fetch_nip11(relay_url, timeout_ms) do started_at = System.monotonic_time() - case Req.get( + case HTTP.get( url: relay_info_url(relay_url), headers: [{"accept", "application/nostr+json"}], decode_body: false, diff --git a/lib/parrhesia/sync/relay_info_client.ex b/lib/parrhesia/sync/relay_info_client.ex index e9d651d..b5cb51b 100644 --- a/lib/parrhesia/sync/relay_info_client.ex +++ b/lib/parrhesia/sync/relay_info_client.ex @@ -1,6 +1,7 @@ defmodule Parrhesia.Sync.RelayInfoClient do @moduledoc false + alias Parrhesia.HTTP alias Parrhesia.Sync.TLS @spec verify_remote_identity(map(), keyword()) :: :ok | {:error, term()} @@ -18,7 +19,7 @@ defmodule Parrhesia.Sync.RelayInfoClient do end defp default_request(url, opts) do - case Req.get( + case HTTP.get( url: url, headers: [{"accept", "application/nostr+json"}], decode_body: false, diff --git a/lib/parrhesia/web/relay_info.ex b/lib/parrhesia/web/relay_info.ex index 683e279..0113da6 100644 --- a/lib/parrhesia/web/relay_info.ex +++ b/lib/parrhesia/web/relay_info.ex @@ -4,21 +4,27 @@ defmodule Parrhesia.Web.RelayInfo do """ alias Parrhesia.API.Identity + alias Parrhesia.Metadata alias Parrhesia.NIP43 alias Parrhesia.Web.Listener @spec document(Listener.t()) :: map() def document(listener) do - %{ - "name" => "Parrhesia", + document = %{ + "name" => Metadata.name(), "description" => "Nostr/Marmot relay", "pubkey" => relay_pubkey(), "self" => relay_pubkey(), "supported_nips" => supported_nips(), "software" => "https://git.teralink.net/self/parrhesia", - "version" => Application.spec(:parrhesia, :vsn) |> to_string(), "limitation" => limitations(listener) } + + if Metadata.hide_version?() do + document + else + Map.put(document, "version", Metadata.version()) + end end defp supported_nips do diff --git a/test/parrhesia/config_test.exs b/test/parrhesia/config_test.exs index e6342ef..c20af95 100644 --- a/test/parrhesia/config_test.exs +++ b/test/parrhesia/config_test.exs @@ -4,6 +4,9 @@ defmodule Parrhesia.ConfigTest do alias Parrhesia.Web.Listener test "returns configured relay limits/policies/features" do + assert Parrhesia.Config.get([:metadata, :name]) == "Parrhesia" + assert Parrhesia.Config.get([:metadata, :version]) == "0.5.0" + assert Parrhesia.Config.get([:metadata, :hide_version?]) == true assert Parrhesia.Config.get([:limits, :max_frame_bytes]) == 1_048_576 assert Parrhesia.Config.get([:limits, :max_event_bytes]) == 262_144 assert Parrhesia.Config.get([:limits, :max_event_future_skew_seconds]) == 900 diff --git a/test/parrhesia/http_test.exs b/test/parrhesia/http_test.exs new file mode 100644 index 0000000..361eacb --- /dev/null +++ b/test/parrhesia/http_test.exs @@ -0,0 +1,32 @@ +defmodule Parrhesia.HTTPTest do + use ExUnit.Case, async: true + + alias Parrhesia.HTTP + alias Parrhesia.Metadata + + test "default headers advertise the configured user agent" do + assert Metadata.hide_version?() == true + assert HTTP.default_headers() == [{"user-agent", Metadata.user_agent()}] + end + + test "default headers are added without overriding request-specific headers" do + options = + HTTP.put_default_headers( + headers: [{"accept", "application/nostr+json"}], + decode_body: false + ) + + assert Keyword.get(options, :headers) == [ + {"accept", "application/nostr+json"}, + {"user-agent", Metadata.user_agent()} + ] + + assert Keyword.get(options, :decode_body) == false + end + + test "explicit user-agent overrides suppress the default case-insensitively" do + options = HTTP.put_default_headers(headers: [{"User-Agent", "custom-agent"}]) + + assert Keyword.get(options, :headers) == [{"User-Agent", "custom-agent"}] + end +end diff --git a/test/parrhesia/web/relay_info_test.exs b/test/parrhesia/web/relay_info_test.exs new file mode 100644 index 0000000..a95009d --- /dev/null +++ b/test/parrhesia/web/relay_info_test.exs @@ -0,0 +1,17 @@ +defmodule Parrhesia.Web.RelayInfoTest do + use ExUnit.Case, async: true + + alias Parrhesia.Web.Listener + alias Parrhesia.Web.RelayInfo + + test "nip-11 omits version when metadata hides it" do + document = + :parrhesia + |> Application.get_env(:listeners, %{}) + |> Keyword.fetch!(:public) + |> then(&Listener.from_opts(listener: &1)) + |> RelayInfo.document() + + refute Map.has_key?(document, "version") + end +end