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();