From 6a03c6b2ad42e606a441fb5a9d48cff4946b9e1e Mon Sep 17 00:00:00 2001 From: DariusIII Date: Tue, 21 Apr 2026 11:24:14 +0200 Subject: [PATCH] Fix errors with some of files --- .../Commands/NntmuxRemoveBadReleases.php | 4 +- app/Http/Controllers/Api/ApiController.php | 20 ++- app/Http/Controllers/GetNzbController.php | 32 ++-- app/Services/Nzb/NzbService.php | 103 +++++++++++- app/Support/FilenameSanitizer.php | 107 +++++++++++++ tests/Unit/FilenameSanitizerTest.php | 57 +++++++ tests/Unit/NzbServicePathResolutionTest.php | 149 ++++++++++++++++++ tests/Unit/Support/FilenameSanitizerTest.php | 57 +++++++ 8 files changed, 506 insertions(+), 23 deletions(-) create mode 100644 app/Support/FilenameSanitizer.php create mode 100644 tests/Unit/FilenameSanitizerTest.php create mode 100644 tests/Unit/NzbServicePathResolutionTest.php create mode 100644 tests/Unit/Support/FilenameSanitizerTest.php diff --git a/app/Console/Commands/NntmuxRemoveBadReleases.php b/app/Console/Commands/NntmuxRemoveBadReleases.php index 32e918e6d..986107ca6 100644 --- a/app/Console/Commands/NntmuxRemoveBadReleases.php +++ b/app/Console/Commands/NntmuxRemoveBadReleases.php @@ -10,7 +10,6 @@ use App\Models\ReleaseFile; use App\Services\Nzb\NzbService; use App\Services\ReleaseImageService; use Illuminate\Console\Command; -use Illuminate\Support\Facades\File; class NntmuxRemoveBadReleases extends Command { @@ -49,8 +48,7 @@ class NntmuxRemoveBadReleases extends Command // Select releases with password status -2 and smaller and delete them. Also delete the files from the filesystem. $badReleases = Release::query()->where('passwordstatus', '<=', -2)->get(); foreach ($badReleases as $badRelease) { - $nzbPath = app(NzbService::class)->getNzbPath($badRelease->guid); - File::delete($nzbPath); + app(NzbService::class)->deleteNzb($badRelease->guid); (new ReleaseImageService)->delete($badRelease->guid); // Delete from search index Search::deleteRelease($badRelease->id); diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php index 0dfb35bc7..7cd0181b5 100644 --- a/app/Http/Controllers/Api/ApiController.php +++ b/app/Http/Controllers/Api/ApiController.php @@ -17,6 +17,7 @@ use App\Models\UserRequest; use App\Services\RegistrationStatusService; use App\Services\Releases\ReleaseBrowseService; use App\Services\Releases\ReleaseSearchService; +use App\Support\FilenameSanitizer; use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\Routing\ResponseFactory; use Illuminate\Http\RedirectResponse; @@ -30,6 +31,7 @@ use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; +use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\StreamedResponse; class ApiController extends BasePageController @@ -448,9 +450,23 @@ class ApiController extends BasePageController $data = ReleaseNfo::getReleaseNfo($rel->id); if (! empty($data)) { if ($request->has('o') && $request->input('o') === 'file') { - return response()->streamDownload(function () use ($data) { + $filename = FilenameSanitizer::sanitize($rel->searchname, "nfo-{$rel->id}"); + $asciiFallback = FilenameSanitizer::asciiFallback($filename, "nfo-{$rel->id}"); + + $response = response()->stream(function () use ($data) { echo $data['nfo']; - }, $rel['searchname'].'.nfo', ['Content-type:' => 'application/octet-stream']); + }, 200, ['Content-Type' => 'application/octet-stream']); + + $response->headers->set( + 'Content-Disposition', + HeaderUtils::makeDisposition( + 'attachment', + $filename.'.nfo', + $asciiFallback.'.nfo' + ) + ); + + return $response; } echo nl2br(cp437toUTF($data['nfo'])); diff --git a/app/Http/Controllers/GetNzbController.php b/app/Http/Controllers/GetNzbController.php index 1c7959034..7aa9b7551 100644 --- a/app/Http/Controllers/GetNzbController.php +++ b/app/Http/Controllers/GetNzbController.php @@ -9,6 +9,7 @@ use App\Models\User; use App\Models\UserDownload; use App\Models\UsersRelease; use App\Services\Nzb\NzbService; +use App\Support\FilenameSanitizer; use Exception; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -16,6 +17,7 @@ use Illuminate\Http\Response; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; use STS\ZipStream\Builder; +use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\StreamedResponse; class GetNzbController extends BasePageController @@ -248,8 +250,8 @@ class GetNzbController extends BasePageController string $releaseId ): Response|StreamedResponse { // Get NZB file path and validate - $nzbPath = app(NzbService::class)->getNzbPath($releaseId); - if (! File::exists($nzbPath)) { + $nzbPath = app(NzbService::class)->nzbPath($releaseId); + if ($nzbPath === false) { return showApiError(300, 'NZB file not found!'); } @@ -266,13 +268,25 @@ class GetNzbController extends BasePageController $headers = $this->buildNzbHeaders($releaseId, $uid, $rssToken, $releaseData); // Stream modified NZB content - $cleanName = $this->sanitizeFilename($releaseData->searchname); + $cleanName = FilenameSanitizer::sanitize($releaseData->searchname, "release-{$releaseId}"); + $asciiFallbackName = FilenameSanitizer::asciiFallback($cleanName, "release-{$releaseId}"); - return response()->streamDownload( + $response = response()->stream( fn () => $this->streamModifiedNzbContent($nzbPath, $uid), - $cleanName.self::NZB_SUFFIX, + 200, $headers ); + + $response->headers->set( + 'Content-Disposition', + HeaderUtils::makeDisposition( + 'attachment', + $cleanName.self::NZB_SUFFIX, + $asciiFallbackName.self::NZB_SUFFIX + ) + ); + + return $response; } /** @@ -378,12 +392,4 @@ class GetNzbController extends BasePageController gzclose($fileHandle); } - - /** - * Sanitize filename for download - */ - private function sanitizeFilename(string $filename): string - { - return str_replace([',', ' ', '/', '\\'], '_', $filename); - } } diff --git a/app/Services/Nzb/NzbService.php b/app/Services/Nzb/NzbService.php index 816044a34..432794be3 100644 --- a/app/Services/Nzb/NzbService.php +++ b/app/Services/Nzb/NzbService.php @@ -43,6 +43,13 @@ class NzbService */ protected string $siteNzbPath; + /** + * Candidate base paths to search for existing NZB files. + * + * @var list + */ + protected array $siteNzbPaths = []; + /** * String used for head in NZB XML file. */ @@ -59,10 +66,8 @@ class NzbService $nzbSplitLevel = 1; } $this->nzbSplitLevel = $nzbSplitLevel; - $this->siteNzbPath = config('nntmux_settings.path_to_nzbs'); - if (! Str::endsWith($this->siteNzbPath, '/')) { - $this->siteNzbPath .= '/'; - } + $this->siteNzbPaths = $this->buildCandidateNzbBasePaths((string) config('nntmux_settings.path_to_nzbs')); + $this->siteNzbPath = $this->selectPreferredNzbBasePath($this->siteNzbPaths); $this->nzbCommentString = sprintf( 'NZB Generated by: NNTmux %s', now()->format('F j, Y, g:i a O') @@ -228,6 +233,20 @@ class NzbService return $nzbPath; } + /** + * Build a path on a specific NZB base directory. + */ + protected function buildNzbPathAtBasePath(string $basePath, string $releaseGuid, int $levelsToSplit): string + { + $nzbPath = ''; + + for ($i = 0; $i < $levelsToSplit && $i < 32; $i++) { + $nzbPath .= $releaseGuid[$i].'/'; + } + + return $basePath.$nzbPath; + } + /** * Retrieve path + filename of the NZB to be stored. * @@ -254,9 +273,18 @@ class NzbService */ public function nzbPath(string $releaseGuid): bool|string { + $levelsToSplit = $this->nzbSplitLevel === 0 ? 1 : $this->nzbSplitLevel; + + foreach ($this->siteNzbPaths as $basePath) { + $nzbFile = $this->buildNzbPathAtBasePath($basePath, $releaseGuid, $levelsToSplit).$releaseGuid.'.nzb.gz'; + if (is_file($nzbFile)) { + return $nzbFile; + } + } + $nzbFile = $this->getNzbPath($releaseGuid); - return File::isFile($nzbFile) ? $nzbFile : false; + return is_file($nzbFile) ? $nzbFile : false; } /** @@ -309,6 +337,71 @@ class NzbService return $this->siteNzbPath; } + /** + * @return list + */ + protected function buildCandidateNzbBasePaths(string $configuredPath): array + { + $defaultPath = $this->normalizeNzbBasePath(storage_path('nzb')); + $configuredPath = $configuredPath !== '' ? $configuredPath : $defaultPath; + $configuredPath = $this->normalizeNzbBasePath($configuredPath); + + $candidatePaths = [$configuredPath]; + $remappedStoragePath = $this->remapStoragePathToCurrentApplication($configuredPath); + + if ($remappedStoragePath !== null && ! in_array($remappedStoragePath, $candidatePaths, true)) { + $candidatePaths[] = $remappedStoragePath; + } + + if (! in_array($defaultPath, $candidatePaths, true)) { + $candidatePaths[] = $defaultPath; + } + + return $candidatePaths; + } + + /** + * @param list $candidatePaths + */ + protected function selectPreferredNzbBasePath(array $candidatePaths): string + { + $configuredPath = $candidatePaths[0] ?? $this->normalizeNzbBasePath(storage_path('nzb')); + $remappedStoragePath = $candidatePaths[1] ?? null; + + if ( + $remappedStoragePath !== null && + $configuredPath !== $remappedStoragePath && + ! is_dir($configuredPath) && + is_dir($remappedStoragePath) && + str_contains(str_replace('\\', '/', $configuredPath), '/storage/') + ) { + return $remappedStoragePath; + } + + return $configuredPath; + } + + protected function remapStoragePathToCurrentApplication(string $path): ?string + { + $normalizedPath = str_replace('\\', '/', $path); + $storageMarker = '/storage/'; + + if (! str_contains($normalizedPath, $storageMarker)) { + return null; + } + + $relativeStoragePath = Str::after($normalizedPath, $storageMarker); + + return $this->normalizeNzbBasePath(storage_path($relativeStoragePath)); + } + + protected function normalizeNzbBasePath(string $path): string + { + $normalizedPath = str_replace('\\', '/', trim($path)); + + return rtrim($normalizedPath, '/').'/'; + } + /** * Build the NZB subject line in the shape expected by downstream parsers. */ diff --git a/app/Support/FilenameSanitizer.php b/app/Support/FilenameSanitizer.php new file mode 100644 index 000000000..9e28eac14 --- /dev/null +++ b/app/Support/FilenameSanitizer.php @@ -0,0 +1,107 @@ + + */ + private const array UNICODE_PATH_SEPARATORS = [ + '⁄', + '∕', + '⧸', + '/', + '\', + '﹨', + '⧵', + '⟋', + '⟍', + '╱', + '╲', + ]; + + public static function sanitize(?string $name, string $fallback = 'download'): string + { + $sanitized = self::normalize($name); + + if ($sanitized === '') { + return self::safeFallback($fallback); + } + + return self::finalize($sanitized, $fallback); + } + + public static function asciiFallback(?string $name, string $fallback = 'download'): string + { + $sanitized = self::sanitize($name, $fallback); + $ascii = Str::ascii($sanitized); + $ascii = preg_replace('/[^A-Za-z0-9._-]+/', '_', $ascii) ?? $ascii; + $ascii = preg_replace('/_+/', '_', $ascii) ?? $ascii; + + return self::finalize($ascii, $fallback); + } + + private static function normalize(?string $name): string + { + $normalized = trim((string) $name); + + if ($normalized === '') { + return ''; + } + + if (class_exists(Normalizer::class)) { + $candidate = Normalizer::normalize($normalized, Normalizer::FORM_KC); + if (is_string($candidate)) { + $normalized = $candidate; + } + } + + $normalized = str_replace(self::UNICODE_PATH_SEPARATORS, '/', $normalized); + $normalized = preg_replace('/[\x00-\x1F\x7F]+/u', '', $normalized) ?? $normalized; + $normalized = str_replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], '_', $normalized); + $normalized = preg_replace('/[\s,]+/u', '_', $normalized) ?? $normalized; + $normalized = preg_replace('/_+/', '_', $normalized) ?? $normalized; + + return self::trimFilename($normalized); + } + + private static function finalize(string $name, string $fallback): string + { + $name = mb_substr($name, 0, self::MAX_FILENAME_LENGTH); + $name = self::trimFilename($name); + + if ($name === '' || $name === '.' || $name === '..') { + return self::safeFallback($fallback); + } + + return $name; + } + + private static function safeFallback(string $fallback): string + { + $safeFallback = Str::ascii($fallback); + $safeFallback = preg_replace('/[^A-Za-z0-9._-]+/', '_', $safeFallback) ?? $safeFallback; + $safeFallback = preg_replace('/_+/', '_', $safeFallback) ?? $safeFallback; + $safeFallback = mb_substr($safeFallback, 0, self::MAX_FILENAME_LENGTH); + $safeFallback = self::trimFilename($safeFallback); + + if ($safeFallback === '' || $safeFallback === '.' || $safeFallback === '..') { + return 'download'; + } + + return $safeFallback; + } + + private static function trimFilename(string $name): string + { + return ltrim(trim($name, " \t\n\r\0\x0B._-"), '.'); + } +} diff --git a/tests/Unit/FilenameSanitizerTest.php b/tests/Unit/FilenameSanitizerTest.php new file mode 100644 index 000000000..8b4e00be4 --- /dev/null +++ b/tests/Unit/FilenameSanitizerTest.php @@ -0,0 +1,57 @@ +assertSame('Show_Name_Part_Alt_Dual_Full_Back', $sanitized); + } + + public function test_sanitize_removes_control_characters_and_reserved_windows_characters(): void + { + $sanitized = FilenameSanitizer::sanitize("Bad\0Name:*?\"<>|\x1FTest"); + + $this->assertSame('BadName_Test', $sanitized); + } + + public function test_sanitize_collapses_commas_and_whitespace_to_single_underscores(): void + { + $sanitized = FilenameSanitizer::sanitize(' The File, Name , Part 2 '); + + $this->assertSame('The_File_Name_Part_2', $sanitized); + } + + public function test_sanitize_uses_fallback_for_empty_or_dot_only_names(): void + { + $this->assertSame('release-123', FilenameSanitizer::sanitize(null, 'release-123')); + $this->assertSame('release-123', FilenameSanitizer::sanitize('', 'release-123')); + $this->assertSame('release-123', FilenameSanitizer::sanitize('...___---', 'release-123')); + } + + public function test_sanitize_truncates_to_safe_length(): void + { + $sanitized = FilenameSanitizer::sanitize(str_repeat('a', 300)); + + $this->assertSame(200, mb_strlen($sanitized)); + $this->assertSame(str_repeat('a', 200), $sanitized); + } + + public function test_ascii_fallback_returns_safe_ascii_filename(): void + { + $fallback = FilenameSanitizer::asciiFallback('Résumé/Season 1\\Finale'); + + $this->assertMatchesRegularExpression('/^[\x20-\x7E]+$/', $fallback); + $this->assertStringNotContainsString('/', $fallback); + $this->assertStringNotContainsString('\\', $fallback); + $this->assertSame($fallback, FilenameSanitizer::asciiFallback($fallback)); + } +} diff --git a/tests/Unit/NzbServicePathResolutionTest.php b/tests/Unit/NzbServicePathResolutionTest.php new file mode 100644 index 000000000..f4db2464b --- /dev/null +++ b/tests/Unit/NzbServicePathResolutionTest.php @@ -0,0 +1,149 @@ +makeServiceWithoutConstructor(); + $preferredPath = \Closure::bind( + fn (array $paths): string => $this->selectPreferredNzbBasePath($paths), + $service, + NzbService::class + )([$configuredPath, $runtimePath]); + + $this->assertSame($runtimePath, $preferredPath); + } finally { + $this->deleteDirectory($tempDir); + } + } + + public function test_nzb_path_returns_existing_file_from_alternate_candidate_base_paths(): void + { + $tempDir = sys_get_temp_dir().'/nzb-path-'.uniqid('', true); + $guid = '4aabfe07-daff-4d28-9d1d-d2a4ab7b6511'; + $configuredPath = $tempDir.'/foreign-app/storage/nzb/'; + $runtimePath = $tempDir.'/runtime-app/storage/nzb/'; + $expectedFile = $runtimePath.'4/a/a/b/'.$guid.'.nzb.gz'; + mkdir(dirname($expectedFile), 0777, true); + file_put_contents($expectedFile, 'test'); + + try { + $service = $this->makeServiceWithoutConstructor(); + \Closure::bind( + function (int $splitLevel, string $primaryPath, array $paths): void { + $this->nzbSplitLevel = $splitLevel; + $this->siteNzbPath = $primaryPath; + $this->siteNzbPaths = $paths; + }, + $service, + NzbService::class + )(4, $configuredPath, [$configuredPath, $runtimePath]); + + $this->assertSame($expectedFile, $service->nzbPath($guid)); + } finally { + $this->deleteDirectory($tempDir); + } + } + + public function test_nzb_path_honors_split_level_one_when_searching_candidate_paths(): void + { + $tempDir = sys_get_temp_dir().'/nzb-path-split-one-'.uniqid('', true); + $guid = '4aabfe07-daff-4d28-9d1d-d2a4ab7b6511'; + $configuredPath = $tempDir.'/foreign-app/storage/nzb/'; + $runtimePath = $tempDir.'/runtime-app/storage/nzb/'; + $expectedFile = $runtimePath.'4/'.$guid.'.nzb.gz'; + mkdir(dirname($expectedFile), 0777, true); + file_put_contents($expectedFile, 'test'); + + try { + $service = $this->makeServiceWithoutConstructor(); + \Closure::bind( + function (int $splitLevel, string $primaryPath, array $paths): void { + $this->nzbSplitLevel = $splitLevel; + $this->siteNzbPath = $primaryPath; + $this->siteNzbPaths = $paths; + }, + $service, + NzbService::class + )(1, $configuredPath, [$configuredPath, $runtimePath]); + + $this->assertSame($expectedFile, $service->nzbPath($guid)); + } finally { + $this->deleteDirectory($tempDir); + } + } + + public function test_nzb_path_works_with_existing_resources_nzb_base_path(): void + { + $tempDir = sys_get_temp_dir().'/nzb-path-resources-'.uniqid('', true); + $guid = '4aabfe07-daff-4d28-9d1d-d2a4ab7b6511'; + $configuredPath = $tempDir.'/resources/nzb/'; + $storagePath = $tempDir.'/storage/nzb/'; + $expectedFile = $configuredPath.'4/'.$guid.'.nzb.gz'; + $configuredLinkTarget = rtrim($configuredPath, '/'); + $storageLinkPath = rtrim($storagePath, '/'); + + mkdir(dirname($expectedFile), 0777, true); + mkdir(dirname($storageLinkPath), 0777, true); + symlink($configuredLinkTarget, $storageLinkPath); + file_put_contents($expectedFile, 'test'); + + try { + $service = $this->makeServiceWithoutConstructor(); + \Closure::bind( + function (int $splitLevel, string $primaryPath, array $paths): void { + $this->nzbSplitLevel = $splitLevel; + $this->siteNzbPath = $primaryPath; + $this->siteNzbPaths = $paths; + }, + $service, + NzbService::class + )(1, $configuredPath, [$configuredPath, $storagePath]); + + $this->assertSame($expectedFile, $service->nzbPath($guid)); + } finally { + $this->deleteDirectory($tempDir); + } + } + + private function makeServiceWithoutConstructor(): NzbService + { + return (new ReflectionClass(NzbService::class))->newInstanceWithoutConstructor(); + } + + private function deleteDirectory(string $path): void + { + if (! is_dir($path)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $item) { + if ($item->isDir()) { + rmdir($item->getPathname()); + } else { + unlink($item->getPathname()); + } + } + + rmdir($path); + } +} diff --git a/tests/Unit/Support/FilenameSanitizerTest.php b/tests/Unit/Support/FilenameSanitizerTest.php new file mode 100644 index 000000000..6dd08c840 --- /dev/null +++ b/tests/Unit/Support/FilenameSanitizerTest.php @@ -0,0 +1,57 @@ +assertSame('Show_Name_Part_Alt_Dual_Full_Back', $sanitized); + } + + public function test_sanitize_removes_control_characters_and_reserved_windows_characters(): void + { + $sanitized = FilenameSanitizer::sanitize("Bad\0Name:*?\"<>|\x1FTest"); + + $this->assertSame('BadName_Test', $sanitized); + } + + public function test_sanitize_collapses_commas_and_whitespace_to_single_underscores(): void + { + $sanitized = FilenameSanitizer::sanitize(' The File, Name , Part 2 '); + + $this->assertSame('The_File_Name_Part_2', $sanitized); + } + + public function test_sanitize_uses_fallback_for_empty_or_dot_only_names(): void + { + $this->assertSame('release-123', FilenameSanitizer::sanitize(null, 'release-123')); + $this->assertSame('release-123', FilenameSanitizer::sanitize('', 'release-123')); + $this->assertSame('release-123', FilenameSanitizer::sanitize('...___---', 'release-123')); + } + + public function test_sanitize_truncates_to_safe_length(): void + { + $sanitized = FilenameSanitizer::sanitize(str_repeat('a', 300)); + + $this->assertSame(200, mb_strlen($sanitized)); + $this->assertSame(str_repeat('a', 200), $sanitized); + } + + public function test_ascii_fallback_returns_safe_ascii_filename(): void + { + $fallback = FilenameSanitizer::asciiFallback('Résumé/Season 1\\Finale'); + + $this->assertMatchesRegularExpression('/^[\x20-\x7E]+$/', $fallback); + $this->assertStringNotContainsString('/', $fallback); + $this->assertStringNotContainsString('\\', $fallback); + $this->assertSame($fallback, FilenameSanitizer::asciiFallback($fallback)); + } +}