Websites/app/logical/rate_limiter.rb
2024-05-02 22:04:43 -05:00

182 lines
7.2 KiB
Ruby

# frozen_string_literal: true
class RateLimiter
# rubocop:disable Style/EmptyMethod
def get_global(name, ip); end
def get_route(name, domain, path, ip); end
def get_global_ttl(name, ip); end
def get_route_ttl(name, domain, path, ip); end
def consume_global(name, window, limit, ip); end
def consume_route(name, domain, path, window, limit, ip); end
def fix_infinite_expiry(key, val, expiry); end
# rubocop:enable Style/EmptyMethod
def global_key(name, ip)
"rl:#{name}:global:#{ip}"
end
def route_bucket(domain, path)
Base64.urlsafe_encode64("domain=#{domain},path=#{path}", padding: false)
end
def route_key(name, domain, path, ip)
"rl:#{name}:#{route_bucket(domain, path)}:#{ip}"
end
# TODO: https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers (when completed, it's not practically usable currently)
# `X-RateLimit` here is loosely based on draft 7, with policy merged into one header, and converted to json
# properties:
# * `limit`=integer (required) The maximum amount of requests that can be made in `window`
# * `remaining`=integer (required) The remaining requests in `window`
# * `reset`=integer (required) The time after which `limit` resets
# * `window`=integer (required) The window size in seconds
# * `burst`=integer (optional) The burst limit for window
# * `comment`=string (optional) A comment for the limit
#
# Examples:
# * `X-RateLimit: [{ "limit": 2, "remaining": 1, "reset": 2, "window": 2, "comment": "route; bucket=ZG9tYWluPXYyLnlpZmYucmVzdCxwYXRoPS9mdXJyeS95aWZmL2dheQ" }]`
# * `X-RateLimit: [{ "limit": 7, "remaining": 5, "reset": 8, "window": 10, "comment": "global; authed" }]`
# * `X-RateLimit: [{ "limit": 2, "remaining": 1, "reset": 2, "window": 2, "comment": "route; bucket=ZG9tYWluPXYyLnlpZmYucmVzdCxwYXRoPS9mdXJyeS95aWZmL2dheQ" }, { "limit": 7, "remaining": 5, "reset": 8, "window": 10, "comment": "global" }]`
def process(request, ignore_auth: false, short_limit: nil, short_window: nil, long_limit: nil, long_window: nil)
ip = request.remote_ip
path = request.path.downcase
domain = request.domain
if ignore_auth
apikey = APIKey.anon
else
apikey = APIKey.from_request(request)
end
short_limit ||= apikey.limit_short
short_window ||= apikey.window_short
long_limit ||= apikey.limit_long
long_window ||= apikey.window_long
headers = {}
if domain == "yiff.rest" && path.start_with?("/v2")
domain = "v2.yiff.rest"
path = path[3..]
end
return [:INVALID_KEY, nil, headers] if apikey.nil?
authed = ""
authed = "; authed" unless apikey.is_anon?
rroute = consume_route("yiffy2", domain, path, short_window, short_limit, ip)
expires_route = get_route_ttl("yiffy2", domain, path, ip)
remaining = (short_limit - rroute).clamp(0, short_limit)
reset = expires_route.nil? ? short_window : Time.now.to_i + expires_route
reset_after = expires_route.nil? ? 0 : expires_route
bucket = route_bucket(domain, path)
rl = [{ limit: short_limit, remaining: remaining, reset: reset / 1000, window: short_window / 1000, comment: "route; bucket=#{bucket}#{authed}" }]
headers["X-RateLimit-Limit"] = short_limit.to_s
headers["X-RateLimit-Remaining"] = remaining.to_s
headers["X-RateLimit-Reset"] = reset.to_s
headers["X-RateLimit-Reset-After"] = reset_after.to_s
headers["X-RateLimit-Bucket"] = bucket
headers["X-RateLimit-Precision"] = "millisecond"
rglobal = consume_global("yiffy2", long_window, long_limit, ip)
expires_global = get_global_ttl("yiffy2", ip)
fix_infinite_expiry(global_key("yiffy2", ip), expires_global, long_window)
global_remaining = (long_limit - rglobal).clamp(0, long_limit)
global_reset = expires_global.nil? ? long_window : Time.now.to_i + expires_global
global_reset_after = expires_global.nil? ? 0 : expires_global
headers["X-RateLimit-Global-Limit"] = long_limit.to_s
headers["X-RateLimit-Global-Remaining"] = global_remaining.to_s
headers["X-RateLimit-Global-Reset"] = global_reset.to_s
headers["X-RateLimit-Global-Reset-After"] = global_reset_after.to_s
headers["X-RateLimit-Global-Precision"] = "millisecond"
rl << { limit: long_limit, remaining: global_remaining, reset: global_reset / 1000, window: long_window / 1000, comment: "global#{authed}" }
headers["X-RateLimit"] = rl.to_json
if (rroute - 1) >= short_limit
route_ra = expires_route.nil? ? short_window / 1000 : expires_route / 1000
headers["Retry-After"] = route_ra.to_s
Websites.config.yiffyapi_ratelimit_webhook.execute({
embeds: [
{
title: "Rate Limit Exceeded",
description: <<~DESC.strip,
**Host:** **#{domain}**
Path: **#{path}**
Auth: **#{apikey.is_anon? ? 'no' : "Yes (`#{apikey.key}`)"}**
User Agent: `#{request.user_agent}`
IP: `#{request.remote_ip}`
Global: <:redTick:865401803256627221>
Info:
\u25fd Limit: **#{short_limit}**
\u25fd Remaining: **#{remaining}**
\u25fd Reset: **#{reset}**
\u25fd Reset After: **#{reset_after}**
\u25fd Bucket: **#{bucket}**
\u25fd Decoded Bucket: **#{Base64.urlsafe_decode64(bucket)}**
DESC
color: 0xDC143C,
timestamp: Time.now.iso8601,
},
],
})
return [:RATELIMIT_SHORT, {
limit: short_limit,
remaining: remaining,
reset: reset,
resetAfter: reset_after,
retryAfter: route_ra.ceil,
bucket: bucket,
precision: "millisecond",
global: false,
}, headers,]
end
if (rglobal - 1) >= long_limit
global_ra = expires_global.nil? ? long_window / 1000 : expires_global / 1000
headers["Retry-After"] = global_ra.to_s
Websites.config.yiffyapi_ratelimit_webhook.execute({
embeds: [
{
title: "Rate Limit Exceeded",
description: <<~DESC.strip,
**Host:** **#{domain}**
Path: **#{path}**
Auth: **#{apikey.is_anon? ? 'no' : "Yes (`#{apikey.key}`)"}**
User Agent: `#{request.user_agent}`
IP: `#{request.remote_ip}`
Global: <:greenTick:865401802920951819>
Info:
\u25fd Limit: **#{long_limit}**
\u25fd Remaining: **#{global_remaining}**
\u25fd Reset: **#{global_reset}**
\u25fd Reset After: **#{global_reset_after}**
DESC
color: 0xDC143C,
timestamp: Time.now.iso8601,
},
],
})
return [:RATELIMIT_LONG, {
limit: long_limit,
remaining: global_remaining,
reset: global_reset,
resetAfter: global_reset_after,
retryAfter: global_ra.ceil,
precision: "millisecond",
global: true,
}, headers,]
end
[nil, nil, headers]
end
def self.limiter
@limiter ||= RateLimiter::Redis.new
end
def self.process(request, ignore_auth: false)
limiter.process(request, ignore_auth: ignore_auth)
end
end