diff --git a/app/Constants/Config/VideoConstants.php b/app/Constants/Config/VideoConstants.php index 396fe1485..a82054369 100644 --- a/app/Constants/Config/VideoConstants.php +++ b/app/Constants/Config/VideoConstants.php @@ -21,6 +21,8 @@ class VideoConstants final public const PATH_QUALIFIED = 'video.path'; + final public const RATE_LIMITER_QUALIFIED = 'video.rate_limiter'; + final public const STREAMING_METHOD_QUALIFIED = 'video.streaming_method'; final public const URL_QUALIFIED = 'video.url'; diff --git a/app/Events/Wiki/Video/VideoThrottled.php b/app/Events/Wiki/Video/VideoThrottled.php new file mode 100644 index 000000000..f9c8838f2 --- /dev/null +++ b/app/Events/Wiki/Video/VideoThrottled.php @@ -0,0 +1,56 @@ + "Video '**{$this->video->getName()}**' throttled for user '**$this->user**'", + 'color' => EmbedColor::YELLOW, + ]); + } + + /** + * Get Discord channel the message will be sent to. + * + * @return string + */ + public function getDiscordChannel(): string + { + return Config::get(ServiceConstants::ADMIN_DISCORD_CHANNEL_QUALIFIED); + } +} diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index bced65d9a..1574fad72 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -7,16 +7,19 @@ namespace App\Providers; use App\Constants\Config\AudioConstants; use App\Constants\Config\DumpConstants; use App\Constants\Config\VideoConstants; +use App\Events\Wiki\Video\VideoThrottled; use App\Models\Auth\User; use App\Models\Wiki\Anime\AnimeSynonym; use App\Models\Wiki\Anime\AnimeTheme; use App\Models\Wiki\Anime\Theme\AnimeThemeEntry; +use App\Models\Wiki\Video; use App\Models\Wiki\Video\VideoScript; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Route; @@ -105,5 +108,20 @@ class RouteServiceProvider extends ServiceProvider return Limit::perMinute(90)->by(Auth::check() ? Auth::id() : $request->ip()); }); + + RateLimiter::for('video', function (Request $request) { + $limit = Config::get(VideoConstants::RATE_LIMITER_QUALIFIED); + + if ($limit <= 0) { + return Limit::none(); + } + + return Limit::perMinute($limit)->by($request->ip())->response(function (Request $request) { + /** @var Video $video */ + $video = $request->route('video'); + + VideoThrottled::dispatch($video, Crypt::encryptString($request->ip())); + }); + }); } } diff --git a/config/video.php b/config/video.php index 175101228..743596242 100644 --- a/config/video.php +++ b/config/video.php @@ -67,6 +67,19 @@ return [ 'encoder_version' => env('VIDEO_ENCODER_VERSION'), + /* + |-------------------------------------------------------------------------- + | Video Rate Limiter + |-------------------------------------------------------------------------- + | + | This value represents the number of requests permitted to stream video per minute. + | If set to a value less than or equal to zero, the limiter shall be unlimited. + | If set to a value greater than 0, the limiter shall restrict by that value. + | + */ + + 'rate_limiter' => (int) env('VIDEO_RATE_LIMITER', -1), + /* |-------------------------------------------------------------------------- | Video Scripts diff --git a/routes/video.php b/routes/video.php index 0ab99268b..19c836d19 100644 --- a/routes/video.php +++ b/routes/video.php @@ -14,4 +14,4 @@ $isVideoStreamingAllowed = Str::of('is_feature_enabled:') Route::get('/{video}', [VideoController::class, 'show']) ->name('video.show') - ->middleware([$isVideoStreamingAllowed, 'without_trashed:video', 'record_view:video']); + ->middleware([$isVideoStreamingAllowed, 'without_trashed:video', 'throttle:video', 'record_view:video']); diff --git a/tests/Feature/Http/Wiki/Video/VideoTest.php b/tests/Feature/Http/Wiki/Video/VideoTest.php index c44a2e537..ee3d27a0e 100644 --- a/tests/Feature/Http/Wiki/Video/VideoTest.php +++ b/tests/Feature/Http/Wiki/Video/VideoTest.php @@ -7,11 +7,14 @@ namespace Tests\Feature\Http\Wiki\Video; use App\Constants\Config\FlagConstants; use App\Constants\Config\VideoConstants; use App\Enums\Http\StreamingMethod; +use App\Events\Wiki\Video\VideoThrottled; +use App\Jobs\SendDiscordNotificationJob; use App\Models\Wiki\Video; use Illuminate\Foundation\Testing\WithFaker; -use Illuminate\Foundation\Testing\WithoutEvents; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Storage; use Symfony\Component\HttpFoundation\StreamedResponse; use Tests\TestCase; @@ -22,7 +25,6 @@ use Tests\TestCase; class VideoTest extends TestCase { use WithFaker; - use WithoutEvents; /** * If video streaming is disabled through the 'flags.allow_video_streams' property, @@ -32,6 +34,8 @@ class VideoTest extends TestCase */ public function testVideoStreamingNotAllowedForbidden(): void { + $this->withoutEvents(); + Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED)); Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, false); @@ -50,6 +54,8 @@ class VideoTest extends TestCase */ public function testSoftDeleteVideoStreamingForbidden(): void { + $this->withoutEvents(); + Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED)); Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true); @@ -70,6 +76,8 @@ class VideoTest extends TestCase */ public function testViewRecordingNotAllowed(): void { + $this->withoutEvents(); + Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED)); Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true); @@ -89,6 +97,8 @@ class VideoTest extends TestCase */ public function testViewRecordingIsAllowed(): void { + $this->withoutEvents(); + Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED)); Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true); @@ -108,6 +118,8 @@ class VideoTest extends TestCase */ public function testViewRecordingCooldown(): void { + $this->withoutEvents(); + Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED)); Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true); @@ -129,6 +141,8 @@ class VideoTest extends TestCase */ public function testInvalidStreamingMethodError(): void { + $this->withoutEvents(); + Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED)); Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true); @@ -148,6 +162,8 @@ class VideoTest extends TestCase */ public function testStreamedThroughResponse(): void { + $this->withoutEvents(); + Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED)); Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true); @@ -167,6 +183,8 @@ class VideoTest extends TestCase */ public function testStreamedThroughNginxRedirect(): void { + $this->withoutEvents(); + Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED)); Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true); @@ -178,4 +196,102 @@ class VideoTest extends TestCase $response->assertHeader('X-Accel-Redirect'); } + + /** + * If the video rate limit is less than or equal to zero, videos shall not be throttled. + * + * @return void + */ + public function testNotThrottled(): void + { + $this->withoutEvents(); + + Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED)); + + Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true); + Config::set(VideoConstants::STREAMING_METHOD_QUALIFIED, StreamingMethod::getRandomValue()); + + $video = Video::factory()->createOne(); + + $response = $this->get(route('video.show', ['video' => $video])); + + $response->assertHeaderMissing('X-RateLimit-Limit'); + $response->assertHeaderMissing('X-RateLimit-Remaining'); + } + + /** + * If the video rate limit is greater than or equal to zero, videos shall be throttled. + * + * @return void + */ + public function testRateLimited(): void + { + $this->withoutEvents(); + + Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED)); + + Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true); + Config::set(VideoConstants::STREAMING_METHOD_QUALIFIED, StreamingMethod::getRandomValue()); + Config::set(VideoConstants::RATE_LIMITER_QUALIFIED, $this->faker->randomDigitNotNull()); + + $video = Video::factory()->createOne(); + + $response = $this->get(route('video.show', ['video' => $video])); + + $response->assertHeader('X-RateLimit-Limit'); + $response->assertHeader('X-RateLimit-Remaining'); + } + + /** + * If the video rate limit attempt is exceeded, a VideoThrottled event shall be dispatched. + * + * @return void + */ + public function testThrottledEvent(): void + { + $limit = $this->faker->randomDigitNotNull(); + + Event::fake(); + + Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED)); + + Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true); + Config::set(VideoConstants::STREAMING_METHOD_QUALIFIED, StreamingMethod::getRandomValue()); + Config::set(VideoConstants::RATE_LIMITER_QUALIFIED, $limit); + + $video = Video::factory()->createOne(); + + Collection::times($limit + 1, function () use ($video) { + $this->get(route('video.show', ['video' => $video])); + }); + + Event::assertDispatched(VideoThrottled::class); + } + + /** + * If the video rate limit attempt is exceeded, a SendDiscordNotification job shall be dispatched. + * + * @return void + */ + public function testThrottledNotification(): void + { + $limit = $this->faker->randomDigitNotNull(); + + Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED)); + + Bus::fake(SendDiscordNotificationJob::class); + + Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true); + Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true); + Config::set(VideoConstants::STREAMING_METHOD_QUALIFIED, StreamingMethod::getRandomValue()); + Config::set(VideoConstants::RATE_LIMITER_QUALIFIED, $limit); + + $video = Video::factory()->createOne(); + + Collection::times($limit + 1, function () use ($video) { + $this->get(route('video.show', ['video' => $video])); + }); + + Bus::assertDispatched(SendDiscordNotificationJob::class); + } }