diff --git a/lib/EbookDownload.php b/lib/EbookDownload.php index 18ca2fdf..250a94f5 100644 --- a/lib/EbookDownload.php +++ b/lib/EbookDownload.php @@ -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]); + } } diff --git a/lib/Enums/HttpCode.php b/lib/Enums/HttpCode.php index 20d58acd..66698034 100644 --- a/lib/Enums/HttpCode.php +++ b/lib/Enums/HttpCode.php @@ -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; diff --git a/lib/Formatter.php b/lib/Formatter.php index 3a14292a..b30c8d5f 100644 --- a/lib/Formatter.php +++ b/lib/Formatter.php @@ -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 `$`. * diff --git a/www/ebooks/download.php b/www/ebooks/download.php index 0819138a..87e3a683 100644 --- a/www/ebooks/download.php +++ b/www/ebooks/download.php @@ -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); - } - catch(Exceptions\InvalidEbookDownloadException){ - // Pass. Allow the download to continue even if it isn't recorded. + 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); + } } 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.