Fix errors with some of files

This commit is contained in:
DariusIII
2026-04-21 11:24:14 +02:00
parent 02b0727c89
commit 6a03c6b2ad
8 changed files with 506 additions and 23 deletions
@@ -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);
+18 -2
View File
@@ -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']));
+19 -13
View File
@@ -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);
}
}
+98 -5
View File
@@ -43,6 +43,13 @@ class NzbService
*/
protected string $siteNzbPath;
/**
* Candidate base paths to search for existing NZB files.
*
* @var list<string>
*/
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<string>
*/
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<string> $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.
*/
+107
View File
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Support;
use Illuminate\Support\Str;
use Normalizer;
final class FilenameSanitizer
{
private const int MAX_FILENAME_LENGTH = 200;
/**
* @var list<string>
*/
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._-"), '.');
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Support\FilenameSanitizer;
use PHPUnit\Framework\TestCase;
final class FilenameSanitizerTest extends TestCase
{
public function test_sanitize_replaces_forbidden_and_unicode_path_characters(): void
{
$sanitized = FilenameSanitizer::sanitize(' Show / Name \\ Part Alt Dual FullBack ');
$this->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));
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Services\Nzb\NzbService;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
final class NzbServicePathResolutionTest extends TestCase
{
public function test_select_preferred_base_path_prefers_existing_runtime_storage_path_for_foreign_storage_root(): void
{
$tempDir = sys_get_temp_dir().'/nzb-service-'.uniqid('', true);
$configuredPath = $tempDir.'/foreign-app/storage/nzb/';
$runtimePath = $tempDir.'/runtime-app/storage/nzb/';
mkdir($runtimePath, 0777, true);
try {
$service = $this->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);
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Support;
use App\Support\FilenameSanitizer;
use PHPUnit\Framework\TestCase;
final class FilenameSanitizerTest extends TestCase
{
public function test_sanitize_replaces_forbidden_and_unicode_path_characters(): void
{
$sanitized = FilenameSanitizer::sanitize(' Show / Name \\ Part Alt Dual FullBack ');
$this->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));
}
}