From de9007c9a52fbdcbb656806d90640340249baf61 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Mon, 3 Aug 2026 23:49:54 +0200 Subject: [PATCH] Respect imported nzb category --- app/Services/Nzb/NzbImportService.php | 140 ++++++++++++++++++-- app/Services/Nzb/NzbService.php | 12 +- tests/Unit/NzbImportServiceTest.php | 99 ++++++++++++++ tests/Unit/NzbServicePathResolutionTest.php | 31 ++++- 4 files changed, 263 insertions(+), 19 deletions(-) diff --git a/app/Services/Nzb/NzbImportService.php b/app/Services/Nzb/NzbImportService.php index 12a7b82b7..215a1bdaf 100644 --- a/app/Services/Nzb/NzbImportService.php +++ b/app/Services/Nzb/NzbImportService.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Services\Nzb; use App\Enums\NzbImportStatus; +use App\Models\Category; use App\Models\Predb; use App\Models\Release; use App\Models\Settings; @@ -196,22 +197,38 @@ class NzbImportService $nzbFileName = $useNzbName === true ? $this->deriveReleaseNameFromNzbPath($nzbFilePath) : ''; try { $importStatus = $this->scanNZBFile($nzbXML, $nzbFileName, $source); - } catch (\Exception $e) { - $this->echoOut('ERROR: Problem inserting: '.$nzbFilePath); + } catch (\Throwable $exception) { + Log::error('NZB import failed while scanning or inserting a release.', [ + 'path' => $nzbFilePath, + 'exception' => $exception, + ]); + + $message = 'ERROR: Problem inserting: '.$nzbFilePath; + if (! $this->browser) { + $message .= ': '.$exception->getMessage(); + } + + $this->echoOut($message); $importStatus = NzbImportStatus::Failed; } if ($importStatus === NzbImportStatus::Inserted) { - // Try to copy the NZB to the NZB folder. - $path = $this->nzb->getNzbPath($this->relGuid, 0, true); + $path = null; + try { + $path = $this->nzb->getNzbPath($this->relGuid, 0, true); + $stored = $this->writeCompressedNzb($path, $nzbString); + } catch (\Throwable $exception) { + Log::error('NZB import failed while storing the compressed file.', [ + 'guid' => $this->relGuid, + 'path' => $path, + 'exception' => $exception, + ]); + $stored = false; + } - // Try to compress the NZB file in the NZB folder. - $fp = gzopen($path, 'w5'); - gzwrite($fp, $nzbString); - gzclose($fp); - - if (! File::isFile($path)) { - $this->echoOut('ERROR: Problem compressing NZB file to: '.$path); + if (! $stored) { + $destination = $path ?? $this->relGuid; + $this->echoOut('ERROR: Problem compressing NZB file to: '.$destination); // Remove the release. Release::query()->where('guid', $this->relGuid)->delete(); @@ -303,6 +320,46 @@ class NzbImportService return rtrim($name, ". \t\n\r\0\x0B"); } + protected function writeCompressedNzb(string $path, string $contents): bool + { + $handle = @gzopen($path, 'w5'); + if ($handle === false) { + Log::error('Unable to open imported NZB destination for writing.', ['path' => $path]); + + return false; + } + + $stored = false; + + try { + $written = gzwrite($handle, $contents); + if ($written !== \strlen($contents) || ! gzclose($handle)) { + Log::error('Unable to write the complete imported NZB file.', ['path' => $path]); + + return false; + } + + $handle = null; + $stored = File::isFile($path); + + return $stored; + } catch (\Throwable $exception) { + Log::error('Imported NZB compression failed.', [ + 'path' => $path, + 'exception' => $exception, + ]); + + return false; + } finally { + if (\is_resource($handle)) { + @gzclose($handle); + } + if (! $stored) { + File::delete($path); + } + } + } + /** * Scan and process an NZB file. * @@ -413,10 +470,62 @@ class NzbImportService 'groupName' => $groupName, 'totalFiles' => $totalFiles, 'totalSize' => $totalSize, + 'nzbCategoryId' => $this->resolveNzbCategoryId($nzbXML), ] ); } + protected function resolveNzbCategoryId(mixed $nzbXML): ?int + { + if (! $nzbXML instanceof \SimpleXMLElement) { + return null; + } + + $categoryMetadata = []; + foreach ($nzbXML->head->meta ?? [] as $meta) { + if (mb_strtolower(trim((string) $meta['type'])) !== 'category') { + continue; + } + + $value = trim((string) $meta); + if ($value !== '') { + $categoryMetadata[] = $value; + } + } + + if ($categoryMetadata === []) { + return null; + } + + $activeCategories = Category::query() + ->where('status', Category::STATUS_ACTIVE) + ->get(['id', 'title']); + $resolvedCategoryIds = []; + + foreach ($categoryMetadata as $value) { + if (ctype_digit($value)) { + $matchingCategories = $activeCategories->filter( + static fn (Category $category): bool => $category->id === (int) $value + ); + } else { + $normalizedValue = mb_strtolower($value); + $matchingCategories = $activeCategories->filter( + static fn (Category $category): bool => mb_strtolower($category->title) === $normalizedValue + ); + } + + if ($matchingCategories->count() !== 1) { + return null; + } + + $resolvedCategoryIds[] = (int) $matchingCategories->first()->id; + } + + $resolvedCategoryIds = array_values(array_unique($resolvedCategoryIds)); + + return count($resolvedCategoryIds) === 1 ? $resolvedCategoryIds[0] : null; + } + /** * Insert the NZB details into the database. * @@ -498,7 +607,12 @@ class NzbImportService return NzbImportStatus::Duplicate; } - $determinedCategory = $this->category->determineCategory($nzbDetails['groups_id'], $cleanName, $escapedFromName); + $categoryId = $nzbDetails['nzbCategoryId']; + if (! \is_int($categoryId)) { + $determinedCategory = $this->category->determineCategory($nzbDetails['groups_id'], $cleanName, $escapedFromName); + $categoryId = (int) $determinedCategory['categories_id']; + } + $relID = Release::insertRelease( [ 'name' => $escapedSubject, @@ -509,7 +623,7 @@ class NzbImportService 'postdate' => $nzbDetails['postDate'], 'fromname' => $escapedFromName, 'size' => $nzbDetails['totalSize'], - 'categories_id' => $determinedCategory['categories_id'], + 'categories_id' => $categoryId, 'isrenamed' => $renamed, 'predb_id' => $predbIdInt, 'nzbstatus' => NzbService::NZB_ADDED, diff --git a/app/Services/Nzb/NzbService.php b/app/Services/Nzb/NzbService.php index 7ab26c734..7f95fc4b3 100644 --- a/app/Services/Nzb/NzbService.php +++ b/app/Services/Nzb/NzbService.php @@ -366,16 +366,18 @@ class NzbService */ public function buildNzbPath(string $releaseGuid, int $levelsToSplit, bool $createIfNotExist): string { - $nzbPath = ''; + $nzbPath = $this->siteNzbPath; for ($i = 0; $i < $levelsToSplit && $i < 32; $i++) { $nzbPath .= $releaseGuid[$i].'/'; - } - $nzbPath = $this->siteNzbPath.$nzbPath; + if ($createIfNotExist && ! File::isDirectory($nzbPath)) { + if (! File::makeDirectory($nzbPath, 0775) && ! File::isDirectory($nzbPath)) { // @phpstan-ignore booleanNot.alwaysTrue + throw new \RuntimeException(sprintf('Directory "%s" was not created', $nzbPath)); + } - if ($createIfNotExist && ! File::isDirectory($nzbPath) && ! File::makeDirectory($nzbPath, 0777, true) && ! File::isDirectory($nzbPath)) { // @phpstan-ignore booleanNot.alwaysTrue - throw new \RuntimeException(sprintf('Directory "%s" was not created', $nzbPath)); + File::chmod($nzbPath, 02775); + } } return $nzbPath; diff --git a/tests/Unit/NzbImportServiceTest.php b/tests/Unit/NzbImportServiceTest.php index 31538a4e2..17e46c1ff 100644 --- a/tests/Unit/NzbImportServiceTest.php +++ b/tests/Unit/NzbImportServiceTest.php @@ -5,10 +5,13 @@ declare(strict_types=1); namespace Tests\Unit; use App\Enums\NzbImportStatus; +use App\Models\Category; use App\Services\Nzb\NzbImportService; use Illuminate\Contracts\Console\Kernel; +use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use PDO; use PHPUnit\Framework\Attributes\DataProvider; use Tests\TestCase; @@ -68,6 +71,12 @@ final class NzbImportServiceTest extends TestCase DB::purge(); DB::reconnect(); Cache::flush(); + + Schema::create('categories', function (Blueprint $table): void { + $table->integer('id')->primary(); + $table->string('title'); + $table->integer('status'); + }); } protected function tearDown(): void @@ -228,6 +237,66 @@ final class NzbImportServiceTest extends TestCase $this->assertSame($expected, $service->deriveForTest($input)); } + public function test_nzb_category_metadata_resolves_active_id_and_unique_case_insensitive_title(): void + { + $this->insertCategory(2040, 'HD', Category::STATUS_ACTIVE); + $this->insertCategory(3040, 'Lossless', Category::STATUS_ACTIVE); + + $this->assertSame(2040, $this->resolveNzbCategory(' 2040 ')); + $this->assertSame(3040, $this->resolveNzbCategory(' lossLESS ')); + $this->assertSame(2040, $this->resolveNzbCategory( + '2040', + ' xmlns="http://www.newzbin.com/DTD/2003/nzb"' + )); + } + + public function test_nzb_category_metadata_rejects_unknown_inactive_disabled_and_ambiguous_values(): void + { + $this->insertCategory(2040, 'HD', Category::STATUS_ACTIVE); + $this->insertCategory(3040, 'Lossless', Category::STATUS_INACTIVE); + $this->insertCategory(5040, 'HD', Category::STATUS_ACTIVE); + $this->insertCategory(6040, 'X264', Category::STATUS_DISABLED); + + $this->assertNull($this->resolveNzbCategory('9999')); + $this->assertNull($this->resolveNzbCategory('3040')); + $this->assertNull($this->resolveNzbCategory('X264')); + $this->assertNull($this->resolveNzbCategory('HD')); + } + + public function test_nzb_category_metadata_rejects_conflicting_matches(): void + { + $this->insertCategory(2040, 'Movie HD', Category::STATUS_ACTIVE); + $this->insertCategory(5040, 'TV HD', Category::STATUS_ACTIVE); + + $this->assertNull($this->resolveNzbCategory( + '2040TV HD' + )); + } + + public function test_nzb_category_metadata_falls_back_when_category_is_missing_or_blank(): void + { + $this->assertNull($this->resolveNzbXml('')); + $this->assertNull($this->resolveNzbCategory('')); + $this->assertNull($this->resolveNzbCategory(' ')); + $this->assertNull($this->resolveNzbCategory('secret')); + } + + public function test_compressed_nzb_write_returns_false_for_an_unwritable_destination(): void + { + $service = new class(['Browser' => true]) extends NzbImportService + { + public function writeForTest(string $path, string $contents): bool + { + return $this->writeCompressedNzb($path, $contents); + } + }; + + $path = sys_get_temp_dir().'/missing-'.bin2hex(random_bytes(5)).'/release.nzb.gz'; + + $this->assertFalse($service->writeForTest($path, '')); + $this->assertFileDoesNotExist($path); + } + private function makeNzbFile(string $suffix): string { $path = sys_get_temp_dir().'/'.$suffix.'-'.bin2hex(random_bytes(5)).'.nzb'; @@ -236,6 +305,36 @@ final class NzbImportServiceTest extends TestCase return $path; } + private function insertCategory(int $id, string $title, int $status): void + { + DB::table('categories')->insert([ + 'id' => $id, + 'title' => $title, + 'status' => $status, + ]); + } + + private function resolveNzbCategory(string $headMetadata, string $nzbAttributes = ''): ?int + { + return $this->resolveNzbXml("{$headMetadata}"); + } + + private function resolveNzbXml(string $xml): ?int + { + $service = new class(['Browser' => true]) extends NzbImportService + { + public function resolveForTest(\SimpleXMLElement $nzb): ?int + { + return $this->resolveNzbCategoryId($nzb); + } + }; + + $nzb = simplexml_load_string($xml); + $this->assertInstanceOf(\SimpleXMLElement::class, $nzb); + + return $service->resolveForTest($nzb); + } + private function setEnvironmentValue(string $key, ?string $value): void { if ($value === null) { diff --git a/tests/Unit/NzbServicePathResolutionTest.php b/tests/Unit/NzbServicePathResolutionTest.php index 8c1ce9a42..7311809ef 100644 --- a/tests/Unit/NzbServicePathResolutionTest.php +++ b/tests/Unit/NzbServicePathResolutionTest.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace Tests\Unit; use App\Services\Nzb\NzbService; -use PHPUnit\Framework\TestCase; use ReflectionClass; +use Tests\TestCase; final class NzbServicePathResolutionTest extends TestCase { @@ -120,6 +120,35 @@ final class NzbServicePathResolutionTest extends TestCase } } + public function test_build_nzb_path_creates_group_writable_setgid_directories_despite_umask(): void + { + $tempDir = sys_get_temp_dir().'/nzb-path-permissions-'.uniqid('', true); + $basePath = $tempDir.'/'; + mkdir($basePath, 0775, true); + $previousUmask = umask(0022); + + try { + $service = $this->makeServiceWithoutConstructor(); + \Closure::bind( + function (string $path): void { + $this->siteNzbPath = $path; + }, + $service, + NzbService::class + )($basePath); + + $path = $service->buildNzbPath('4aabfe07-daff-4d28-9d1d-d2a4ab7b6511', 4, true); + + $this->assertSame($basePath.'4/a/a/b/', $path); + foreach ([$basePath.'4', $basePath.'4/a', $basePath.'4/a/a', $basePath.'4/a/a/b'] as $directory) { + $this->assertSame(02775, fileperms($directory) & 07777); + } + } finally { + umask($previousUmask); + $this->deleteDirectory($tempDir); + } + } + private function makeServiceWithoutConstructor(): NzbService { return (new ReflectionClass(NzbService::class))->newInstanceWithoutConstructor();