From de005d3e7e494ca3ba67fed564fc8c139b671dfd Mon Sep 17 00:00:00 2001 From: DariusIII Date: Tue, 14 Jul 2026 20:18:04 +0200 Subject: [PATCH] Update nzb and nfo import --- app/Console/Commands/ImportNfos.php | 69 ++++++ app/Console/Commands/ImportNzbs.php | 108 ++++---- app/Http/Controllers/Api/ApiController.php | 103 ++++---- app/Services/Nzb/NfoImportService.php | 136 ++++++++++ app/Services/Nzb/NzbImportService.php | 50 +++- app/Services/Nzb/NzbUploadManifestService.php | 234 ++++++++++++++++++ app/Services/Nzb/NzbUploadStagingService.php | 119 ++++++--- docs/newznab_api_specification.txt | 18 +- docs/nntmux_api_v2.md | 29 ++- .../nntmux_api_v2.postman_collection.json | 6 +- tests/Feature/ApiRequestMatrixTest.php | 71 +++++- tests/Unit/NfoImportServiceTest.php | 230 +++++++++++++++++ tests/Unit/NzbUploadStagingServiceTest.php | 125 ++++++---- 13 files changed, 1082 insertions(+), 216 deletions(-) create mode 100644 app/Console/Commands/ImportNfos.php create mode 100644 app/Services/Nzb/NfoImportService.php create mode 100644 app/Services/Nzb/NzbUploadManifestService.php create mode 100644 tests/Unit/NfoImportServiceTest.php diff --git a/app/Console/Commands/ImportNfos.php b/app/Console/Commands/ImportNfos.php new file mode 100644 index 000000000..eb619b509 --- /dev/null +++ b/app/Console/Commands/ImportNfos.php @@ -0,0 +1,69 @@ +option('folder'); + if (! is_string($folderOption) || trim($folderOption) === '') { + $this->error('Folder path must not be empty'); + + return self::FAILURE; + } + + $folder = rtrim($folderOption, '/\\'); + if (! File::isDirectory($folder)) { + $this->error('Folder path does not exist: '.$folder); + + return self::FAILURE; + } + + try { + $manifestPaths = $manifests->findManifests($folder); + } catch (Throwable $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + + $imported = $skipped = $failed = 0; + foreach ($manifestPaths as $manifestPath) { + $result = $importer->importManifest( + $manifestPath, + (bool) $this->option('delete'), + (bool) $this->option('delete-failed'), + ); + + match ($result['status']) { + 'imported' => $imported++, + 'failed' => $failed++, + default => $skipped++, + }; + + if ($result['status'] === 'failed') { + $this->warn(basename(dirname($manifestPath)).': '.$result['message']); + } + } + + $this->info("Processed {$imported} NFOs, skipped {$skipped}, failed {$failed}."); + + return $failed === 0 ? self::SUCCESS : self::FAILURE; + } +} diff --git a/app/Console/Commands/ImportNzbs.php b/app/Console/Commands/ImportNzbs.php index 43645ec10..e25e4e0c4 100644 --- a/app/Console/Commands/ImportNzbs.php +++ b/app/Console/Commands/ImportNzbs.php @@ -4,10 +4,14 @@ declare(strict_types=1); namespace App\Console\Commands; +use App\Enums\NzbImportStatus; use App\Services\Nzb\NzbImportService; +use App\Services\Nzb\NzbUploadManifestService; use Illuminate\Console\Command; -use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Support\Facades\File; +use Illuminate\Support\Str; +use SplFileInfo; +use Throwable; class ImportNzbs extends Command { @@ -33,57 +37,61 @@ class ImportNzbs extends Command /** * Execute the console command. */ - public function handle(): void + public function handle(NzbUploadManifestService $manifests): void { - if ($this->option('folder')) { - if ($this->option('filename')) { - $useNzbName = true; - } else { - $useNzbName = false; - } - if ($this->option('delete')) { - $deleteNZB = true; - } else { - $deleteNZB = false; - } - if ($this->option('delete-failed')) { - $deleteFailedNZB = true; - } else { - $deleteFailedNZB = false; - } - if ($this->option('source')) { - $source = (int) $this->option('source'); - } else { - $source = 1; - } - $importFolder = $this->option('folder'); - $folders = File::directories($importFolder); - if (empty($folders)) { - $this->info('Importing NZB files from '.$importFolder); - $files = File::allFiles($importFolder); - $NZBImport = new NzbImportService; - - try { - $NZBImport->beginImport($files, $useNzbName, $deleteNZB, $deleteFailedNZB, $source); - } catch (FileNotFoundException $e) { - $this->error($e->getMessage()); - } - } else { - foreach ($folders as $folder) { - $this->info('Importing NZB files from '.$folder); - $files = File::allFiles($folder); - $NZBImport = new NzbImportService; - - try { - $NZBImport->beginImport($files, $useNzbName, $deleteNZB, $deleteFailedNZB, $source); - } catch (FileNotFoundException $e) { - $this->error($e->getMessage()); - } - } - } - } else { + $folderOption = $this->option('folder'); + if (! is_string($folderOption) || trim($folderOption) === '') { $this->error('Folder path must not be empty'); - exit(); + + return; + } + + $importFolder = rtrim($folderOption, '/\\'); + if (! File::isDirectory($importFolder)) { + $this->error('Folder path does not exist: '.$importFolder); + + return; + } + + $useNzbName = (bool) $this->option('filename'); + $deleteNZB = (bool) $this->option('delete'); + $deleteFailedNZB = (bool) $this->option('delete-failed'); + $source = $this->option('source') ? (int) $this->option('source') : 1; + + try { + $files = array_values(array_filter( + File::allFiles($importFolder), + static function (SplFileInfo $file) use ($manifests): bool { + $path = $file->getPathname(); + if (! Str::endsWith(strtolower($path), ['.nzb', '.nzb.gz'])) { + return true; + } + + return $manifests->shouldImportNzb($path); + } + )); + + $this->info('Importing NZB files from '.$importFolder); + $importer = new NzbImportService; + $importer->beginImport( + $files, + $useNzbName, + $deleteNZB, + $deleteFailedNZB, + $source, + static function (array $result) use ($manifests): void { + /** @var NzbImportStatus $status */ + $status = $result['status']; + $manifests->recordNzbOutcome( + $result['path'], + $status, + $result['release_id'], + $result['release_guid'], + ); + } + ); + } catch (Throwable $exception) { + $this->error($exception->getMessage()); } } } diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php index 168809ded..5f2589bc7 100644 --- a/app/Http/Controllers/Api/ApiController.php +++ b/app/Http/Controllers/Api/ApiController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; +use App\Exceptions\NzbUploadException; use App\Http\Controllers\BasePageController; use App\Http\Controllers\GetNzbController; use App\Models\Category; @@ -17,6 +18,7 @@ use App\Services\Api\ApiReleaseRowCache; use App\Services\Api\ApiUsageService; use App\Services\Api\ApiUserResolver; use App\Services\Api\V1\ApiV1Presenter; +use App\Services\Nzb\NzbUploadStagingService; use App\Services\Releases\ReleaseBrowseService; use App\Services\Releases\ReleaseSearchService; use App\Support\FilenameSanitizer; @@ -25,12 +27,11 @@ use Illuminate\Contracts\Routing\ResponseFactory; 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; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\File; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -55,6 +56,8 @@ class ApiController extends BasePageController private ApiV1Presenter $presenter; + private NzbUploadStagingService $nzbUploadStagingService; + public function __construct( ReleaseSearchService $releaseSearchService, ReleaseBrowseService $releaseBrowseService, @@ -64,6 +67,7 @@ class ApiController extends BasePageController ?ApiUserResolver $userResolver = null, ?ApiCapabilitiesService $capabilitiesService = null, ?ApiV1Presenter $presenter = null, + ?NzbUploadStagingService $nzbUploadStagingService = null, ) { parent::__construct(); $this->releaseSearchService = $releaseSearchService; @@ -74,6 +78,7 @@ class ApiController extends BasePageController $this->userResolver = $userResolver ?? app(ApiUserResolver::class); $this->capabilitiesService = $capabilitiesService ?? app(ApiCapabilitiesService::class); $this->presenter = $presenter ?? app(ApiV1Presenter::class); + $this->nzbUploadStagingService = $nzbUploadStagingService ?? app(NzbUploadStagingService::class); } /** @@ -695,76 +700,60 @@ class ApiController extends BasePageController } break; // - // nzb / nfo add request - // curl -X POST -F "file=@./The.File.nzb" "site_url/api/V1/api?t=nzbadd&apikey=xxx" - // curl -X POST -F "file=@./The.File.nfo" "site_url/api/V1/api?t=nzbadd&apikey=xxx" + // Paired NZB / NFO add request. The legacy single `file` field remains supported. // case 'nzbAdd': if (! User::canPost($uid)) { return showApiError(102, 'Insufficient privileges/not authorized'); } - if ($request->missing('file')) { - return showApiError(200, 'Missing parameter (file is required for adding an NZB or NFO)'); - } - if ($request->missing('apikey')) { - return showApiError(200, 'Missing parameter (apikey is required for adding an NZB or NFO)'); - } + $usesLegacyField = $request->has('file') || $request->hasFile('file'); + $usesModernFields = $request->hasAny(['nzb', 'nfo']) + || $request->hasFile('nzb') + || $request->hasFile('nfo'); - if (! $request->hasFile('file')) { - return showApiError(600, 'Failed to load upload (no file)'); + if ($usesLegacyField && $usesModernFields) { + return showApiError(201, 'Use either file or nzb/nfo upload fields, not both'); + } + if (! $usesLegacyField && ! $usesModernFields) { + return showApiError(200, 'Missing parameter (nzb file is required)'); } $this->usageService->record($res, $request); - $nzbFile = $request->file('file'); + try { + if ($usesModernFields) { + $nzb = $request->file('nzb'); + if (! $nzb instanceof UploadedFile) { + return showApiError(200, 'Missing parameter (nzb file is required)'); + } - // Save the file to the server, get the name without the extension. - if ($nzbFile !== null) { - $ext = strtolower((string) $nzbFile->getClientOriginalExtension()); - if (! in_array($ext, ['nzb', 'nfo'], true)) { - return showApiError(600, 'Failed to load NZB (file is not an NZB or NFO file)'); + $nfo = $request->file('nfo'); + if ($nfo !== null && ! $nfo instanceof UploadedFile) { + return showApiError(600, 'Invalid nfo upload'); + } + + $staged = $this->nzbUploadStagingService->stage($nzb, $nfo); + $name = $staged['name']; + } else { + $file = $request->file('file'); + if (! $file instanceof UploadedFile) { + return showApiError(600, 'Failed to load upload (no file)'); + } + + $name = $this->nzbUploadStagingService->stageLegacyFile($file)['name']; } - - $content = $nzbFile->getContent(); - if (! is_string($content)) { - return showApiError(600, 'Failed to load upload'); - } - - if ($ext === 'nzb' && ! isValidNewznabNzb($content)) { - return showApiError(600, 'Failed to load NZB (invalid NZB payload)'); - } - - // Same max raw size as NfoService::MAX_NFO_SIZE (64KB). - $maxNfoUploadBytes = 65535; - if ($ext === 'nfo' && ($content === '' || strlen($content) > $maxNfoUploadBytes)) { - return showApiError(600, 'Failed to load NFO (empty or too large)'); - } - - if (! File::isDirectory(config('nntmux.nzb_upload_folder'))) { - @File::makeDirectory(config('nntmux.nzb_upload_folder'), 0775, true); - } - - if (File::put(config('nntmux.nzb_upload_folder').$nzbFile->getClientOriginalName(), $content)) { - Log::channel('nzb_upload')->info('File uploaded by API: '.$nzbFile->getClientOriginalName().' (type: '.$ext.')'); - - $successXml = sprintf( - "\n\n", - (string) $request->input('cat', ''), - htmlspecialchars(pathinfo($nzbFile->getClientOriginalName(), PATHINFO_FILENAME), ENT_QUOTES, 'UTF-8') - ); - - return response($successXml, 200)->header('Content-type', 'text/xml'); - } - - Log::channel('nzb_upload')->warning('File upload by API failed to write: '.$nzbFile->getClientOriginalName().' (type: '.$ext.')'); - } else { - Log::channel('nzb_upload')->warning('File upload by API failed: no file provided'); - - return showApiError(603, 'Failed to write file to disk'); + } catch (NzbUploadException $exception) { + return showApiError(600, $exception->getMessage()); } - return showApiError(603, 'Failed to write file to disk'); + $successXml = sprintf( + "\n\n", + (string) $request->input('cat', ''), + htmlspecialchars($name, ENT_QUOTES, 'UTF-8') + ); + + return response($successXml, 200)->header('Content-type', 'text/xml'); // Capabilities request. case 'c': diff --git a/app/Services/Nzb/NfoImportService.php b/app/Services/Nzb/NfoImportService.php new file mode 100644 index 000000000..f75a55106 --- /dev/null +++ b/app/Services/Nzb/NfoImportService.php @@ -0,0 +1,136 @@ +manifests->read($manifestPath); + $state = $manifest['state']; + + if ($state === NzbUploadManifestService::STATE_NFO_IMPORTED) { + return ['status' => 'skipped', 'message' => 'NFO was already imported']; + } + + if (in_array($state, [ + NzbUploadManifestService::STATE_NZB_DUPLICATE, + NzbUploadManifestService::STATE_NZB_FAILED, + ], true)) { + if ($deleteFailed && is_array($manifest['nfo'] ?? null)) { + try { + $nfoPath = $this->manifests->resolvePayloadPath( + $manifestPath, + $manifest['nfo']['filename'], + 'nfo' + ); + $this->filesystem->delete($nfoPath); + } catch (Throwable) { + // The terminal NZB outcome remains authoritative even if its NFO is already gone. + } + } + + return ['status' => 'skipped', 'message' => "NFO has no imported NZB release ({$state})"]; + } + + if (! in_array($state, [ + NzbUploadManifestService::STATE_NZB_IMPORTED, + NzbUploadManifestService::STATE_NFO_FAILED, + ], true)) { + return ['status' => 'skipped', 'message' => "NFO is waiting for NZB import ({$state})"]; + } + + if (! is_array($manifest['nfo'] ?? null)) { + return ['status' => 'skipped', 'message' => 'Upload has no paired NFO']; + } + + $releaseId = $manifest['release_id'] ?? null; + $releaseGuid = $manifest['release_guid'] ?? null; + if (! is_int($releaseId) || $releaseId <= 0 || ! is_string($releaseGuid) || $releaseGuid === '') { + throw new RuntimeException('Upload manifest has no valid release identity'); + } + + $releaseExists = Release::query() + ->whereKey($releaseId) + ->where('guid', $releaseGuid) + ->exists(); + if (! $releaseExists) { + throw new RuntimeException('The release recorded by the upload manifest no longer exists'); + } + + $nfoPath = $this->manifests->resolvePayloadPath( + $manifestPath, + $manifest['nfo']['filename'], + 'nfo' + ); + $content = $this->filesystem->get($nfoPath); + if ($content === '' || strlen($content) > self::MAX_NFO_SIZE) { + throw new RuntimeException('NFO import must not be empty or exceed 65535 bytes'); + } + + if (! $this->nfoService->storeNfoContent($releaseId, $content)) { + throw new RuntimeException('Failed to store NFO content for the release'); + } + + $this->manifests->recordNfoOutcome($manifestPath, true); + if ($delete && ! $this->filesystem->delete($nfoPath)) { + Log::channel('nzb_upload')->warning('Imported NFO could not be deleted', [ + 'manifest' => $manifestPath, + 'filename' => basename($nfoPath), + ]); + } + + Log::channel('nzb_upload')->info('Paired NFO imported', [ + 'upload_id' => $manifest['upload_id'], + 'release_id' => $releaseId, + 'release_guid' => $releaseGuid, + 'filename' => basename($nfoPath), + ]); + + return ['status' => 'imported', 'message' => "Imported {$manifest['nfo']['filename']}"]; + } catch (Throwable $exception) { + try { + $this->manifests->recordNfoOutcome($manifestPath, false, $exception->getMessage()); + } catch (Throwable $manifestException) { + Log::channel('nzb_upload')->error('Failed to record NFO import failure', [ + 'manifest' => $manifestPath, + 'error' => $manifestException->getMessage(), + ]); + } + + if ($deleteFailed && $nfoPath !== null && $this->filesystem->isFile($nfoPath)) { + $this->filesystem->delete($nfoPath); + } + + Log::channel('nzb_upload')->warning('Paired NFO import failed', [ + 'manifest' => $manifestPath, + 'error' => $exception->getMessage(), + ]); + + return ['status' => 'failed', 'message' => $exception->getMessage()]; + } + } +} diff --git a/app/Services/Nzb/NzbImportService.php b/app/Services/Nzb/NzbImportService.php index eea44e209..12a7b82b7 100644 --- a/app/Services/Nzb/NzbImportService.php +++ b/app/Services/Nzb/NzbImportService.php @@ -59,6 +59,8 @@ class NzbImportService */ protected string $relGuid; + protected ?int $relId = null; + public mixed $echoCLI; public NzbService $nzb; @@ -84,10 +86,18 @@ class NzbImportService /** * Begin importing NZB files. * + * @param null|callable(array{path:string,status:NzbImportStatus,release_id:int|null,release_guid:string|null}):void $resultCallback + * * @throws FileNotFoundException */ - public function beginImport(mixed $filesToProcess, bool $useNzbName = false, bool $delete = false, bool $deleteFailed = false, int $source = 1): bool|string - { + public function beginImport( + mixed $filesToProcess, + bool $useNzbName = false, + bool $delete = false, + bool $deleteFailed = false, + int $source = 1, + ?callable $resultCallback = null, + ): bool|string { // Get all the groups in the DB. if (! $this->getAllGroups()) { if ($this->browser) { @@ -104,7 +114,8 @@ class NzbImportService $nzbFiles = []; foreach ($filesToProcess as $file) { $filePath = $file instanceof \SplFileInfo ? $file->getPathname() : (string) $file; - if (Str::endsWith($filePath, '.nzb') || Str::endsWith($filePath, '.nzb.gz')) { + $lowerFilePath = strtolower($filePath); + if (Str::endsWith($lowerFilePath, '.nzb') || Str::endsWith($lowerFilePath, '.nzb.gz')) { $nzbFiles[] = $filePath; } } @@ -123,12 +134,33 @@ class NzbImportService $this->echoOut("Filtered out {$totalFilesFiltered} non-NZB files. Processing ".count($nzbFiles).' NZB files.'); } + $reportResult = static function ( + string $path, + NzbImportStatus $status, + ?int $releaseId = null, + ?string $releaseGuid = null, + ) use ($resultCallback): void { + if ($resultCallback === null) { + return; + } + + $resultCallback([ + 'path' => $path, + 'status' => $status, + 'release_id' => $status === NzbImportStatus::Inserted ? $releaseId : null, + 'release_guid' => $status === NzbImportStatus::Inserted ? $releaseGuid : null, + ]); + }; + // Loop over the NZB file names only. foreach ($nzbFiles as $nzbFilePath) { // Check if the file is really there. if (File::isFile($nzbFilePath)) { + $this->relGuid = ''; + $this->relId = null; + // Get the contents of the NZB file as a string. - if (Str::endsWith($nzbFilePath, '.nzb.gz')) { + if (Str::endsWith(strtolower($nzbFilePath), '.nzb.gz')) { $nzbString = unzipGzipFile($nzbFilePath); } else { $nzbString = File::get($nzbFilePath); @@ -136,6 +168,7 @@ class NzbImportService if ($nzbString === false) { $this->echoOut('ERROR: Unable to read: '.$nzbFilePath); + $reportResult($nzbFilePath, NzbImportStatus::Failed); if ($deleteFailed) { File::delete($nzbFilePath); @@ -149,6 +182,7 @@ class NzbImportService $nzbXML = @simplexml_load_string($nzbString); if ($nzbXML === false || strtolower($nzbXML->getName()) !== 'nzb') { $this->echoOut('ERROR: Unable to load NZB XML data: '.$nzbFilePath); + $reportResult($nzbFilePath, NzbImportStatus::Failed); if ($deleteFailed) { File::delete($nzbFilePath); @@ -181,12 +215,15 @@ class NzbImportService // Remove the release. Release::query()->where('guid', $this->relGuid)->delete(); + $reportResult($nzbFilePath, NzbImportStatus::Failed); if ($deleteFailed) { File::delete($nzbFilePath); } $nzbsSkipped++; } else { + $reportResult($nzbFilePath, NzbImportStatus::Inserted, $this->relId, $this->relGuid); + if ($delete) { // Remove the nzb file. File::delete($nzbFilePath); @@ -195,6 +232,8 @@ class NzbImportService $nzbsImported++; } } else { + $reportResult($nzbFilePath, $importStatus); + if ($importStatus === NzbImportStatus::Duplicate) { $nzbsDuplicate++; @@ -218,6 +257,7 @@ class NzbImportService } } else { $this->echoOut('ERROR: Unable to fetch: '.$nzbFilePath); + $reportResult($nzbFilePath, NzbImportStatus::Failed); $nzbsSkipped++; } } @@ -482,6 +522,8 @@ class NzbImportService return NzbImportStatus::Failed; } + $this->relId = (int) $relID; + return NzbImportStatus::Inserted; } diff --git a/app/Services/Nzb/NzbUploadManifestService.php b/app/Services/Nzb/NzbUploadManifestService.php new file mode 100644 index 000000000..7fdb0cfb7 --- /dev/null +++ b/app/Services/Nzb/NzbUploadManifestService.php @@ -0,0 +1,234 @@ + + */ + public function create(string $directory, string $uploadId, string $nzbFilename, ?string $nfoFilename): array + { + $timestamp = now()->toIso8601String(); + $manifest = [ + 'version' => 1, + 'upload_id' => $uploadId, + 'state' => self::STATE_STAGED, + 'nzb' => ['filename' => $nzbFilename], + 'nfo' => $nfoFilename === null ? null : ['filename' => $nfoFilename], + 'release_id' => null, + 'release_guid' => null, + 'last_error' => null, + 'created_at' => $timestamp, + 'updated_at' => $timestamp, + ]; + + $this->write($directory.DIRECTORY_SEPARATOR.self::FILENAME, $manifest); + + return $manifest; + } + + /** + * @return array + */ + public function read(string $manifestPath): array + { + if (! $this->filesystem->isFile($manifestPath)) { + throw new RuntimeException("Upload manifest does not exist: {$manifestPath}"); + } + + $contents = $this->filesystem->get($manifestPath); + try { + $manifest = json_decode($contents, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new RuntimeException("Invalid upload manifest JSON: {$manifestPath}", previous: $exception); + } + + if (! is_array($manifest) + || ($manifest['version'] ?? null) !== 1 + || ! is_string($manifest['upload_id'] ?? null) + || ! is_string($manifest['state'] ?? null) + || ! is_array($manifest['nzb'] ?? null) + || ! is_string($manifest['nzb']['filename'] ?? null)) { + throw new RuntimeException("Invalid upload manifest structure: {$manifestPath}"); + } + + $this->assertSafeFilename($manifest['nzb']['filename'], 'nzb'); + if (($manifest['nfo'] ?? null) !== null) { + if (! is_array($manifest['nfo']) || ! is_string($manifest['nfo']['filename'] ?? null)) { + throw new RuntimeException("Invalid NFO manifest entry: {$manifestPath}"); + } + $this->assertSafeFilename($manifest['nfo']['filename'], 'nfo'); + } + + return $manifest; + } + + /** + * @return list + */ + public function findManifests(string $folder): array + { + if (! $this->filesystem->isDirectory($folder)) { + return []; + } + + return array_values(array_map( + static fn (SplFileInfo $file): string => $file->getPathname(), + array_filter( + $this->filesystem->allFiles($folder), + static fn (SplFileInfo $file): bool => $file->getFilename() === self::FILENAME + ) + )); + } + + public function recordNzbOutcome( + string $nzbPath, + NzbImportStatus $status, + ?int $releaseId, + ?string $releaseGuid, + ?string $error = null, + ): void { + $manifestPath = dirname($nzbPath).DIRECTORY_SEPARATOR.self::FILENAME; + if (! $this->filesystem->isFile($manifestPath)) { + return; + } + + $manifest = $this->read($manifestPath); + if (! hash_equals($manifest['nzb']['filename'], basename($nzbPath))) { + throw new RuntimeException("NZB path does not match its upload manifest: {$nzbPath}"); + } + + if ($status === NzbImportStatus::Inserted) { + if ($releaseId === null || $releaseGuid === null || $releaseGuid === '') { + throw new RuntimeException("Inserted NZB has no release identity: {$nzbPath}"); + } + + $manifest['state'] = self::STATE_NZB_IMPORTED; + $manifest['release_id'] = $releaseId; + $manifest['release_guid'] = $releaseGuid; + $manifest['last_error'] = null; + } elseif ($status === NzbImportStatus::Duplicate) { + $manifest['state'] = self::STATE_NZB_DUPLICATE; + $manifest['release_id'] = null; + $manifest['release_guid'] = null; + $manifest['last_error'] = $error ?? 'NZB import detected an existing duplicate release'; + } else { + $manifest['state'] = self::STATE_NZB_FAILED; + $manifest['release_id'] = null; + $manifest['release_guid'] = null; + $manifest['last_error'] = $error ?? "NZB import ended with status {$status->value}"; + } + + $manifest['updated_at'] = now()->toIso8601String(); + $this->write($manifestPath, $manifest); + } + + public function shouldImportNzb(string $nzbPath): bool + { + $manifestPath = dirname($nzbPath).DIRECTORY_SEPARATOR.self::FILENAME; + if (! $this->filesystem->isFile($manifestPath)) { + return true; + } + + $manifest = $this->read($manifestPath); + if (! hash_equals($manifest['nzb']['filename'], basename($nzbPath))) { + throw new RuntimeException("NZB path does not match its upload manifest: {$nzbPath}"); + } + + return in_array($manifest['state'], [self::STATE_STAGED, self::STATE_NZB_FAILED], true); + } + + public function recordNfoOutcome(string $manifestPath, bool $imported, ?string $error = null): void + { + $manifest = $this->read($manifestPath); + $manifest['state'] = $imported ? self::STATE_NFO_IMPORTED : self::STATE_NFO_FAILED; + $manifest['last_error'] = $imported ? null : ($error ?? 'NFO import failed'); + $manifest['updated_at'] = now()->toIso8601String(); + $this->write($manifestPath, $manifest); + } + + /** + * Resolve a payload filename without allowing a manifest to escape its upload directory. + */ + public function resolvePayloadPath(string $manifestPath, string $filename, string $extension): string + { + $this->assertSafeFilename($filename, $extension); + + $directory = realpath(dirname($manifestPath)); + if ($directory === false) { + throw new RuntimeException("Upload directory does not exist: {$manifestPath}"); + } + + $path = $directory.DIRECTORY_SEPARATOR.$filename; + $realPath = realpath($path); + if ($realPath === false || ! is_file($realPath) || dirname($realPath) !== $directory) { + throw new RuntimeException("Upload payload is missing or outside its manifest directory: {$filename}"); + } + + return $realPath; + } + + /** + * @param array $manifest + */ + private function write(string $manifestPath, array $manifest): void + { + try { + $json = json_encode( + $manifest, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR + ).PHP_EOL; + } catch (\JsonException $exception) { + throw new RuntimeException('Failed to encode upload manifest', previous: $exception); + } + + $temporaryPath = $manifestPath.'.tmp.'.bin2hex(random_bytes(6)); + try { + if ($this->filesystem->put($temporaryPath, $json) === false + || ! $this->filesystem->move($temporaryPath, $manifestPath)) { + throw new RuntimeException("Failed to write upload manifest: {$manifestPath}"); + } + } finally { + if ($this->filesystem->exists($temporaryPath)) { + $this->filesystem->delete($temporaryPath); + } + } + } + + private function assertSafeFilename(string $filename, string $extension): void + { + if ($filename === '' + || $filename === '.' + || $filename === '..' + || basename($filename) !== $filename + || str_contains($filename, "\0") + || strtolower((string) pathinfo($filename, PATHINFO_EXTENSION)) !== $extension + || trim((string) pathinfo($filename, PATHINFO_FILENAME)) === '') { + throw new RuntimeException("Unsafe {$extension} filename in upload manifest"); + } + } +} diff --git a/app/Services/Nzb/NzbUploadStagingService.php b/app/Services/Nzb/NzbUploadStagingService.php index 67699e926..19ed60f07 100644 --- a/app/Services/Nzb/NzbUploadStagingService.php +++ b/app/Services/Nzb/NzbUploadStagingService.php @@ -8,12 +8,20 @@ use App\Exceptions\NzbUploadException; use Illuminate\Filesystem\Filesystem; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; final class NzbUploadStagingService { private const MAX_NFO_SIZE = 65535; - public function __construct(private readonly Filesystem $filesystem) {} + private NzbUploadManifestService $manifests; + + public function __construct( + private readonly Filesystem $filesystem, + ?NzbUploadManifestService $manifests = null, + ) { + $this->manifests = $manifests ?? new NzbUploadManifestService($filesystem); + } /** * @return array{name:string,files:array{nzb:array{filename:string,type:string},nfo:array{filename:string,type:string}|null}} @@ -22,20 +30,12 @@ final class NzbUploadStagingService { [$nzbName, $nzbBase, $nzbContent] = $this->validateFile($nzb, 'nzb'); $nfoDetails = $nfo === null ? null : $this->validateFile($nfo, 'nfo'); + $folder = $this->ensureUploadFolder(); - 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); + $uploadId = Str::uuid()->toString(); + $uploadDirectory = $folder.DIRECTORY_SEPARATOR.$uploadId; + if (! $this->filesystem->makeDirectory($uploadDirectory, 0775, true)) { + throw new NzbUploadException('Failed to create upload staging directory', 500); } $files = [[$nzbName, $nzbContent, 'nzb']]; @@ -43,32 +43,26 @@ final class NzbUploadStagingService $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; + $path = $uploadDirectory.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), + $this->manifests->create( + $uploadDirectory, + $uploadId, + $nzbName, + $nfoDetails[0] ?? null, + ); + } catch (\Throwable $exception) { + if ($this->filesystem->isDirectory($uploadDirectory) + && ! $this->filesystem->deleteDirectory($uploadDirectory)) { + Log::channel('nzb_upload')->error('Failed to roll back partial API upload', [ + 'upload_id' => $uploadId, + 'files' => array_column($files, 0), ]); throw new NzbUploadException('Failed to write upload pair and roll back partial files', 500); @@ -82,7 +76,8 @@ final class NzbUploadStagingService } foreach ($files as [$filename, , $type]) { - Log::channel('nzb_upload')->info('File uploaded by API v2', [ + Log::channel('nzb_upload')->info('File staged by API', [ + 'upload_id' => $uploadId, 'filename' => $filename, 'type' => $type, ]); @@ -97,6 +92,46 @@ final class NzbUploadStagingService ]; } + /** + * Preserve the API v1 single-file fallback while routing NZBs through manifested staging. + * + * @return array{name:string,filename:string,type:'nzb'|'nfo'} + */ + public function stageLegacyFile(UploadedFile $file): array + { + $extension = strtolower((string) $file->getClientOriginalExtension()); + if (! in_array($extension, ['nzb', 'nfo'], true)) { + throw new NzbUploadException('File is not an NZB or NFO file', 400); + } + + if ($extension === 'nzb') { + $staged = $this->stage($file); + + return [ + 'name' => $staged['name'], + 'filename' => $staged['files']['nzb']['filename'], + 'type' => 'nzb', + ]; + } + + [$filename, $basename, $content] = $this->validateFile($file, 'nfo'); + $folder = $this->ensureUploadFolder(); + $path = $folder.DIRECTORY_SEPARATOR.$filename; + if ($this->filesystem->exists($path)) { + throw new NzbUploadException("A staged file named {$filename} already exists", 409); + } + if ($this->filesystem->put($path, $content) === false) { + throw new NzbUploadException("Failed to write {$filename} to disk", 500); + } + + Log::channel('nzb_upload')->info('Legacy NFO file staged by API v1', [ + 'filename' => $filename, + 'type' => 'nfo', + ]); + + return ['name' => $basename, 'filename' => $filename, 'type' => 'nfo']; + } + /** @return array{string,string,string} */ private function validateFile(UploadedFile $file, string $expectedExtension): array { @@ -135,4 +170,20 @@ final class NzbUploadStagingService return [$filename, $basename, $content]; } + + private function ensureUploadFolder(): string + { + $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); + } + + return $folder; + } } diff --git a/docs/newznab_api_specification.txt b/docs/newznab_api_specification.txt index 8a0de2d8c..597b86bb6 100755 --- a/docs/newznab_api_specification.txt +++ b/docs/newznab_api_specification.txt @@ -225,15 +225,23 @@ newznab-tmux https://github.com/NNTmux/newznab-tmux Parameters: t=nzbadd apikey=xxxx - file= + nzb= Optional: - cat, includemeta, dupecheck, nfo, medianfo + nfo= + cat, includemeta, dupecheck, medianfo + + Legacy fallback: + file= + + The legacy file field cannot be mixed with nzb or nfo in the same request. Behavior: - Valid .nzb and .nfo uploads are written to the same NZB upload folder - (NZB_UPLOAD_FOLDER). NFO files are not linked to releases at upload time; - a separate import command is expected to process them later. + The nzb/nfo fields use the same atomic manifested staging flow as API v2. + Each pair is isolated below NZB_UPLOAD_FOLDER and the NFO basename does not + need to match the NZB basename. Run nntmux:import-nzbs first and then + nntmux:import-nfos for the same folder to link the NFO to the new release. + Duplicate or failed NZB imports do not link their NFO files. Error handling: Uses XML error codes from section 6. diff --git a/docs/nntmux_api_v2.md b/docs/nntmux_api_v2.md index 20f70a346..8a2e13cb5 100644 --- a/docs/nntmux_api_v2.md +++ b/docs/nntmux_api_v2.md @@ -198,13 +198,12 @@ Fields: |---|---|---| | `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. | +| `nfo` | no | `.nfo` file with any safe basename, 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. +When `nfo` is supplied, its basename does not need to match the NZB basename. +The request is atomic: both files are validated before staging in an isolated +upload directory, and a partial write is rolled back. NZB-only example: @@ -215,7 +214,7 @@ curl -X POST -F "api_token=" -F "nzb=@Release.nzb" https:///api/v2/ Paired example: ```bash -curl -X POST -F "api_token=" -F "cat=5040" -F "nzb=@Release.nzb" -F "nfo=@Release.nfo" https:///api/v2/nzbadd +curl -X POST -F "api_token=" -F "cat=5040" -F "nzb=@Release.nzb" -F "nfo=@scene-info.nfo" https:///api/v2/nzbadd ``` Successful staging returns HTTP `201`: @@ -228,14 +227,24 @@ Successful staging returns HTTP `201`: "category": "5040", "files": { "nzb": { "filename": "Release.nzb", "type": "nzb" }, - "nfo": { "filename": "Release.nfo", "type": "nfo" } + "nfo": { "filename": "scene-info.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`. +Staging does not synchronously create a release. Import the NZB files first, +then import their paired NFO files using the manifest identity recorded by the +NZB importer: + +```bash +php artisan nntmux:import-nzbs --folder=/path/to/NZB_UPLOAD_FOLDER +php artisan nntmux:import-nfos --folder=/path/to/NZB_UPLOAD_FOLDER +``` + +The NFO importer replaces an existing NFO for the resolved release. Add +`--delete` to remove successfully imported NFO payloads or `--delete-failed` +to remove payloads that cannot be linked. NZB-only responses set `files.nfo` +to `null`. ## Response Models diff --git a/docs/postman/nntmux_api_v2.postman_collection.json b/docs/postman/nntmux_api_v2.postman_collection.json index e61e42740..7d2162f84 100644 --- a/docs/postman/nntmux_api_v2.postman_collection.json +++ b/docs/postman/nntmux_api_v2.postman_collection.json @@ -610,10 +610,10 @@ { "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" } + { "key": "nfo", "type": "file", "src": "scene-info.nfo" } ] }, - "description": "Atomically stages an NZB and an optional NFO with a matching basename. Category is echoed metadata only.", + "description": "Atomically stages an NZB and an optional NFO with any safe basename. Run nntmux:import-nzbs before nntmux:import-nfos. Category is echoed metadata only.", "url": { "raw": "{{baseUrl}}/api/v2/nzbadd", "host": ["{{baseUrl}}"], @@ -637,7 +637,7 @@ "_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}" + "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\": \"scene-info.nfo\", \"type\": \"nfo\" }\n }\n}" } ] } diff --git a/tests/Feature/ApiRequestMatrixTest.php b/tests/Feature/ApiRequestMatrixTest.php index be22e18c1..bf6db6973 100644 --- a/tests/Feature/ApiRequestMatrixTest.php +++ b/tests/Feature/ApiRequestMatrixTest.php @@ -690,14 +690,61 @@ class ApiRequestMatrixTest extends TestCase ->assertJsonPath('password', 0); } - public function test_v2_nzbadd_stages_a_paired_nzb_and_nfo(): void + public function test_v1_nzbadd_stages_differently_named_nzb_and_nfo_fields(): void + { + $token = (string) DB::table('users')->value('api_token'); + + $this->post('/api/v1/api?t=nzbadd&apikey='.$token, [ + 'cat' => '5040', + 'nzb' => UploadedFile::fake()->createWithContent( + 'Release.nzb', + '', + ), + 'nfo' => UploadedFile::fake()->createWithContent('scene-info.nfo', 'release information'), + ])->assertOk() + ->assertHeader('Content-Type', 'text/xml; charset=UTF-8') + ->assertSee('', false); + + $this->assertNotNull($this->findStagedFile('Release.nzb')); + $this->assertNotNull($this->findStagedFile('scene-info.nfo')); + $this->assertNotNull($this->findStagedFile('nntmux-upload.json')); + } + + public function test_v1_nzbadd_preserves_legacy_file_fallback_and_rejects_mixed_contracts(): void + { + $token = (string) DB::table('users')->value('api_token'); + + $this->post('/api/v1/api?t=nzbadd&apikey='.$token, [ + 'file' => UploadedFile::fake()->createWithContent( + 'Legacy.nzb', + '', + ), + ])->assertOk() + ->assertSee('name="Legacy"', false); + + $this->assertNotNull($this->findStagedFile('Legacy.nzb')); + + $this->post('/api/v1/api?t=nzbadd&apikey='.$token, [ + 'file' => UploadedFile::fake()->createWithContent( + 'Mixed.nzb', + '', + ), + 'nzb' => UploadedFile::fake()->createWithContent( + 'Modern.nzb', + '', + ), + ])->assertBadRequest() + ->assertSee('value('api_token'); $nzb = UploadedFile::fake()->createWithContent( 'Release.nzb', '', ); - $nfo = UploadedFile::fake()->createWithContent('Release.nfo', 'release information'); + $nfo = UploadedFile::fake()->createWithContent('scene-info.nfo', 'release information'); $this->post('/api/v2/nzbadd', [ 'api_token' => $token, @@ -710,10 +757,11 @@ class ApiRequestMatrixTest extends TestCase ->assertJsonPath('name', 'Release') ->assertJsonPath('category', '5040') ->assertJsonPath('files.nzb.filename', 'Release.nzb') - ->assertJsonPath('files.nfo.filename', 'Release.nfo'); + ->assertJsonPath('files.nfo.filename', 'scene-info.nfo'); - $this->assertFileExists($this->nzbUploadFolder.'/Release.nzb'); - $this->assertFileExists($this->nzbUploadFolder.'/Release.nfo'); + $this->assertNotNull($this->findStagedFile('Release.nzb')); + $this->assertNotNull($this->findStagedFile('scene-info.nfo')); + $this->assertNotNull($this->findStagedFile('nntmux-upload.json')); $this->assertSame(0, DB::table('user_requests')->count()); } @@ -737,7 +785,7 @@ class ApiRequestMatrixTest extends TestCase ])->assertCreated(); $this->assertSame(1, DB::table('user_requests')->count()); - $this->assertFileExists($this->nzbUploadFolder.'/QuotaExempt.nzb'); + $this->assertNotNull($this->findStagedFile('QuotaExempt.nzb')); } public function test_v2_nzbadd_requires_posting_privileges(): void @@ -926,6 +974,17 @@ class ApiRequestMatrixTest extends TestCase }); } + private function findStagedFile(string $filename): ?string + { + foreach ((new Filesystem)->allFiles($this->nzbUploadFolder) as $file) { + if ($file->getFilename() === $filename) { + return $file->getPathname(); + } + } + + return null; + } + private function setEnvironmentValue(string $key, ?string $value): void { if ($value === null) { diff --git a/tests/Unit/NfoImportServiceTest.php b/tests/Unit/NfoImportServiceTest.php new file mode 100644 index 000000000..8699e7eee --- /dev/null +++ b/tests/Unit/NfoImportServiceTest.php @@ -0,0 +1,230 @@ + */ + private array $originalEnvironment = []; + + public function createApplication() + { + $this->databasePath = sys_get_temp_dir().'/nntmux-nfo-import-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', ''), + ('timeoutseconds', '60')"); + + $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' => $this->databasePath, + 'nntmux.tmp_unrar_path' => sys_get_temp_dir().'/nntmux-nfo-import-tmp', + ]); + DB::purge(); + DB::reconnect(); + + Schema::dropAllTables(); + Schema::create('settings', function (Blueprint $table): void { + $table->string('name')->primary(); + $table->text('value')->nullable(); + }); + Schema::create('releases', function (Blueprint $table): void { + $table->increments('id'); + $table->string('guid')->unique(); + $table->integer('nfostatus')->default(-1); + }); + Schema::create('release_nfos', function (Blueprint $table): void { + $table->unsignedInteger('releases_id')->primary(); + $table->binary('nfo')->nullable(); + }); + DB::table('settings')->insert([ + ['name' => 'timeoutseconds', 'value' => '60'], + ]); + + $this->uploadFolder = sys_get_temp_dir().'/nntmux-nfo-import-'.bin2hex(random_bytes(6)); + (new Filesystem)->makeDirectory($this->uploadFolder, 0775, true); + } + + protected function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->uploadFolder); + (new Filesystem)->deleteDirectory(sys_get_temp_dir().'/nntmux-nfo-import-tmp'); + + 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_replaces_an_existing_nfo_for_the_manifest_release(): void + { + $guid = '11111111-1111-4111-8111-111111111111'; + DB::table('releases')->insert(['id' => 1, 'guid' => $guid, 'nfostatus' => -1]); + DB::table('release_nfos')->insert([ + 'releases_id' => 1, + 'nfo' => "\x1f\x8b\x08\x00".gzcompress('old information'), + ]); + + [$manifestPath, $nfoPath, $manifests] = $this->makeResolvedUpload($guid); + $service = new NfoImportService(new Filesystem, $manifests, new NfoService); + + $result = $service->importManifest($manifestPath); + + $this->assertSame('imported', $result['status']); + $stored = DB::table('release_nfos')->where('releases_id', 1)->value('nfo'); + $this->assertIsString($stored); + $this->assertSame('new release information', gzuncompress(substr($stored, 4))); + $this->assertSame(1, DB::table('releases')->where('id', 1)->value('nfostatus')); + $this->assertSame(NzbUploadManifestService::STATE_NFO_IMPORTED, $manifests->read($manifestPath)['state']); + $this->assertFileExists($nfoPath); + } + + public function test_it_deletes_a_successfully_imported_nfo_when_requested(): void + { + $guid = '22222222-2222-4222-8222-222222222222'; + DB::table('releases')->insert(['id' => 1, 'guid' => $guid, 'nfostatus' => -1]); + [$manifestPath, $nfoPath, $manifests] = $this->makeResolvedUpload($guid); + + $result = (new NfoImportService(new Filesystem, $manifests, new NfoService)) + ->importManifest($manifestPath, delete: true); + + $this->assertSame('imported', $result['status']); + $this->assertFileDoesNotExist($nfoPath); + $this->assertFileExists($manifestPath); + } + + public function test_duplicate_nzb_manifest_is_not_linked_and_can_delete_its_nfo(): void + { + $filesystem = new Filesystem; + $manifests = new NzbUploadManifestService($filesystem); + $directory = $this->uploadFolder.'/duplicate'; + $filesystem->makeDirectory($directory, 0775, true); + $filesystem->put($directory.'/Release.nzb', ''); + $filesystem->put($directory.'/different.nfo', 'duplicate release information'); + $manifestPath = $directory.'/'.NzbUploadManifestService::FILENAME; + $manifests->create($directory, 'duplicate', 'Release.nzb', 'different.nfo'); + $manifests->recordNzbOutcome( + $directory.'/Release.nzb', + NzbImportStatus::Duplicate, + null, + null, + ); + + $result = (new NfoImportService($filesystem, $manifests, new NfoService)) + ->importManifest($manifestPath, deleteFailed: true); + + $this->assertSame('skipped', $result['status']); + $this->assertFileDoesNotExist($directory.'/different.nfo'); + $this->assertSame(NzbUploadManifestService::STATE_NZB_DUPLICATE, $manifests->read($manifestPath)['state']); + } + + public function test_it_rejects_a_manifest_path_that_escapes_the_upload_directory(): void + { + $filesystem = new Filesystem; + $directory = $this->uploadFolder.'/unsafe'; + $filesystem->makeDirectory($directory, 0775, true); + $outsidePath = $this->uploadFolder.'/outside.nfo'; + $filesystem->put($outsidePath, 'outside information'); + $manifestPath = $directory.'/'.NzbUploadManifestService::FILENAME; + $filesystem->put($manifestPath, json_encode([ + 'version' => 1, + 'upload_id' => 'unsafe', + 'state' => NzbUploadManifestService::STATE_NZB_IMPORTED, + 'nzb' => ['filename' => 'Release.nzb'], + 'nfo' => ['filename' => '../outside.nfo'], + 'release_id' => 1, + 'release_guid' => 'unsafe-guid', + ], JSON_THROW_ON_ERROR)); + + $manifests = new NzbUploadManifestService($filesystem); + $result = (new NfoImportService($filesystem, $manifests, new NfoService)) + ->importManifest($manifestPath, deleteFailed: true); + + $this->assertSame('failed', $result['status']); + $this->assertFileExists($outsidePath); + } + + /** + * @return array{string,string,NzbUploadManifestService} + */ + private function makeResolvedUpload(string $guid): array + { + $filesystem = new Filesystem; + $manifests = new NzbUploadManifestService($filesystem); + $directory = $this->uploadFolder.'/resolved'; + $filesystem->makeDirectory($directory, 0775, true); + $nzbPath = $directory.'/Release.nzb'; + $nfoPath = $directory.'/different.nfo'; + $filesystem->put($nzbPath, ''); + $filesystem->put($nfoPath, 'new release information'); + $manifestPath = $directory.'/'.NzbUploadManifestService::FILENAME; + $manifests->create($directory, 'resolved', 'Release.nzb', 'different.nfo'); + $manifests->recordNzbOutcome($nzbPath, NzbImportStatus::Inserted, 1, $guid); + + return [$manifestPath, $nfoPath, $manifests]; + } + + 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; + } +} diff --git a/tests/Unit/NzbUploadStagingServiceTest.php b/tests/Unit/NzbUploadStagingServiceTest.php index df79e612f..dbc770eab 100644 --- a/tests/Unit/NzbUploadStagingServiceTest.php +++ b/tests/Unit/NzbUploadStagingServiceTest.php @@ -5,11 +5,11 @@ declare(strict_types=1); namespace Tests\Unit; use App\Exceptions\NzbUploadException; +use App\Services\Nzb\NzbUploadManifestService; 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; @@ -75,41 +75,59 @@ final class NzbUploadStagingServiceTest extends TestCase } } - public function test_it_stages_a_matching_nzb_and_nfo_pair(): void + public function test_it_stages_an_nzb_and_arbitrarily_named_nfo_with_a_manifest(): void { $result = (new NzbUploadStagingService(new Filesystem))->stage( $this->nzb('Release.nzb'), - UploadedFile::fake()->createWithContent('Release.nfo', 'release information'), + UploadedFile::fake()->createWithContent('scene-info.nfo', 'release information'), + ); + + $uploadDirectory = $this->onlyUploadDirectory(); + $manifest = json_decode( + (string) file_get_contents($uploadDirectory.'/'.NzbUploadManifestService::FILENAME), + true, + flags: JSON_THROW_ON_ERROR, ); $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'); + $this->assertSame('scene-info.nfo', $result['files']['nfo']['filename']); + $this->assertFileExists($uploadDirectory.'/Release.nzb'); + $this->assertFileExists($uploadDirectory.'/scene-info.nfo'); + $this->assertSame(NzbUploadManifestService::STATE_STAGED, $manifest['state']); + $this->assertSame('Release.nzb', $manifest['nzb']['filename']); + $this->assertSame('scene-info.nfo', $manifest['nfo']['filename']); + $this->assertSame(basename($uploadDirectory), $manifest['upload_id']); } public function test_it_stages_an_nzb_without_an_nfo(): void { $result = (new NzbUploadStagingService(new Filesystem))->stage($this->nzb('Solo.nzb')); + $uploadDirectory = $this->onlyUploadDirectory(); + $this->assertNull($result['files']['nfo']); - $this->assertFileExists($this->uploadFolder.'/Solo.nzb'); + $this->assertFileExists($uploadDirectory.'/Solo.nzb'); + $this->assertFileExists($uploadDirectory.'/'.NzbUploadManifestService::FILENAME); } - public function test_it_rejects_mismatched_pair_names_before_writing(): void + public function test_it_isolates_repeated_generic_nfo_names(): 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); - } + $service = new NzbUploadStagingService(new Filesystem); + $service->stage( + $this->nzb('First.nzb'), + UploadedFile::fake()->createWithContent('info.nfo', 'first release information'), + ); + $service->stage( + $this->nzb('Second.nzb'), + UploadedFile::fake()->createWithContent('info.nfo', 'second release information'), + ); - $this->assertDirectoryDoesNotExist($this->uploadFolder); + $directories = (new Filesystem)->directories($this->uploadFolder); + $this->assertCount(2, $directories); + foreach ($directories as $directory) { + $this->assertFileExists($directory.'/info.nfo'); + } } public function test_it_rejects_an_invalid_nzb_payload(): void @@ -133,37 +151,19 @@ final class NzbUploadStagingServiceTest extends TestCase ); } - 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(); + $filesystem = new class extends Filesystem + { + public function put($path, $contents, $lock = false): int|bool + { + if (str_ends_with((string) $path, '.nfo')) { + return false; + } + + return parent::put($path, $contents, $lock); + } + }; $this->expectException(NzbUploadException::class); $this->expectExceptionMessage('Failed to write Release.nfo to disk'); @@ -174,6 +174,29 @@ final class NzbUploadStagingServiceTest extends TestCase ); } + public function test_rollback_removes_the_partial_upload_directory(): void + { + $filesystem = new class extends Filesystem + { + public function put($path, $contents, $lock = false): int|bool + { + return str_ends_with((string) $path, '.nfo') + ? false + : parent::put($path, $contents, $lock); + } + }; + + try { + (new NzbUploadStagingService($filesystem))->stage( + $this->nzb('Release.nzb'), + UploadedFile::fake()->createWithContent('Release.nfo', 'release information'), + ); + $this->fail('Expected staged pair write to fail.'); + } catch (NzbUploadException) { + $this->assertSame([], $filesystem->directories($this->uploadFolder)); + } + } + private function nzb(string $name): UploadedFile { return UploadedFile::fake()->createWithContent( @@ -182,6 +205,14 @@ final class NzbUploadStagingServiceTest extends TestCase ); } + private function onlyUploadDirectory(): string + { + $directories = (new Filesystem)->directories($this->uploadFolder); + $this->assertCount(1, $directories); + + return $directories[0]; + } + private function setEnvironmentValue(string $key, ?string $value): void { if ($value === null) {