216 lines
6 KiB
Perl
216 lines
6 KiB
Perl
|
|
#!/usr/bin/perl
|
||
|
|
#
|
||
|
|
# DW::Request::RateLimit
|
||
|
|
#
|
||
|
|
# Module to handle rate limiting for the site using a leaky bucket algorithm.
|
||
|
|
#
|
||
|
|
# Authors:
|
||
|
|
# Mark Smith <mark@dreamwidth.org>
|
||
|
|
#
|
||
|
|
# Copyright (c) 2025 by Dreamwidth Studios, LLC.
|
||
|
|
#
|
||
|
|
# This program is free software; you may redistribute it and/or modify it under
|
||
|
|
# the same terms as Perl itself. For a copy of the license, please reference
|
||
|
|
# 'perldoc perlartistic' or 'perldoc perlgpl'.
|
||
|
|
#
|
||
|
|
|
||
|
|
package DW::RateLimit;
|
||
|
|
|
||
|
|
use strict;
|
||
|
|
use v5.10;
|
||
|
|
use Log::Log4perl;
|
||
|
|
my $log = Log::Log4perl->get_logger(__PACKAGE__);
|
||
|
|
|
||
|
|
use LJ::MemCache;
|
||
|
|
use LJ::User;
|
||
|
|
use Time::HiRes qw(time);
|
||
|
|
|
||
|
|
# Class to handle rate limiting
|
||
|
|
package DW::RateLimit::Limit;
|
||
|
|
|
||
|
|
use strict;
|
||
|
|
use warnings;
|
||
|
|
|
||
|
|
sub new {
|
||
|
|
my ( $class, %opts ) = @_;
|
||
|
|
my $self = bless {
|
||
|
|
name => $opts{name},
|
||
|
|
max_count => $opts{max_count},
|
||
|
|
interval_secs => $opts{per_interval_secs},
|
||
|
|
key_prefix => "ratelimit:",
|
||
|
|
mode => $opts{mode} || 'block',
|
||
|
|
|
||
|
|
# Calculate refill rate (tokens per second)
|
||
|
|
refill_rate => $opts{max_count} / $opts{per_interval_secs},
|
||
|
|
}, $class;
|
||
|
|
return $self;
|
||
|
|
}
|
||
|
|
|
||
|
|
# Check the rate limit status
|
||
|
|
# Returns a hash containing:
|
||
|
|
# exceeded: 1 if they have exceeded the limit, 0 if they haven't
|
||
|
|
# time_remaining: seconds until the rate limit resets (0 if not exceeded)
|
||
|
|
# count: current count of requests
|
||
|
|
sub check {
|
||
|
|
my ( $self, %opts ) = @_;
|
||
|
|
|
||
|
|
# Handle ignore mode - always return not exceeded
|
||
|
|
return { exceeded => 0, time_remaining => 0, count => 0 } if $self->{mode} eq 'ignore';
|
||
|
|
|
||
|
|
# Get the key to use for this rate limit
|
||
|
|
my $key = $self->_get_key(%opts);
|
||
|
|
return { exceeded => 0, time_remaining => 0, count => 0 } unless $key;
|
||
|
|
|
||
|
|
# Get the current state from memcache
|
||
|
|
my $state_str = LJ::MemCache::get($key);
|
||
|
|
my ( $level, $last_update );
|
||
|
|
if ($state_str) {
|
||
|
|
( $level, $last_update ) = split( ':', $state_str );
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
$level = $self->{max_count};
|
||
|
|
$last_update = time();
|
||
|
|
}
|
||
|
|
|
||
|
|
# Calculate time elapsed since last update
|
||
|
|
my $now = time();
|
||
|
|
my $elapsed = $now - $last_update;
|
||
|
|
|
||
|
|
# Calculate new bucket level after refill
|
||
|
|
my $new_level = $level + ( $elapsed * $self->{refill_rate} );
|
||
|
|
$new_level = $self->{max_count} if $new_level > $self->{max_count};
|
||
|
|
|
||
|
|
# Calculate current count (tokens used)
|
||
|
|
my $count = $self->{max_count} - $new_level;
|
||
|
|
|
||
|
|
# If we're at or over the limit
|
||
|
|
if ( $new_level < 1 ) {
|
||
|
|
|
||
|
|
# Log if in log mode
|
||
|
|
if ( $self->{mode} eq 'log' ) {
|
||
|
|
$log->info("RateLimit: Exceeded limit on $key");
|
||
|
|
return { exceeded => 0, time_remaining => 0, count => $count };
|
||
|
|
}
|
||
|
|
|
||
|
|
# Calculate time remaining until reset
|
||
|
|
# When bucket is empty, time remaining is the full interval
|
||
|
|
my $time_remaining = $self->{interval_secs};
|
||
|
|
return { exceeded => 1, time_remaining => $time_remaining, count => $count };
|
||
|
|
}
|
||
|
|
|
||
|
|
# Decrement the counter and update timestamp
|
||
|
|
$new_level -= 1;
|
||
|
|
my $new_state_str = "$new_level:$now";
|
||
|
|
|
||
|
|
# Store the new state
|
||
|
|
LJ::MemCache::set( $key, $new_state_str, $self->{interval_secs} );
|
||
|
|
|
||
|
|
# Return success with no time remaining
|
||
|
|
return { exceeded => 0, time_remaining => 0, count => $count + 1 };
|
||
|
|
}
|
||
|
|
|
||
|
|
# Reset the counter for this rate limit
|
||
|
|
sub reset {
|
||
|
|
my ( $self, %opts ) = @_;
|
||
|
|
|
||
|
|
# In ignore mode, do nothing
|
||
|
|
return 1 if $self->{mode} eq 'ignore';
|
||
|
|
|
||
|
|
my $key = $self->_get_key(%opts);
|
||
|
|
return 0 unless $key;
|
||
|
|
|
||
|
|
# Set the state to full bucket and current time
|
||
|
|
my $new_state_str = "$self->{max_count}:" . time();
|
||
|
|
|
||
|
|
# Store the new state with the full interval
|
||
|
|
LJ::MemCache::set( $key, $new_state_str, $self->{interval_secs} );
|
||
|
|
return 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
# Internal method to generate the memcache key
|
||
|
|
sub _get_key {
|
||
|
|
my ( $self, %opts ) = @_;
|
||
|
|
|
||
|
|
my @key_parts = ( $self->{key_prefix}, $self->{name} );
|
||
|
|
|
||
|
|
# Add userid if provided
|
||
|
|
if ( my $userid = $opts{userid} ) {
|
||
|
|
push @key_parts, "user:$userid";
|
||
|
|
}
|
||
|
|
|
||
|
|
# Add IP if provided
|
||
|
|
if ( my $ip = $opts{ip} ) {
|
||
|
|
push @key_parts, "ip:$ip";
|
||
|
|
}
|
||
|
|
|
||
|
|
# Add any additional identifiers
|
||
|
|
if ( my $identifiers = $opts{identifiers} ) {
|
||
|
|
foreach my $id ( sort keys %$identifiers ) {
|
||
|
|
push @key_parts, "$id:$identifiers->{$id}";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return join( ":", @key_parts );
|
||
|
|
}
|
||
|
|
|
||
|
|
# Package methods for DW::RateLimit
|
||
|
|
package DW::RateLimit;
|
||
|
|
|
||
|
|
use strict;
|
||
|
|
use warnings;
|
||
|
|
|
||
|
|
# Parse a rate limit string in the format "count/interval[unit]"
|
||
|
|
# Examples: "1/5s", "10/1m", "100/1h", "1000/1d"
|
||
|
|
sub _parse_rate_string {
|
||
|
|
my ( $class, $rate_string ) = @_;
|
||
|
|
return undef unless $rate_string && $rate_string =~ /^(\d+)\/(\d+)([smhd])$/;
|
||
|
|
|
||
|
|
my ( $max_count, $interval, $unit ) = ( $1, $2, $3 );
|
||
|
|
|
||
|
|
# Convert interval to seconds based on unit
|
||
|
|
my $per_interval_secs = $interval;
|
||
|
|
$per_interval_secs *= 60 if $unit eq 'm';
|
||
|
|
$per_interval_secs *= 3600 if $unit eq 'h';
|
||
|
|
$per_interval_secs *= 86400 if $unit eq 'd';
|
||
|
|
|
||
|
|
return {
|
||
|
|
max_count => $max_count,
|
||
|
|
per_interval_secs => $per_interval_secs
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
# Get a rate limit object
|
||
|
|
sub get {
|
||
|
|
my ( $class, $name, %opts ) = @_;
|
||
|
|
|
||
|
|
# Validate required parameters
|
||
|
|
return undef unless $name && $opts{rate};
|
||
|
|
|
||
|
|
# Parse the rate string
|
||
|
|
my $parsed = $class->_parse_rate_string( $opts{rate} );
|
||
|
|
return undef unless $parsed;
|
||
|
|
|
||
|
|
# Check for configuration overrides
|
||
|
|
if ( $LJ::RATE_LIMITS{$name} ) {
|
||
|
|
my $config = $LJ::RATE_LIMITS{$name};
|
||
|
|
|
||
|
|
# Handle rate string in config
|
||
|
|
if ( $config->{rate} ) {
|
||
|
|
my $config_parsed = $class->_parse_rate_string( $config->{rate} );
|
||
|
|
if ($config_parsed) {
|
||
|
|
$parsed = $config_parsed;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
$opts{mode} = $config->{mode} if defined $config->{mode};
|
||
|
|
}
|
||
|
|
|
||
|
|
return DW::RateLimit::Limit->new(
|
||
|
|
name => $name,
|
||
|
|
max_count => $parsed->{max_count},
|
||
|
|
per_interval_secs => $parsed->{per_interval_secs},
|
||
|
|
mode => $opts{mode},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
1;
|