feat: video throttling (#637)

This commit is contained in:
Kyrch
2025-05-12 02:47:47 -03:00
committed by GitHub
parent 002358af1b
commit 6f8bcd9ba0
8 changed files with 202 additions and 1 deletions
+1
View File
@@ -291,6 +291,7 @@ VIDEO_DISKS=videos_local
VIDEO_DISK_ROOT=
VIDEO_PATH=/video
VIDEO_URL=
VIDEO_RATE_LIMITER=90
VIDEO_STREAMING_METHOD=response
VIDEO_NGINX_REDIRECT=
+1
View File
@@ -289,6 +289,7 @@ VIDEO_DISKS=videos_local
VIDEO_DISK_ROOT=
VIDEO_PATH=/video
VIDEO_URL=
VIDEO_RATE_LIMITER=90
VIDEO_STREAMING_METHOD=response
VIDEO_NGINX_REDIRECT=
+2
View File
@@ -17,6 +17,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';
+66
View File
@@ -0,0 +1,66 @@
<?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->value,
]);
}
/**
* Get Discord channel the message will be sent to.
*
* @return string
*/
public function getDiscordChannel(): string
{
return Config::get(ServiceConstants::ADMIN_DISCORD_CHANNEL_QUALIFIED);
}
/**
* Determine if the message should be sent.
*
* @return bool
*/
public function shouldSendDiscordMessage(): bool
{
return true;
}
}
+18
View File
@@ -9,12 +9,15 @@ use App\Constants\Config\AudioConstants;
use App\Constants\Config\DumpConstants;
use App\Constants\Config\VideoConstants;
use App\Enums\Auth\SpecialPermission;
use App\Events\Wiki\Video\VideoThrottled;
use App\Models\Auth\User;
use App\Models\Wiki\Video;
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;
@@ -70,6 +73,21 @@ class RouteServiceProvider extends ServiceProvider
return Limit::perMinute(90)->by(Auth::check() ? Auth::id() : $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()));
});
});
$this->routes(function () {
Route::middleware('web')
->domain(Config::get('web.url'))
+13
View File
@@ -38,6 +38,19 @@ return [
'path' => env('VIDEO_PATH'),
/*
|--------------------------------------------------------------------------
| 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 Streaming
+1 -1
View File
@@ -15,4 +15,4 @@ $isVideoStreamingAllowed = Str::of(EnsureFeaturesAreActive::class)
Route::get('/{video}', [VideoController::class, 'show'])
->name('video.show')
->middleware([$isVideoStreamingAllowed, 'record_view:video']);
->middleware([$isVideoStreamingAllowed, 'record_view:video', 'throttle:video']);
+100
View File
@@ -8,13 +8,17 @@ use App\Constants\Config\VideoConstants;
use App\Constants\FeatureConstants;
use App\Enums\Auth\SpecialPermission;
use App\Enums\Http\StreamingMethod;
use App\Events\Wiki\Video\VideoThrottled;
use App\Features\AllowVideoStreams;
use App\Jobs\SendDiscordNotificationJob;
use App\Models\Auth\User;
use App\Models\Wiki\Video;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Arr;
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 Laravel\Pennant\Feature;
use Laravel\Sanctum\Sanctum;
@@ -207,4 +211,100 @@ 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
{
Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED));
Feature::activate(AllowVideoStreams::class);
Config::set(VideoConstants::STREAMING_METHOD_QUALIFIED, Arr::random(StreamingMethod::cases())->value);
Config::set(VideoConstants::RATE_LIMITER_QUALIFIED, -1);
$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
{
Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED));
Feature::activate(AllowVideoStreams::class);
Config::set(VideoConstants::STREAMING_METHOD_QUALIFIED, Arr::random(StreamingMethod::cases())->value);
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));
Feature::activate(AllowVideoStreams::class);
Config::set(VideoConstants::STREAMING_METHOD_QUALIFIED, Arr::random(StreamingMethod::cases())->value);
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);
Feature::activate(FeatureConstants::ALLOW_DISCORD_NOTIFICATIONS);
Feature::activate(AllowVideoStreams::class);
Config::set(VideoConstants::STREAMING_METHOD_QUALIFIED, Arr::random(StreamingMethod::cases())->value);
Config::set(VideoConstants::RATE_LIMITER_QUALIFIED, $limit);
Event::fakeExcept(VideoThrottled::class);
$video = Video::factory()->createOne();
Collection::times($limit + 1, function () use ($video) {
$this->get(route('video.show', ['video' => $video]));
});
Bus::assertDispatched(SendDiscordNotificationJob::class);
}
}