feat: add configurable throttling to video (#536)

This commit is contained in:
paranarimasu
2023-03-01 08:29:01 -06:00
committed by GitHub
parent 134a82ecc9
commit c0464406d3
6 changed files with 208 additions and 3 deletions
+2
View File
@@ -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';
+56
View File
@@ -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
View File
@@ -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()));
});
});
}
}
+13
View File
@@ -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
+1 -1
View File
@@ -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']);
+118 -2
View File
@@ -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);
}
}