mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 01:08:56 +00:00
Fix errors with some of files
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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']));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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._-"), '.');
|
||||
}
|
||||
}
|
||||
@@ -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 ⧸ Full/Back\ ');
|
||||
|
||||
$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));
|
||||
}
|
||||
}
|
||||
@@ -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 ⧸ Full/Back\ ');
|
||||
|
||||
$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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user