defmodule SyntropyWeb.Plugs.RateLimiter do @moduledoc """ Applies `Syntropy.RateLimiter` to API pipelines. Each pipeline declares its class (`plug SyntropyWeb.Plugs.RateLimiter, class: :read`). Requests are keyed by the bearer token (hashed — the plaintext never becomes a bucket key) or, absent one, by the peer IP. Exhausted buckets answer 429 with a `retry-after` header. """ import Plug.Conn import Phoenix.Controller, only: [json: 2] alias Syntropy.RateLimiter alias SyntropyWeb.ApiAuth @spec init(keyword()) :: RateLimiter.class() def init(opts), do: Keyword.get(opts, :class, :read) @spec call(Plug.Conn.t(), RateLimiter.class()) :: Plug.Conn.t() def call(conn, class) do if RateLimiter.enabled?() do case RateLimiter.allow(identity(conn), class) do :ok -> conn {:error, retry_after_seconds} -> conn |> put_resp_header("retry-after", Integer.to_string(retry_after_seconds)) |> put_status(:too_many_requests) |> json(%{ error: %{ code: "rate_limited", message: "Rate limit exceeded for #{class} requests. Retry in #{retry_after_seconds}s." } }) |> halt() end else conn end end defp identity(conn) do case get_req_header(conn, "authorization") do [header | _rest] when is_binary(header) and header != "" -> "token:" <> ApiAuth.hash_token(header) _missing -> "ip:" <> ip_string(conn.remote_ip) end end defp ip_string(ip) do ip |> :inet.ntoa() |> to_string() end end