diff --git a/lib/gc_index_relay/nostr.ex b/lib/gc_index_relay/nostr.ex index 1ab2725..b5beb2c 100644 --- a/lib/gc_index_relay/nostr.ex +++ b/lib/gc_index_relay/nostr.ex @@ -23,10 +23,13 @@ defmodule GcIndexRelay.Nostr do """ @spec get_event(binary()) :: {:ok, PubEvent.t()} | {:error, :not_found} def get_event(id) when is_binary(id) do - Event - |> Repo.get(id) - |> Repo.preload(:tags) - |> PubEvent.from_db() + with {:ok, binary_id} <- Base.decode16(id, case: :lower), + %Event{} = event <- Repo.get(Event, binary_id), + event_with_tags <- Repo.preload(event, :tags) do + PubEvent.from_db(event_with_tags) + else + _ -> {:error, :not_found} + end end @doc """ @@ -56,9 +59,11 @@ defmodule GcIndexRelay.Nostr do with {:ok, event} <- Validator.validate_id(event), {:ok, event} <- Validator.validate_signature(event) do db_event = PubEvent.to_db(event) + tags_as_maps = Enum.map(db_event.tags, &Map.from_struct/1) + attrs = db_event |> Map.from_struct() |> Map.put(:tags, tags_as_maps) %Event{} - |> Event.changeset(Map.from_struct(db_event)) + |> Event.changeset(attrs) |> Repo.insert() end end @@ -68,10 +73,10 @@ defmodule GcIndexRelay.Nostr do """ @spec delete_event(PubEvent.t()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} def delete_event(event) when is_struct(event, PubEvent) do - db_event = PubEvent.to_db(event) - - %Event{} - |> Event.changeset(Map.from_struct(db_event)) - |> Repo.delete() + with {:ok, binary_id} <- Base.decode16(event.id, case: :lower) do + Repo.delete(%Event{id: binary_id}) + else + _ -> {:error, :not_found} + end end end diff --git a/lib/gc_index_relay/nostr/event.ex b/lib/gc_index_relay/nostr/event.ex index 934615c..0193451 100644 --- a/lib/gc_index_relay/nostr/event.ex +++ b/lib/gc_index_relay/nostr/event.ex @@ -19,7 +19,7 @@ defmodule GcIndexRelay.Nostr.Event do alias GcIndexRelay.Nostr.Tag - @primary_key {:id, :binary_id, autogenerate: false} + @primary_key {:id, :binary, autogenerate: false} schema "events" do field :pubkey, :binary field :created_at, :utc_datetime diff --git a/lib/gc_index_relay/nostr/filter.ex b/lib/gc_index_relay/nostr/filter.ex index 5b6e04a..e64e7ed 100644 --- a/lib/gc_index_relay/nostr/filter.ex +++ b/lib/gc_index_relay/nostr/filter.ex @@ -248,12 +248,20 @@ defmodule GcIndexRelay.Nostr.Filter do @spec apply_ids(Ecto.Query.t(), [String.t()] | nil) :: Ecto.Query.t() defp apply_ids(query, nil), do: query defp apply_ids(query, []), do: query - defp apply_ids(query, ids), do: where(query, [e], e.id in ^ids) + + defp apply_ids(query, ids) do + binary_ids = Enum.map(ids, &Base.decode16!(&1, case: :lower)) + where(query, [e], e.id in ^binary_ids) + end @spec apply_authors(Ecto.Query.t(), [String.t()] | nil) :: Ecto.Query.t() defp apply_authors(query, nil), do: query defp apply_authors(query, []), do: query - defp apply_authors(query, authors), do: where(query, [e], e.pubkey in ^authors) + + defp apply_authors(query, authors) do + binary_authors = Enum.map(authors, &Base.decode16!(&1, case: :lower)) + where(query, [e], e.pubkey in ^binary_authors) + end @spec apply_kinds(Ecto.Query.t(), [integer()] | nil) :: Ecto.Query.t() defp apply_kinds(query, nil), do: query @@ -262,11 +270,19 @@ defmodule GcIndexRelay.Nostr.Filter do @spec apply_since(Ecto.Query.t(), integer() | nil) :: Ecto.Query.t() defp apply_since(query, nil), do: query - defp apply_since(query, since), do: where(query, [e], e.created_at >= ^since) + + defp apply_since(query, since) when is_integer(since) do + datetime = DateTime.from_unix!(since) + where(query, [e], e.created_at >= ^datetime) + end @spec apply_until(Ecto.Query.t(), integer() | nil) :: Ecto.Query.t() defp apply_until(query, nil), do: query - defp apply_until(query, until), do: where(query, [e], e.created_at <= ^until) + + defp apply_until(query, until) when is_integer(until) do + datetime = DateTime.from_unix!(until) + where(query, [e], e.created_at <= ^datetime) + end @spec apply_tags(Ecto.Query.t(), map() | nil) :: Ecto.Query.t() defp apply_tags(query, nil), do: query diff --git a/lib/gc_index_relay/nostr/tag.ex b/lib/gc_index_relay/nostr/tag.ex index 5501c00..dfaf26c 100644 --- a/lib/gc_index_relay/nostr/tag.ex +++ b/lib/gc_index_relay/nostr/tag.ex @@ -6,7 +6,7 @@ defmodule GcIndexRelay.Nostr.Tag do field :name, :string field :value, :string field :additional_values, {:array, :string} - belongs_to :event, GcIndexRelay.Nostr.Event + belongs_to :event, GcIndexRelay.Nostr.Event, type: :binary end @doc false diff --git a/lib/gc_index_relay_web/controllers/event_controller.ex b/lib/gc_index_relay_web/controllers/event_controller.ex index 610e998..bd5670c 100644 --- a/lib/gc_index_relay_web/controllers/event_controller.ex +++ b/lib/gc_index_relay_web/controllers/event_controller.ex @@ -20,12 +20,14 @@ defmodule GcIndexRelayWeb.EventController do end def create(conn, %{"event" => event_params}) do - # TODO: Add 400 for invalid event in FallbackController - with {:ok, %Event{} = event} <- Nostr.create_event(event_params) do + atoms_map = Map.new(event_params, fn {k, v} -> {String.to_existing_atom(k), v} end) + pub_event = struct(PubEvent, atoms_map) + + with {:ok, _event} <- Nostr.create_event(pub_event) do conn |> put_status(:created) - |> put_resp_header("location", ~p"/api/events/#{event.id}") - |> render(:show, event: event) + |> put_resp_header("location", ~p"/api/events/#{pub_event.id}") + |> render(:show, event: pub_event) end end @@ -45,9 +47,9 @@ defmodule GcIndexRelayWeb.EventController do end def show(conn, %{"id" => id}) do - # TODO: Add 404 for event not found in FallbackController - event = Nostr.get_event(id) - render(conn, :show, event: event) + with {:ok, pub_event} <- Nostr.get_event(id) do + render(conn, :show, event: pub_event) + end end swagger_path :delete do diff --git a/lib/gc_index_relay_web/controllers/event_json.ex b/lib/gc_index_relay_web/controllers/event_json.ex index 4379e4d..5afa408 100644 --- a/lib/gc_index_relay_web/controllers/event_json.ex +++ b/lib/gc_index_relay_web/controllers/event_json.ex @@ -1,5 +1,5 @@ defmodule GcIndexRelayWeb.EventJSON do - alias GcIndexRelay.Nostr.Event + alias GcIndexRelay.Nostr.PubEvent @doc """ Renders a single event. @@ -8,7 +8,7 @@ defmodule GcIndexRelayWeb.EventJSON do %{data: data(event)} end - defp data(%Event{} = event) do + defp data(%PubEvent{} = event) do %{ id: event.id, pubkey: event.pubkey, diff --git a/lib/gc_index_relay_web/controllers/fallback_controller.ex b/lib/gc_index_relay_web/controllers/fallback_controller.ex index 44b8aa8..0738f37 100644 --- a/lib/gc_index_relay_web/controllers/fallback_controller.ex +++ b/lib/gc_index_relay_web/controllers/fallback_controller.ex @@ -7,11 +7,18 @@ defmodule GcIndexRelayWeb.FallbackController do use GcIndexRelayWeb, :controller # This clause handles errors returned by Ecto's insert/update/delete. + # Returns 409 Conflict for duplicate event IDs, 422 for other changeset errors. def call(conn, {:error, %Ecto.Changeset{} = changeset}) do - conn - |> put_status(:unprocessable_entity) - |> put_view(json: GcIndexRelayWeb.ChangesetJSON) - |> render(:error, changeset: changeset) + if duplicate_id_error?(changeset) do + conn + |> put_status(:conflict) + |> json(%{errors: %{detail: "Event already exists"}}) + else + conn + |> put_status(:unprocessable_entity) + |> put_view(json: GcIndexRelayWeb.ChangesetJSON) + |> render(:error, changeset: changeset) + end end # This clause handles string error messages (e.g., from filter validation). @@ -28,4 +35,11 @@ defmodule GcIndexRelayWeb.FallbackController do |> put_view(html: GcIndexRelayWeb.ErrorHTML, json: GcIndexRelayWeb.ErrorJSON) |> render(:"404") end + + defp duplicate_id_error?(%Ecto.Changeset{errors: errors}) do + Enum.any?(errors, fn + {:id, {_, opts}} -> Keyword.get(opts, :constraint) == :unique + _ -> false + end) + end end diff --git a/lib/gc_index_relay_web/controllers/filter_controller.ex b/lib/gc_index_relay_web/controllers/filter_controller.ex index 143afbc..a6a79bc 100644 --- a/lib/gc_index_relay_web/controllers/filter_controller.ex +++ b/lib/gc_index_relay_web/controllers/filter_controller.ex @@ -83,7 +83,7 @@ defmodule GcIndexRelayWeb.FilterController do @spec validate_param_values(map()) :: {:ok, map()} | {:error, String.t()} def validate_param_values(params) do - %{:limit => limit} = params + %{"limit" => limit} = params if limit < 1 or limit > 100 do {:error, "The filter limit must be between 1 and 100."} @@ -123,7 +123,9 @@ defmodule GcIndexRelayWeb.FilterController do params |> Map.keys() |> Enum.reject(fn key -> - key in known_keys or String.starts_with?(key, "#") + key in known_keys or String.starts_with?(key, "#") or + (byte_size(key) == 1 and + ((key >= "a" and key <= "z") or (key >= "A" and key <= "Z"))) end) case unknown_keys do @@ -135,16 +137,22 @@ defmodule GcIndexRelayWeb.FilterController do # Parse individual parameters from strings to proper types @spec parse_params(map()) :: {:ok, map()} | {:error, String.t()} defp parse_params(params) do - result = - params - |> Enum.reduce_while({:ok, %{}}, fn {key, value}, {:ok, acc} -> - case parse_param(key, value) do - {:ok, parsed_value} -> {:cont, {:ok, Map.put(acc, key, parsed_value)}} - {:error, _} = error -> {:halt, error} - end - end) - - result + params + |> Enum.reduce_while({:ok, %{}}, fn {key, value}, {:ok, acc} -> + case parse_param(key, value) do + {:ok, parsed_value} -> + out_key = + if byte_size(key) == 1 and + ((key >= "a" and key <= "z") or (key >= "A" and key <= "Z")), + do: "#" <> key, + else: key + + {:cont, {:ok, Map.put(acc, out_key, parsed_value)}} + + {:error, _} = error -> + {:halt, error} + end + end) end # Parse individual parameter based on its key @@ -183,7 +191,8 @@ defmodule GcIndexRelayWeb.FilterController do defp parse_param("limit", value) do case Integer.parse(value) do - {int, ""} -> {:ok, int} + {int, ""} when int >= 1 and int <= 100 -> {:ok, int} + {int, ""} when is_integer(int) -> {:error, "The limit must be between 1 and 100."} _ -> {:error, "Invalid limit value: '#{value}' must be an integer"} end end @@ -192,4 +201,10 @@ defmodule GcIndexRelayWeb.FilterController do defp parse_param("#" <> _tag_name, value) do {:ok, String.split(value, ",")} end + + # Handle single-letter tag filters without "#" prefix (e.g., "p" instead of "#p") + # The "#" is trimmed by URL fragment parsing; bare single-letter keys are treated as tag filters + defp parse_param(<> = _key, value) when letter in ?a..?z do + {:ok, String.split(value, ",")} + end end diff --git a/priv/repo/migrations/20260129043804_add_nostr_events.exs b/priv/repo/migrations/20260129043804_add_nostr_events.exs index ad82ac1..afb5062 100644 --- a/priv/repo/migrations/20260129043804_add_nostr_events.exs +++ b/priv/repo/migrations/20260129043804_add_nostr_events.exs @@ -3,7 +3,7 @@ defmodule GcIndexRelay.Repo.Migrations.AddNostrEvents do def change do create table(:events, primary_key: false) do - add :id, :binary_id, primary_key: true + add :id, :binary, primary_key: true add :pubkey, :binary, null: false add :created_at, :utc_datetime, null: false add :kind, :integer, null: false @@ -18,7 +18,7 @@ defmodule GcIndexRelay.Repo.Migrations.AddNostrEvents do add :name, :string, null: false add :value, :string, null: false add :additional_values, {:array, :string} - add :event_id, references(:events, type: :binary_id, on_delete: :delete_all), null: false + add :event_id, references(:events, type: :binary, on_delete: :delete_all), null: false end create index(:tags, [:event_id]) diff --git a/test/gc_index_relay_web/controllers/filter_controller_test.exs b/test/gc_index_relay_web/controllers/filter_controller_test.exs index 8841bca..d20130e 100644 --- a/test/gc_index_relay_web/controllers/filter_controller_test.exs +++ b/test/gc_index_relay_web/controllers/filter_controller_test.exs @@ -73,7 +73,7 @@ defmodule GcIndexRelayWeb.FilterControllerTest do {:ok, _} = GcIndexRelay.Nostr.create_event(tagged_pub_event) event_fixture(%{kind: 2, created_at: 1_640_000_001}) - conn = get(conn, ~p"/api/events?since=0&until=9999999999&limit=10&#p=abc123def456") + conn = get(conn, ~p"/api/events?since=0&until=9999999999&limit=10&p=abc123def456") assert %{"data" => events} = json_response(conn, 200) assert length(events) == 1