410 lines
13 KiB
Elixir
410 lines
13 KiB
Elixir
defmodule Parrhesia.API.Events do
|
|
@moduledoc """
|
|
Canonical event publish, query, and count API.
|
|
"""
|
|
|
|
alias Parrhesia.API.Events.PublishResult
|
|
alias Parrhesia.API.RequestContext
|
|
alias Parrhesia.Fanout.Dispatcher
|
|
alias Parrhesia.Fanout.MultiNode
|
|
alias Parrhesia.NIP43
|
|
alias Parrhesia.Policy.EventPolicy
|
|
alias Parrhesia.Protocol
|
|
alias Parrhesia.Protocol.Filter
|
|
alias Parrhesia.Storage
|
|
alias Parrhesia.Telemetry
|
|
|
|
@default_max_event_bytes 262_144
|
|
|
|
@marmot_kinds MapSet.new([
|
|
443,
|
|
444,
|
|
445,
|
|
1059,
|
|
10_050,
|
|
10_051,
|
|
446,
|
|
447,
|
|
448,
|
|
449
|
|
])
|
|
|
|
@spec publish(map(), keyword()) :: {:ok, PublishResult.t()} | {:error, term()}
|
|
def publish(event, opts \\ [])
|
|
|
|
def publish(event, opts) when is_map(event) and is_list(opts) do
|
|
started_at = System.monotonic_time()
|
|
event_id = Map.get(event, "id", "")
|
|
telemetry_metadata = telemetry_metadata_for_event(event)
|
|
|
|
with {:ok, context} <- fetch_context(opts),
|
|
:ok <- validate_event_payload_size(event, max_event_bytes(opts)),
|
|
:ok <- Protocol.validate_event(event),
|
|
:ok <- EventPolicy.authorize_write(event, context.authenticated_pubkeys, context),
|
|
{:ok, publish_state} <- NIP43.prepare_publish(event, nip43_opts(opts, context)),
|
|
{:ok, _stored, message} <- persist_event(event) do
|
|
Telemetry.emit(
|
|
[:parrhesia, :ingest, :stop],
|
|
%{duration: System.monotonic_time() - started_at},
|
|
telemetry_metadata
|
|
)
|
|
|
|
emit_ingest_result(telemetry_metadata, :accepted, :accepted)
|
|
|
|
message =
|
|
case NIP43.finalize_publish(event, publish_state, nip43_opts(opts, context)) do
|
|
{:ok, override} when is_binary(override) -> override
|
|
:ok -> message
|
|
end
|
|
|
|
Dispatcher.dispatch(event)
|
|
maybe_publish_multi_node(event)
|
|
|
|
{:ok,
|
|
%PublishResult{
|
|
event_id: event_id,
|
|
accepted: true,
|
|
message: message,
|
|
reason: nil
|
|
}}
|
|
else
|
|
{:error, :invalid_context} = error ->
|
|
emit_ingest_result(telemetry_metadata, :rejected, :invalid_context)
|
|
error
|
|
|
|
{:error, reason} ->
|
|
emit_ingest_result(telemetry_metadata, :rejected, reason)
|
|
|
|
{:ok,
|
|
%PublishResult{
|
|
event_id: event_id,
|
|
accepted: false,
|
|
message: error_message_for_publish_failure(reason),
|
|
reason: reason
|
|
}}
|
|
end
|
|
end
|
|
|
|
def publish(_event, _opts), do: {:error, :invalid_event}
|
|
|
|
@spec query([map()], keyword()) :: {:ok, [map()]} | {:error, term()}
|
|
def query(filters, opts \\ [])
|
|
|
|
def query(filters, opts) when is_list(filters) and is_list(opts) do
|
|
started_at = System.monotonic_time()
|
|
telemetry_metadata = telemetry_metadata_for_filters(filters, :query)
|
|
|
|
with {:ok, context} <- fetch_context(opts),
|
|
:ok <- maybe_validate_filters(filters, opts),
|
|
:ok <- maybe_authorize_read(filters, context, opts),
|
|
{:ok, events} <- Storage.events().query(%{}, filters, storage_query_opts(context, opts)) do
|
|
events = NIP43.dynamic_events(filters, nip43_opts(opts, context)) ++ events
|
|
|
|
Telemetry.emit(
|
|
[:parrhesia, :query, :stop],
|
|
%{duration: System.monotonic_time() - started_at, result_count: length(events)},
|
|
telemetry_metadata
|
|
)
|
|
|
|
emit_query_result(telemetry_metadata, :ok)
|
|
|
|
{:ok, events}
|
|
else
|
|
{:error, reason} = error ->
|
|
emit_query_result(telemetry_metadata, :error, reason)
|
|
error
|
|
end
|
|
end
|
|
|
|
def query(_filters, _opts), do: {:error, :invalid_filters}
|
|
|
|
@spec count([map()], keyword()) :: {:ok, non_neg_integer() | map()} | {:error, term()}
|
|
def count(filters, opts \\ [])
|
|
|
|
def count(filters, opts) when is_list(filters) and is_list(opts) do
|
|
started_at = System.monotonic_time()
|
|
telemetry_metadata = telemetry_metadata_for_filters(filters, :count)
|
|
|
|
with {:ok, context} <- fetch_context(opts),
|
|
:ok <- maybe_validate_filters(filters, opts),
|
|
:ok <- maybe_authorize_read(filters, context, opts),
|
|
{:ok, count} <-
|
|
Storage.events().count(%{}, filters, requester_pubkeys: requester_pubkeys(context)),
|
|
count <- count + NIP43.dynamic_count(filters, nip43_opts(opts, context)),
|
|
{:ok, result} <- maybe_build_count_result(filters, count, Keyword.get(opts, :options)) do
|
|
Telemetry.emit(
|
|
[:parrhesia, :query, :stop],
|
|
%{duration: System.monotonic_time() - started_at, result_count: count},
|
|
telemetry_metadata
|
|
)
|
|
|
|
emit_query_result(telemetry_metadata, :ok)
|
|
|
|
{:ok, result}
|
|
else
|
|
{:error, reason} = error ->
|
|
emit_query_result(telemetry_metadata, :error, reason)
|
|
error
|
|
end
|
|
end
|
|
|
|
def count(_filters, _opts), do: {:error, :invalid_filters}
|
|
|
|
defp maybe_validate_filters(filters, opts) do
|
|
if Keyword.get(opts, :validate_filters?, true) do
|
|
Filter.validate_filters(filters)
|
|
else
|
|
:ok
|
|
end
|
|
end
|
|
|
|
defp maybe_authorize_read(filters, context, opts) do
|
|
if Keyword.get(opts, :authorize_read?, true) do
|
|
EventPolicy.authorize_read(filters, context.authenticated_pubkeys, context)
|
|
else
|
|
:ok
|
|
end
|
|
end
|
|
|
|
defp storage_query_opts(context, opts) do
|
|
[
|
|
max_filter_limit:
|
|
Keyword.get(opts, :max_filter_limit, Parrhesia.Config.get([:limits, :max_filter_limit])),
|
|
requester_pubkeys: requester_pubkeys(context)
|
|
]
|
|
end
|
|
|
|
defp requester_pubkeys(%RequestContext{} = context),
|
|
do: MapSet.to_list(context.authenticated_pubkeys)
|
|
|
|
defp maybe_build_count_result(_filters, count, nil) when is_integer(count), do: {:ok, count}
|
|
|
|
defp maybe_build_count_result(filters, count, options)
|
|
when is_integer(count) and is_map(options) do
|
|
build_count_payload(filters, count, options)
|
|
end
|
|
|
|
defp maybe_build_count_result(_filters, count, _options) when is_integer(count),
|
|
do: {:ok, count}
|
|
|
|
defp maybe_build_count_result(_filters, count, _options), do: {:ok, count}
|
|
|
|
defp build_count_payload(filters, count, options) do
|
|
include_hll? =
|
|
Map.get(options, "hll", false) and Parrhesia.Config.get([:features, :nip_45_count], true)
|
|
|
|
payload = %{"count" => count, "approximate" => false}
|
|
|
|
payload =
|
|
if include_hll? do
|
|
Map.put(payload, "hll", generate_hll_payload(filters, count))
|
|
else
|
|
payload
|
|
end
|
|
|
|
{:ok, payload}
|
|
end
|
|
|
|
defp generate_hll_payload(filters, count) do
|
|
filters
|
|
|> JSON.encode!()
|
|
|> then(&"#{&1}:#{count}")
|
|
|> then(&:crypto.hash(:sha256, &1))
|
|
|> Base.encode64()
|
|
end
|
|
|
|
defp persist_event(event) do
|
|
kind = Map.get(event, "kind")
|
|
|
|
cond do
|
|
kind in [5, 62] -> persist_control_event(kind, event)
|
|
ephemeral_kind?(kind) -> persist_ephemeral_event()
|
|
true -> persist_regular_event(event)
|
|
end
|
|
end
|
|
|
|
defp persist_control_event(5, event) do
|
|
with {:ok, deleted_count} <- Storage.events().delete_by_request(%{}, event) do
|
|
{:ok, deleted_count, "ok: deletion request processed"}
|
|
end
|
|
end
|
|
|
|
defp persist_control_event(62, event) do
|
|
with {:ok, deleted_count} <- Storage.events().vanish(%{}, event) do
|
|
{:ok, deleted_count, "ok: vanish request processed"}
|
|
end
|
|
end
|
|
|
|
defp persist_ephemeral_event do
|
|
if accept_ephemeral_events?() do
|
|
{:ok, :ephemeral, "ok: ephemeral event accepted"}
|
|
else
|
|
{:error, :ephemeral_events_disabled}
|
|
end
|
|
end
|
|
|
|
defp persist_regular_event(event) do
|
|
case Storage.events().put_event(%{}, event) do
|
|
{:ok, persisted_event} -> {:ok, persisted_event, "ok: event stored"}
|
|
{:error, :duplicate_event} -> {:error, :duplicate_event}
|
|
{:error, reason} -> {:error, reason}
|
|
end
|
|
end
|
|
|
|
defp maybe_publish_multi_node(event) do
|
|
MultiNode.publish(event)
|
|
:ok
|
|
catch
|
|
:exit, _reason -> :ok
|
|
end
|
|
|
|
defp telemetry_metadata_for_event(event) do
|
|
%{traffic_class: traffic_class_for_event(event)}
|
|
end
|
|
|
|
defp telemetry_metadata_for_filters(filters, operation) do
|
|
%{traffic_class: traffic_class_for_filters(filters), operation: operation}
|
|
end
|
|
|
|
defp traffic_class_for_filters(filters) do
|
|
if Enum.any?(filters, &marmot_filter?/1) do
|
|
:marmot
|
|
else
|
|
:generic
|
|
end
|
|
end
|
|
|
|
defp marmot_filter?(filter) when is_map(filter) do
|
|
has_marmot_kind? =
|
|
case Map.get(filter, "kinds") do
|
|
kinds when is_list(kinds) -> Enum.any?(kinds, &MapSet.member?(@marmot_kinds, &1))
|
|
_other -> false
|
|
end
|
|
|
|
has_marmot_kind? or Map.has_key?(filter, "#h") or Map.has_key?(filter, "#i")
|
|
end
|
|
|
|
defp marmot_filter?(_filter), do: false
|
|
|
|
defp traffic_class_for_event(event) when is_map(event) do
|
|
if MapSet.member?(@marmot_kinds, Map.get(event, "kind")) do
|
|
:marmot
|
|
else
|
|
:generic
|
|
end
|
|
end
|
|
|
|
defp traffic_class_for_event(_event), do: :generic
|
|
|
|
defp emit_ingest_result(metadata, outcome, reason) do
|
|
Telemetry.emit(
|
|
[:parrhesia, :ingest, :result],
|
|
%{count: 1},
|
|
Map.merge(metadata, %{outcome: outcome, reason: normalize_reason(reason)})
|
|
)
|
|
end
|
|
|
|
defp emit_query_result(metadata, outcome, reason \\ nil) do
|
|
Telemetry.emit(
|
|
[:parrhesia, :query, :result],
|
|
%{count: 1},
|
|
Map.merge(
|
|
metadata,
|
|
%{outcome: outcome, reason: normalize_reason(reason || outcome)}
|
|
)
|
|
)
|
|
end
|
|
|
|
defp normalize_reason(reason) when is_atom(reason), do: reason
|
|
defp normalize_reason(reason) when is_binary(reason), do: reason
|
|
defp normalize_reason(nil), do: :none
|
|
defp normalize_reason(_reason), do: :unknown
|
|
|
|
defp fetch_context(opts) do
|
|
case Keyword.get(opts, :context) do
|
|
%RequestContext{} = context -> {:ok, context}
|
|
_other -> {:error, :invalid_context}
|
|
end
|
|
end
|
|
|
|
defp nip43_opts(opts, %RequestContext{} = context) do
|
|
[context: context, relay_url: Application.get_env(:parrhesia, :relay_url)]
|
|
|> Kernel.++(Keyword.take(opts, [:path, :private_key, :configured_private_key]))
|
|
end
|
|
|
|
defp error_message_for_publish_failure(:duplicate_event),
|
|
do: "duplicate: event already stored"
|
|
|
|
defp error_message_for_publish_failure(:event_too_large),
|
|
do: "invalid: event exceeds max event size"
|
|
|
|
defp error_message_for_publish_failure(:ephemeral_events_disabled),
|
|
do: "blocked: ephemeral events are disabled"
|
|
|
|
defp error_message_for_publish_failure(reason)
|
|
when reason in [
|
|
:auth_required,
|
|
:pubkey_not_allowed,
|
|
:restricted_giftwrap,
|
|
:sync_write_not_allowed,
|
|
:protected_event_requires_auth,
|
|
:protected_event_pubkey_mismatch,
|
|
:pow_below_minimum,
|
|
:pubkey_banned,
|
|
:event_banned,
|
|
:media_metadata_tags_exceeded,
|
|
:media_metadata_tag_value_too_large,
|
|
:media_metadata_url_too_long,
|
|
:media_metadata_invalid_url,
|
|
:media_metadata_invalid_hash,
|
|
:media_metadata_invalid_mime,
|
|
:media_metadata_mime_not_allowed,
|
|
:media_metadata_unsupported_version,
|
|
:push_notification_relay_tags_exceeded,
|
|
:push_notification_payload_too_large,
|
|
:push_notification_replay_window_exceeded,
|
|
:push_notification_missing_expiration,
|
|
:push_notification_expiration_too_far,
|
|
:push_notification_server_recipients_exceeded
|
|
],
|
|
do: EventPolicy.error_message(reason)
|
|
|
|
defp error_message_for_publish_failure(reason) when is_binary(reason), do: reason
|
|
defp error_message_for_publish_failure(reason), do: "error: #{inspect(reason)}"
|
|
|
|
defp validate_event_payload_size(event, max_event_bytes)
|
|
when is_map(event) and is_integer(max_event_bytes) and max_event_bytes > 0 do
|
|
if byte_size(JSON.encode!(event)) <= max_event_bytes do
|
|
:ok
|
|
else
|
|
{:error, :event_too_large}
|
|
end
|
|
end
|
|
|
|
defp validate_event_payload_size(_event, _max_event_bytes), do: :ok
|
|
|
|
defp max_event_bytes(opts) do
|
|
opts
|
|
|> Keyword.get(:max_event_bytes, configured_max_event_bytes())
|
|
|> normalize_max_event_bytes()
|
|
end
|
|
|
|
defp normalize_max_event_bytes(value) when is_integer(value) and value > 0, do: value
|
|
defp normalize_max_event_bytes(_value), do: configured_max_event_bytes()
|
|
|
|
defp configured_max_event_bytes do
|
|
:parrhesia
|
|
|> Application.get_env(:limits, [])
|
|
|> Keyword.get(:max_event_bytes, @default_max_event_bytes)
|
|
end
|
|
|
|
defp ephemeral_kind?(kind) when is_integer(kind), do: kind >= 20_000 and kind < 30_000
|
|
defp ephemeral_kind?(_kind), do: false
|
|
|
|
defp accept_ephemeral_events? do
|
|
:parrhesia
|
|
|> Application.get_env(:policies, [])
|
|
|> Keyword.get(:accept_ephemeral_events, true)
|
|
end
|
|
end
|