Update nzb and nfo import

This commit is contained in:
DariusIII
2026-07-14 20:18:04 +02:00
parent 567a614a02
commit de005d3e7e
13 changed files with 1082 additions and 216 deletions
+69
View File
@@ -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;
}
}
+58 -50
View File
@@ -4,10 +4,14 @@ declare(strict_types=1);
namespace App\Console\Commands;
use App\Enums\NzbImportStatus;
use App\Services\Nzb\NzbImportService;
use App\Services\Nzb\NzbUploadManifestService;
use Illuminate\Console\Command;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use SplFileInfo;
use Throwable;
class ImportNzbs extends Command
{
@@ -33,57 +37,61 @@ class ImportNzbs extends Command
/**
* Execute the console command.
*/
public function handle(): void
public function handle(NzbUploadManifestService $manifests): void
{
if ($this->option('folder')) {
if ($this->option('filename')) {
$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 {
$folderOption = $this->option('folder');
if (! is_string($folderOption) || trim($folderOption) === '') {
$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());
}
}
}
+46 -57
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Exceptions\NzbUploadException;
use App\Http\Controllers\BasePageController;
use App\Http\Controllers\GetNzbController;
use App\Models\Category;
@@ -17,6 +18,7 @@ use App\Services\Api\ApiReleaseRowCache;
use App\Services\Api\ApiUsageService;
use App\Services\Api\ApiUserResolver;
use App\Services\Api\V1\ApiV1Presenter;
use App\Services\Nzb\NzbUploadStagingService;
use App\Services\Releases\ReleaseBrowseService;
use App\Services\Releases\ReleaseSearchService;
use App\Support\FilenameSanitizer;
@@ -25,12 +27,11 @@ use Illuminate\Contracts\Routing\ResponseFactory;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -55,6 +56,8 @@ class ApiController extends BasePageController
private ApiV1Presenter $presenter;
private NzbUploadStagingService $nzbUploadStagingService;
public function __construct(
ReleaseSearchService $releaseSearchService,
ReleaseBrowseService $releaseBrowseService,
@@ -64,6 +67,7 @@ class ApiController extends BasePageController
?ApiUserResolver $userResolver = null,
?ApiCapabilitiesService $capabilitiesService = null,
?ApiV1Presenter $presenter = null,
?NzbUploadStagingService $nzbUploadStagingService = null,
) {
parent::__construct();
$this->releaseSearchService = $releaseSearchService;
@@ -74,6 +78,7 @@ class ApiController extends BasePageController
$this->userResolver = $userResolver ?? app(ApiUserResolver::class);
$this->capabilitiesService = $capabilitiesService ?? app(ApiCapabilitiesService::class);
$this->presenter = $presenter ?? app(ApiV1Presenter::class);
$this->nzbUploadStagingService = $nzbUploadStagingService ?? app(NzbUploadStagingService::class);
}
/**
@@ -695,76 +700,60 @@ class ApiController extends BasePageController
}
break;
//
// nzb / nfo add request
// 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"
// Paired NZB / NFO add request. The legacy single `file` field remains supported.
//
case 'nzbAdd':
if (! User::canPost($uid)) {
return showApiError(102, 'Insufficient privileges/not authorized');
}
if ($request->missing('file')) {
return showApiError(200, 'Missing parameter (file is required for adding an NZB or NFO)');
}
if ($request->missing('apikey')) {
return showApiError(200, 'Missing parameter (apikey is required for adding an NZB or NFO)');
}
$usesLegacyField = $request->has('file') || $request->hasFile('file');
$usesModernFields = $request->hasAny(['nzb', 'nfo'])
|| $request->hasFile('nzb')
|| $request->hasFile('nfo');
if (! $request->hasFile('file')) {
return showApiError(600, 'Failed to load upload (no file)');
if ($usesLegacyField && $usesModernFields) {
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);
$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.
if ($nzbFile !== null) {
$ext = strtolower((string) $nzbFile->getClientOriginalExtension());
if (! in_array($ext, ['nzb', 'nfo'], true)) {
return showApiError(600, 'Failed to load NZB (file is not an NZB or NFO file)');
$nfo = $request->file('nfo');
if ($nfo !== null && ! $nfo instanceof UploadedFile) {
return showApiError(600, 'Invalid nfo upload');
}
$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'];
}
$content = $nzbFile->getContent();
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');
} catch (NzbUploadException $exception) {
return showApiError(600, $exception->getMessage());
}
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.
case 'c':
+136
View File
@@ -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()];
}
}
}
+46 -4
View File
@@ -59,6 +59,8 @@ class NzbImportService
*/
protected string $relGuid;
protected ?int $relId = null;
public mixed $echoCLI;
public NzbService $nzb;
@@ -84,10 +86,18 @@ class NzbImportService
/**
* Begin importing NZB files.
*
* @param null|callable(array{path:string,status:NzbImportStatus,release_id:int|null,release_guid:string|null}):void $resultCallback
*
* @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.
if (! $this->getAllGroups()) {
if ($this->browser) {
@@ -104,7 +114,8 @@ class NzbImportService
$nzbFiles = [];
foreach ($filesToProcess as $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;
}
}
@@ -123,12 +134,33 @@ class NzbImportService
$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.
foreach ($nzbFiles as $nzbFilePath) {
// Check if the file is really there.
if (File::isFile($nzbFilePath)) {
$this->relGuid = '';
$this->relId = null;
// 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);
} else {
$nzbString = File::get($nzbFilePath);
@@ -136,6 +168,7 @@ class NzbImportService
if ($nzbString === false) {
$this->echoOut('ERROR: Unable to read: '.$nzbFilePath);
$reportResult($nzbFilePath, NzbImportStatus::Failed);
if ($deleteFailed) {
File::delete($nzbFilePath);
@@ -149,6 +182,7 @@ class NzbImportService
$nzbXML = @simplexml_load_string($nzbString);
if ($nzbXML === false || strtolower($nzbXML->getName()) !== 'nzb') {
$this->echoOut('ERROR: Unable to load NZB XML data: '.$nzbFilePath);
$reportResult($nzbFilePath, NzbImportStatus::Failed);
if ($deleteFailed) {
File::delete($nzbFilePath);
@@ -181,12 +215,15 @@ class NzbImportService
// Remove the release.
Release::query()->where('guid', $this->relGuid)->delete();
$reportResult($nzbFilePath, NzbImportStatus::Failed);
if ($deleteFailed) {
File::delete($nzbFilePath);
}
$nzbsSkipped++;
} else {
$reportResult($nzbFilePath, NzbImportStatus::Inserted, $this->relId, $this->relGuid);
if ($delete) {
// Remove the nzb file.
File::delete($nzbFilePath);
@@ -195,6 +232,8 @@ class NzbImportService
$nzbsImported++;
}
} else {
$reportResult($nzbFilePath, $importStatus);
if ($importStatus === NzbImportStatus::Duplicate) {
$nzbsDuplicate++;
@@ -218,6 +257,7 @@ class NzbImportService
}
} else {
$this->echoOut('ERROR: Unable to fetch: '.$nzbFilePath);
$reportResult($nzbFilePath, NzbImportStatus::Failed);
$nzbsSkipped++;
}
}
@@ -482,6 +522,8 @@ class NzbImportService
return NzbImportStatus::Failed;
}
$this->relId = (int) $relID;
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");
}
}
}
+85 -34
View File
@@ -8,12 +8,20 @@ use App\Exceptions\NzbUploadException;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
final class NzbUploadStagingService
{
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}}
@@ -22,20 +30,12 @@ final class NzbUploadStagingService
{
[$nzbName, $nzbBase, $nzbContent] = $this->validateFile($nzb, 'nzb');
$nfoDetails = $nfo === null ? null : $this->validateFile($nfo, 'nfo');
$folder = $this->ensureUploadFolder();
if ($nfoDetails !== null && strcasecmp($nzbBase, $nfoDetails[1]) !== 0) {
throw new NzbUploadException('NZB and NFO filenames must have matching basenames', 400);
}
$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);
$uploadId = Str::uuid()->toString();
$uploadDirectory = $folder.DIRECTORY_SEPARATOR.$uploadId;
if (! $this->filesystem->makeDirectory($uploadDirectory, 0775, true)) {
throw new NzbUploadException('Failed to create upload staging directory', 500);
}
$files = [[$nzbName, $nzbContent, 'nzb']];
@@ -43,32 +43,26 @@ final class NzbUploadStagingService
$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 {
foreach ($files as [$filename, $content, $type]) {
$path = $folder.DIRECTORY_SEPARATOR.$filename;
$path = $uploadDirectory.DIRECTORY_SEPARATOR.$filename;
if ($this->filesystem->put($path, $content) === false) {
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) {
Log::channel('nzb_upload')->error('Failed to roll back partial API v2 upload', [
'files' => array_map('basename', $created),
$this->manifests->create(
$uploadDirectory,
$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);
@@ -82,7 +76,8 @@ final class NzbUploadStagingService
}
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,
'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} */
private function validateFile(UploadedFile $file, string $expectedExtension): array
{
@@ -135,4 +170,20 @@ final class NzbUploadStagingService
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;
}
}