Limit ebook downloads per IP address

This commit is contained in:
Mike Colagrosso 2025-07-16 18:20:30 -06:00 committed by Alex Cabal
parent 6c69ee0bdf
commit 0a71382a25
4 changed files with 58 additions and 9 deletions

View file

@ -45,10 +45,7 @@ class EbookDownload{
$this->UserAgent = null;
}
// The `IpAddress` column expects IPv6 address strings.
if(is_string($this->IpAddress) && filter_var($this->IpAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)){
$this->IpAddress = '::ffff:' . $this->IpAddress;
}
$this->IpAddress = Formatter::ToIpv6($this->IpAddress);
if($error->HasExceptions){
throw $error;
@ -86,4 +83,26 @@ class EbookDownload{
and Created < ?
', [$startDate, $endDate], EbookDownload::class);
}
/**
* Gets the count of `EbookDownload` objects by the given IP address newer than the given created time.
*
* @param string $ipAddress The IP address to search for.
* @param DateTimeImmutable $startDateTime The minimum creation timestamp (inclusive).
* @return int The total number of `EbookDownload` objects matching the criteria.
*/
public static function GetCountByIpAddressSince(?string $ipAddress, DateTimeImmutable $startDateTime): int{
if(!isset($ipAddress)){
return 0;
}
$ipAddress = Formatter::ToIpv6($ipAddress);
return Db::QueryInt('
SELECT count(*)
from EbookDownloads
where IpAddress = ?
and Created >= ?
', [$ipAddress, $startDateTime]);
}
}

View file

@ -24,6 +24,7 @@ enum HttpCode: int{
case Conflict = 409;
case Gone = 410;
case UnprocessableContent = 422;
case TooManyRequests = 429;
case InternalServerError = 500;
case ServiceUnavailable = 503;

View file

@ -149,6 +149,17 @@ class Formatter{
return $output;
}
/**
* Format a valid IPv4 address to IPv6. Other strings are unchanged.
*/
public static function ToIpv6(?string $ipAddress): ?string{
if(filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)){
return '::ffff:' . $ipAddress;
}
return $ipAddress;
}
/**
* Format a float into a USD currency string. The result is prepended with `$`.
*

View file

@ -7,6 +7,11 @@ $downloadUrl = null;
$downloadCount = HttpInput::Int(COOKIE, 'download-count') ?? 0;
$source = Enums\EbookDownloadSource::tryFrom(HttpInput::Str(GET, 'source') ?? '');
$shortDownloadLimit = 10;
$shortDownloadTime = NOW->modify('-30 seconds');
$longDownloadLimit = 100;
$longDownloadTime = NOW->modify('-1 day');
// Skip the thank you page if any of these are true:
// * The user is logged in.
// * Their `download-count` cookie is above some amount.
@ -40,11 +45,17 @@ try{
/** @var string|null $userAgent */
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null;
try{
$ebook->AddDownload($ipAddress, $userAgent);
if(!isset(Session::$User)){
// Check for excessive downloads.
$shortDownloadCount = EbookDownload::GetCountByIpAddressSince($ipAddress, $shortDownloadTime);
if($shortDownloadCount > $shortDownloadLimit){
Template::ExitWithCode(Enums\HttpCode::TooManyRequests);
}
$longDownloadCount = EbookDownload::GetCountByIpAddressSince($ipAddress, $longDownloadTime);
if($longDownloadCount > $longDownloadLimit){
Template::ExitWithCode(Enums\HttpCode::TooManyRequests);
}
catch(Exceptions\InvalidEbookDownloadException){
// Pass. Allow the download to continue even if it isn't recorded.
}
if($skipThankYouPage){
@ -56,6 +67,13 @@ try{
throw new Exceptions\InvalidFileException();
}
try{
$ebook->AddDownload($ipAddress, $userAgent);
}
catch(Exceptions\InvalidEbookDownloadException){
// Pass. Allow the download to continue even if it isn't recorded.
}
// Everything OK, serve the file using Apache.
// The `xsendfile` Apache module tells Apache to serve the file, including `not-modified` or `etag` headers.
// Much more efficient than reading it in PHP and outputting it that way.