mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Update nzb and nfo import
This commit is contained in:
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Nzb\NfoImportService;
|
||||||
|
use App\Services\Nzb\NzbUploadManifestService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class ImportNfos extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'nntmux:import-nfos
|
||||||
|
{--folder= : Import folder path}
|
||||||
|
{--delete : Delete NFO files after import}
|
||||||
|
{--delete-failed : Delete NFO files after terminal or failed imports}';
|
||||||
|
|
||||||
|
protected $description = 'Import paired NFO files for previously imported NZB releases';
|
||||||
|
|
||||||
|
public function handle(NzbUploadManifestService $manifests, NfoImportService $importer): int
|
||||||
|
{
|
||||||
|
$folderOption = $this->option('folder');
|
||||||
|
if (! is_string($folderOption) || trim($folderOption) === '') {
|
||||||
|
$this->error('Folder path must not be empty');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$folder = rtrim($folderOption, '/\\');
|
||||||
|
if (! File::isDirectory($folder)) {
|
||||||
|
$this->error('Folder path does not exist: '.$folder);
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$manifestPaths = $manifests->findManifests($folder);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$this->error($exception->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$imported = $skipped = $failed = 0;
|
||||||
|
foreach ($manifestPaths as $manifestPath) {
|
||||||
|
$result = $importer->importManifest(
|
||||||
|
$manifestPath,
|
||||||
|
(bool) $this->option('delete'),
|
||||||
|
(bool) $this->option('delete-failed'),
|
||||||
|
);
|
||||||
|
|
||||||
|
match ($result['status']) {
|
||||||
|
'imported' => $imported++,
|
||||||
|
'failed' => $failed++,
|
||||||
|
default => $skipped++,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($result['status'] === 'failed') {
|
||||||
|
$this->warn(basename(dirname($manifestPath)).': '.$result['message']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Processed {$imported} NFOs, skipped {$skipped}, failed {$failed}.");
|
||||||
|
|
||||||
|
return $failed === 0 ? self::SUCCESS : self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,14 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Enums\NzbImportStatus;
|
||||||
use App\Services\Nzb\NzbImportService;
|
use App\Services\Nzb\NzbImportService;
|
||||||
|
use App\Services\Nzb\NzbUploadManifestService;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use SplFileInfo;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class ImportNzbs extends Command
|
class ImportNzbs extends Command
|
||||||
{
|
{
|
||||||
@@ -33,57 +37,61 @@ class ImportNzbs extends Command
|
|||||||
/**
|
/**
|
||||||
* Execute the console command.
|
* Execute the console command.
|
||||||
*/
|
*/
|
||||||
public function handle(): void
|
public function handle(NzbUploadManifestService $manifests): void
|
||||||
{
|
{
|
||||||
if ($this->option('folder')) {
|
$folderOption = $this->option('folder');
|
||||||
if ($this->option('filename')) {
|
if (! is_string($folderOption) || trim($folderOption) === '') {
|
||||||
$useNzbName = true;
|
|
||||||
} else {
|
|
||||||
$useNzbName = false;
|
|
||||||
}
|
|
||||||
if ($this->option('delete')) {
|
|
||||||
$deleteNZB = true;
|
|
||||||
} else {
|
|
||||||
$deleteNZB = false;
|
|
||||||
}
|
|
||||||
if ($this->option('delete-failed')) {
|
|
||||||
$deleteFailedNZB = true;
|
|
||||||
} else {
|
|
||||||
$deleteFailedNZB = false;
|
|
||||||
}
|
|
||||||
if ($this->option('source')) {
|
|
||||||
$source = (int) $this->option('source');
|
|
||||||
} else {
|
|
||||||
$source = 1;
|
|
||||||
}
|
|
||||||
$importFolder = $this->option('folder');
|
|
||||||
$folders = File::directories($importFolder);
|
|
||||||
if (empty($folders)) {
|
|
||||||
$this->info('Importing NZB files from '.$importFolder);
|
|
||||||
$files = File::allFiles($importFolder);
|
|
||||||
$NZBImport = new NzbImportService;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$NZBImport->beginImport($files, $useNzbName, $deleteNZB, $deleteFailedNZB, $source);
|
|
||||||
} catch (FileNotFoundException $e) {
|
|
||||||
$this->error($e->getMessage());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
foreach ($folders as $folder) {
|
|
||||||
$this->info('Importing NZB files from '.$folder);
|
|
||||||
$files = File::allFiles($folder);
|
|
||||||
$NZBImport = new NzbImportService;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$NZBImport->beginImport($files, $useNzbName, $deleteNZB, $deleteFailedNZB, $source);
|
|
||||||
} catch (FileNotFoundException $e) {
|
|
||||||
$this->error($e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$this->error('Folder path must not be empty');
|
$this->error('Folder path must not be empty');
|
||||||
exit();
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$importFolder = rtrim($folderOption, '/\\');
|
||||||
|
if (! File::isDirectory($importFolder)) {
|
||||||
|
$this->error('Folder path does not exist: '.$importFolder);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$useNzbName = (bool) $this->option('filename');
|
||||||
|
$deleteNZB = (bool) $this->option('delete');
|
||||||
|
$deleteFailedNZB = (bool) $this->option('delete-failed');
|
||||||
|
$source = $this->option('source') ? (int) $this->option('source') : 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$files = array_values(array_filter(
|
||||||
|
File::allFiles($importFolder),
|
||||||
|
static function (SplFileInfo $file) use ($manifests): bool {
|
||||||
|
$path = $file->getPathname();
|
||||||
|
if (! Str::endsWith(strtolower($path), ['.nzb', '.nzb.gz'])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $manifests->shouldImportNzb($path);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->info('Importing NZB files from '.$importFolder);
|
||||||
|
$importer = new NzbImportService;
|
||||||
|
$importer->beginImport(
|
||||||
|
$files,
|
||||||
|
$useNzbName,
|
||||||
|
$deleteNZB,
|
||||||
|
$deleteFailedNZB,
|
||||||
|
$source,
|
||||||
|
static function (array $result) use ($manifests): void {
|
||||||
|
/** @var NzbImportStatus $status */
|
||||||
|
$status = $result['status'];
|
||||||
|
$manifests->recordNzbOutcome(
|
||||||
|
$result['path'],
|
||||||
|
$status,
|
||||||
|
$result['release_id'],
|
||||||
|
$result['release_guid'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$this->error($exception->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Exceptions\NzbUploadException;
|
||||||
use App\Http\Controllers\BasePageController;
|
use App\Http\Controllers\BasePageController;
|
||||||
use App\Http\Controllers\GetNzbController;
|
use App\Http\Controllers\GetNzbController;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
@@ -17,6 +18,7 @@ use App\Services\Api\ApiReleaseRowCache;
|
|||||||
use App\Services\Api\ApiUsageService;
|
use App\Services\Api\ApiUsageService;
|
||||||
use App\Services\Api\ApiUserResolver;
|
use App\Services\Api\ApiUserResolver;
|
||||||
use App\Services\Api\V1\ApiV1Presenter;
|
use App\Services\Api\V1\ApiV1Presenter;
|
||||||
|
use App\Services\Nzb\NzbUploadStagingService;
|
||||||
use App\Services\Releases\ReleaseBrowseService;
|
use App\Services\Releases\ReleaseBrowseService;
|
||||||
use App\Services\Releases\ReleaseSearchService;
|
use App\Services\Releases\ReleaseSearchService;
|
||||||
use App\Support\FilenameSanitizer;
|
use App\Support\FilenameSanitizer;
|
||||||
@@ -25,12 +27,11 @@ use Illuminate\Contracts\Routing\ResponseFactory;
|
|||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Routing\Redirector;
|
use Illuminate\Routing\Redirector;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\File;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
@@ -55,6 +56,8 @@ class ApiController extends BasePageController
|
|||||||
|
|
||||||
private ApiV1Presenter $presenter;
|
private ApiV1Presenter $presenter;
|
||||||
|
|
||||||
|
private NzbUploadStagingService $nzbUploadStagingService;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
ReleaseSearchService $releaseSearchService,
|
ReleaseSearchService $releaseSearchService,
|
||||||
ReleaseBrowseService $releaseBrowseService,
|
ReleaseBrowseService $releaseBrowseService,
|
||||||
@@ -64,6 +67,7 @@ class ApiController extends BasePageController
|
|||||||
?ApiUserResolver $userResolver = null,
|
?ApiUserResolver $userResolver = null,
|
||||||
?ApiCapabilitiesService $capabilitiesService = null,
|
?ApiCapabilitiesService $capabilitiesService = null,
|
||||||
?ApiV1Presenter $presenter = null,
|
?ApiV1Presenter $presenter = null,
|
||||||
|
?NzbUploadStagingService $nzbUploadStagingService = null,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->releaseSearchService = $releaseSearchService;
|
$this->releaseSearchService = $releaseSearchService;
|
||||||
@@ -74,6 +78,7 @@ class ApiController extends BasePageController
|
|||||||
$this->userResolver = $userResolver ?? app(ApiUserResolver::class);
|
$this->userResolver = $userResolver ?? app(ApiUserResolver::class);
|
||||||
$this->capabilitiesService = $capabilitiesService ?? app(ApiCapabilitiesService::class);
|
$this->capabilitiesService = $capabilitiesService ?? app(ApiCapabilitiesService::class);
|
||||||
$this->presenter = $presenter ?? app(ApiV1Presenter::class);
|
$this->presenter = $presenter ?? app(ApiV1Presenter::class);
|
||||||
|
$this->nzbUploadStagingService = $nzbUploadStagingService ?? app(NzbUploadStagingService::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -695,76 +700,60 @@ class ApiController extends BasePageController
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
//
|
//
|
||||||
// nzb / nfo add request
|
// Paired NZB / NFO add request. The legacy single `file` field remains supported.
|
||||||
// curl -X POST -F "file=@./The.File.nzb" "site_url/api/V1/api?t=nzbadd&apikey=xxx"
|
|
||||||
// curl -X POST -F "file=@./The.File.nfo" "site_url/api/V1/api?t=nzbadd&apikey=xxx"
|
|
||||||
//
|
//
|
||||||
case 'nzbAdd':
|
case 'nzbAdd':
|
||||||
if (! User::canPost($uid)) {
|
if (! User::canPost($uid)) {
|
||||||
return showApiError(102, 'Insufficient privileges/not authorized');
|
return showApiError(102, 'Insufficient privileges/not authorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->missing('file')) {
|
$usesLegacyField = $request->has('file') || $request->hasFile('file');
|
||||||
return showApiError(200, 'Missing parameter (file is required for adding an NZB or NFO)');
|
$usesModernFields = $request->hasAny(['nzb', 'nfo'])
|
||||||
}
|
|| $request->hasFile('nzb')
|
||||||
if ($request->missing('apikey')) {
|
|| $request->hasFile('nfo');
|
||||||
return showApiError(200, 'Missing parameter (apikey is required for adding an NZB or NFO)');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $request->hasFile('file')) {
|
if ($usesLegacyField && $usesModernFields) {
|
||||||
return showApiError(600, 'Failed to load upload (no file)');
|
return showApiError(201, 'Use either file or nzb/nfo upload fields, not both');
|
||||||
|
}
|
||||||
|
if (! $usesLegacyField && ! $usesModernFields) {
|
||||||
|
return showApiError(200, 'Missing parameter (nzb file is required)');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->usageService->record($res, $request);
|
$this->usageService->record($res, $request);
|
||||||
|
|
||||||
$nzbFile = $request->file('file');
|
try {
|
||||||
|
if ($usesModernFields) {
|
||||||
|
$nzb = $request->file('nzb');
|
||||||
|
if (! $nzb instanceof UploadedFile) {
|
||||||
|
return showApiError(200, 'Missing parameter (nzb file is required)');
|
||||||
|
}
|
||||||
|
|
||||||
// Save the file to the server, get the name without the extension.
|
$nfo = $request->file('nfo');
|
||||||
if ($nzbFile !== null) {
|
if ($nfo !== null && ! $nfo instanceof UploadedFile) {
|
||||||
$ext = strtolower((string) $nzbFile->getClientOriginalExtension());
|
return showApiError(600, 'Invalid nfo upload');
|
||||||
if (! in_array($ext, ['nzb', 'nfo'], true)) {
|
}
|
||||||
return showApiError(600, 'Failed to load NZB (file is not an NZB or NFO file)');
|
|
||||||
|
$staged = $this->nzbUploadStagingService->stage($nzb, $nfo);
|
||||||
|
$name = $staged['name'];
|
||||||
|
} else {
|
||||||
|
$file = $request->file('file');
|
||||||
|
if (! $file instanceof UploadedFile) {
|
||||||
|
return showApiError(600, 'Failed to load upload (no file)');
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $this->nzbUploadStagingService->stageLegacyFile($file)['name'];
|
||||||
}
|
}
|
||||||
|
} catch (NzbUploadException $exception) {
|
||||||
$content = $nzbFile->getContent();
|
return showApiError(600, $exception->getMessage());
|
||||||
if (! is_string($content)) {
|
|
||||||
return showApiError(600, 'Failed to load upload');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($ext === 'nzb' && ! isValidNewznabNzb($content)) {
|
|
||||||
return showApiError(600, 'Failed to load NZB (invalid NZB payload)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same max raw size as NfoService::MAX_NFO_SIZE (64KB).
|
|
||||||
$maxNfoUploadBytes = 65535;
|
|
||||||
if ($ext === 'nfo' && ($content === '' || strlen($content) > $maxNfoUploadBytes)) {
|
|
||||||
return showApiError(600, 'Failed to load NFO (empty or too large)');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! File::isDirectory(config('nntmux.nzb_upload_folder'))) {
|
|
||||||
@File::makeDirectory(config('nntmux.nzb_upload_folder'), 0775, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (File::put(config('nntmux.nzb_upload_folder').$nzbFile->getClientOriginalName(), $content)) {
|
|
||||||
Log::channel('nzb_upload')->info('File uploaded by API: '.$nzbFile->getClientOriginalName().' (type: '.$ext.')');
|
|
||||||
|
|
||||||
$successXml = sprintf(
|
|
||||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<success id=\"0\" guid=\"\" categoryid=\"%s\" name=\"%s\" />\n",
|
|
||||||
(string) $request->input('cat', ''),
|
|
||||||
htmlspecialchars(pathinfo($nzbFile->getClientOriginalName(), PATHINFO_FILENAME), ENT_QUOTES, 'UTF-8')
|
|
||||||
);
|
|
||||||
|
|
||||||
return response($successXml, 200)->header('Content-type', 'text/xml');
|
|
||||||
}
|
|
||||||
|
|
||||||
Log::channel('nzb_upload')->warning('File upload by API failed to write: '.$nzbFile->getClientOriginalName().' (type: '.$ext.')');
|
|
||||||
} else {
|
|
||||||
Log::channel('nzb_upload')->warning('File upload by API failed: no file provided');
|
|
||||||
|
|
||||||
return showApiError(603, 'Failed to write file to disk');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return showApiError(603, 'Failed to write file to disk');
|
$successXml = sprintf(
|
||||||
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<success id=\"0\" guid=\"\" categoryid=\"%s\" name=\"%s\" />\n",
|
||||||
|
(string) $request->input('cat', ''),
|
||||||
|
htmlspecialchars($name, ENT_QUOTES, 'UTF-8')
|
||||||
|
);
|
||||||
|
|
||||||
|
return response($successXml, 200)->header('Content-type', 'text/xml');
|
||||||
|
|
||||||
// Capabilities request.
|
// Capabilities request.
|
||||||
case 'c':
|
case 'c':
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Nzb;
|
||||||
|
|
||||||
|
use App\Models\Release;
|
||||||
|
use App\Services\NfoService;
|
||||||
|
use Illuminate\Filesystem\Filesystem;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class NfoImportService
|
||||||
|
{
|
||||||
|
private const MAX_NFO_SIZE = 65535;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Filesystem $filesystem,
|
||||||
|
private readonly NzbUploadManifestService $manifests,
|
||||||
|
private readonly NfoService $nfoService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{status:'imported'|'skipped'|'failed',message:string}
|
||||||
|
*/
|
||||||
|
public function importManifest(string $manifestPath, bool $delete = false, bool $deleteFailed = false): array
|
||||||
|
{
|
||||||
|
$nfoPath = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$manifest = $this->manifests->read($manifestPath);
|
||||||
|
$state = $manifest['state'];
|
||||||
|
|
||||||
|
if ($state === NzbUploadManifestService::STATE_NFO_IMPORTED) {
|
||||||
|
return ['status' => 'skipped', 'message' => 'NFO was already imported'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($state, [
|
||||||
|
NzbUploadManifestService::STATE_NZB_DUPLICATE,
|
||||||
|
NzbUploadManifestService::STATE_NZB_FAILED,
|
||||||
|
], true)) {
|
||||||
|
if ($deleteFailed && is_array($manifest['nfo'] ?? null)) {
|
||||||
|
try {
|
||||||
|
$nfoPath = $this->manifests->resolvePayloadPath(
|
||||||
|
$manifestPath,
|
||||||
|
$manifest['nfo']['filename'],
|
||||||
|
'nfo'
|
||||||
|
);
|
||||||
|
$this->filesystem->delete($nfoPath);
|
||||||
|
} catch (Throwable) {
|
||||||
|
// The terminal NZB outcome remains authoritative even if its NFO is already gone.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['status' => 'skipped', 'message' => "NFO has no imported NZB release ({$state})"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($state, [
|
||||||
|
NzbUploadManifestService::STATE_NZB_IMPORTED,
|
||||||
|
NzbUploadManifestService::STATE_NFO_FAILED,
|
||||||
|
], true)) {
|
||||||
|
return ['status' => 'skipped', 'message' => "NFO is waiting for NZB import ({$state})"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($manifest['nfo'] ?? null)) {
|
||||||
|
return ['status' => 'skipped', 'message' => 'Upload has no paired NFO'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$releaseId = $manifest['release_id'] ?? null;
|
||||||
|
$releaseGuid = $manifest['release_guid'] ?? null;
|
||||||
|
if (! is_int($releaseId) || $releaseId <= 0 || ! is_string($releaseGuid) || $releaseGuid === '') {
|
||||||
|
throw new RuntimeException('Upload manifest has no valid release identity');
|
||||||
|
}
|
||||||
|
|
||||||
|
$releaseExists = Release::query()
|
||||||
|
->whereKey($releaseId)
|
||||||
|
->where('guid', $releaseGuid)
|
||||||
|
->exists();
|
||||||
|
if (! $releaseExists) {
|
||||||
|
throw new RuntimeException('The release recorded by the upload manifest no longer exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
$nfoPath = $this->manifests->resolvePayloadPath(
|
||||||
|
$manifestPath,
|
||||||
|
$manifest['nfo']['filename'],
|
||||||
|
'nfo'
|
||||||
|
);
|
||||||
|
$content = $this->filesystem->get($nfoPath);
|
||||||
|
if ($content === '' || strlen($content) > self::MAX_NFO_SIZE) {
|
||||||
|
throw new RuntimeException('NFO import must not be empty or exceed 65535 bytes');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->nfoService->storeNfoContent($releaseId, $content)) {
|
||||||
|
throw new RuntimeException('Failed to store NFO content for the release');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->manifests->recordNfoOutcome($manifestPath, true);
|
||||||
|
if ($delete && ! $this->filesystem->delete($nfoPath)) {
|
||||||
|
Log::channel('nzb_upload')->warning('Imported NFO could not be deleted', [
|
||||||
|
'manifest' => $manifestPath,
|
||||||
|
'filename' => basename($nfoPath),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::channel('nzb_upload')->info('Paired NFO imported', [
|
||||||
|
'upload_id' => $manifest['upload_id'],
|
||||||
|
'release_id' => $releaseId,
|
||||||
|
'release_guid' => $releaseGuid,
|
||||||
|
'filename' => basename($nfoPath),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ['status' => 'imported', 'message' => "Imported {$manifest['nfo']['filename']}"];
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
try {
|
||||||
|
$this->manifests->recordNfoOutcome($manifestPath, false, $exception->getMessage());
|
||||||
|
} catch (Throwable $manifestException) {
|
||||||
|
Log::channel('nzb_upload')->error('Failed to record NFO import failure', [
|
||||||
|
'manifest' => $manifestPath,
|
||||||
|
'error' => $manifestException->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($deleteFailed && $nfoPath !== null && $this->filesystem->isFile($nfoPath)) {
|
||||||
|
$this->filesystem->delete($nfoPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::channel('nzb_upload')->warning('Paired NFO import failed', [
|
||||||
|
'manifest' => $manifestPath,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ['status' => 'failed', 'message' => $exception->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,8 @@ class NzbImportService
|
|||||||
*/
|
*/
|
||||||
protected string $relGuid;
|
protected string $relGuid;
|
||||||
|
|
||||||
|
protected ?int $relId = null;
|
||||||
|
|
||||||
public mixed $echoCLI;
|
public mixed $echoCLI;
|
||||||
|
|
||||||
public NzbService $nzb;
|
public NzbService $nzb;
|
||||||
@@ -84,10 +86,18 @@ class NzbImportService
|
|||||||
/**
|
/**
|
||||||
* Begin importing NZB files.
|
* Begin importing NZB files.
|
||||||
*
|
*
|
||||||
|
* @param null|callable(array{path:string,status:NzbImportStatus,release_id:int|null,release_guid:string|null}):void $resultCallback
|
||||||
|
*
|
||||||
* @throws FileNotFoundException
|
* @throws FileNotFoundException
|
||||||
*/
|
*/
|
||||||
public function beginImport(mixed $filesToProcess, bool $useNzbName = false, bool $delete = false, bool $deleteFailed = false, int $source = 1): bool|string
|
public function beginImport(
|
||||||
{
|
mixed $filesToProcess,
|
||||||
|
bool $useNzbName = false,
|
||||||
|
bool $delete = false,
|
||||||
|
bool $deleteFailed = false,
|
||||||
|
int $source = 1,
|
||||||
|
?callable $resultCallback = null,
|
||||||
|
): bool|string {
|
||||||
// Get all the groups in the DB.
|
// Get all the groups in the DB.
|
||||||
if (! $this->getAllGroups()) {
|
if (! $this->getAllGroups()) {
|
||||||
if ($this->browser) {
|
if ($this->browser) {
|
||||||
@@ -104,7 +114,8 @@ class NzbImportService
|
|||||||
$nzbFiles = [];
|
$nzbFiles = [];
|
||||||
foreach ($filesToProcess as $file) {
|
foreach ($filesToProcess as $file) {
|
||||||
$filePath = $file instanceof \SplFileInfo ? $file->getPathname() : (string) $file;
|
$filePath = $file instanceof \SplFileInfo ? $file->getPathname() : (string) $file;
|
||||||
if (Str::endsWith($filePath, '.nzb') || Str::endsWith($filePath, '.nzb.gz')) {
|
$lowerFilePath = strtolower($filePath);
|
||||||
|
if (Str::endsWith($lowerFilePath, '.nzb') || Str::endsWith($lowerFilePath, '.nzb.gz')) {
|
||||||
$nzbFiles[] = $filePath;
|
$nzbFiles[] = $filePath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,12 +134,33 @@ class NzbImportService
|
|||||||
$this->echoOut("Filtered out {$totalFilesFiltered} non-NZB files. Processing ".count($nzbFiles).' NZB files.');
|
$this->echoOut("Filtered out {$totalFilesFiltered} non-NZB files. Processing ".count($nzbFiles).' NZB files.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$reportResult = static function (
|
||||||
|
string $path,
|
||||||
|
NzbImportStatus $status,
|
||||||
|
?int $releaseId = null,
|
||||||
|
?string $releaseGuid = null,
|
||||||
|
) use ($resultCallback): void {
|
||||||
|
if ($resultCallback === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resultCallback([
|
||||||
|
'path' => $path,
|
||||||
|
'status' => $status,
|
||||||
|
'release_id' => $status === NzbImportStatus::Inserted ? $releaseId : null,
|
||||||
|
'release_guid' => $status === NzbImportStatus::Inserted ? $releaseGuid : null,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
// Loop over the NZB file names only.
|
// Loop over the NZB file names only.
|
||||||
foreach ($nzbFiles as $nzbFilePath) {
|
foreach ($nzbFiles as $nzbFilePath) {
|
||||||
// Check if the file is really there.
|
// Check if the file is really there.
|
||||||
if (File::isFile($nzbFilePath)) {
|
if (File::isFile($nzbFilePath)) {
|
||||||
|
$this->relGuid = '';
|
||||||
|
$this->relId = null;
|
||||||
|
|
||||||
// Get the contents of the NZB file as a string.
|
// Get the contents of the NZB file as a string.
|
||||||
if (Str::endsWith($nzbFilePath, '.nzb.gz')) {
|
if (Str::endsWith(strtolower($nzbFilePath), '.nzb.gz')) {
|
||||||
$nzbString = unzipGzipFile($nzbFilePath);
|
$nzbString = unzipGzipFile($nzbFilePath);
|
||||||
} else {
|
} else {
|
||||||
$nzbString = File::get($nzbFilePath);
|
$nzbString = File::get($nzbFilePath);
|
||||||
@@ -136,6 +168,7 @@ class NzbImportService
|
|||||||
|
|
||||||
if ($nzbString === false) {
|
if ($nzbString === false) {
|
||||||
$this->echoOut('ERROR: Unable to read: '.$nzbFilePath);
|
$this->echoOut('ERROR: Unable to read: '.$nzbFilePath);
|
||||||
|
$reportResult($nzbFilePath, NzbImportStatus::Failed);
|
||||||
|
|
||||||
if ($deleteFailed) {
|
if ($deleteFailed) {
|
||||||
File::delete($nzbFilePath);
|
File::delete($nzbFilePath);
|
||||||
@@ -149,6 +182,7 @@ class NzbImportService
|
|||||||
$nzbXML = @simplexml_load_string($nzbString);
|
$nzbXML = @simplexml_load_string($nzbString);
|
||||||
if ($nzbXML === false || strtolower($nzbXML->getName()) !== 'nzb') {
|
if ($nzbXML === false || strtolower($nzbXML->getName()) !== 'nzb') {
|
||||||
$this->echoOut('ERROR: Unable to load NZB XML data: '.$nzbFilePath);
|
$this->echoOut('ERROR: Unable to load NZB XML data: '.$nzbFilePath);
|
||||||
|
$reportResult($nzbFilePath, NzbImportStatus::Failed);
|
||||||
|
|
||||||
if ($deleteFailed) {
|
if ($deleteFailed) {
|
||||||
File::delete($nzbFilePath);
|
File::delete($nzbFilePath);
|
||||||
@@ -181,12 +215,15 @@ class NzbImportService
|
|||||||
|
|
||||||
// Remove the release.
|
// Remove the release.
|
||||||
Release::query()->where('guid', $this->relGuid)->delete();
|
Release::query()->where('guid', $this->relGuid)->delete();
|
||||||
|
$reportResult($nzbFilePath, NzbImportStatus::Failed);
|
||||||
|
|
||||||
if ($deleteFailed) {
|
if ($deleteFailed) {
|
||||||
File::delete($nzbFilePath);
|
File::delete($nzbFilePath);
|
||||||
}
|
}
|
||||||
$nzbsSkipped++;
|
$nzbsSkipped++;
|
||||||
} else {
|
} else {
|
||||||
|
$reportResult($nzbFilePath, NzbImportStatus::Inserted, $this->relId, $this->relGuid);
|
||||||
|
|
||||||
if ($delete) {
|
if ($delete) {
|
||||||
// Remove the nzb file.
|
// Remove the nzb file.
|
||||||
File::delete($nzbFilePath);
|
File::delete($nzbFilePath);
|
||||||
@@ -195,6 +232,8 @@ class NzbImportService
|
|||||||
$nzbsImported++;
|
$nzbsImported++;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
$reportResult($nzbFilePath, $importStatus);
|
||||||
|
|
||||||
if ($importStatus === NzbImportStatus::Duplicate) {
|
if ($importStatus === NzbImportStatus::Duplicate) {
|
||||||
$nzbsDuplicate++;
|
$nzbsDuplicate++;
|
||||||
|
|
||||||
@@ -218,6 +257,7 @@ class NzbImportService
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$this->echoOut('ERROR: Unable to fetch: '.$nzbFilePath);
|
$this->echoOut('ERROR: Unable to fetch: '.$nzbFilePath);
|
||||||
|
$reportResult($nzbFilePath, NzbImportStatus::Failed);
|
||||||
$nzbsSkipped++;
|
$nzbsSkipped++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -482,6 +522,8 @@ class NzbImportService
|
|||||||
return NzbImportStatus::Failed;
|
return NzbImportStatus::Failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->relId = (int) $relID;
|
||||||
|
|
||||||
return NzbImportStatus::Inserted;
|
return NzbImportStatus::Inserted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Nzb;
|
||||||
|
|
||||||
|
use App\Enums\NzbImportStatus;
|
||||||
|
use Illuminate\Filesystem\Filesystem;
|
||||||
|
use RuntimeException;
|
||||||
|
use SplFileInfo;
|
||||||
|
|
||||||
|
final class NzbUploadManifestService
|
||||||
|
{
|
||||||
|
public const FILENAME = 'nntmux-upload.json';
|
||||||
|
|
||||||
|
public const STATE_STAGED = 'staged';
|
||||||
|
|
||||||
|
public const STATE_NZB_IMPORTED = 'nzb_imported';
|
||||||
|
|
||||||
|
public const STATE_NZB_DUPLICATE = 'nzb_duplicate';
|
||||||
|
|
||||||
|
public const STATE_NZB_FAILED = 'nzb_failed';
|
||||||
|
|
||||||
|
public const STATE_NFO_IMPORTED = 'nfo_imported';
|
||||||
|
|
||||||
|
public const STATE_NFO_FAILED = 'nfo_failed';
|
||||||
|
|
||||||
|
public function __construct(private readonly Filesystem $filesystem) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function create(string $directory, string $uploadId, string $nzbFilename, ?string $nfoFilename): array
|
||||||
|
{
|
||||||
|
$timestamp = now()->toIso8601String();
|
||||||
|
$manifest = [
|
||||||
|
'version' => 1,
|
||||||
|
'upload_id' => $uploadId,
|
||||||
|
'state' => self::STATE_STAGED,
|
||||||
|
'nzb' => ['filename' => $nzbFilename],
|
||||||
|
'nfo' => $nfoFilename === null ? null : ['filename' => $nfoFilename],
|
||||||
|
'release_id' => null,
|
||||||
|
'release_guid' => null,
|
||||||
|
'last_error' => null,
|
||||||
|
'created_at' => $timestamp,
|
||||||
|
'updated_at' => $timestamp,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->write($directory.DIRECTORY_SEPARATOR.self::FILENAME, $manifest);
|
||||||
|
|
||||||
|
return $manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function read(string $manifestPath): array
|
||||||
|
{
|
||||||
|
if (! $this->filesystem->isFile($manifestPath)) {
|
||||||
|
throw new RuntimeException("Upload manifest does not exist: {$manifestPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$contents = $this->filesystem->get($manifestPath);
|
||||||
|
try {
|
||||||
|
$manifest = json_decode($contents, true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\JsonException $exception) {
|
||||||
|
throw new RuntimeException("Invalid upload manifest JSON: {$manifestPath}", previous: $exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($manifest)
|
||||||
|
|| ($manifest['version'] ?? null) !== 1
|
||||||
|
|| ! is_string($manifest['upload_id'] ?? null)
|
||||||
|
|| ! is_string($manifest['state'] ?? null)
|
||||||
|
|| ! is_array($manifest['nzb'] ?? null)
|
||||||
|
|| ! is_string($manifest['nzb']['filename'] ?? null)) {
|
||||||
|
throw new RuntimeException("Invalid upload manifest structure: {$manifestPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSafeFilename($manifest['nzb']['filename'], 'nzb');
|
||||||
|
if (($manifest['nfo'] ?? null) !== null) {
|
||||||
|
if (! is_array($manifest['nfo']) || ! is_string($manifest['nfo']['filename'] ?? null)) {
|
||||||
|
throw new RuntimeException("Invalid NFO manifest entry: {$manifestPath}");
|
||||||
|
}
|
||||||
|
$this->assertSafeFilename($manifest['nfo']['filename'], 'nfo');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function findManifests(string $folder): array
|
||||||
|
{
|
||||||
|
if (! $this->filesystem->isDirectory($folder)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_map(
|
||||||
|
static fn (SplFileInfo $file): string => $file->getPathname(),
|
||||||
|
array_filter(
|
||||||
|
$this->filesystem->allFiles($folder),
|
||||||
|
static fn (SplFileInfo $file): bool => $file->getFilename() === self::FILENAME
|
||||||
|
)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordNzbOutcome(
|
||||||
|
string $nzbPath,
|
||||||
|
NzbImportStatus $status,
|
||||||
|
?int $releaseId,
|
||||||
|
?string $releaseGuid,
|
||||||
|
?string $error = null,
|
||||||
|
): void {
|
||||||
|
$manifestPath = dirname($nzbPath).DIRECTORY_SEPARATOR.self::FILENAME;
|
||||||
|
if (! $this->filesystem->isFile($manifestPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest = $this->read($manifestPath);
|
||||||
|
if (! hash_equals($manifest['nzb']['filename'], basename($nzbPath))) {
|
||||||
|
throw new RuntimeException("NZB path does not match its upload manifest: {$nzbPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status === NzbImportStatus::Inserted) {
|
||||||
|
if ($releaseId === null || $releaseGuid === null || $releaseGuid === '') {
|
||||||
|
throw new RuntimeException("Inserted NZB has no release identity: {$nzbPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest['state'] = self::STATE_NZB_IMPORTED;
|
||||||
|
$manifest['release_id'] = $releaseId;
|
||||||
|
$manifest['release_guid'] = $releaseGuid;
|
||||||
|
$manifest['last_error'] = null;
|
||||||
|
} elseif ($status === NzbImportStatus::Duplicate) {
|
||||||
|
$manifest['state'] = self::STATE_NZB_DUPLICATE;
|
||||||
|
$manifest['release_id'] = null;
|
||||||
|
$manifest['release_guid'] = null;
|
||||||
|
$manifest['last_error'] = $error ?? 'NZB import detected an existing duplicate release';
|
||||||
|
} else {
|
||||||
|
$manifest['state'] = self::STATE_NZB_FAILED;
|
||||||
|
$manifest['release_id'] = null;
|
||||||
|
$manifest['release_guid'] = null;
|
||||||
|
$manifest['last_error'] = $error ?? "NZB import ended with status {$status->value}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest['updated_at'] = now()->toIso8601String();
|
||||||
|
$this->write($manifestPath, $manifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shouldImportNzb(string $nzbPath): bool
|
||||||
|
{
|
||||||
|
$manifestPath = dirname($nzbPath).DIRECTORY_SEPARATOR.self::FILENAME;
|
||||||
|
if (! $this->filesystem->isFile($manifestPath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest = $this->read($manifestPath);
|
||||||
|
if (! hash_equals($manifest['nzb']['filename'], basename($nzbPath))) {
|
||||||
|
throw new RuntimeException("NZB path does not match its upload manifest: {$nzbPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($manifest['state'], [self::STATE_STAGED, self::STATE_NZB_FAILED], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordNfoOutcome(string $manifestPath, bool $imported, ?string $error = null): void
|
||||||
|
{
|
||||||
|
$manifest = $this->read($manifestPath);
|
||||||
|
$manifest['state'] = $imported ? self::STATE_NFO_IMPORTED : self::STATE_NFO_FAILED;
|
||||||
|
$manifest['last_error'] = $imported ? null : ($error ?? 'NFO import failed');
|
||||||
|
$manifest['updated_at'] = now()->toIso8601String();
|
||||||
|
$this->write($manifestPath, $manifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a payload filename without allowing a manifest to escape its upload directory.
|
||||||
|
*/
|
||||||
|
public function resolvePayloadPath(string $manifestPath, string $filename, string $extension): string
|
||||||
|
{
|
||||||
|
$this->assertSafeFilename($filename, $extension);
|
||||||
|
|
||||||
|
$directory = realpath(dirname($manifestPath));
|
||||||
|
if ($directory === false) {
|
||||||
|
throw new RuntimeException("Upload directory does not exist: {$manifestPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $directory.DIRECTORY_SEPARATOR.$filename;
|
||||||
|
$realPath = realpath($path);
|
||||||
|
if ($realPath === false || ! is_file($realPath) || dirname($realPath) !== $directory) {
|
||||||
|
throw new RuntimeException("Upload payload is missing or outside its manifest directory: {$filename}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $realPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $manifest
|
||||||
|
*/
|
||||||
|
private function write(string $manifestPath, array $manifest): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$json = json_encode(
|
||||||
|
$manifest,
|
||||||
|
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
|
||||||
|
).PHP_EOL;
|
||||||
|
} catch (\JsonException $exception) {
|
||||||
|
throw new RuntimeException('Failed to encode upload manifest', previous: $exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
$temporaryPath = $manifestPath.'.tmp.'.bin2hex(random_bytes(6));
|
||||||
|
try {
|
||||||
|
if ($this->filesystem->put($temporaryPath, $json) === false
|
||||||
|
|| ! $this->filesystem->move($temporaryPath, $manifestPath)) {
|
||||||
|
throw new RuntimeException("Failed to write upload manifest: {$manifestPath}");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if ($this->filesystem->exists($temporaryPath)) {
|
||||||
|
$this->filesystem->delete($temporaryPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertSafeFilename(string $filename, string $extension): void
|
||||||
|
{
|
||||||
|
if ($filename === ''
|
||||||
|
|| $filename === '.'
|
||||||
|
|| $filename === '..'
|
||||||
|
|| basename($filename) !== $filename
|
||||||
|
|| str_contains($filename, "\0")
|
||||||
|
|| strtolower((string) pathinfo($filename, PATHINFO_EXTENSION)) !== $extension
|
||||||
|
|| trim((string) pathinfo($filename, PATHINFO_FILENAME)) === '') {
|
||||||
|
throw new RuntimeException("Unsafe {$extension} filename in upload manifest");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,12 +8,20 @@ use App\Exceptions\NzbUploadException;
|
|||||||
use Illuminate\Filesystem\Filesystem;
|
use Illuminate\Filesystem\Filesystem;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
final class NzbUploadStagingService
|
final class NzbUploadStagingService
|
||||||
{
|
{
|
||||||
private const MAX_NFO_SIZE = 65535;
|
private const MAX_NFO_SIZE = 65535;
|
||||||
|
|
||||||
public function __construct(private readonly Filesystem $filesystem) {}
|
private NzbUploadManifestService $manifests;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Filesystem $filesystem,
|
||||||
|
?NzbUploadManifestService $manifests = null,
|
||||||
|
) {
|
||||||
|
$this->manifests = $manifests ?? new NzbUploadManifestService($filesystem);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{name:string,files:array{nzb:array{filename:string,type:string},nfo:array{filename:string,type:string}|null}}
|
* @return array{name:string,files:array{nzb:array{filename:string,type:string},nfo:array{filename:string,type:string}|null}}
|
||||||
@@ -22,20 +30,12 @@ final class NzbUploadStagingService
|
|||||||
{
|
{
|
||||||
[$nzbName, $nzbBase, $nzbContent] = $this->validateFile($nzb, 'nzb');
|
[$nzbName, $nzbBase, $nzbContent] = $this->validateFile($nzb, 'nzb');
|
||||||
$nfoDetails = $nfo === null ? null : $this->validateFile($nfo, 'nfo');
|
$nfoDetails = $nfo === null ? null : $this->validateFile($nfo, 'nfo');
|
||||||
|
$folder = $this->ensureUploadFolder();
|
||||||
|
|
||||||
if ($nfoDetails !== null && strcasecmp($nzbBase, $nfoDetails[1]) !== 0) {
|
$uploadId = Str::uuid()->toString();
|
||||||
throw new NzbUploadException('NZB and NFO filenames must have matching basenames', 400);
|
$uploadDirectory = $folder.DIRECTORY_SEPARATOR.$uploadId;
|
||||||
}
|
if (! $this->filesystem->makeDirectory($uploadDirectory, 0775, true)) {
|
||||||
|
throw new NzbUploadException('Failed to create upload staging directory', 500);
|
||||||
$folder = config('nntmux.nzb_upload_folder');
|
|
||||||
if (! is_string($folder) || trim($folder) === '') {
|
|
||||||
throw new NzbUploadException('NZB upload folder is not configured', 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
$folder = rtrim($folder, DIRECTORY_SEPARATOR);
|
|
||||||
if (! $this->filesystem->isDirectory($folder)
|
|
||||||
&& ! $this->filesystem->makeDirectory($folder, 0775, true)) {
|
|
||||||
throw new NzbUploadException('Failed to create NZB upload folder', 500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$files = [[$nzbName, $nzbContent, 'nzb']];
|
$files = [[$nzbName, $nzbContent, 'nzb']];
|
||||||
@@ -43,32 +43,26 @@ final class NzbUploadStagingService
|
|||||||
$files[] = [$nfoDetails[0], $nfoDetails[2], 'nfo'];
|
$files[] = [$nfoDetails[0], $nfoDetails[2], 'nfo'];
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($files as [$filename]) {
|
|
||||||
if ($this->filesystem->exists($folder.DIRECTORY_SEPARATOR.$filename)) {
|
|
||||||
throw new NzbUploadException("A staged file named {$filename} already exists", 409);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$created = [];
|
|
||||||
try {
|
try {
|
||||||
foreach ($files as [$filename, $content, $type]) {
|
foreach ($files as [$filename, $content, $type]) {
|
||||||
$path = $folder.DIRECTORY_SEPARATOR.$filename;
|
$path = $uploadDirectory.DIRECTORY_SEPARATOR.$filename;
|
||||||
if ($this->filesystem->put($path, $content) === false) {
|
if ($this->filesystem->put($path, $content) === false) {
|
||||||
throw new NzbUploadException("Failed to write {$filename} to disk", 500);
|
throw new NzbUploadException("Failed to write {$filename} to disk", 500);
|
||||||
}
|
}
|
||||||
$created[] = $path;
|
|
||||||
}
|
|
||||||
} catch (\Throwable $exception) {
|
|
||||||
$rollbackFailed = false;
|
|
||||||
foreach ($created as $path) {
|
|
||||||
if ($this->filesystem->exists($path) && ! $this->filesystem->delete($path)) {
|
|
||||||
$rollbackFailed = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($rollbackFailed) {
|
$this->manifests->create(
|
||||||
Log::channel('nzb_upload')->error('Failed to roll back partial API v2 upload', [
|
$uploadDirectory,
|
||||||
'files' => array_map('basename', $created),
|
$uploadId,
|
||||||
|
$nzbName,
|
||||||
|
$nfoDetails[0] ?? null,
|
||||||
|
);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
if ($this->filesystem->isDirectory($uploadDirectory)
|
||||||
|
&& ! $this->filesystem->deleteDirectory($uploadDirectory)) {
|
||||||
|
Log::channel('nzb_upload')->error('Failed to roll back partial API upload', [
|
||||||
|
'upload_id' => $uploadId,
|
||||||
|
'files' => array_column($files, 0),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
throw new NzbUploadException('Failed to write upload pair and roll back partial files', 500);
|
throw new NzbUploadException('Failed to write upload pair and roll back partial files', 500);
|
||||||
@@ -82,7 +76,8 @@ final class NzbUploadStagingService
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($files as [$filename, , $type]) {
|
foreach ($files as [$filename, , $type]) {
|
||||||
Log::channel('nzb_upload')->info('File uploaded by API v2', [
|
Log::channel('nzb_upload')->info('File staged by API', [
|
||||||
|
'upload_id' => $uploadId,
|
||||||
'filename' => $filename,
|
'filename' => $filename,
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
]);
|
]);
|
||||||
@@ -97,6 +92,46 @@ final class NzbUploadStagingService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preserve the API v1 single-file fallback while routing NZBs through manifested staging.
|
||||||
|
*
|
||||||
|
* @return array{name:string,filename:string,type:'nzb'|'nfo'}
|
||||||
|
*/
|
||||||
|
public function stageLegacyFile(UploadedFile $file): array
|
||||||
|
{
|
||||||
|
$extension = strtolower((string) $file->getClientOriginalExtension());
|
||||||
|
if (! in_array($extension, ['nzb', 'nfo'], true)) {
|
||||||
|
throw new NzbUploadException('File is not an NZB or NFO file', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($extension === 'nzb') {
|
||||||
|
$staged = $this->stage($file);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $staged['name'],
|
||||||
|
'filename' => $staged['files']['nzb']['filename'],
|
||||||
|
'type' => 'nzb',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
[$filename, $basename, $content] = $this->validateFile($file, 'nfo');
|
||||||
|
$folder = $this->ensureUploadFolder();
|
||||||
|
$path = $folder.DIRECTORY_SEPARATOR.$filename;
|
||||||
|
if ($this->filesystem->exists($path)) {
|
||||||
|
throw new NzbUploadException("A staged file named {$filename} already exists", 409);
|
||||||
|
}
|
||||||
|
if ($this->filesystem->put($path, $content) === false) {
|
||||||
|
throw new NzbUploadException("Failed to write {$filename} to disk", 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::channel('nzb_upload')->info('Legacy NFO file staged by API v1', [
|
||||||
|
'filename' => $filename,
|
||||||
|
'type' => 'nfo',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ['name' => $basename, 'filename' => $filename, 'type' => 'nfo'];
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array{string,string,string} */
|
/** @return array{string,string,string} */
|
||||||
private function validateFile(UploadedFile $file, string $expectedExtension): array
|
private function validateFile(UploadedFile $file, string $expectedExtension): array
|
||||||
{
|
{
|
||||||
@@ -135,4 +170,20 @@ final class NzbUploadStagingService
|
|||||||
|
|
||||||
return [$filename, $basename, $content];
|
return [$filename, $basename, $content];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function ensureUploadFolder(): string
|
||||||
|
{
|
||||||
|
$folder = config('nntmux.nzb_upload_folder');
|
||||||
|
if (! is_string($folder) || trim($folder) === '') {
|
||||||
|
throw new NzbUploadException('NZB upload folder is not configured', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$folder = rtrim($folder, DIRECTORY_SEPARATOR);
|
||||||
|
if (! $this->filesystem->isDirectory($folder)
|
||||||
|
&& ! $this->filesystem->makeDirectory($folder, 0775, true)) {
|
||||||
|
throw new NzbUploadException('Failed to create NZB upload folder', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $folder;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,15 +225,23 @@ newznab-tmux https://github.com/NNTmux/newznab-tmux
|
|||||||
Parameters:
|
Parameters:
|
||||||
t=nzbadd
|
t=nzbadd
|
||||||
apikey=xxxx
|
apikey=xxxx
|
||||||
file=<multipart nzb or nfo file>
|
nzb=<multipart nzb file>
|
||||||
|
|
||||||
Optional:
|
Optional:
|
||||||
cat, includemeta, dupecheck, nfo, medianfo
|
nfo=<multipart nfo file with any basename>
|
||||||
|
cat, includemeta, dupecheck, medianfo
|
||||||
|
|
||||||
|
Legacy fallback:
|
||||||
|
file=<single multipart nzb or nfo file>
|
||||||
|
|
||||||
|
The legacy file field cannot be mixed with nzb or nfo in the same request.
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
Valid .nzb and .nfo uploads are written to the same NZB upload folder
|
The nzb/nfo fields use the same atomic manifested staging flow as API v2.
|
||||||
(NZB_UPLOAD_FOLDER). NFO files are not linked to releases at upload time;
|
Each pair is isolated below NZB_UPLOAD_FOLDER and the NFO basename does not
|
||||||
a separate import command is expected to process them later.
|
need to match the NZB basename. Run nntmux:import-nzbs first and then
|
||||||
|
nntmux:import-nfos for the same folder to link the NFO to the new release.
|
||||||
|
Duplicate or failed NZB imports do not link their NFO files.
|
||||||
|
|
||||||
Error handling:
|
Error handling:
|
||||||
Uses XML error codes from section 6.
|
Uses XML error codes from section 6.
|
||||||
|
|||||||
+19
-10
@@ -198,13 +198,12 @@ Fields:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `api_token` | yes | API token for a verified, enabled user. |
|
| `api_token` | yes | API token for a verified, enabled user. |
|
||||||
| `nzb` | yes | Valid `.nzb` file staged for deferred import. |
|
| `nzb` | yes | Valid `.nzb` file staged for deferred import. |
|
||||||
| `nfo` | no | Matching `.nfo` file, maximum 65,535 bytes. |
|
| `nfo` | no | `.nfo` file with any safe basename, maximum 65,535 bytes. |
|
||||||
| `cat` | no | Echoed as response metadata; it does not affect import categorization. |
|
| `cat` | no | Echoed as response metadata; it does not affect import categorization. |
|
||||||
|
|
||||||
When `nfo` is supplied, its basename must match the NZB basename
|
When `nfo` is supplied, its basename does not need to match the NZB basename.
|
||||||
case-insensitively (for example, `Release.nzb` and `Release.nfo`). The request
|
The request is atomic: both files are validated before staging in an isolated
|
||||||
is atomic: both files are validated before staging, existing files are never
|
upload directory, and a partial write is rolled back.
|
||||||
overwritten, and a partial write is rolled back.
|
|
||||||
|
|
||||||
NZB-only example:
|
NZB-only example:
|
||||||
|
|
||||||
@@ -215,7 +214,7 @@ curl -X POST -F "api_token=<token>" -F "nzb=@Release.nzb" https://<host>/api/v2/
|
|||||||
Paired example:
|
Paired example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST -F "api_token=<token>" -F "cat=5040" -F "nzb=@Release.nzb" -F "nfo=@Release.nfo" https://<host>/api/v2/nzbadd
|
curl -X POST -F "api_token=<token>" -F "cat=5040" -F "nzb=@Release.nzb" -F "nfo=@scene-info.nfo" https://<host>/api/v2/nzbadd
|
||||||
```
|
```
|
||||||
|
|
||||||
Successful staging returns HTTP `201`:
|
Successful staging returns HTTP `201`:
|
||||||
@@ -228,14 +227,24 @@ Successful staging returns HTTP `201`:
|
|||||||
"category": "5040",
|
"category": "5040",
|
||||||
"files": {
|
"files": {
|
||||||
"nzb": { "filename": "Release.nzb", "type": "nzb" },
|
"nzb": { "filename": "Release.nzb", "type": "nzb" },
|
||||||
"nfo": { "filename": "Release.nfo", "type": "nfo" }
|
"nfo": { "filename": "scene-info.nfo", "type": "nfo" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Staging does not synchronously create a release. The existing importer consumes
|
Staging does not synchronously create a release. Import the NZB files first,
|
||||||
the files from `NZB_UPLOAD_FOLDER` later. NZB-only responses set `files.nfo` to
|
then import their paired NFO files using the manifest identity recorded by the
|
||||||
`null`.
|
NZB importer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan nntmux:import-nzbs --folder=/path/to/NZB_UPLOAD_FOLDER
|
||||||
|
php artisan nntmux:import-nfos --folder=/path/to/NZB_UPLOAD_FOLDER
|
||||||
|
```
|
||||||
|
|
||||||
|
The NFO importer replaces an existing NFO for the resolved release. Add
|
||||||
|
`--delete` to remove successfully imported NFO payloads or `--delete-failed`
|
||||||
|
to remove payloads that cannot be linked. NZB-only responses set `files.nfo`
|
||||||
|
to `null`.
|
||||||
|
|
||||||
## Response Models
|
## Response Models
|
||||||
|
|
||||||
|
|||||||
@@ -610,10 +610,10 @@
|
|||||||
{ "key": "api_token", "value": "{{apiToken}}", "type": "text" },
|
{ "key": "api_token", "value": "{{apiToken}}", "type": "text" },
|
||||||
{ "key": "cat", "value": "5040", "type": "text" },
|
{ "key": "cat", "value": "5040", "type": "text" },
|
||||||
{ "key": "nzb", "type": "file", "src": "Release.nzb" },
|
{ "key": "nzb", "type": "file", "src": "Release.nzb" },
|
||||||
{ "key": "nfo", "type": "file", "src": "Release.nfo" }
|
{ "key": "nfo", "type": "file", "src": "scene-info.nfo" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"description": "Atomically stages an NZB and an optional NFO with a matching basename. Category is echoed metadata only.",
|
"description": "Atomically stages an NZB and an optional NFO with any safe basename. Run nntmux:import-nzbs before nntmux:import-nfos. Category is echoed metadata only.",
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{baseUrl}}/api/v2/nzbadd",
|
"raw": "{{baseUrl}}/api/v2/nzbadd",
|
||||||
"host": ["{{baseUrl}}"],
|
"host": ["{{baseUrl}}"],
|
||||||
@@ -637,7 +637,7 @@
|
|||||||
"_postman_previewlanguage": "json",
|
"_postman_previewlanguage": "json",
|
||||||
"header": [{ "key": "Content-Type", "value": "application/json" }],
|
"header": [{ "key": "Content-Type", "value": "application/json" }],
|
||||||
"cookie": [],
|
"cookie": [],
|
||||||
"body": "{\n \"success\": true,\n \"status\": \"staged\",\n \"name\": \"Release\",\n \"category\": \"5040\",\n \"files\": {\n \"nzb\": { \"filename\": \"Release.nzb\", \"type\": \"nzb\" },\n \"nfo\": { \"filename\": \"Release.nfo\", \"type\": \"nfo\" }\n }\n}"
|
"body": "{\n \"success\": true,\n \"status\": \"staged\",\n \"name\": \"Release\",\n \"category\": \"5040\",\n \"files\": {\n \"nzb\": { \"filename\": \"Release.nzb\", \"type\": \"nzb\" },\n \"nfo\": { \"filename\": \"scene-info.nfo\", \"type\": \"nfo\" }\n }\n}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -690,14 +690,61 @@ class ApiRequestMatrixTest extends TestCase
|
|||||||
->assertJsonPath('password', 0);
|
->assertJsonPath('password', 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_v2_nzbadd_stages_a_paired_nzb_and_nfo(): void
|
public function test_v1_nzbadd_stages_differently_named_nzb_and_nfo_fields(): void
|
||||||
|
{
|
||||||
|
$token = (string) DB::table('users')->value('api_token');
|
||||||
|
|
||||||
|
$this->post('/api/v1/api?t=nzbadd&apikey='.$token, [
|
||||||
|
'cat' => '5040',
|
||||||
|
'nzb' => UploadedFile::fake()->createWithContent(
|
||||||
|
'Release.nzb',
|
||||||
|
'<?xml version="1.0"?><nzb xmlns="http://www.newzbin.com/DTD/2003/nzb"></nzb>',
|
||||||
|
),
|
||||||
|
'nfo' => UploadedFile::fake()->createWithContent('scene-info.nfo', 'release information'),
|
||||||
|
])->assertOk()
|
||||||
|
->assertHeader('Content-Type', 'text/xml; charset=UTF-8')
|
||||||
|
->assertSee('<success id="0" guid="" categoryid="5040" name="Release" />', false);
|
||||||
|
|
||||||
|
$this->assertNotNull($this->findStagedFile('Release.nzb'));
|
||||||
|
$this->assertNotNull($this->findStagedFile('scene-info.nfo'));
|
||||||
|
$this->assertNotNull($this->findStagedFile('nntmux-upload.json'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_v1_nzbadd_preserves_legacy_file_fallback_and_rejects_mixed_contracts(): void
|
||||||
|
{
|
||||||
|
$token = (string) DB::table('users')->value('api_token');
|
||||||
|
|
||||||
|
$this->post('/api/v1/api?t=nzbadd&apikey='.$token, [
|
||||||
|
'file' => UploadedFile::fake()->createWithContent(
|
||||||
|
'Legacy.nzb',
|
||||||
|
'<?xml version="1.0"?><nzb xmlns="http://www.newzbin.com/DTD/2003/nzb"></nzb>',
|
||||||
|
),
|
||||||
|
])->assertOk()
|
||||||
|
->assertSee('name="Legacy"', false);
|
||||||
|
|
||||||
|
$this->assertNotNull($this->findStagedFile('Legacy.nzb'));
|
||||||
|
|
||||||
|
$this->post('/api/v1/api?t=nzbadd&apikey='.$token, [
|
||||||
|
'file' => UploadedFile::fake()->createWithContent(
|
||||||
|
'Mixed.nzb',
|
||||||
|
'<?xml version="1.0"?><nzb xmlns="http://www.newzbin.com/DTD/2003/nzb"></nzb>',
|
||||||
|
),
|
||||||
|
'nzb' => UploadedFile::fake()->createWithContent(
|
||||||
|
'Modern.nzb',
|
||||||
|
'<?xml version="1.0"?><nzb xmlns="http://www.newzbin.com/DTD/2003/nzb"></nzb>',
|
||||||
|
),
|
||||||
|
])->assertBadRequest()
|
||||||
|
->assertSee('<error code="201"', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_v2_nzbadd_stages_a_differently_named_nzb_and_nfo(): void
|
||||||
{
|
{
|
||||||
$token = (string) DB::table('users')->value('api_token');
|
$token = (string) DB::table('users')->value('api_token');
|
||||||
$nzb = UploadedFile::fake()->createWithContent(
|
$nzb = UploadedFile::fake()->createWithContent(
|
||||||
'Release.nzb',
|
'Release.nzb',
|
||||||
'<?xml version="1.0"?><nzb xmlns="http://www.newzbin.com/DTD/2003/nzb"></nzb>',
|
'<?xml version="1.0"?><nzb xmlns="http://www.newzbin.com/DTD/2003/nzb"></nzb>',
|
||||||
);
|
);
|
||||||
$nfo = UploadedFile::fake()->createWithContent('Release.nfo', 'release information');
|
$nfo = UploadedFile::fake()->createWithContent('scene-info.nfo', 'release information');
|
||||||
|
|
||||||
$this->post('/api/v2/nzbadd', [
|
$this->post('/api/v2/nzbadd', [
|
||||||
'api_token' => $token,
|
'api_token' => $token,
|
||||||
@@ -710,10 +757,11 @@ class ApiRequestMatrixTest extends TestCase
|
|||||||
->assertJsonPath('name', 'Release')
|
->assertJsonPath('name', 'Release')
|
||||||
->assertJsonPath('category', '5040')
|
->assertJsonPath('category', '5040')
|
||||||
->assertJsonPath('files.nzb.filename', 'Release.nzb')
|
->assertJsonPath('files.nzb.filename', 'Release.nzb')
|
||||||
->assertJsonPath('files.nfo.filename', 'Release.nfo');
|
->assertJsonPath('files.nfo.filename', 'scene-info.nfo');
|
||||||
|
|
||||||
$this->assertFileExists($this->nzbUploadFolder.'/Release.nzb');
|
$this->assertNotNull($this->findStagedFile('Release.nzb'));
|
||||||
$this->assertFileExists($this->nzbUploadFolder.'/Release.nfo');
|
$this->assertNotNull($this->findStagedFile('scene-info.nfo'));
|
||||||
|
$this->assertNotNull($this->findStagedFile('nntmux-upload.json'));
|
||||||
$this->assertSame(0, DB::table('user_requests')->count());
|
$this->assertSame(0, DB::table('user_requests')->count());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,7 +785,7 @@ class ApiRequestMatrixTest extends TestCase
|
|||||||
])->assertCreated();
|
])->assertCreated();
|
||||||
|
|
||||||
$this->assertSame(1, DB::table('user_requests')->count());
|
$this->assertSame(1, DB::table('user_requests')->count());
|
||||||
$this->assertFileExists($this->nzbUploadFolder.'/QuotaExempt.nzb');
|
$this->assertNotNull($this->findStagedFile('QuotaExempt.nzb'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_v2_nzbadd_requires_posting_privileges(): void
|
public function test_v2_nzbadd_requires_posting_privileges(): void
|
||||||
@@ -926,6 +974,17 @@ class ApiRequestMatrixTest extends TestCase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function findStagedFile(string $filename): ?string
|
||||||
|
{
|
||||||
|
foreach ((new Filesystem)->allFiles($this->nzbUploadFolder) as $file) {
|
||||||
|
if ($file->getFilename() === $filename) {
|
||||||
|
return $file->getPathname();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private function setEnvironmentValue(string $key, ?string $value): void
|
private function setEnvironmentValue(string $key, ?string $value): void
|
||||||
{
|
{
|
||||||
if ($value === null) {
|
if ($value === null) {
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use App\Enums\NzbImportStatus;
|
||||||
|
use App\Services\NfoService;
|
||||||
|
use App\Services\Nzb\NfoImportService;
|
||||||
|
use App\Services\Nzb\NzbUploadManifestService;
|
||||||
|
use Illuminate\Contracts\Console\Kernel;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Filesystem\Filesystem;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use PDO;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class NfoImportServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $databasePath;
|
||||||
|
|
||||||
|
private string $uploadFolder;
|
||||||
|
|
||||||
|
/** @var array<string, string|false> */
|
||||||
|
private array $originalEnvironment = [];
|
||||||
|
|
||||||
|
public function createApplication()
|
||||||
|
{
|
||||||
|
$this->databasePath = sys_get_temp_dir().'/nntmux-nfo-import-test.sqlite';
|
||||||
|
$this->originalEnvironment = [
|
||||||
|
'APP_ENV' => getenv('APP_ENV'),
|
||||||
|
'DB_CONNECTION' => getenv('DB_CONNECTION'),
|
||||||
|
'DB_DATABASE' => getenv('DB_DATABASE'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (file_exists($this->databasePath)) {
|
||||||
|
unlink($this->databasePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = new PDO('sqlite:'.$this->databasePath);
|
||||||
|
$pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)');
|
||||||
|
$pdo->exec("INSERT INTO settings (name, value) VALUES
|
||||||
|
('categorizeforeign', '0'),
|
||||||
|
('catwebdl', '0'),
|
||||||
|
('innerfileblacklist', ''),
|
||||||
|
('timeoutseconds', '60')");
|
||||||
|
|
||||||
|
$this->setEnvironmentValue('APP_ENV', 'testing');
|
||||||
|
$this->setEnvironmentValue('DB_CONNECTION', 'sqlite');
|
||||||
|
$this->setEnvironmentValue('DB_DATABASE', $this->databasePath);
|
||||||
|
|
||||||
|
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||||
|
$app->make(Kernel::class)->bootstrap();
|
||||||
|
|
||||||
|
return $app;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
config([
|
||||||
|
'database.default' => 'sqlite',
|
||||||
|
'database.connections.sqlite.database' => $this->databasePath,
|
||||||
|
'nntmux.tmp_unrar_path' => sys_get_temp_dir().'/nntmux-nfo-import-tmp',
|
||||||
|
]);
|
||||||
|
DB::purge();
|
||||||
|
DB::reconnect();
|
||||||
|
|
||||||
|
Schema::dropAllTables();
|
||||||
|
Schema::create('settings', function (Blueprint $table): void {
|
||||||
|
$table->string('name')->primary();
|
||||||
|
$table->text('value')->nullable();
|
||||||
|
});
|
||||||
|
Schema::create('releases', function (Blueprint $table): void {
|
||||||
|
$table->increments('id');
|
||||||
|
$table->string('guid')->unique();
|
||||||
|
$table->integer('nfostatus')->default(-1);
|
||||||
|
});
|
||||||
|
Schema::create('release_nfos', function (Blueprint $table): void {
|
||||||
|
$table->unsignedInteger('releases_id')->primary();
|
||||||
|
$table->binary('nfo')->nullable();
|
||||||
|
});
|
||||||
|
DB::table('settings')->insert([
|
||||||
|
['name' => 'timeoutseconds', 'value' => '60'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->uploadFolder = sys_get_temp_dir().'/nntmux-nfo-import-'.bin2hex(random_bytes(6));
|
||||||
|
(new Filesystem)->makeDirectory($this->uploadFolder, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
(new Filesystem)->deleteDirectory($this->uploadFolder);
|
||||||
|
(new Filesystem)->deleteDirectory(sys_get_temp_dir().'/nntmux-nfo-import-tmp');
|
||||||
|
|
||||||
|
if (file_exists($this->databasePath)) {
|
||||||
|
unlink($this->databasePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
|
||||||
|
foreach ($this->originalEnvironment as $key => $value) {
|
||||||
|
$this->setEnvironmentValue($key, $value === false ? null : $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_replaces_an_existing_nfo_for_the_manifest_release(): void
|
||||||
|
{
|
||||||
|
$guid = '11111111-1111-4111-8111-111111111111';
|
||||||
|
DB::table('releases')->insert(['id' => 1, 'guid' => $guid, 'nfostatus' => -1]);
|
||||||
|
DB::table('release_nfos')->insert([
|
||||||
|
'releases_id' => 1,
|
||||||
|
'nfo' => "\x1f\x8b\x08\x00".gzcompress('old information'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
[$manifestPath, $nfoPath, $manifests] = $this->makeResolvedUpload($guid);
|
||||||
|
$service = new NfoImportService(new Filesystem, $manifests, new NfoService);
|
||||||
|
|
||||||
|
$result = $service->importManifest($manifestPath);
|
||||||
|
|
||||||
|
$this->assertSame('imported', $result['status']);
|
||||||
|
$stored = DB::table('release_nfos')->where('releases_id', 1)->value('nfo');
|
||||||
|
$this->assertIsString($stored);
|
||||||
|
$this->assertSame('new release information', gzuncompress(substr($stored, 4)));
|
||||||
|
$this->assertSame(1, DB::table('releases')->where('id', 1)->value('nfostatus'));
|
||||||
|
$this->assertSame(NzbUploadManifestService::STATE_NFO_IMPORTED, $manifests->read($manifestPath)['state']);
|
||||||
|
$this->assertFileExists($nfoPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_deletes_a_successfully_imported_nfo_when_requested(): void
|
||||||
|
{
|
||||||
|
$guid = '22222222-2222-4222-8222-222222222222';
|
||||||
|
DB::table('releases')->insert(['id' => 1, 'guid' => $guid, 'nfostatus' => -1]);
|
||||||
|
[$manifestPath, $nfoPath, $manifests] = $this->makeResolvedUpload($guid);
|
||||||
|
|
||||||
|
$result = (new NfoImportService(new Filesystem, $manifests, new NfoService))
|
||||||
|
->importManifest($manifestPath, delete: true);
|
||||||
|
|
||||||
|
$this->assertSame('imported', $result['status']);
|
||||||
|
$this->assertFileDoesNotExist($nfoPath);
|
||||||
|
$this->assertFileExists($manifestPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_duplicate_nzb_manifest_is_not_linked_and_can_delete_its_nfo(): void
|
||||||
|
{
|
||||||
|
$filesystem = new Filesystem;
|
||||||
|
$manifests = new NzbUploadManifestService($filesystem);
|
||||||
|
$directory = $this->uploadFolder.'/duplicate';
|
||||||
|
$filesystem->makeDirectory($directory, 0775, true);
|
||||||
|
$filesystem->put($directory.'/Release.nzb', '<nzb></nzb>');
|
||||||
|
$filesystem->put($directory.'/different.nfo', 'duplicate release information');
|
||||||
|
$manifestPath = $directory.'/'.NzbUploadManifestService::FILENAME;
|
||||||
|
$manifests->create($directory, 'duplicate', 'Release.nzb', 'different.nfo');
|
||||||
|
$manifests->recordNzbOutcome(
|
||||||
|
$directory.'/Release.nzb',
|
||||||
|
NzbImportStatus::Duplicate,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = (new NfoImportService($filesystem, $manifests, new NfoService))
|
||||||
|
->importManifest($manifestPath, deleteFailed: true);
|
||||||
|
|
||||||
|
$this->assertSame('skipped', $result['status']);
|
||||||
|
$this->assertFileDoesNotExist($directory.'/different.nfo');
|
||||||
|
$this->assertSame(NzbUploadManifestService::STATE_NZB_DUPLICATE, $manifests->read($manifestPath)['state']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_a_manifest_path_that_escapes_the_upload_directory(): void
|
||||||
|
{
|
||||||
|
$filesystem = new Filesystem;
|
||||||
|
$directory = $this->uploadFolder.'/unsafe';
|
||||||
|
$filesystem->makeDirectory($directory, 0775, true);
|
||||||
|
$outsidePath = $this->uploadFolder.'/outside.nfo';
|
||||||
|
$filesystem->put($outsidePath, 'outside information');
|
||||||
|
$manifestPath = $directory.'/'.NzbUploadManifestService::FILENAME;
|
||||||
|
$filesystem->put($manifestPath, json_encode([
|
||||||
|
'version' => 1,
|
||||||
|
'upload_id' => 'unsafe',
|
||||||
|
'state' => NzbUploadManifestService::STATE_NZB_IMPORTED,
|
||||||
|
'nzb' => ['filename' => 'Release.nzb'],
|
||||||
|
'nfo' => ['filename' => '../outside.nfo'],
|
||||||
|
'release_id' => 1,
|
||||||
|
'release_guid' => 'unsafe-guid',
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
$manifests = new NzbUploadManifestService($filesystem);
|
||||||
|
$result = (new NfoImportService($filesystem, $manifests, new NfoService))
|
||||||
|
->importManifest($manifestPath, deleteFailed: true);
|
||||||
|
|
||||||
|
$this->assertSame('failed', $result['status']);
|
||||||
|
$this->assertFileExists($outsidePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{string,string,NzbUploadManifestService}
|
||||||
|
*/
|
||||||
|
private function makeResolvedUpload(string $guid): array
|
||||||
|
{
|
||||||
|
$filesystem = new Filesystem;
|
||||||
|
$manifests = new NzbUploadManifestService($filesystem);
|
||||||
|
$directory = $this->uploadFolder.'/resolved';
|
||||||
|
$filesystem->makeDirectory($directory, 0775, true);
|
||||||
|
$nzbPath = $directory.'/Release.nzb';
|
||||||
|
$nfoPath = $directory.'/different.nfo';
|
||||||
|
$filesystem->put($nzbPath, '<nzb></nzb>');
|
||||||
|
$filesystem->put($nfoPath, 'new release information');
|
||||||
|
$manifestPath = $directory.'/'.NzbUploadManifestService::FILENAME;
|
||||||
|
$manifests->create($directory, 'resolved', 'Release.nzb', 'different.nfo');
|
||||||
|
$manifests->recordNzbOutcome($nzbPath, NzbImportStatus::Inserted, 1, $guid);
|
||||||
|
|
||||||
|
return [$manifestPath, $nfoPath, $manifests];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setEnvironmentValue(string $key, ?string $value): void
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
putenv($key);
|
||||||
|
unset($_ENV[$key], $_SERVER[$key]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
putenv($key.'='.$value);
|
||||||
|
$_ENV[$key] = $value;
|
||||||
|
$_SERVER[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Unit;
|
namespace Tests\Unit;
|
||||||
|
|
||||||
use App\Exceptions\NzbUploadException;
|
use App\Exceptions\NzbUploadException;
|
||||||
|
use App\Services\Nzb\NzbUploadManifestService;
|
||||||
use App\Services\Nzb\NzbUploadStagingService;
|
use App\Services\Nzb\NzbUploadStagingService;
|
||||||
use Illuminate\Contracts\Console\Kernel;
|
use Illuminate\Contracts\Console\Kernel;
|
||||||
use Illuminate\Filesystem\Filesystem;
|
use Illuminate\Filesystem\Filesystem;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Mockery;
|
|
||||||
use PDO;
|
use PDO;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -75,41 +75,59 @@ final class NzbUploadStagingServiceTest extends TestCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_stages_a_matching_nzb_and_nfo_pair(): void
|
public function test_it_stages_an_nzb_and_arbitrarily_named_nfo_with_a_manifest(): void
|
||||||
{
|
{
|
||||||
$result = (new NzbUploadStagingService(new Filesystem))->stage(
|
$result = (new NzbUploadStagingService(new Filesystem))->stage(
|
||||||
$this->nzb('Release.nzb'),
|
$this->nzb('Release.nzb'),
|
||||||
UploadedFile::fake()->createWithContent('Release.nfo', 'release information'),
|
UploadedFile::fake()->createWithContent('scene-info.nfo', 'release information'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$uploadDirectory = $this->onlyUploadDirectory();
|
||||||
|
$manifest = json_decode(
|
||||||
|
(string) file_get_contents($uploadDirectory.'/'.NzbUploadManifestService::FILENAME),
|
||||||
|
true,
|
||||||
|
flags: JSON_THROW_ON_ERROR,
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertSame('Release', $result['name']);
|
$this->assertSame('Release', $result['name']);
|
||||||
$this->assertSame('Release.nzb', $result['files']['nzb']['filename']);
|
$this->assertSame('Release.nzb', $result['files']['nzb']['filename']);
|
||||||
$this->assertSame('Release.nfo', $result['files']['nfo']['filename']);
|
$this->assertSame('scene-info.nfo', $result['files']['nfo']['filename']);
|
||||||
$this->assertFileExists($this->uploadFolder.'/Release.nzb');
|
$this->assertFileExists($uploadDirectory.'/Release.nzb');
|
||||||
$this->assertFileExists($this->uploadFolder.'/Release.nfo');
|
$this->assertFileExists($uploadDirectory.'/scene-info.nfo');
|
||||||
|
$this->assertSame(NzbUploadManifestService::STATE_STAGED, $manifest['state']);
|
||||||
|
$this->assertSame('Release.nzb', $manifest['nzb']['filename']);
|
||||||
|
$this->assertSame('scene-info.nfo', $manifest['nfo']['filename']);
|
||||||
|
$this->assertSame(basename($uploadDirectory), $manifest['upload_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_stages_an_nzb_without_an_nfo(): void
|
public function test_it_stages_an_nzb_without_an_nfo(): void
|
||||||
{
|
{
|
||||||
$result = (new NzbUploadStagingService(new Filesystem))->stage($this->nzb('Solo.nzb'));
|
$result = (new NzbUploadStagingService(new Filesystem))->stage($this->nzb('Solo.nzb'));
|
||||||
|
|
||||||
|
$uploadDirectory = $this->onlyUploadDirectory();
|
||||||
|
|
||||||
$this->assertNull($result['files']['nfo']);
|
$this->assertNull($result['files']['nfo']);
|
||||||
$this->assertFileExists($this->uploadFolder.'/Solo.nzb');
|
$this->assertFileExists($uploadDirectory.'/Solo.nzb');
|
||||||
|
$this->assertFileExists($uploadDirectory.'/'.NzbUploadManifestService::FILENAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_rejects_mismatched_pair_names_before_writing(): void
|
public function test_it_isolates_repeated_generic_nfo_names(): void
|
||||||
{
|
{
|
||||||
try {
|
$service = new NzbUploadStagingService(new Filesystem);
|
||||||
(new NzbUploadStagingService(new Filesystem))->stage(
|
$service->stage(
|
||||||
$this->nzb('Release.nzb'),
|
$this->nzb('First.nzb'),
|
||||||
UploadedFile::fake()->createWithContent('Different.nfo', 'release information'),
|
UploadedFile::fake()->createWithContent('info.nfo', 'first release information'),
|
||||||
);
|
);
|
||||||
$this->fail('Expected a mismatched pair to be rejected.');
|
$service->stage(
|
||||||
} catch (NzbUploadException $exception) {
|
$this->nzb('Second.nzb'),
|
||||||
$this->assertSame(400, $exception->status);
|
UploadedFile::fake()->createWithContent('info.nfo', 'second release information'),
|
||||||
}
|
);
|
||||||
|
|
||||||
$this->assertDirectoryDoesNotExist($this->uploadFolder);
|
$directories = (new Filesystem)->directories($this->uploadFolder);
|
||||||
|
$this->assertCount(2, $directories);
|
||||||
|
foreach ($directories as $directory) {
|
||||||
|
$this->assertFileExists($directory.'/info.nfo');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_rejects_an_invalid_nzb_payload(): void
|
public function test_it_rejects_an_invalid_nzb_payload(): void
|
||||||
@@ -133,37 +151,19 @@ final class NzbUploadStagingServiceTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_rejects_a_collision_without_writing_either_file(): void
|
|
||||||
{
|
|
||||||
(new Filesystem)->makeDirectory($this->uploadFolder, 0775, true);
|
|
||||||
file_put_contents($this->uploadFolder.'/Release.nfo', 'pending');
|
|
||||||
|
|
||||||
try {
|
|
||||||
(new NzbUploadStagingService(new Filesystem))->stage(
|
|
||||||
$this->nzb('Release.nzb'),
|
|
||||||
UploadedFile::fake()->createWithContent('Release.nfo', 'new information'),
|
|
||||||
);
|
|
||||||
$this->fail('Expected an existing staged file to be rejected.');
|
|
||||||
} catch (NzbUploadException $exception) {
|
|
||||||
$this->assertSame(409, $exception->status);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->assertFileDoesNotExist($this->uploadFolder.'/Release.nzb');
|
|
||||||
$this->assertSame('pending', file_get_contents($this->uploadFolder.'/Release.nfo'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_rolls_back_the_nzb_when_the_nfo_write_fails(): void
|
public function test_it_rolls_back_the_nzb_when_the_nfo_write_fails(): void
|
||||||
{
|
{
|
||||||
$nzbPath = $this->uploadFolder.'/Release.nzb';
|
$filesystem = new class extends Filesystem
|
||||||
$nfoPath = $this->uploadFolder.'/Release.nfo';
|
{
|
||||||
$filesystem = Mockery::mock(Filesystem::class);
|
public function put($path, $contents, $lock = false): int|bool
|
||||||
$filesystem->shouldReceive('isDirectory')->once()->with($this->uploadFolder)->andReturnTrue();
|
{
|
||||||
$filesystem->shouldReceive('exists')->once()->with($nzbPath)->andReturnFalse();
|
if (str_ends_with((string) $path, '.nfo')) {
|
||||||
$filesystem->shouldReceive('exists')->once()->with($nfoPath)->andReturnFalse();
|
return false;
|
||||||
$filesystem->shouldReceive('put')->once()->with($nzbPath, Mockery::type('string'))->andReturn(100);
|
}
|
||||||
$filesystem->shouldReceive('put')->once()->with($nfoPath, 'release information')->andReturnFalse();
|
|
||||||
$filesystem->shouldReceive('exists')->once()->with($nzbPath)->andReturnTrue();
|
return parent::put($path, $contents, $lock);
|
||||||
$filesystem->shouldReceive('delete')->once()->with($nzbPath)->andReturnTrue();
|
}
|
||||||
|
};
|
||||||
|
|
||||||
$this->expectException(NzbUploadException::class);
|
$this->expectException(NzbUploadException::class);
|
||||||
$this->expectExceptionMessage('Failed to write Release.nfo to disk');
|
$this->expectExceptionMessage('Failed to write Release.nfo to disk');
|
||||||
@@ -174,6 +174,29 @@ final class NzbUploadStagingServiceTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_rollback_removes_the_partial_upload_directory(): void
|
||||||
|
{
|
||||||
|
$filesystem = new class extends Filesystem
|
||||||
|
{
|
||||||
|
public function put($path, $contents, $lock = false): int|bool
|
||||||
|
{
|
||||||
|
return str_ends_with((string) $path, '.nfo')
|
||||||
|
? false
|
||||||
|
: parent::put($path, $contents, $lock);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new NzbUploadStagingService($filesystem))->stage(
|
||||||
|
$this->nzb('Release.nzb'),
|
||||||
|
UploadedFile::fake()->createWithContent('Release.nfo', 'release information'),
|
||||||
|
);
|
||||||
|
$this->fail('Expected staged pair write to fail.');
|
||||||
|
} catch (NzbUploadException) {
|
||||||
|
$this->assertSame([], $filesystem->directories($this->uploadFolder));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function nzb(string $name): UploadedFile
|
private function nzb(string $name): UploadedFile
|
||||||
{
|
{
|
||||||
return UploadedFile::fake()->createWithContent(
|
return UploadedFile::fake()->createWithContent(
|
||||||
@@ -182,6 +205,14 @@ final class NzbUploadStagingServiceTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function onlyUploadDirectory(): string
|
||||||
|
{
|
||||||
|
$directories = (new Filesystem)->directories($this->uploadFolder);
|
||||||
|
$this->assertCount(1, $directories);
|
||||||
|
|
||||||
|
return $directories[0];
|
||||||
|
}
|
||||||
|
|
||||||
private function setEnvironmentValue(string $key, ?string $value): void
|
private function setEnvironmentValue(string $key, ?string $value): void
|
||||||
{
|
{
|
||||||
if ($value === null) {
|
if ($value === null) {
|
||||||
|
|||||||
Reference in New Issue
Block a user