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.
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('', false);
$response->assertDontSee('name="login"', false);
$response->assertDontSee('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('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('', false);
$response->assertDontSee('name="login"', false);
$response->assertDontSee('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('', false);
+ $response->assertDontSee('name="login"', false);
+ $response->assertDontSee('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('', false);
$response->assertDontSee('name="login"', false);
$response->assertDontSee('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('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('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('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);