Skip to content

Commit

Permalink
feat: add configurable throttling to video (#536)
Browse files Browse the repository at this point in the history
  • Loading branch information
paranarimasu authored Mar 1, 2023
1 parent 134a82e commit c046440
Show file tree
Hide file tree
Showing 6 changed files with 208 additions and 3 deletions.
2 changes: 2 additions & 0 deletions app/Constants/Config/VideoConstants.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
56 changes: 56 additions & 0 deletions app/Events/Wiki/Video/VideoThrottled.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace App\Events\Wiki\Video;

use App\Constants\Config\ServiceConstants;
use App\Contracts\Events\DiscordMessageEvent;
use App\Enums\Discord\EmbedColor;
use App\Models\Wiki\Video;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Config;
use NotificationChannels\Discord\DiscordMessage;

/**
* Class VideoThrottled.
*/
class VideoThrottled implements DiscordMessageEvent
{
use Dispatchable;
use SerializesModels;

/**
* Create new event instance.
*
* @param Video $video
* @param string $user
*/
public function __construct(protected Video $video, protected string $user)
{
}

/**
* Get Discord message payload.
*
* @return DiscordMessage
*/
public function getDiscordMessage(): DiscordMessage
{
return DiscordMessage::create('', [
'description' => "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);
}
}
18 changes: 18 additions & 0 deletions app/Providers/RouteServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,19 @@
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;

Expand Down Expand Up @@ -105,5 +108,20 @@ protected function configureRateLimiting(): void

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()));
});
});
}
}
13 changes: 13 additions & 0 deletions config/video.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,19 @@

'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
Expand Down
2 changes: 1 addition & 1 deletion routes/video.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@

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']);
120 changes: 118 additions & 2 deletions tests/Feature/Http/Wiki/Video/VideoTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
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;
Expand All @@ -22,7 +25,6 @@
class VideoTest extends TestCase
{
use WithFaker;
use WithoutEvents;

/**
* If video streaming is disabled through the 'flags.allow_video_streams' property,
Expand All @@ -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);
Expand All @@ -50,6 +54,8 @@ public function testVideoStreamingNotAllowedForbidden(): void
*/
public function testSoftDeleteVideoStreamingForbidden(): void
{
$this->withoutEvents();

Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED));

Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true);
Expand All @@ -70,6 +76,8 @@ public function testSoftDeleteVideoStreamingForbidden(): void
*/
public function testViewRecordingNotAllowed(): void
{
$this->withoutEvents();

Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED));

Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true);
Expand All @@ -89,6 +97,8 @@ public function testViewRecordingNotAllowed(): void
*/
public function testViewRecordingIsAllowed(): void
{
$this->withoutEvents();

Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED));

Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true);
Expand All @@ -108,6 +118,8 @@ public function testViewRecordingIsAllowed(): void
*/
public function testViewRecordingCooldown(): void
{
$this->withoutEvents();

Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED));

Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true);
Expand All @@ -129,6 +141,8 @@ public function testViewRecordingCooldown(): void
*/
public function testInvalidStreamingMethodError(): void
{
$this->withoutEvents();

Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED));

Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true);
Expand All @@ -148,6 +162,8 @@ public function testInvalidStreamingMethodError(): void
*/
public function testStreamedThroughResponse(): void
{
$this->withoutEvents();

Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED));

Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true);
Expand All @@ -167,6 +183,8 @@ public function testStreamedThroughResponse(): void
*/
public function testStreamedThroughNginxRedirect(): void
{
$this->withoutEvents();

Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED));

Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true);
Expand All @@ -178,4 +196,102 @@ public function testStreamedThroughNginxRedirect(): void

$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);
}
}

0 comments on commit c046440

Please sign in to comment.