# 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