refactor: use enums for auth guards, middleware groups, and rate limits

This commit is contained in:
Roardom
2025-06-22 19:46:13 +00:00
parent cf95a900e5
commit 46bfccc8b7
17 changed files with 146 additions and 47 deletions

View File

@@ -16,6 +16,7 @@ declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Enums\AuthGuard;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
@@ -33,7 +34,7 @@ class UpdateUserPassword implements UpdatesUserPasswords
public function update(User $user, array $input): void
{
Validator::make($input, [
'current_password' => ['required', 'string', 'current_password:web'],
'current_password' => ['required', 'string', 'current_password:'.AuthGuard::WEB->value],
'password' => $this->passwordRules(),
], [
'current_password.current_password' => __('The provided password does not match your current password.'),

23
app/Enums/AuthGuard.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Roardom <roardom@protonmail.com>
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
declare(strict_types=1);
namespace App\Enums;
enum AuthGuard: string
{
case API = 'api';
case WEB = 'web';
}

View File

@@ -0,0 +1,30 @@
<?php
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Roardom <roardom@protonmail.com>
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
declare(strict_types=1);
namespace App\Enums;
enum GlobalRateLimit: string
{
case ANNOUNCE = 'announce';
case API = 'api';
case AUTHENTICATED_IMAGES = 'authenticated-images';
case CHAT = 'chat';
case IGDB = 'igdb';
case RSS = 'rss';
case SEARCH = 'search';
case TMDB = 'tmdb';
case WEB = 'web';
}

View File

@@ -0,0 +1,26 @@
<?php
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Roardom <roardom@protonmail.com>
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
declare(strict_types=1);
namespace App\Enums;
enum MiddlewareGroup: string
{
case ANNOUNCE = 'announce';
case API = 'api';
case CHAT = 'chat';
case RSS = 'rss';
case WEB = 'web';
}

View File

@@ -17,6 +17,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\API;
use App\DTO\TorrentSearchFiltersDTO;
use App\Enums\AuthGuard;
use App\Helpers\Bencode;
use App\Helpers\TorrentHelper;
use App\Helpers\TorrentTools;
@@ -487,7 +488,7 @@ class TorrentController extends BaseController
TorrentHelper::approveHelper($torrent->id);
}
return $this->sendResponse(route('torrent.download.rsskey', ['id' => $torrent->id, 'rsskey' => auth('api')->user()->rsskey]), 'Torrent uploaded successfully.');
return $this->sendResponse(route('torrent.download.rsskey', ['id' => $torrent->id, 'rsskey' => auth(AuthGuard::API->value)->user()->rsskey]), 'Torrent uploaded successfully.');
}
/**
@@ -714,8 +715,8 @@ class TorrentController extends BaseController
// Auth keys must not be cached
$torrents->through(function ($torrent) {
$torrent['attributes']['download_link'] = route('torrent.download.rsskey', ['id' => $torrent['id'], 'rsskey' => auth('api')->user()->rsskey]);
$torrent['attributes']['magnet_link'] = config('torrent.magnet') ? 'magnet:?dn='.$torrent['attributes']['name'].'&xt=urn:btih:'.$torrent['attributes']['info_hash'].'&as='.route('torrent.download.rsskey', ['id' => $torrent['id'], 'rsskey' => auth('api')->user()->rsskey]).'&tr='.route('announce', ['passkey' => auth('api')->user()->passkey]).'&xl='.$torrent['attributes']['size'] : null;
$torrent['attributes']['download_link'] = route('torrent.download.rsskey', ['id' => $torrent['id'], 'rsskey' => auth(AuthGuard::API->value)->user()->rsskey]);
$torrent['attributes']['magnet_link'] = config('torrent.magnet') ? 'magnet:?dn='.$torrent['attributes']['name'].'&xt=urn:btih:'.$torrent['attributes']['info_hash'].'&as='.route('torrent.download.rsskey', ['id' => $torrent['id'], 'rsskey' => auth(AuthGuard::API->value)->user()->rsskey]).'&tr='.route('announce', ['passkey' => auth('api')->user()->passkey]).'&xl='.$torrent['attributes']['size'] : null;
return $torrent;
});

View File

@@ -16,6 +16,8 @@ declare(strict_types=1);
namespace App\Http;
use App\Enums\GlobalRateLimit;
use App\Enums\MiddlewareGroup;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
@@ -44,7 +46,7 @@ class Kernel extends HttpKernel
* @var array<string, array<int, class-string|string>>
*/
protected $middlewareGroups = [
'web' => [
MiddlewareGroup::WEB->value => [
\Illuminate\Cookie\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
@@ -54,9 +56,9 @@ class Kernel extends HttpKernel
\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class,
Middleware\UpdateLastAction::class,
\HDVinnie\SecureHeaders\SecureHeadersMiddleware::class,
'throttle:web',
'throttle:'.GlobalRateLimit::WEB->value,
],
'chat' => [
MiddlewareGroup::CHAT->value => [
\Illuminate\Cookie\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
@@ -66,16 +68,16 @@ class Kernel extends HttpKernel
\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class,
Middleware\UpdateLastAction::class,
\HDVinnie\SecureHeaders\SecureHeadersMiddleware::class,
'throttle:chat',
'throttle:'.GlobalRateLimit::CHAT->value,
],
'api' => [
'throttle:api',
MiddlewareGroup::API->value => [
'throttle:'.GlobalRateLimit::API->value,
],
'announce' => [
'throttle:announce',
MiddlewareGroup::ANNOUNCE->value => [
'throttle:'.GlobalRateLimit::ANNOUNCE->value,
],
'rss' => [
'throttle:rss',
MiddlewareGroup::RSS->value => [
'throttle:'.GlobalRateLimit::RSS->value,
],
];

View File

@@ -16,6 +16,7 @@ declare(strict_types=1);
namespace App\Http\Resources;
use App\Enums\AuthGuard;
use Illuminate\Http\Resources\Json\JsonResource;
/**
@@ -76,8 +77,8 @@ class TorrentResource extends JsonResource
'distributor_id' => $this->when($this->distributor_id !== null, $this->distributor_id),
'region_id' => $this->when($this->region_id !== null, $this->region_id),
'created_at' => $this->created_at,
'download_link' => route('torrent.download.rsskey', ['id' => $this->id, 'rsskey' => auth('api')->user()->rsskey]),
'magnet_link' => $this->when(config('torrent.magnet') === true, 'magnet:?dn='.$this->name.'&xt=urn:btih:'.bin2hex($this->info_hash).'&as='.route('torrent.download.rsskey', ['id' => $this->id, 'rsskey' => auth('api')->user()->rsskey]).'&tr='.route('announce', ['passkey' => auth('api')->user()->passkey]).'&xl='.$this->size),
'download_link' => route('torrent.download.rsskey', ['id' => $this->id, 'rsskey' => auth(AuthGuard::API->value)->user()->rsskey]),
'magnet_link' => $this->when(config('torrent.magnet') === true, 'magnet:?dn='.$this->name.'&xt=urn:btih:'.bin2hex($this->info_hash).'&as='.route('torrent.download.rsskey', ['id' => $this->id, 'rsskey' => auth(AuthGuard::API->value)->user()->rsskey]).'&tr='.route('announce', ['passkey' => auth('api')->user()->passkey]).'&xl='.$this->size),
'details_link' => route('torrents.show', ['id' => $this->id]),
],
];

View File

@@ -16,6 +16,7 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Enums\GlobalRateLimit;
use App\Models\IgdbCompany;
use App\Models\IgdbGame;
use App\Models\IgdbGenre;
@@ -55,7 +56,7 @@ class ProcessIgdbGameJob implements ShouldQueue
return [
Skip::when(cache()->has("igdb-game-scraper:{$this->id}")),
new WithoutOverlapping((string) $this->id)->dontRelease()->expireAfter(30),
new RateLimited('igdb'),
new RateLimited(GlobalRateLimit::IGDB),
];
}

View File

@@ -16,6 +16,7 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Enums\GlobalRateLimit;
use App\Models\TmdbCollection;
use App\Models\TmdbCompany;
use App\Models\TmdbCredit;
@@ -58,7 +59,7 @@ class ProcessMovieJob implements ShouldQueue
return [
Skip::when(cache()->has("tmdb-movie-scraper:{$this->id}")),
new WithoutOverlapping((string) $this->id)->dontRelease()->expireAfter(30),
new RateLimited('tmdb'),
new RateLimited(GlobalRateLimit::TMDB),
];
}

View File

@@ -16,6 +16,7 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Enums\GlobalRateLimit;
use App\Models\TmdbCompany;
use App\Models\TmdbCredit;
use App\Models\TmdbGenre;
@@ -74,7 +75,7 @@ class ProcessTvJob implements ShouldQueue
return [
Skip::when(cache()->has("tmdb-tv-scraper:{$this->id}")),
new WithoutOverlapping((string) $this->id)->dontRelease()->expireAfter(30),
new RateLimited('tmdb'),
new RateLimited(GlobalRateLimit::TMDB),
];
}

View File

@@ -16,6 +16,8 @@ declare(strict_types=1);
namespace App\Providers;
use App\Enums\GlobalRateLimit;
use App\Enums\MiddlewareGroup;
use Illuminate\Auth\Middleware\RedirectIfAuthenticated;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
@@ -45,21 +47,21 @@ class RouteServiceProvider extends ServiceProvider
$this->routes(function (): void {
Route::prefix('api')
->middleware(['chat'])
->middleware(MiddlewareGroup::CHAT->value)
->group(base_path('routes/vue.php'));
Route::middleware('web')
Route::middleware(MiddlewareGroup::WEB->value)
->group(base_path('routes/web.php'));
Route::prefix('api')
->middleware('api')
->middleware(MiddlewareGroup::API->value)
->group(base_path('routes/api.php'));
Route::prefix('announce')
->middleware('announce')
->middleware(MiddlewareGroup::ANNOUNCE->value)
->group(base_path('routes/announce.php'));
Route::middleware('rss')
Route::middleware(MiddlewareGroup::RSS->value)
->group(base_path('routes/rss.php'));
});
@@ -71,7 +73,7 @@ class RouteServiceProvider extends ServiceProvider
*/
protected function configureRateLimiting(): void
{
RateLimiter::for('web', fn (Request $request): Limit => $request->user()
RateLimiter::for(GlobalRateLimit::WEB, fn (Request $request): Limit => $request->user()
? Limit::perMinute(
cache()->remember(
'group:'.$request->user()->group_id.':is_modo',
@@ -83,14 +85,14 @@ class RouteServiceProvider extends ServiceProvider
)
->by('web'.$request->user()->id)
: Limit::perMinute(8)->by('web'.$request->ip()));
RateLimiter::for('api', fn (Request $request) => Limit::perMinute(30)->by('api'.$request->ip()));
RateLimiter::for('announce', fn (Request $request) => Limit::perMinute(500)->by('announce'.$request->ip()));
RateLimiter::for('chat', fn (Request $request) => Limit::perMinute(60)->by('chat'.($request->user()?->id ?? $request->ip())));
RateLimiter::for('rss', fn (Request $request) => Limit::perMinute(30)->by('rss'.$request->ip()));
RateLimiter::for('authenticated-images', fn (Request $request): Limit => Limit::perMinute(200)->by('authenticated-images:'.$request->user()->id));
RateLimiter::for('search', fn (Request $request): Limit => Limit::perMinute(100)->by('search:'.$request->user()->id));
RateLimiter::for('tmdb', fn (): Limit => Limit::perSecond(2));
RateLimiter::for('igdb', fn (): Limit => Limit::perSecond(2));
RateLimiter::for(GlobalRateLimit::API, fn (Request $request) => Limit::perMinute(30)->by('api'.$request->ip()));
RateLimiter::for(GlobalRateLimit::ANNOUNCE, fn (Request $request) => Limit::perMinute(500)->by('announce'.$request->ip()));
RateLimiter::for(GlobalRateLimit::CHAT, fn (Request $request) => Limit::perMinute(60)->by('chat'.($request->user()?->id ?? $request->ip())));
RateLimiter::for(GlobalRateLimit::RSS, fn (Request $request) => Limit::perMinute(30)->by('rss'.$request->ip()));
RateLimiter::for(GlobalRateLimit::AUTHENTICATED_IMAGES, fn (Request $request): Limit => Limit::perMinute(200)->by('authenticated-images:'.$request->user()->id));
RateLimiter::for(GlobalRateLimit::SEARCH, fn (Request $request): Limit => Limit::perMinute(100)->by('search:'.$request->user()->id));
RateLimiter::for(GlobalRateLimit::TMDB, fn (): Limit => Limit::perSecond(2));
RateLimiter::for(GlobalRateLimit::IGDB, fn (): Limit => Limit::perSecond(2));
}
protected function removeIndexPhpFromUrl(): void

View File

@@ -2,6 +2,8 @@
declare(strict_types=1);
use App\Enums\AuthGuard;
return [
/*
|--------------------------------------------------------------------------
@@ -15,7 +17,7 @@ return [
*/
'defaults' => [
'guard' => 'web',
'guard' => AuthGuard::WEB->value,
'passwords' => 'users',
],
@@ -37,12 +39,12 @@ return [
*/
'guards' => [
'web' => [
AuthGuard::WEB->value => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
AuthGuard::API->value => [
'driver' => 'token',
'provider' => 'users',
'hash' => false,

View File

@@ -2,6 +2,8 @@
declare(strict_types=1);
use App\Enums\AuthGuard;
use App\Enums\MiddlewareGroup;
use App\Providers\RouteServiceProvider;
use Laravel\Fortify\Features;
@@ -17,7 +19,7 @@ return [
|
*/
'guard' => 'web',
'guard' => AuthGuard::WEB->value,
/*
|--------------------------------------------------------------------------
@@ -90,7 +92,7 @@ return [
|
*/
'middleware' => ['web'],
'middleware' => [MiddlewareGroup::WEB->value],
/*
|--------------------------------------------------------------------------

View File

@@ -2,6 +2,9 @@
declare(strict_types=1);
use App\Enums\AuthGuard;
use App\Enums\GlobalRateLimit;
use App\Enums\MiddlewareGroup;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\URL;
@@ -34,7 +37,7 @@ if (config('unit3d.proxy_scheme')) {
if (config('unit3d.root_url_override')) {
URL::forceRootUrl(config('unit3d.root_url_override'));
}
Route::middleware(['auth:api', 'banned'])->group(function (): void {
Route::middleware(['auth:'.AuthGuard::API->value, 'banned'])->group(function (): void {
// Torrents System
Route::prefix('torrents')->group(function (): void {
Route::get('/', [App\Http\Controllers\API\TorrentController::class, 'index'])->name('api.torrents.index');
@@ -48,7 +51,7 @@ Route::middleware(['auth:api', 'banned'])->group(function (): void {
});
// Internal front-end web API routes
Route::name('api.')->middleware(['web', 'auth', 'banned', 'verified'])->group(function (): void {
Route::name('api.')->middleware([MiddlewareGroup::WEB->value, 'auth', 'banned', 'verified'])->group(function (): void {
Route::prefix('bookmarks')->name('bookmarks.')->group(function (): void {
Route::post('/{torrentId}', [App\Http\Controllers\API\BookmarkController::class, 'store'])->name('store');
Route::delete('/{torrentId}', [App\Http\Controllers\API\BookmarkController::class, 'destroy'])->name('destroy');
@@ -59,5 +62,5 @@ Route::name('api.')->middleware(['web', 'auth', 'banned', 'verified'])->group(fu
Route::post('/{postId}/dislike', [App\Http\Controllers\API\DislikeController::class, 'store'])->name('dislike.store');
});
Route::get('/quicksearch', [App\Http\Controllers\API\QuickSearchController::class, 'index'])->name('quicksearch')->middleware('throttle:search')->withoutMiddleware('throttle:web');
Route::get('/quicksearch', [App\Http\Controllers\API\QuickSearchController::class, 'index'])->name('quicksearch')->middleware('throttle:'.GlobalRateLimit::SEARCH->value)->withoutMiddleware('throttle:'.GlobalRateLimit::WEB->value);
});

View File

@@ -14,6 +14,7 @@ declare(strict_types=1);
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
use App\Enums\GlobalRateLimit;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\URL;
use Laravel\Fortify\Http\Controllers\AuthenticatedSessionController;
@@ -107,7 +108,7 @@ Route::middleware('language')->group(function (): void {
});
// Authenticated Images
Route::prefix('authenticated-images')->name('authenticated_images.')->middleware('throttle:authenticated-images')->withoutMiddleware('throttle:web')->group(function (): void {
Route::prefix('authenticated-images')->name('authenticated_images.')->middleware('throttle:'.GlobalRateLimit::AUTHENTICATED_IMAGES->value)->withoutMiddleware('throttle:'.GlobalRateLimit::WEB->value)->group(function (): void {
Route::get('/article-images/{article}', [App\Http\Controllers\AuthenticatedImageController::class, 'articleImage'])->name('article_image');
Route::get('/category-images/{category}', [App\Http\Controllers\AuthenticatedImageController::class, 'categoryImage'])->name('category_image');
Route::get('/playlist-images/{playlist}', [App\Http\Controllers\AuthenticatedImageController::class, 'playlistImage'])->name('playlist_image');

View File

@@ -14,6 +14,7 @@ declare(strict_types=1);
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
use App\Enums\AuthGuard;
use App\Models\Category;
use App\Models\Torrent;
use App\Models\User;
@@ -23,7 +24,7 @@ test('filter returns an ok response', function (): void {
$user = User::factory()->create();
$response = $this->actingAs($user, 'api')->getJson('api/torrents/filter');
$response = $this->actingAs($user, AuthGuard::API->value)->getJson('api/torrents/filter');
$response->assertOk();
$response->assertJsonStructure([
]);

View File

@@ -16,6 +16,7 @@ declare(strict_types=1);
namespace Tests\Old;
use App\Enums\AuthGuard;
use App\Enums\ModerationStatus;
use App\Models\Category;
use App\Models\Resolution;
@@ -39,7 +40,7 @@ final class TorrentControllerTest extends TestCase
{
$user = User::factory()->create();
$response = $this->actingAs($user, 'api')->getJson('api/torrents/filter');
$response = $this->actingAs($user, AuthGuard::API->value)->getJson('api/torrents/filter');
$response->assertOk()
->assertJson([
@@ -65,7 +66,7 @@ final class TorrentControllerTest extends TestCase
{
$user = User::factory()->create();
$response = $this->actingAs($user, 'api')->getJson(route('api.torrents.index'));
$response = $this->actingAs($user, AuthGuard::API->value)->getJson(route('api.torrents.index'));
$response->assertOk()
->assertJson([
@@ -99,7 +100,7 @@ final class TorrentControllerTest extends TestCase
'status' => ModerationStatus::APPROVED,
]);
$response = $this->actingAs($user, 'api')->getJson(\sprintf('api/torrents/%s', $torrent->id));
$response = $this->actingAs($user, AuthGuard::API->value)->getJson(\sprintf('api/torrents/%s', $torrent->id));
$response->assertOk()
->assertJson([
@@ -123,7 +124,7 @@ final class TorrentControllerTest extends TestCase
$torrent = Torrent::factory()->make();
$response = $this->actingAs($user, 'api')->postJson('api/torrents/upload', [
$response = $this->actingAs($user, AuthGuard::API->value)->postJson('api/torrents/upload', [
'torrent' => new UploadedFile(
base_path('tests/Resources/Pony Music - Mind Fragments (2014).torrent'),
'Pony Music - Mind Fragments (2014).torrent'