mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Update APIv2 endpoints
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class NzbUploadException extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message, public readonly int $status)
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Data\Api\ReleaseData;
|
||||
use App\Exceptions\NzbUploadException;
|
||||
use App\Facades\Search;
|
||||
use App\Http\Controllers\BasePageController;
|
||||
use App\Http\Controllers\GetNzbController;
|
||||
@@ -17,6 +18,7 @@ use App\Services\Api\ApiReleaseRowCache;
|
||||
use App\Services\Api\ApiUsageService;
|
||||
use App\Services\Api\ApiUserResolver;
|
||||
use App\Services\Api\V2\ApiV2Presenter;
|
||||
use App\Services\Nzb\NzbUploadStagingService;
|
||||
use App\Services\Releases\ReleaseBrowseService;
|
||||
use App\Services\Releases\ReleaseSearchService;
|
||||
use App\Services\Search\DTO\SearchCursor;
|
||||
@@ -27,6 +29,7 @@ use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Routing\Redirector;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -52,6 +55,8 @@ class ApiV2Controller extends BasePageController
|
||||
|
||||
private SearchCursorCodec $cursorCodec;
|
||||
|
||||
private NzbUploadStagingService $nzbUploadStagingService;
|
||||
|
||||
/** @var array{enabled: bool, offset: int, limit: int, query_hash: string, cursor: SearchCursor|null} */
|
||||
private array $paginationContext = ['enabled' => false, 'offset' => 0, 'limit' => 0, 'query_hash' => '', 'cursor' => null];
|
||||
|
||||
@@ -70,6 +75,7 @@ class ApiV2Controller extends BasePageController
|
||||
?ApiV2Presenter $presenter = null,
|
||||
?ApiCapabilitiesService $capabilitiesService = null,
|
||||
?SearchCursorCodec $cursorCodec = null,
|
||||
?NzbUploadStagingService $nzbUploadStagingService = null,
|
||||
) {
|
||||
$this->releaseSearchService = $releaseSearchService;
|
||||
$this->releaseBrowseService = $releaseBrowseService;
|
||||
@@ -80,13 +86,14 @@ class ApiV2Controller extends BasePageController
|
||||
$this->presenter = $presenter ?? app(ApiV2Presenter::class);
|
||||
$this->capabilitiesService = $capabilitiesService ?? app(ApiCapabilitiesService::class);
|
||||
$this->cursorCodec = $cursorCodec ?? app(SearchCursorCodec::class);
|
||||
$this->nzbUploadStagingService = $nzbUploadStagingService ?? app(NzbUploadStagingService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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|JsonResponse
|
||||
private function resolveUser(Request $request, bool $enforceRequestLimit = true): User|JsonResponse
|
||||
{
|
||||
if ($request->missing('api_token') || $request->isNotFilled('api_token')) {
|
||||
return apiJsonError(200, 'Missing parameter (api_token)');
|
||||
@@ -106,11 +113,13 @@ class ApiV2Controller extends BasePageController
|
||||
|
||||
$user->loadMissing('role');
|
||||
|
||||
$userStats = $this->userStatsFor($user);
|
||||
$thisRequests = (int) ($userStats->api_count ?? 0);
|
||||
$maxRequests = (int) $user->role->apirequests;
|
||||
if ($thisRequests > $maxRequests) {
|
||||
return apiJsonError(500, 'Request limit reached');
|
||||
if ($enforceRequestLimit) {
|
||||
$userStats = $this->userStatsFor($user);
|
||||
$thisRequests = (int) ($userStats->api_count ?? 0);
|
||||
$maxRequests = (int) $user->role->apirequests;
|
||||
if ($thisRequests > $maxRequests) {
|
||||
return apiJsonError(500, 'Request limit reached');
|
||||
}
|
||||
}
|
||||
|
||||
return $user;
|
||||
@@ -744,6 +753,42 @@ class ApiV2Controller extends BasePageController
|
||||
return $this->jsonResponse(['data' => 'No such item (the guid you provided has no release in our database)'], 404);
|
||||
}
|
||||
|
||||
public function nzbAdd(Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->resolveUser($request, false);
|
||||
if ($user instanceof JsonResponse) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
if (! $user->can_post) {
|
||||
return $this->jsonResponse(['error' => 'Insufficient privileges/not authorized'], 403);
|
||||
}
|
||||
|
||||
$nzb = $request->file('nzb');
|
||||
if (! $nzb instanceof UploadedFile) {
|
||||
return $this->jsonResponse(['error' => 'Missing parameter (nzb file is required)'], 400);
|
||||
}
|
||||
|
||||
$nfo = $request->file('nfo');
|
||||
if ($nfo !== null && ! $nfo instanceof UploadedFile) {
|
||||
return $this->jsonResponse(['error' => 'Invalid nfo upload'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$staged = $this->nzbUploadStagingService->stage($nzb, $nfo);
|
||||
} catch (NzbUploadException $exception) {
|
||||
return $this->jsonResponse(['error' => $exception->getMessage()], $exception->status);
|
||||
}
|
||||
|
||||
return $this->jsonResponse([
|
||||
'success' => true,
|
||||
'status' => 'staged',
|
||||
'name' => $staged['name'],
|
||||
'category' => $request->input('cat'),
|
||||
'files' => $staged['files'],
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function details(Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->resolveUser($request);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Nzb;
|
||||
|
||||
use App\Exceptions\NzbUploadException;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
final class NzbUploadStagingService
|
||||
{
|
||||
private const MAX_NFO_SIZE = 65535;
|
||||
|
||||
public function __construct(private readonly Filesystem $filesystem) {}
|
||||
|
||||
/**
|
||||
* @return array{name:string,files:array{nzb:array{filename:string,type:string},nfo:array{filename:string,type:string}|null}}
|
||||
*/
|
||||
public function stage(UploadedFile $nzb, ?UploadedFile $nfo = null): array
|
||||
{
|
||||
[$nzbName, $nzbBase, $nzbContent] = $this->validateFile($nzb, 'nzb');
|
||||
$nfoDetails = $nfo === null ? null : $this->validateFile($nfo, 'nfo');
|
||||
|
||||
if ($nfoDetails !== null && strcasecmp($nzbBase, $nfoDetails[1]) !== 0) {
|
||||
throw new NzbUploadException('NZB and NFO filenames must have matching basenames', 400);
|
||||
}
|
||||
|
||||
$folder = config('nntmux.nzb_upload_folder');
|
||||
if (! is_string($folder) || trim($folder) === '') {
|
||||
throw new NzbUploadException('NZB upload folder is not configured', 500);
|
||||
}
|
||||
|
||||
$folder = rtrim($folder, DIRECTORY_SEPARATOR);
|
||||
if (! $this->filesystem->isDirectory($folder)
|
||||
&& ! $this->filesystem->makeDirectory($folder, 0775, true)) {
|
||||
throw new NzbUploadException('Failed to create NZB upload folder', 500);
|
||||
}
|
||||
|
||||
$files = [[$nzbName, $nzbContent, 'nzb']];
|
||||
if ($nfoDetails !== null) {
|
||||
$files[] = [$nfoDetails[0], $nfoDetails[2], 'nfo'];
|
||||
}
|
||||
|
||||
foreach ($files as [$filename]) {
|
||||
if ($this->filesystem->exists($folder.DIRECTORY_SEPARATOR.$filename)) {
|
||||
throw new NzbUploadException("A staged file named {$filename} already exists", 409);
|
||||
}
|
||||
}
|
||||
|
||||
$created = [];
|
||||
try {
|
||||
foreach ($files as [$filename, $content, $type]) {
|
||||
$path = $folder.DIRECTORY_SEPARATOR.$filename;
|
||||
if ($this->filesystem->put($path, $content) === false) {
|
||||
throw new NzbUploadException("Failed to write {$filename} to disk", 500);
|
||||
}
|
||||
$created[] = $path;
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$rollbackFailed = false;
|
||||
foreach ($created as $path) {
|
||||
if ($this->filesystem->exists($path) && ! $this->filesystem->delete($path)) {
|
||||
$rollbackFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($rollbackFailed) {
|
||||
Log::channel('nzb_upload')->error('Failed to roll back partial API v2 upload', [
|
||||
'files' => array_map('basename', $created),
|
||||
]);
|
||||
|
||||
throw new NzbUploadException('Failed to write upload pair and roll back partial files', 500);
|
||||
}
|
||||
|
||||
if ($exception instanceof NzbUploadException) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
throw new NzbUploadException('Failed to write upload pair to disk', 500);
|
||||
}
|
||||
|
||||
foreach ($files as [$filename, , $type]) {
|
||||
Log::channel('nzb_upload')->info('File uploaded by API v2', [
|
||||
'filename' => $filename,
|
||||
'type' => $type,
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $nzbBase,
|
||||
'files' => [
|
||||
'nzb' => ['filename' => $nzbName, 'type' => 'nzb'],
|
||||
'nfo' => $nfoDetails === null ? null : ['filename' => $nfoDetails[0], 'type' => 'nfo'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{string,string,string} */
|
||||
private function validateFile(UploadedFile $file, string $expectedExtension): array
|
||||
{
|
||||
if (! $file->isValid()) {
|
||||
throw new NzbUploadException("Invalid {$expectedExtension} upload", 400);
|
||||
}
|
||||
|
||||
$filename = $file->getClientOriginalName();
|
||||
if ($filename === ''
|
||||
|| $filename === '.'
|
||||
|| $filename === '..'
|
||||
|| str_contains($filename, '/')
|
||||
|| str_contains($filename, '\\')
|
||||
|| str_contains($filename, "\0")) {
|
||||
throw new NzbUploadException("Unsafe {$expectedExtension} filename", 400);
|
||||
}
|
||||
|
||||
$extension = strtolower((string) pathinfo($filename, PATHINFO_EXTENSION));
|
||||
$basename = (string) pathinfo($filename, PATHINFO_FILENAME);
|
||||
if ($extension !== $expectedExtension || trim($basename) === '') {
|
||||
throw new NzbUploadException("The {$expectedExtension} field must contain a .{$expectedExtension} file", 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$content = $file->getContent();
|
||||
} catch (\Throwable) {
|
||||
throw new NzbUploadException("Failed to read {$expectedExtension} upload", 400);
|
||||
}
|
||||
|
||||
if ($expectedExtension === 'nzb' && ! isValidNewznabNzb($content)) {
|
||||
throw new NzbUploadException('Invalid NZB payload', 400);
|
||||
}
|
||||
if ($expectedExtension === 'nfo' && ($content === '' || strlen($content) > self::MAX_NFO_SIZE)) {
|
||||
throw new NzbUploadException('NFO upload must not be empty or exceed 65535 bytes', 400);
|
||||
}
|
||||
|
||||
return [$filename, $basename, $content];
|
||||
}
|
||||
}
|
||||
+52
-3
@@ -21,6 +21,7 @@ https://<host>/api/v2
|
||||
- `GET /capabilities` is public.
|
||||
- All other v2 routes require `api_token`.
|
||||
- Route-level middleware uses token-aware throttling (`apiRateLimit`) and accepts `api_token` or the legacy `apikey` alias when that middleware is reused.
|
||||
- `POST /nzbadd` is intentionally exempt from route-level rate limiting and API request quotas, and uploads are not recorded as API usage. Authentication and posting privileges still apply.
|
||||
- Controller-level auth errors return a JSON error envelope:
|
||||
|
||||
```json
|
||||
@@ -185,6 +186,57 @@ Selectors:
|
||||
|---|---|---|
|
||||
| `api_token` | yes | API token for a verified, enabled user. |
|
||||
| `nzb` | yes | Valid `.nzb` file staged for deferred import. |
|
||||
| `nfo` | no | Matching `.nfo` file, maximum 65,535 bytes. |
|
||||
| `cat` | no | Echoed as response metadata; it does not affect import categorization. |
|
||||
|
||||
When `nfo` is supplied, its basename must match the NZB basename
|
||||
case-insensitively (for example, `Release.nzb` and `Release.nfo`). The request
|
||||
is atomic: both files are validated before staging, existing files are never
|
||||
overwritten, and a partial write is rolled back.
|
||||
|
||||
NZB-only example:
|
||||
|
||||
```bash
|
||||
curl -X POST -F "api_token=<token>" -F "nzb=@Release.nzb" https://<host>/api/v2/nzbadd
|
||||
```
|
||||
|
||||
Paired example:
|
||||
|
||||
```bash
|
||||
curl -X POST -F "api_token=<token>" -F "cat=5040" -F "nzb=@Release.nzb" -F "nfo=@Release.nfo" https://<host>/api/v2/nzbadd
|
||||
```
|
||||
|
||||
Successful staging returns HTTP `201`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status": "staged",
|
||||
"name": "Release",
|
||||
"category": "5040",
|
||||
"files": {
|
||||
"nzb": { "filename": "Release.nzb", "type": "nzb" },
|
||||
"nfo": { "filename": "Release.nfo", "type": "nfo" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Staging does not synchronously create a release. The existing importer consumes
|
||||
the files from `NZB_UPLOAD_FOLDER` later. NZB-only responses set `files.nfo` to
|
||||
`null`.
|
||||
|
||||
## Response Models
|
||||
|
||||
### Search Envelope (`/search`, `/tv`, `/movies`, `/audio`, `/books`, `/anime`)
|
||||
|
||||
```json
|
||||
{
|
||||
"Total": 123,
|
||||
"apiCurrent": 2,
|
||||
"apiMax": 1000,
|
||||
"grabCurrent": 1,
|
||||
"grabMax": 100,
|
||||
"apiOldestTime": "Wed, 20 Nov 2024 12:00:00 +0000",
|
||||
"grabOldestTime": "",
|
||||
"results": []
|
||||
}
|
||||
@@ -226,9 +278,6 @@ The following are intentionally not part of v2 JSON API:
|
||||
- `commentadd`
|
||||
- `cartadd`
|
||||
- `cartdel`
|
||||
- `nzbadd`
|
||||
|
||||
NZB upload remains in v1 (`/api/v1/api?t=nzbadd`).
|
||||
|
||||
## Postman Collection
|
||||
|
||||
|
||||
@@ -574,7 +574,74 @@
|
||||
"body": "{\n \"error\": \"Missing parameter (api_token)\"\n}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Add NZB",
|
||||
"item": [
|
||||
{
|
||||
"name": "Stage NZB Only",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{ "key": "api_token", "value": "{{apiToken}}", "type": "text" },
|
||||
{ "key": "nzb", "type": "file", "src": "Release.nzb" }
|
||||
]
|
||||
},
|
||||
"description": "Stages one NZB for deferred import. The authenticated user must have posting privileges.",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/v2/nzbadd",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "v2", "nzbadd"]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Stage Paired NZB and NFO",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{ "key": "api_token", "value": "{{apiToken}}", "type": "text" },
|
||||
{ "key": "cat", "value": "5040", "type": "text" },
|
||||
{ "key": "nzb", "type": "file", "src": "Release.nzb" },
|
||||
{ "key": "nfo", "type": "file", "src": "Release.nfo" }
|
||||
]
|
||||
},
|
||||
"description": "Atomically stages an NZB and an optional NFO with a matching basename. Category is echoed metadata only.",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/v2/nzbadd",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "v2", "nzbadd"]
|
||||
}
|
||||
},
|
||||
"response": [
|
||||
{
|
||||
"name": "Created",
|
||||
"originalRequest": {
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/v2/nzbadd",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "v2", "nzbadd"]
|
||||
}
|
||||
},
|
||||
"status": "Created",
|
||||
"code": 201,
|
||||
"_postman_previewlanguage": "json",
|
||||
"header": [{ "key": "Content-Type", "value": "application/json" }],
|
||||
"cookie": [],
|
||||
"body": "{\n \"success\": true,\n \"status\": \"staged\",\n \"name\": \"Release\",\n \"category\": \"5040\",\n \"files\": {\n \"nzb\": { \"filename\": \"Release.nzb\", \"type\": \"nzb\" },\n \"nfo\": { \"filename\": \"Release.nfo\", \"type\": \"nfo\" }\n }\n}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ Route::prefix('v1')->group(function () {
|
||||
|
||||
Route::prefix('v2')->group(function () {
|
||||
Route::get('capabilities', [ApiV2Controller::class, 'capabilities']);
|
||||
Route::post('nzbadd', [ApiV2Controller::class, 'nzbAdd']);
|
||||
});
|
||||
|
||||
Route::prefix('v2')->middleware('apiRateLimit')->group(function () {
|
||||
|
||||
@@ -9,26 +9,67 @@ use App\Http\Controllers\Api\ApiV2Controller;
|
||||
use App\Models\Category;
|
||||
use App\Services\Releases\ReleaseBrowseService;
|
||||
use App\Services\Releases\ReleaseSearchService;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Mockery;
|
||||
use PDO;
|
||||
use ReflectionClass;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ApiRequestMatrixTest extends TestCase
|
||||
{
|
||||
private string $nzbUploadFolder;
|
||||
|
||||
private string $databasePath;
|
||||
|
||||
/** @var array<string, string|false> */
|
||||
private array $originalEnvironment = [];
|
||||
|
||||
public function createApplication()
|
||||
{
|
||||
$this->databasePath = sys_get_temp_dir().'/nntmux-api-matrix-test.sqlite';
|
||||
$this->originalEnvironment = [
|
||||
'APP_ENV' => getenv('APP_ENV'),
|
||||
'DB_CONNECTION' => getenv('DB_CONNECTION'),
|
||||
'DB_DATABASE' => getenv('DB_DATABASE'),
|
||||
];
|
||||
|
||||
if (file_exists($this->databasePath)) {
|
||||
unlink($this->databasePath);
|
||||
}
|
||||
|
||||
$pdo = new PDO('sqlite:'.$this->databasePath);
|
||||
$pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)');
|
||||
$pdo->exec("INSERT INTO settings (name, value) VALUES
|
||||
('categorizeforeign', '0'),
|
||||
('catwebdl', '0'),
|
||||
('innerfileblacklist', '')");
|
||||
|
||||
$this->setEnvironmentValue('APP_ENV', 'testing');
|
||||
$this->setEnvironmentValue('DB_CONNECTION', 'sqlite');
|
||||
$this->setEnvironmentValue('DB_DATABASE', $this->databasePath);
|
||||
|
||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
|
||||
return $app;
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'database.default' => 'sqlite',
|
||||
'database.connections.sqlite.database' => ':memory:',
|
||||
'database.connections.sqlite.database' => $this->databasePath,
|
||||
'mail.from.address' => 'api-matrix@example.test',
|
||||
'app.key' => 'base64:'.base64_encode(random_bytes(32)),
|
||||
]);
|
||||
@@ -36,11 +77,30 @@ class ApiRequestMatrixTest extends TestCase
|
||||
DB::purge();
|
||||
DB::reconnect();
|
||||
Cache::flush();
|
||||
Schema::dropAllTables();
|
||||
|
||||
$this->nzbUploadFolder = sys_get_temp_dir().'/nntmux-api-matrix-'.bin2hex(random_bytes(6));
|
||||
config(['nntmux.nzb_upload_folder' => $this->nzbUploadFolder]);
|
||||
|
||||
$this->createSchema();
|
||||
$this->seedData();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
(new Filesystem)->deleteDirectory($this->nzbUploadFolder);
|
||||
|
||||
if (file_exists($this->databasePath)) {
|
||||
unlink($this->databasePath);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
|
||||
foreach ($this->originalEnvironment as $key => $value) {
|
||||
$this->setEnvironmentValue($key, $value === false ? null : $value);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_v1_invalid_sort_returns_xml_201_error(): void
|
||||
{
|
||||
$token = (string) DB::table('users')->value('api_token');
|
||||
@@ -630,6 +690,71 @@ class ApiRequestMatrixTest extends TestCase
|
||||
->assertJsonPath('password', 0);
|
||||
}
|
||||
|
||||
public function test_v2_nzbadd_stages_a_paired_nzb_and_nfo(): void
|
||||
{
|
||||
$token = (string) DB::table('users')->value('api_token');
|
||||
$nzb = UploadedFile::fake()->createWithContent(
|
||||
'Release.nzb',
|
||||
'<?xml version="1.0"?><nzb xmlns="http://www.newzbin.com/DTD/2003/nzb"></nzb>',
|
||||
);
|
||||
$nfo = UploadedFile::fake()->createWithContent('Release.nfo', 'release information');
|
||||
|
||||
$this->post('/api/v2/nzbadd', [
|
||||
'api_token' => $token,
|
||||
'cat' => '5040',
|
||||
'nzb' => $nzb,
|
||||
'nfo' => $nfo,
|
||||
])->assertCreated()
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('status', 'staged')
|
||||
->assertJsonPath('name', 'Release')
|
||||
->assertJsonPath('category', '5040')
|
||||
->assertJsonPath('files.nzb.filename', 'Release.nzb')
|
||||
->assertJsonPath('files.nfo.filename', 'Release.nfo');
|
||||
|
||||
$this->assertFileExists($this->nzbUploadFolder.'/Release.nzb');
|
||||
$this->assertFileExists($this->nzbUploadFolder.'/Release.nfo');
|
||||
$this->assertSame(0, DB::table('user_requests')->count());
|
||||
}
|
||||
|
||||
public function test_v2_nzbadd_ignores_an_exhausted_api_quota_without_recording_usage(): void
|
||||
{
|
||||
$userId = (int) DB::table('users')->value('id');
|
||||
$token = (string) DB::table('users')->value('api_token');
|
||||
DB::table('roles')->where('id', 1)->update(['apirequests' => 0]);
|
||||
DB::table('user_requests')->insert([
|
||||
'users_id' => $userId,
|
||||
'request' => '/api/v2/search?api_token=redacted',
|
||||
'timestamp' => now(),
|
||||
]);
|
||||
|
||||
$this->post('/api/v2/nzbadd', [
|
||||
'api_token' => $token,
|
||||
'nzb' => UploadedFile::fake()->createWithContent(
|
||||
'QuotaExempt.nzb',
|
||||
'<?xml version="1.0"?><nzb xmlns="http://www.newzbin.com/DTD/2003/nzb"></nzb>',
|
||||
),
|
||||
])->assertCreated();
|
||||
|
||||
$this->assertSame(1, DB::table('user_requests')->count());
|
||||
$this->assertFileExists($this->nzbUploadFolder.'/QuotaExempt.nzb');
|
||||
}
|
||||
|
||||
public function test_v2_nzbadd_requires_posting_privileges(): void
|
||||
{
|
||||
DB::table('users')->update(['can_post' => false]);
|
||||
$token = (string) DB::table('users')->value('api_token');
|
||||
|
||||
$this->post('/api/v2/nzbadd', [
|
||||
'api_token' => $token,
|
||||
'nzb' => UploadedFile::fake()->createWithContent(
|
||||
'Release.nzb',
|
||||
'<?xml version="1.0"?><nzb xmlns="http://www.newzbin.com/DTD/2003/nzb"></nzb>',
|
||||
),
|
||||
])->assertForbidden()
|
||||
->assertJsonPath('error', 'Insufficient privileges/not authorized');
|
||||
}
|
||||
|
||||
private function createSchema(): void
|
||||
{
|
||||
Schema::create('roles', function (Blueprint $table): void {
|
||||
@@ -655,6 +780,7 @@ class ApiRequestMatrixTest extends TestCase
|
||||
$table->boolean('verified')->default(true);
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->integer('rate_limit')->default(60);
|
||||
$table->boolean('can_post')->default(true);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
@@ -800,13 +926,29 @@ class ApiRequestMatrixTest extends TestCase
|
||||
});
|
||||
}
|
||||
|
||||
private function setEnvironmentValue(string $key, ?string $value): void
|
||||
{
|
||||
if ($value === null) {
|
||||
putenv($key);
|
||||
unset($_ENV[$key], $_SERVER[$key]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
putenv($key.'='.$value);
|
||||
$_ENV[$key] = $value;
|
||||
$_SERVER[$key] = $value;
|
||||
}
|
||||
|
||||
private function seedData(): void
|
||||
{
|
||||
DB::table('settings')->insert([
|
||||
['name' => 'strapline', 'value' => 'Test strapline'],
|
||||
['name' => 'metakeywords', 'value' => 'test,api'],
|
||||
['name' => 'registerstatus', 'value' => '0'],
|
||||
['name' => 'categorizeforeign', 'value' => '0'],
|
||||
['name' => 'catwebdl', 'value' => '0'],
|
||||
['name' => 'innerfileblacklist', 'value' => ''],
|
||||
['name' => 'title', 'value' => 'NNTmux Test'],
|
||||
['name' => 'home_link', 'value' => '/'],
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Exceptions\NzbUploadException;
|
||||
use App\Services\Nzb\NzbUploadStagingService;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Mockery;
|
||||
use PDO;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class NzbUploadStagingServiceTest extends TestCase
|
||||
{
|
||||
private string $uploadFolder;
|
||||
|
||||
private string $databasePath;
|
||||
|
||||
/** @var array<string, string|false> */
|
||||
private array $originalEnvironment = [];
|
||||
|
||||
public function createApplication()
|
||||
{
|
||||
$this->databasePath = sys_get_temp_dir().'/nntmux-nzb-upload-service-test.sqlite';
|
||||
$this->originalEnvironment = [
|
||||
'APP_ENV' => getenv('APP_ENV'),
|
||||
'DB_CONNECTION' => getenv('DB_CONNECTION'),
|
||||
'DB_DATABASE' => getenv('DB_DATABASE'),
|
||||
];
|
||||
|
||||
if (file_exists($this->databasePath)) {
|
||||
unlink($this->databasePath);
|
||||
}
|
||||
|
||||
$pdo = new PDO('sqlite:'.$this->databasePath);
|
||||
$pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)');
|
||||
$pdo->exec("INSERT INTO settings (name, value) VALUES
|
||||
('categorizeforeign', '0'),
|
||||
('catwebdl', '0'),
|
||||
('innerfileblacklist', '')");
|
||||
|
||||
$this->setEnvironmentValue('APP_ENV', 'testing');
|
||||
$this->setEnvironmentValue('DB_CONNECTION', 'sqlite');
|
||||
$this->setEnvironmentValue('DB_DATABASE', $this->databasePath);
|
||||
|
||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
|
||||
return $app;
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->uploadFolder = sys_get_temp_dir().'/nntmux-nzb-upload-'.bin2hex(random_bytes(6));
|
||||
config(['nntmux.nzb_upload_folder' => $this->uploadFolder]);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
(new Filesystem)->deleteDirectory($this->uploadFolder);
|
||||
|
||||
if (file_exists($this->databasePath)) {
|
||||
unlink($this->databasePath);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
|
||||
foreach ($this->originalEnvironment as $key => $value) {
|
||||
$this->setEnvironmentValue($key, $value === false ? null : $value);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_it_stages_a_matching_nzb_and_nfo_pair(): void
|
||||
{
|
||||
$result = (new NzbUploadStagingService(new Filesystem))->stage(
|
||||
$this->nzb('Release.nzb'),
|
||||
UploadedFile::fake()->createWithContent('Release.nfo', 'release information'),
|
||||
);
|
||||
|
||||
$this->assertSame('Release', $result['name']);
|
||||
$this->assertSame('Release.nzb', $result['files']['nzb']['filename']);
|
||||
$this->assertSame('Release.nfo', $result['files']['nfo']['filename']);
|
||||
$this->assertFileExists($this->uploadFolder.'/Release.nzb');
|
||||
$this->assertFileExists($this->uploadFolder.'/Release.nfo');
|
||||
}
|
||||
|
||||
public function test_it_stages_an_nzb_without_an_nfo(): void
|
||||
{
|
||||
$result = (new NzbUploadStagingService(new Filesystem))->stage($this->nzb('Solo.nzb'));
|
||||
|
||||
$this->assertNull($result['files']['nfo']);
|
||||
$this->assertFileExists($this->uploadFolder.'/Solo.nzb');
|
||||
}
|
||||
|
||||
public function test_it_rejects_mismatched_pair_names_before_writing(): void
|
||||
{
|
||||
try {
|
||||
(new NzbUploadStagingService(new Filesystem))->stage(
|
||||
$this->nzb('Release.nzb'),
|
||||
UploadedFile::fake()->createWithContent('Different.nfo', 'release information'),
|
||||
);
|
||||
$this->fail('Expected a mismatched pair to be rejected.');
|
||||
} catch (NzbUploadException $exception) {
|
||||
$this->assertSame(400, $exception->status);
|
||||
}
|
||||
|
||||
$this->assertDirectoryDoesNotExist($this->uploadFolder);
|
||||
}
|
||||
|
||||
public function test_it_rejects_an_invalid_nzb_payload(): void
|
||||
{
|
||||
$this->expectException(NzbUploadException::class);
|
||||
$this->expectExceptionMessage('Invalid NZB payload');
|
||||
|
||||
(new NzbUploadStagingService(new Filesystem))->stage(
|
||||
UploadedFile::fake()->createWithContent('Release.nzb', 'not xml'),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_rejects_an_oversized_nfo(): void
|
||||
{
|
||||
$this->expectException(NzbUploadException::class);
|
||||
$this->expectExceptionMessage('NFO upload must not be empty or exceed 65535 bytes');
|
||||
|
||||
(new NzbUploadStagingService(new Filesystem))->stage(
|
||||
$this->nzb('Release.nzb'),
|
||||
UploadedFile::fake()->createWithContent('Release.nfo', str_repeat('x', 65536)),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_rejects_a_collision_without_writing_either_file(): void
|
||||
{
|
||||
(new Filesystem)->makeDirectory($this->uploadFolder, 0775, true);
|
||||
file_put_contents($this->uploadFolder.'/Release.nfo', 'pending');
|
||||
|
||||
try {
|
||||
(new NzbUploadStagingService(new Filesystem))->stage(
|
||||
$this->nzb('Release.nzb'),
|
||||
UploadedFile::fake()->createWithContent('Release.nfo', 'new information'),
|
||||
);
|
||||
$this->fail('Expected an existing staged file to be rejected.');
|
||||
} catch (NzbUploadException $exception) {
|
||||
$this->assertSame(409, $exception->status);
|
||||
}
|
||||
|
||||
$this->assertFileDoesNotExist($this->uploadFolder.'/Release.nzb');
|
||||
$this->assertSame('pending', file_get_contents($this->uploadFolder.'/Release.nfo'));
|
||||
}
|
||||
|
||||
public function test_it_rolls_back_the_nzb_when_the_nfo_write_fails(): void
|
||||
{
|
||||
$nzbPath = $this->uploadFolder.'/Release.nzb';
|
||||
$nfoPath = $this->uploadFolder.'/Release.nfo';
|
||||
$filesystem = Mockery::mock(Filesystem::class);
|
||||
$filesystem->shouldReceive('isDirectory')->once()->with($this->uploadFolder)->andReturnTrue();
|
||||
$filesystem->shouldReceive('exists')->once()->with($nzbPath)->andReturnFalse();
|
||||
$filesystem->shouldReceive('exists')->once()->with($nfoPath)->andReturnFalse();
|
||||
$filesystem->shouldReceive('put')->once()->with($nzbPath, Mockery::type('string'))->andReturn(100);
|
||||
$filesystem->shouldReceive('put')->once()->with($nfoPath, 'release information')->andReturnFalse();
|
||||
$filesystem->shouldReceive('exists')->once()->with($nzbPath)->andReturnTrue();
|
||||
$filesystem->shouldReceive('delete')->once()->with($nzbPath)->andReturnTrue();
|
||||
|
||||
$this->expectException(NzbUploadException::class);
|
||||
$this->expectExceptionMessage('Failed to write Release.nfo to disk');
|
||||
|
||||
(new NzbUploadStagingService($filesystem))->stage(
|
||||
$this->nzb('Release.nzb'),
|
||||
UploadedFile::fake()->createWithContent('Release.nfo', 'release information'),
|
||||
);
|
||||
}
|
||||
|
||||
private function nzb(string $name): UploadedFile
|
||||
{
|
||||
return UploadedFile::fake()->createWithContent(
|
||||
$name,
|
||||
'<?xml version="1.0"?><nzb xmlns="http://www.newzbin.com/DTD/2003/nzb"></nzb>',
|
||||
);
|
||||
}
|
||||
|
||||
private function setEnvironmentValue(string $key, ?string $value): void
|
||||
{
|
||||
if ($value === null) {
|
||||
putenv($key);
|
||||
unset($_ENV[$key], $_SERVER[$key]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
putenv($key.'='.$value);
|
||||
$_ENV[$key] = $value;
|
||||
$_SERVER[$key] = $value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user