defmodule TarakanWeb.Plugs.CodeBrowserRateLimit do @moduledoc """ Per-IP budget for browser traffic, which includes the code browser. Anonymous code browsing spawns git subprocesses against local mirrors and can enqueue outbound mirror fetches, so each client IP gets a generous request budget (default 300/min). IPv6 clients bucket by their /64 prefix. Platform admins and moderators are exempt (operational tooling); the plug runs after the session scope is fetched so the exemption can see the current role. """ import Plug.Conn alias Tarakan.RateLimiter alias TarakanWeb.Plugs.ClientIp @defaults [limit: 300, window_seconds: 60] @unlimited_roles ~w(admin moderator) @doc false def init(opts) do configured = Application.get_env(:tarakan, __MODULE__, []) @defaults |> Keyword.merge(configured) |> Keyword.merge(opts) end @doc false def call(conn, opts) do if unlimited_actor?(conn) do conn else limit = Keyword.fetch!(opts, :limit) window_seconds = Keyword.fetch!(opts, :window_seconds) key = {:code_browser_ip, ClientIp.remote_ip_bucket(conn)} case RateLimiter.check(key, limit, window_seconds) do :ok -> conn {:error, _reason, retry_after} -> reject(conn, retry_after) end end end defp unlimited_actor?(conn) do case conn.assigns[:current_scope] do %{platform_role: role} when role in @unlimited_roles -> true %{account: %{platform_role: role}} when role in @unlimited_roles -> true _ -> false end end defp reject(conn, retry_after) do conn |> put_resp_header("retry-after", Integer.to_string(retry_after)) |> put_resp_content_type("text/plain") |> send_resp(429, "too many requests") |> halt() end end