From f3a707e41b1ccb9d2ba6eccbeb4c75b213f7f330 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Sat, 25 Apr 2026 10:23:05 +0200 Subject: [PATCH] Make API responses uniform --- app/Extensions/helper/helpers.php | 84 +++++++++----- app/Http/Controllers/Api/ApiController.php | 2 +- .../Controllers/Api/ApiInformController.php | 6 +- app/Http/Controllers/Api/ApiV2Controller.php | 56 ++++++---- app/Http/Controllers/BasePageController.php | 6 +- app/Http/Controllers/GetNzbController.php | 8 +- app/Http/Controllers/RssController.php | 10 +- .../Middleware/ThrottleApiRequestsByToken.php | 19 +++- docs/newznab_api_specification.txt | 17 ++- docs/nntmux_api_v2.md | 15 ++- .../nntmux_api_v2.postman_collection.json | 18 +-- resources/views/api/apidesc.blade.php | 10 +- resources/views/api/apiv2desc.blade.php | 5 + resources/views/rss/rssdesc.blade.php | 4 + routes/web.php | 4 +- tests/Feature/ApiRequestMatrixTest.php | 72 ++++++++++-- tests/Feature/NzbAndRssAccessTest.php | 104 +++++++++++++++--- 17 files changed, 330 insertions(+), 110 deletions(-) diff --git a/app/Extensions/helper/helpers.php b/app/Extensions/helper/helpers.php index c5cf42621..ded022d00 100644 --- a/app/Extensions/helper/helpers.php +++ b/app/Extensions/helper/helpers.php @@ -755,7 +755,7 @@ if (! function_exists('unzipGzipFile')) { function unzipGzipFile(string $filePath): false|string { $string = ''; - $gzFile = @gzopen($filePath, 'rb', false); + $gzFile = @gzopen($filePath, 'rb', 0); if ($gzFile) { while (! gzeof($gzFile)) { $temp = gzread($gzFile, 1024); @@ -914,42 +914,68 @@ if (! function_exists('imdb_trailers')) { } } +if (! function_exists('apiErrorDetails')) { + /** + * @return array{code:int,message:string,status:int,header:string} + */ + function apiErrorDetails(int $errorCode = 900, string $errorText = ''): array + { + [$defaultText, $status, $errorHeader] = match ($errorCode) { + 100 => ['Incorrect user credentials', 401, 'HTTP/1.1 401 Unauthorized'], + 101 => ['Account suspended', 403, 'HTTP/1.1 403 Forbidden'], + 102 => ['Insufficient privileges/not authorized', 401, 'HTTP/1.1 401 Unauthorized'], + 103 => ['Registration denied', 403, 'HTTP/1.1 403 Forbidden'], + 104 => ['Registrations are closed', 403, 'HTTP/1.1 403 Forbidden'], + 105 => ['Invalid registration (Email Address Taken)', 403, 'HTTP/1.1 403 Forbidden'], + 106 => ['Invalid registration (Email Address Bad Format)', 403, 'HTTP/1.1 403 Forbidden'], + 107 => ['Registration Failed (Data error)', 400, 'HTTP/1.1 400 Bad Request'], + 200 => ['Missing parameter', 400, 'HTTP/1.1 400 Bad Request'], + 201 => ['Incorrect parameter', 400, 'HTTP/1.1 400 Bad Request'], + 202 => ['No such function', 404, 'HTTP/1.1 404 Not Found'], + 203 => ['Function not available', 400, 'HTTP/1.1 400 Bad Request'], + 300 => ['No such item', 404, 'HTTP/1.1 404 Not Found'], + 310 => ['Item already exists', 409, 'HTTP/1.1 409 Conflict'], + 500 => ['Request limit reached', 429, 'HTTP/1.1 429 Too Many Requests'], + 501 => ['Download limit reached', 429, 'HTTP/1.1 429 Too Many Requests'], + 600 => ['Failed to load NZB', 400, 'HTTP/1.1 400 Bad Request'], + 601 => ['NZB is duplicate', 409, 'HTTP/1.1 409 Conflict'], + 602 => ['NZB is for a non-existent group', 400, 'HTTP/1.1 400 Bad Request'], + 603 => ['NZB failed to write to disk', 500, 'HTTP/1.1 500 Internal Server Error'], + 910 => ['API disabled', 401, 'HTTP/1.1 401 Unauthorized'], + default => ['Unknown error', 400, 'HTTP/1.1 400 Bad Request'], + }; + + return [ + 'code' => $errorCode, + 'message' => $errorText !== '' ? $errorText : $defaultText, + 'status' => $status, + 'header' => $errorHeader, + ]; + } +} + +if (! function_exists('apiJsonError')) { + function apiJsonError(int $errorCode = 900, string $errorText = ''): mixed + { + $error = apiErrorDetails($errorCode, $errorText); + + return response() + ->json(['error' => $error['message']], $error['status']) + ->header('X-NNTmux', 'API ERROR ['.$error['code'].'] '.$error['message']); + } +} + if (! function_exists('showApiError')) { function showApiError(int $errorCode = 900, string $errorText = ''): mixed { - $errorHeader = 'HTTP 1.1 400 Bad Request'; - if ($errorText === '') { - [$errorText, $errorHeader] = match ($errorCode) { - 100 => ['Incorrect user credentials', 'HTTP 1.1 401 Unauthorized'], - 101 => ['Account suspended', 'HTTP 1.1 403 Forbidden'], - 102 => ['Insufficient privileges/not authorized', 'HTTP 1.1 401 Unauthorized'], - 103 => ['Registration denied', 'HTTP 1.1 403 Forbidden'], - 104 => ['Registrations are closed', 'HTTP 1.1 403 Forbidden'], - 105 => ['Invalid registration (Email Address Taken)', 'HTTP 1.1 403 Forbidden'], - 106 => ['Invalid registration (Email Address Bad Format)', 'HTTP 1.1 403 Forbidden'], - 107 => ['Registration Failed (Data error)', 'HTTP 1.1 400 Bad Request'], - 200 => ['Missing parameter', 'HTTP 1.1 400 Bad Request'], - 201 => ['Incorrect parameter', 'HTTP 1.1 400 Bad Request'], - 202 => ['No such function', 'HTTP 1.1 404 Not Found'], - 203 => ['Function not available', 'HTTP 1.1 400 Bad Request'], - 300 => ['No such item', 'HTTP 1.1 404 Not Found'], - 310 => ['Item already exists', 'HTTP 1.1 409 Conflict'], - 500 => ['Request limit reached', 'HTTP 1.1 429 Too Many Requests'], - 501 => ['Download limit reached', 'HTTP 1.1 429 Too Many Requests'], - 600 => ['Failed to load NZB', 'HTTP 1.1 400 Bad Request'], - 601 => ['NZB is duplicate', 'HTTP 1.1 409 Conflict'], - 602 => ['NZB is for a non-existent group', 'HTTP 1.1 400 Bad Request'], - 603 => ['NZB failed to write to disk', 'HTTP 1.1 500 Internal Server Error'], - 910 => ['API disabled', 'HTTP 1.1 401 Unauthorized'], - default => ['Unknown error', 'HTTP 1.1 400 Bad Request'], - }; - } + $error = apiErrorDetails($errorCode, $errorText); + $errorText = $error['message']; $response = "\n". '\n"; - return response($response)->header('Content-type', 'text/xml')->header('Content-Length', (string) strlen($response))->header('X-NNTmux', 'API ERROR ['.$errorCode.'] '.$errorText)->header('HTTP/1.1', $errorHeader); + return response($response, $error['status'])->header('Content-type', 'text/xml')->header('Content-Length', (string) strlen($response))->header('X-NNTmux', 'API ERROR ['.$errorCode.'] '.$errorText)->header('HTTP/1.1', $error['header']); } } diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php index 550c19dd7..4b89c5d37 100644 --- a/app/Http/Controllers/Api/ApiController.php +++ b/app/Http/Controllers/Api/ApiController.php @@ -137,7 +137,7 @@ class ApiController extends BasePageController return showApiError(100, 'Incorrect user credentials (wrong API key)'); } - if ($res->hasRole('Disabled')) { + if ($res->is_disabled || $res->hasRole('Disabled')) { return showApiError(101); } diff --git a/app/Http/Controllers/Api/ApiInformController.php b/app/Http/Controllers/Api/ApiInformController.php index 5e381efc7..d2d2f9752 100644 --- a/app/Http/Controllers/Api/ApiInformController.php +++ b/app/Http/Controllers/Api/ApiInformController.php @@ -27,7 +27,11 @@ class ApiInformController extends Controller $apiToken = $request->has('api_token') && ! empty($request->input('api_token')) ? $request->input('api_token') : ''; $user = User::findVerifiedByApiToken((string) $request->input('api_token')); if (! $user) { - return response()->json(['message' => 'Indexer inform error, wrong api key!'], 404); + return apiJsonError(100); + } + + if ($user->is_disabled || $user->hasRole('Disabled')) { + return apiJsonError(101); } if (! empty($releaseObName) && ! empty($releasePrName) && ! empty($apiToken)) { diff --git a/app/Http/Controllers/Api/ApiV2Controller.php b/app/Http/Controllers/Api/ApiV2Controller.php index 967836f37..f79360004 100644 --- a/app/Http/Controllers/Api/ApiV2Controller.php +++ b/app/Http/Controllers/Api/ApiV2Controller.php @@ -50,23 +50,35 @@ class ApiV2Controller extends BasePageController } /** - * Validate API token and return cached user, or null on failure. + * Validate API token and return cached user, or a normalized JSON API error on failure. * Caches user lookup for 5 minutes to reduce DB hits. */ - private function resolveUser(Request $request): ?User + private function resolveUser(Request $request): User|JsonResponse { if ($request->missing('api_token') || $request->isNotFilled('api_token')) { - return null; + return apiJsonError(200, 'Missing parameter (api_token)'); } $apiToken = $request->input('api_token'); $userCacheKey = 'api_user:'.md5((string) $apiToken); - return Cache::remember($userCacheKey, 300, function () use ($apiToken) { - return User::verifiedApiTokenQuery((string) $apiToken) - ->with('role') + $user = Cache::remember($userCacheKey, 300, function () use ($apiToken) { + return User::query() + ->whereApiToken((string) $apiToken) ->first(); }); + + if (! $user || ! $user->hasVerifiedEmail()) { + return apiJsonError(100); + } + + if ($user->is_disabled || $user->hasRole('Disabled')) { + return apiJsonError(101); + } + + $user->loadMissing('role'); + + return $user; } /** @@ -189,8 +201,8 @@ class ApiV2Controller extends BasePageController public function movie(Request $request): JsonResponse { $user = $this->resolveUser($request); - if (! $user) { - return response()->json(['error' => 'Missing or invalid API key'], 403); + if ($user instanceof JsonResponse) { + return $user; } UserRequest::addApiRequest($user->id, $request->getRequestUri()); @@ -256,8 +268,8 @@ class ApiV2Controller extends BasePageController public function audio(Request $request): JsonResponse|Response { $user = $this->resolveUser($request); - if (! $user) { - return response()->json(['error' => 'Missing or invalid API key'], 403); + if ($user instanceof JsonResponse) { + return $user; } UserRequest::addApiRequest($user->id, $request->getRequestUri()); @@ -308,8 +320,8 @@ class ApiV2Controller extends BasePageController public function books(Request $request): JsonResponse|Response { $user = $this->resolveUser($request); - if (! $user) { - return response()->json(['error' => 'Missing or invalid API key'], 403); + if ($user instanceof JsonResponse) { + return $user; } UserRequest::addApiRequest($user->id, $request->getRequestUri()); @@ -360,8 +372,8 @@ class ApiV2Controller extends BasePageController public function anime(Request $request): JsonResponse|Response { $user = $this->resolveUser($request); - if (! $user) { - return response()->json(['error' => 'Missing or invalid API key'], 403); + if ($user instanceof JsonResponse) { + return $user; } UserRequest::addApiRequest($user->id, $request->getRequestUri()); @@ -416,8 +428,8 @@ class ApiV2Controller extends BasePageController public function apiSearch(Request $request): JsonResponse { $user = $this->resolveUser($request); - if (! $user) { - return response()->json(['error' => 'Missing or invalid API key'], 403); + if ($user instanceof JsonResponse) { + return $user; } UserRequest::addApiRequest($user->id, $request->getRequestUri()); @@ -483,8 +495,8 @@ class ApiV2Controller extends BasePageController public function tv(Request $request): JsonResponse { $user = $this->resolveUser($request); - if (! $user) { - return response()->json(['error' => 'Missing or invalid API key'], 403); + if ($user instanceof JsonResponse) { + return $user; } $catExclusions = User::getCategoryExclusionById($user->id); @@ -559,8 +571,8 @@ class ApiV2Controller extends BasePageController public function getNzb(Request $request): Application|ResponseFactory|JsonResponse|Redirector|RedirectResponse { $user = $this->resolveUser($request); - if (! $user) { - return response()->json(['error' => 'Missing or invalid API key'], 403); + if ($user instanceof JsonResponse) { + return $user; } event(new UserAccessedApi($user, $request->ip())); @@ -576,8 +588,8 @@ class ApiV2Controller extends BasePageController public function details(Request $request): JsonResponse { $user = $this->resolveUser($request); - if (! $user) { - return response()->json(['error' => 'Missing or invalid API key'], 403); + if ($user instanceof JsonResponse) { + return $user; } if ($request->missing('id')) { return response()->json(['error' => 'Missing parameter (guid is required for single release details)'], 400); diff --git a/app/Http/Controllers/BasePageController.php b/app/Http/Controllers/BasePageController.php index f59ddbf59..905037b54 100644 --- a/app/Http/Controllers/BasePageController.php +++ b/app/Http/Controllers/BasePageController.php @@ -17,7 +17,7 @@ use Illuminate\View\View; class BasePageController extends Controller { /** - * @var Collection + * @var \Illuminate\Support\Collection */ public \Illuminate\Support\Collection $settings; // @phpstan-ignore property.phpDocType, class.notFound, missingType.generics @@ -81,6 +81,10 @@ class BasePageController extends Controller if (Auth::check()) { $userId = Auth::id(); $this->userdata = User::find($userId); + if (! $this->userdata?->hasVerifiedEmail()) { + return $next($request); + } + // Cache category exclusions per user (5 minutes) $this->userdata->categoryexclusions = $this->rememberWithCacheFallback( 'user_category_exclusions_'.$userId, diff --git a/app/Http/Controllers/GetNzbController.php b/app/Http/Controllers/GetNzbController.php index 7aa9b7551..951a2cd33 100644 --- a/app/Http/Controllers/GetNzbController.php +++ b/app/Http/Controllers/GetNzbController.php @@ -98,7 +98,11 @@ class GetNzbController extends BasePageController */ private function getUserDataFromSession(): array|Response { - if ($this->userdata->hasRole('Disabled')) { + if (! $this->userdata->hasVerifiedEmail()) { + return showApiError(100); + } + + if ($this->userdata->is_disabled || $this->userdata->hasRole('Disabled')) { return showApiError(101); } @@ -126,7 +130,7 @@ class GetNzbController extends BasePageController return showApiError(100); } - if ($user->hasRole('Disabled')) { + if ($user->is_disabled || $user->hasRole('Disabled')) { return showApiError(101); } diff --git a/app/Http/Controllers/RssController.php b/app/Http/Controllers/RssController.php index 7d0261108..5384a558c 100644 --- a/app/Http/Controllers/RssController.php +++ b/app/Http/Controllers/RssController.php @@ -214,13 +214,13 @@ class RssController extends BasePageController private function userCheck(Request $request): JsonResponse|array { if ($request->missing('api_token')) { - return response()->json(['error' => 'API key is required for viewing the RSS!'], 403); + return apiJsonError(200, 'Missing parameter (api_token)'); } $res = User::findVerifiedByApiToken((string) $request->input('api_token')); if ($res === null) { - return response()->json(['error' => 'Invalid RSS token'], 403); + return apiJsonError(100); } $uid = $res['id']; @@ -233,12 +233,12 @@ class RssController extends BasePageController $grabTime = UserDownload::whereUsersId($uid)->min('timestamp'); $oldestGrabTime = $grabTime !== null ? Carbon::createFromTimeString($grabTime)->toRfc2822String() : ''; - if ($res->hasRole('Disabled')) { - return response()->json(['error' => 'Your account is disabled'], 403); + if ($res->is_disabled || $res->hasRole('Disabled')) { + return apiJsonError(101); } if ($usedRequests > $maxRequests) { - return response()->json(['error' => 'You have reached your daily limit for API requests!'], 403); + return apiJsonError(500, 'Request limit reached ('.$usedRequests.'/'.$maxRequests.')'); } UserRequest::addApiRequest($rssToken, $request->getRequestUri()); diff --git a/app/Http/Middleware/ThrottleApiRequestsByToken.php b/app/Http/Middleware/ThrottleApiRequestsByToken.php index 44e5a3333..60fb825ab 100644 --- a/app/Http/Middleware/ThrottleApiRequestsByToken.php +++ b/app/Http/Middleware/ThrottleApiRequestsByToken.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Middleware; +use App\Enums\UserRole; use App\Models\User; use Closure; use Illuminate\Cache\RateLimiter; @@ -53,17 +54,23 @@ class ThrottleApiRequestsByToken private function resolveUser(Request $request): ?User { - $apiToken = $request->input('api_token'); + $apiToken = $request->input('api_token') ?? $request->input('apikey'); if (! is_string($apiToken) || $apiToken === '') { return null; } - return Cache::remember('api_rate_limit_user:'.md5($apiToken), 300, static function () use ($apiToken) { + $user = Cache::remember('api_rate_limit_user:'.md5($apiToken), 300, static function () use ($apiToken) { return User::verifiedApiTokenQuery($apiToken) - ->select(['id', 'api_token', 'rate_limit']) + ->select(['id', 'roles_id', 'api_token', 'rate_limit']) ->first(); }); + + if ($user?->roles_id === UserRole::DISABLED->value) { + return null; + } + + return $user; } private function rateLimitKey(int $userId): string @@ -74,14 +81,16 @@ class ThrottleApiRequestsByToken private function buildTooManyRequestsResponse(string $rateLimitKey, int $maxAttempts): JsonResponse { $retryAfter = max(1, $this->limiter->availableIn($rateLimitKey)); + $error = apiErrorDetails(500, 'Request limit reached'); return response()->json([ - 'error' => 'API rate limit exceeded.', + 'error' => $error['message'], 'retry_after' => $retryAfter, - ], 429, [ + ], $error['status'], [ 'Retry-After' => (string) $retryAfter, 'X-RateLimit-Limit' => (string) $maxAttempts, 'X-RateLimit-Remaining' => '0', + 'X-NNTmux' => 'API ERROR ['.$error['code'].'] '.$error['message'], ]); } } diff --git a/docs/newznab_api_specification.txt b/docs/newznab_api_specification.txt index 20e96aa91..74dc5a4dd 100755 --- a/docs/newznab_api_specification.txt +++ b/docs/newznab_api_specification.txt @@ -34,8 +34,12 @@ newznab-tmux https://github.com/NNTmux/newznab-tmux implemented by NNTmux. It merges upstream newznab additions with NNTmux-specific compatibility extensions. - All API endpoints return HTTP 200 on protocol success and report semantic - errors in the body (``), unless otherwise noted. + API endpoints return HTTP 200 on protocol success and report semantic + errors in the body (``). Error responses use the HTTP + status that matches the XML error code (for example 400 for missing or + incorrect parameters, 401 for invalid credentials, 403 for suspended + accounts, 404 for missing functions/items, and 429 for request/download + limits). 1.1 Notation @@ -300,6 +304,15 @@ newznab-tmux https://github.com/NNTmux/newznab-tmux 6. Error Codes + HTTP status mapping: + - 100, 102, 910: 401 Unauthorized + - 101, 103, 104, 105, 106: 403 Forbidden + - 107, 200, 201, 203, 600, 602, 900: 400 Bad Request + - 202, 300: 404 Not Found + - 310, 601: 409 Conflict + - 500, 501: 429 Too Many Requests + - 603: 500 Internal Server Error + 100 Incorrect user credentials 101 Account suspended 102 Insufficient privileges/not authorized diff --git a/docs/nntmux_api_v2.md b/docs/nntmux_api_v2.md index e1036bfb1..3ed345a8c 100644 --- a/docs/nntmux_api_v2.md +++ b/docs/nntmux_api_v2.md @@ -20,16 +20,23 @@ https:///api/v2 - `GET /capabilities` is public. - All other v2 routes require `api_token`. -- Route-level middleware uses token-aware throttling (`apiRateLimit`). -- Controller-level auth errors return: +- Route-level middleware uses token-aware throttling (`apiRateLimit`) and accepts `api_token` or the legacy `apikey` alias when that middleware is reused. +- Controller-level auth errors return a JSON error envelope: ```json { - "error": "Missing or invalid API key" + "error": "Missing parameter (api_token)" } ``` -with HTTP `403`. +Common auth/rate-limit statuses: + +| HTTP | Error | +|---:|---| +| 400 | `Missing parameter (api_token)` | +| 401 | `Incorrect user credentials` | +| 403 | `Account suspended` | +| 429 | `Request limit reached` | ## Common Query Parameters diff --git a/docs/postman/nntmux_api_v2.postman_collection.json b/docs/postman/nntmux_api_v2.postman_collection.json index 06bc800b1..cc729139e 100644 --- a/docs/postman/nntmux_api_v2.postman_collection.json +++ b/docs/postman/nntmux_api_v2.postman_collection.json @@ -183,8 +183,8 @@ ] } }, - "status": "Forbidden", - "code": 403, + "status": "Bad Request", + "code": 400, "_postman_previewlanguage": "json", "header": [ { @@ -193,7 +193,7 @@ } ], "cookie": [], - "body": "{\n \"error\": \"Missing or invalid API key\"\n}" + "body": "{\n \"error\": \"Missing parameter (api_token)\"\n}" } ] }, @@ -439,8 +439,8 @@ ] } }, - "status": "Forbidden", - "code": 403, + "status": "Bad Request", + "code": 400, "_postman_previewlanguage": "json", "header": [ { @@ -449,7 +449,7 @@ } ], "cookie": [], - "body": "{\n \"error\": \"Missing or invalid API key\"\n}" + "body": "{\n \"error\": \"Missing parameter (api_token)\"\n}" } ] }, @@ -561,8 +561,8 @@ ] } }, - "status": "Forbidden", - "code": 403, + "status": "Bad Request", + "code": 400, "_postman_previewlanguage": "json", "header": [ { @@ -571,7 +571,7 @@ } ], "cookie": [], - "body": "{\n \"error\": \"Missing or invalid API key\"\n}" + "body": "{\n \"error\": \"Missing parameter (api_token)\"\n}" } ] } diff --git a/resources/views/api/apidesc.blade.php b/resources/views/api/apidesc.blade.php index e5a3119f1..dbc0f6e31 100644 --- a/resources/views/api/apidesc.blade.php +++ b/resources/views/api/apidesc.blade.php @@ -8,8 +8,14 @@

- Here lives the documentation for the API for accessing NZB and index data. API functions can be called by either - logged in users, or by providing an API key. + Here lives the documentation for the API for accessing NZB and index data. API functions require your API key + except public capabilities; direct /getnzb + downloads may also use your logged-in browser session. +

+

+ XML error responses include a <error code="..."> + body and use matching HTTP statuses such as 400 for missing/incorrect parameters, 401 for invalid credentials, + 403 for suspended accounts, 404 for missing functions/items, and 429 for request or download limits.

@auth
diff --git a/resources/views/api/apiv2desc.blade.php b/resources/views/api/apiv2desc.blade.php index afc5ca4b3..8cc5e471b 100644 --- a/resources/views/api/apiv2desc.blade.php +++ b/resources/views/api/apiv2desc.blade.php @@ -11,6 +11,11 @@

Here lives the documentation for the API v2 for accessing NZB and index data. API functions can be called by providing an API token.

+

+ The capabilities endpoint is public. Other v2 endpoints return JSON errors with matching HTTP statuses: + 400 for missing api_token, 401 for invalid or unverified tokens, + 403 for suspended accounts, and 429 for request limits. +

@auth
diff --git a/resources/views/rss/rssdesc.blade.php b/resources/views/rss/rssdesc.blade.php index 7c7d39446..239f46605 100644 --- a/resources/views/rss/rssdesc.blade.php +++ b/resources/views/rss/rssdesc.blade.php @@ -12,6 +12,10 @@ Here you can find RSS feeds for various categories and content types. These feeds provide either descriptions or direct NZB downloads based on your preferences.

+

+ RSS feed URLs require api_token for access by feed readers or other non-browser clients. Token errors return JSON with matching HTTP statuses: + 400 for a missing token, 401 for invalid or unverified tokens, 403 for suspended accounts, and 429 for request limits. +

@auth
diff --git a/routes/web.php b/routes/web.php index 5a1bdc7d2..16c935c86 100644 --- a/routes/web.php +++ b/routes/web.php @@ -154,8 +154,8 @@ Route::middleware(['auth', 'isVerified'])->group(function () { }); Route::match(['GET', 'POST'], 'details/{guid}', [DetailsController::class, 'show'])->name('details'); - Route::match(['GET', 'POST'], 'getnzb/{guid}', [GetNzbController::class, 'getNzb'])->name('getnzb.guid'); - Route::match(['GET', 'POST'], 'getnzb', [GetNzbController::class, 'getNzb'])->name('getnzb'); + Route::match(['GET', 'POST'], 'getnzb/{guid}', [GetNzbController::class, 'getNzb'])->withoutMiddleware(['auth', 'isVerified'])->name('getnzb.guid'); + Route::match(['GET', 'POST'], 'getnzb', [GetNzbController::class, 'getNzb'])->withoutMiddleware(['auth', 'isVerified'])->name('getnzb'); Route::match(['GET', 'POST'], 'rsshelp', [RssController::class, 'showRssDesc'])->name('rsshelp'); Route::match(['GET', 'POST'], 'profile', [ProfileController::class, 'show'])->name('profile'); Route::match(['GET', 'POST'], 'apihelp', [ApiHelpController::class, 'index'])->name('apihelp'); diff --git a/tests/Feature/ApiRequestMatrixTest.php b/tests/Feature/ApiRequestMatrixTest.php index f3858f62b..91ba04cf7 100644 --- a/tests/Feature/ApiRequestMatrixTest.php +++ b/tests/Feature/ApiRequestMatrixTest.php @@ -45,7 +45,7 @@ class ApiRequestMatrixTest extends TestCase $response = $this->get('/api/v1/api?t=search&apikey='.$token.'&q=test&sort=bad_value'); - $response->assertOk(); + $response->assertBadRequest(); $response->assertSee('assertSee('Incorrect parameter (sort', false); } @@ -56,11 +56,48 @@ class ApiRequestMatrixTest extends TestCase $response = $this->get('/api/v1/api?t=search&apikey='.$token.'&q=test&maxage=abc'); - $response->assertOk(); + $response->assertBadRequest(); $response->assertSee('assertSee('maxage must be numeric', false); } + public function test_v1_invalid_apikey_returns_xml_401_error(): void + { + $response = $this->get('/api/v1/api?t=search&apikey=invalid-token&q=test'); + + $response->assertUnauthorized(); + $response->assertSee('', false); + } + + public function test_v2_invalid_api_token_returns_json_401_error(): void + { + $this->getJson('/api/v2/search?api_token=invalid-token&id=test') + ->assertUnauthorized() + ->assertJsonPath('error', 'Incorrect user credentials'); + } + + public function test_v2_disabled_user_is_rejected_before_request_is_recorded(): void + { + DB::table('users')->insert([ + 'username' => 'disabled_matrix_user', + 'email' => 'disabled-matrix@example.test', + 'password' => bcrypt('secret'), + 'roles_id' => 3, + 'api_token' => 'disabled-matrix-token', + 'verified' => 1, + 'email_verified_at' => now(), + 'rate_limit' => 60, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->getJson('/api/v2/search?api_token=disabled-matrix-token&id=test') + ->assertForbidden() + ->assertJsonPath('error', 'Account suspended'); + + $this->assertSame(0, DB::table('user_requests')->count()); + } + public function test_v2_invalid_sort_returns_json_400_error(): void { $token = (string) DB::table('users')->value('api_token'); @@ -331,15 +368,28 @@ class ApiRequestMatrixTest extends TestCase ]); DB::table('roles')->insert([ - 'id' => 1, - 'name' => 'User', - 'guard_name' => 'web', - 'rate_limit' => 60, - 'apirequests' => 1000, - 'downloadrequests' => 100, - 'addyears' => 0, - 'created_at' => now(), - 'updated_at' => now(), + [ + 'id' => 1, + 'name' => 'User', + 'guard_name' => 'web', + 'rate_limit' => 60, + 'apirequests' => 1000, + 'downloadrequests' => 100, + 'addyears' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'id' => 3, + 'name' => 'Disabled', + 'guard_name' => 'web', + 'rate_limit' => 60, + 'apirequests' => 0, + 'downloadrequests' => 0, + 'addyears' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ], ]); DB::table('users')->insert([ diff --git a/tests/Feature/NzbAndRssAccessTest.php b/tests/Feature/NzbAndRssAccessTest.php index 4ab1c20fb..7a44b731d 100644 --- a/tests/Feature/NzbAndRssAccessTest.php +++ b/tests/Feature/NzbAndRssAccessTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Feature; +use App\Models\User; use App\View\Composers\GlobalDataComposer; use Illuminate\Contracts\Console\Kernel; use Illuminate\Database\Schema\Blueprint; @@ -95,7 +96,7 @@ class NzbAndRssAccessTest extends TestCase { $response = $this->get('/getnzb?id=test-guid'); - $response->assertOk(); + $response->assertBadRequest(); $response->assertSee('', false); $response->assertDontSee('name="login"', false); $response->assertDontSee('Login', false); @@ -105,7 +106,7 @@ class NzbAndRssAccessTest extends TestCase { $response = $this->get('/api/v1/api?t=get&id=test-guid'); - $response->assertOk(); + $response->assertBadRequest(); $response->assertSee('<error code="200" description="Missing parameter (apikey)"/>', false); $response->assertDontSee('name="login"', false); $response->assertDontSee('<title>Login', false); @@ -115,9 +116,9 @@ class NzbAndRssAccessTest extends TestCase { $response = $this->getJson('/api/v2/getnzb?id=test-guid'); - $response->assertForbidden(); + $response->assertBadRequest(); $response->assertJson([ - 'error' => 'Missing or invalid API key', + 'error' => 'Missing parameter (api_token)', ]); $response->assertDontSee('name="login"', false); $response->assertDontSee('<title>Login', false); @@ -136,12 +137,49 @@ class NzbAndRssAccessTest extends TestCase $response = $this->get('/getnzb?r=unverified-nzb-token&id=test-guid'); - $response->assertOk(); + $response->assertUnauthorized(); $response->assertSee('<error code="100" description="Incorrect user credentials"/>', false); $response->assertDontSee('name="login"', false); $response->assertDontSee('<title>Login', false); } + public function test_logged_in_unverified_user_cannot_download_nzb_via_session(): void + { + $userId = DB::table('users')->insertGetId([ + 'username' => 'unverified-session-nzb-user', + 'email' => 'unverified-session-nzb@example.test', + 'password' => 'secret', + 'api_token' => 'unverified-session-nzb-token', + 'verified' => 0, + 'email_verified_at' => null, + ]); + $user = User::query()->findOrFail($userId); + + $response = $this->actingAs($user)->get('/getnzb?id=test-guid'); + + $response->assertUnauthorized(); + $response->assertSee('<error code="100" description="Incorrect user credentials"/>', false); + $response->assertDontSee('name="login"', false); + $response->assertDontSee('<title>Login', false); + } + + public function test_logged_in_unverified_user_is_redirected_away_from_site_pages(): void + { + $userId = DB::table('users')->insertGetId([ + 'username' => 'unverified-site-user', + 'email' => 'unverified-site@example.test', + 'password' => 'secret', + 'api_token' => 'unverified-site-token', + 'verified' => 0, + 'email_verified_at' => null, + ]); + $user = User::query()->findOrFail($userId); + + $this->actingAs($user) + ->get('/profile') + ->assertRedirect(route('verification.notice')); + } + public function test_legacy_api_rejects_unverified_users(): void { DB::table('users')->insert([ @@ -155,7 +193,7 @@ class NzbAndRssAccessTest extends TestCase $response = $this->get('/api/v1/api?t=search&apikey=unverified-api-token'); - $response->assertOk(); + $response->assertUnauthorized(); $response->assertSee('<error code="100" description="Incorrect user credentials (wrong API key)"/>', false); $response->assertDontSee('name="login"', false); $response->assertDontSee('<title>Login', false); @@ -174,21 +212,37 @@ class NzbAndRssAccessTest extends TestCase $response = $this->getJson('/api/v2/search?api_token=unverified-api-v2-token&id=test'); - $response->assertForbidden(); + $response->assertUnauthorized(); $response->assertJson([ - 'error' => 'Missing or invalid API key', + 'error' => 'Incorrect user credentials', ]); $response->assertDontSee('name="login"', false); $response->assertDontSee('<title>Login', false); } + public function test_api_inform_rejects_unverified_users(): void + { + DB::table('users')->insert([ + 'username' => 'unverified-inform-user', + 'email' => 'unverified-inform@example.test', + 'password' => 'secret', + 'api_token' => 'unverified-inform-token', + 'verified' => 0, + 'email_verified_at' => null, + ]); + + $this->getJson('/api/inform/release?api_token=unverified-inform-token&relo=old.name&relp=new.name') + ->assertUnauthorized() + ->assertJsonPath('error', 'Incorrect user credentials'); + } + public function test_rss_feed_without_api_token_returns_403_error_instead_of_login_redirect(): void { $response = $this->get('/rss/full-feed'); - $response->assertForbidden(); + $response->assertBadRequest(); $response->assertJson([ - 'error' => 'API key is required for viewing the RSS!', + 'error' => 'Missing parameter (api_token)', ]); $response->assertDontSee('name="login"', false); $response->assertDontSee('<title>Login', false); @@ -207,9 +261,9 @@ class NzbAndRssAccessTest extends TestCase $response = $this->get('/rss/full-feed?api_token=unverified-rss-token'); - $response->assertForbidden(); + $response->assertUnauthorized(); $response->assertJson([ - 'error' => 'Invalid RSS token', + 'error' => 'Incorrect user credentials', ]); $response->assertDontSee('name="login"', false); $response->assertDontSee('<title>Login', false); @@ -253,7 +307,7 @@ class NzbAndRssAccessTest extends TestCase $this->getJson('/api/test-rate-limit?api_token=low-limit-token') ->assertStatus(429) - ->assertJsonPath('error', 'API rate limit exceeded.'); + ->assertJsonPath('error', 'Request limit reached'); $this->getJson('/api/test-rate-limit?api_token=high-limit-token') ->assertOk() @@ -270,7 +324,28 @@ class NzbAndRssAccessTest extends TestCase $this->getJson('/api/test-rate-limit?api_token=high-limit-token') ->assertStatus(429) - ->assertJsonPath('error', 'API rate limit exceeded.'); + ->assertJsonPath('error', 'Request limit reached'); + } + + public function test_api_rate_limit_accepts_legacy_apikey_parameter(): void + { + DB::table('users')->insert([ + 'username' => 'legacy-low-limit-user', + 'email' => 'legacy-low@example.test', + 'password' => 'secret', + 'api_token' => 'legacy-low-limit-token', + 'rate_limit' => 1, + 'verified' => 1, + ]); + + $this->getJson('/api/test-rate-limit?apikey=legacy-low-limit-token') + ->assertOk() + ->assertHeader('X-RateLimit-Limit', '1') + ->assertHeader('X-RateLimit-Remaining', '0'); + + $this->getJson('/api/test-rate-limit?apikey=legacy-low-limit-token') + ->assertStatus(429) + ->assertJsonPath('error', 'Request limit reached'); } private function setEnvironmentValue(string $key, ?string $value): void @@ -311,6 +386,7 @@ class NzbAndRssAccessTest extends TestCase $table->string('username')->unique(); $table->string('email')->unique(); $table->string('password'); + $table->unsignedInteger('roles_id')->default(1); $table->string('api_token')->nullable()->index(); $table->integer('rate_limit')->default(60); $table->boolean('verified')->default(true);