Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
commit
af6077b9c3
455 changed files with 78366 additions and 0 deletions
111
lib/tarakan_web/plugs/api_rate_limit.ex
Normal file
111
lib/tarakan_web/plugs/api_rate_limit.ex
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
defmodule TarakanWeb.Plugs.ApiRateLimit do
|
||||
@moduledoc """
|
||||
Applies independent IP, account, token, and mutation limits to API traffic.
|
||||
|
||||
Every request counts against the per-IP bucket, including Bearer traffic:
|
||||
a request carrying a bogus `Authorization: Bearer …` token must not dodge
|
||||
throttling by failing authentication before the actor buckets run. Valid
|
||||
Bearer requests additionally count against the actor buckets after auth
|
||||
(the double count is deliberate; both buckets share the request limit).
|
||||
|
||||
IPv6 clients bucket by their /64 prefix. Platform admins and moderators
|
||||
are not rate-limited (mass registration / operational tooling).
|
||||
|
||||
Authenticated actors get `@actor_multiplier` × the configured limits. The
|
||||
IP bucket stays at the base limit, so the actor ceiling rises without
|
||||
raising the network one.
|
||||
"""
|
||||
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller, only: [json: 2]
|
||||
|
||||
alias Tarakan.RateLimiter
|
||||
alias TarakanWeb.Plugs.ClientIp
|
||||
|
||||
@defaults [request_limit: 120, mutation_limit: 20, window_seconds: 60]
|
||||
@actor_multiplier 4
|
||||
@unlimited_roles ~w(admin moderator)
|
||||
|
||||
def init(opts) do
|
||||
configured = Application.get_env(:tarakan, __MODULE__, [])
|
||||
@defaults |> Keyword.merge(configured) |> Keyword.merge(opts)
|
||||
end
|
||||
|
||||
def call(conn, opts) do
|
||||
mode = Keyword.fetch!(opts, :mode)
|
||||
|
||||
# Admins/moderators (after auth) are unlimited for operational mass import.
|
||||
if unlimited_actor?(conn) do
|
||||
conn
|
||||
else
|
||||
request_limit = Keyword.fetch!(opts, :request_limit)
|
||||
mutation_limit = Keyword.fetch!(opts, :mutation_limit)
|
||||
window_seconds = Keyword.fetch!(opts, :window_seconds)
|
||||
|
||||
# Pipelines that serve different traffic get separate namespaces, so a
|
||||
# hotlinked public asset cannot spend the budget a contributor's API
|
||||
# client needs from the same network.
|
||||
bucket = Keyword.get(opts, :bucket, :api_ip)
|
||||
|
||||
checks =
|
||||
case mode do
|
||||
:ip -> [{{bucket, remote_ip_bucket(conn)}, request_limit}]
|
||||
:actor -> actor_checks(conn, request_limit, mutation_limit)
|
||||
end
|
||||
|
||||
case Enum.find_value(checks, &limited?(&1, window_seconds)) do
|
||||
nil -> conn
|
||||
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 actor_checks(conn, request_limit, mutation_limit) do
|
||||
scope = conn.assigns[:current_scope]
|
||||
account_id = scope && scope.account_id
|
||||
token_id = scope && scope.token_id
|
||||
|
||||
{request_limit, mutation_limit} =
|
||||
{request_limit * @actor_multiplier, mutation_limit * @actor_multiplier}
|
||||
|
||||
base = [
|
||||
{{:api_account, account_id || :anonymous}, request_limit},
|
||||
{{:api_token, token_id || :session}, request_limit}
|
||||
]
|
||||
|
||||
if conn.method in ~w(POST PUT PATCH DELETE) do
|
||||
[
|
||||
{{:api_account_mutation, account_id || :anonymous}, mutation_limit},
|
||||
{{:api_token_mutation, token_id || :session}, mutation_limit}
|
||||
| base
|
||||
]
|
||||
else
|
||||
base
|
||||
end
|
||||
end
|
||||
|
||||
defp limited?({key, limit}, window_seconds) do
|
||||
case RateLimiter.check(key, limit, window_seconds) do
|
||||
:ok -> nil
|
||||
{:error, _reason, retry_after} -> retry_after
|
||||
end
|
||||
end
|
||||
|
||||
defp reject(conn, retry_after) do
|
||||
conn
|
||||
|> put_resp_header("retry-after", Integer.to_string(retry_after))
|
||||
|> put_status(:too_many_requests)
|
||||
|> json(%{error: "rate limit exceeded", retry_after: retry_after})
|
||||
|> halt()
|
||||
end
|
||||
|
||||
defp remote_ip_bucket(conn), do: ClientIp.remote_ip_bucket(conn)
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue