182 lines
7.2 KiB
Ruby
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
|