mirror of
https://github.com/AnimeThemes/animethemes-server.git
synced 2026-07-11 01:24:46 +02:00
feat: add configurable throttling to video (#536)
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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']);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user