Update APIv2 endpoints

This commit is contained in:
DariusIII
2026-07-14 16:49:15 +02:00
parent efd52fb1d8
commit 567a614a02
8 changed files with 666 additions and 11 deletions
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use RuntimeException;
final class NzbUploadException extends RuntimeException
{
public function __construct(string $message, public readonly int $status)
{
parent::__construct($message);
}
}
+51 -6
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Data\Api\ReleaseData;
use App\Exceptions\NzbUploadException;
use App\Facades\Search;
use App\Http\Controllers\BasePageController;
use App\Http\Controllers\GetNzbController;
@@ -17,6 +18,7 @@ use App\Services\Api\ApiReleaseRowCache;
use App\Services\Api\ApiUsageService;
use App\Services\Api\ApiUserResolver;
use App\Services\Api\V2\ApiV2Presenter;
use App\Services\Nzb\NzbUploadStagingService;
use App\Services\Releases\ReleaseBrowseService;
use App\Services\Releases\ReleaseSearchService;
use App\Services\Search\DTO\SearchCursor;
@@ -27,6 +29,7 @@ use Illuminate\Http\JsonResponse;
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;
@@ -52,6 +55,8 @@ class ApiV2Controller extends BasePageController
private SearchCursorCodec $cursorCodec;
private NzbUploadStagingService $nzbUploadStagingService;
/** @var array{enabled: bool, offset: int, limit: int, query_hash: string, cursor: SearchCursor|null} */
private array $paginationContext = ['enabled' => false, 'offset' => 0, 'limit' => 0, 'query_hash' => '', 'cursor' => null];
@@ -70,6 +75,7 @@ class ApiV2Controller extends BasePageController
?ApiV2Presenter $presenter = null,
?ApiCapabilitiesService $capabilitiesService = null,
?SearchCursorCodec $cursorCodec = null,
?NzbUploadStagingService $nzbUploadStagingService = null,
) {
$this->releaseSearchService = $releaseSearchService;
$this->releaseBrowseService = $releaseBrowseService;
@@ -80,13 +86,14 @@ class ApiV2Controller extends BasePageController
$this->presenter = $presenter ?? app(ApiV2Presenter::class);
$this->capabilitiesService = $capabilitiesService ?? app(ApiCapabilitiesService::class);
$this->cursorCodec = $cursorCodec ?? app(SearchCursorCodec::class);
$this->nzbUploadStagingService = $nzbUploadStagingService ?? app(NzbUploadStagingService::class);
}
/**
* Validate API token and return cached user, or a normalized JSON API error on failure.
* Caches user lookup for 5 minutes to reduce DB hits.
*/
private function resolveUser(Request $request): User|JsonResponse
private function resolveUser(Request $request, bool $enforceRequestLimit = true): User|JsonResponse
{
if ($request->missing('api_token') || $request->isNotFilled('api_token')) {
return apiJsonError(200, 'Missing parameter (api_token)');
@@ -106,11 +113,13 @@ class ApiV2Controller extends BasePageController
$user->loadMissing('role');
$userStats = $this->userStatsFor($user);
$thisRequests = (int) ($userStats->api_count ?? 0);
$maxRequests = (int) $user->role->apirequests;
if ($thisRequests > $maxRequests) {
return apiJsonError(500, 'Request limit reached');
if ($enforceRequestLimit) {
$userStats = $this->userStatsFor($user);
$thisRequests = (int) ($userStats->api_count ?? 0);
$maxRequests = (int) $user->role->apirequests;
if ($thisRequests > $maxRequests) {
return apiJsonError(500, 'Request limit reached');
}
}
return $user;
@@ -744,6 +753,42 @@ class ApiV2Controller extends BasePageController
return $this->jsonResponse(['data' => 'No such item (the guid you provided has no release in our database)'], 404);
}
public function nzbAdd(Request $request): JsonResponse
{
$user = $this->resolveUser($request, false);
if ($user instanceof JsonResponse) {
return $user;
}
if (! $user->can_post) {
return $this->jsonResponse(['error' => 'Insufficient privileges/not authorized'], 403);
}
$nzb = $request->file('nzb');
if (! $nzb instanceof UploadedFile) {
return $this->jsonResponse(['error' => 'Missing parameter (nzb file is required)'], 400);
}
$nfo = $request->file('nfo');
if ($nfo !== null && ! $nfo instanceof UploadedFile) {
return $this->jsonResponse(['error' => 'Invalid nfo upload'], 400);
}
try {
$staged = $this->nzbUploadStagingService->stage($nzb, $nfo);
} catch (NzbUploadException $exception) {
return $this->jsonResponse(['error' => $exception->getMessage()], $exception->status);
}
return $this->jsonResponse([
'success' => true,
'status' => 'staged',
'name' => $staged['name'],
'category' => $request->input('cat'),
'files' => $staged['files'],
], 201);
}
public function details(Request $request): JsonResponse
{
$user = $this->resolveUser($request);
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Services\Nzb;
use App\Exceptions\NzbUploadException;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
final class NzbUploadStagingService
{
private const MAX_NFO_SIZE = 65535;
public function __construct(private readonly Filesystem $filesystem) {}
/**
* @return array{name:string,files:array{nzb:array{filename:string,type:string},nfo:array{filename:string,type:string}|null}}
*/
public function stage(UploadedFile $nzb, ?UploadedFile $nfo = null): array
{
[$nzbName, $nzbBase, $nzbContent] = $this->validateFile($nzb, 'nzb');
$nfoDetails = $nfo === null ? null : $this->validateFile($nfo, 'nfo');
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);
}
$files = [[$nzbName, $nzbContent, 'nzb']];
if ($nfoDetails !== null) {
$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;
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),
]);
throw new NzbUploadException('Failed to write upload pair and roll back partial files', 500);
}
if ($exception instanceof NzbUploadException) {
throw $exception;
}
throw new NzbUploadException('Failed to write upload pair to disk', 500);
}
foreach ($files as [$filename, , $type]) {
Log::channel('nzb_upload')->info('File uploaded by API v2', [
'filename' => $filename,
'type' => $type,
]);
}
return [
'name' => $nzbBase,
'files' => [
'nzb' => ['filename' => $nzbName, 'type' => 'nzb'],
'nfo' => $nfoDetails === null ? null : ['filename' => $nfoDetails[0], 'type' => 'nfo'],
],
];
}
/** @return array{string,string,string} */
private function validateFile(UploadedFile $file, string $expectedExtension): array
{
if (! $file->isValid()) {
throw new NzbUploadException("Invalid {$expectedExtension} upload", 400);
}
$filename = $file->getClientOriginalName();
if ($filename === ''
|| $filename === '.'
|| $filename === '..'
|| str_contains($filename, '/')
|| str_contains($filename, '\\')
|| str_contains($filename, "\0")) {
throw new NzbUploadException("Unsafe {$expectedExtension} filename", 400);
}
$extension = strtolower((string) pathinfo($filename, PATHINFO_EXTENSION));
$basename = (string) pathinfo($filename, PATHINFO_FILENAME);
if ($extension !== $expectedExtension || trim($basename) === '') {
throw new NzbUploadException("The {$expectedExtension} field must contain a .{$expectedExtension} file", 400);
}
try {
$content = $file->getContent();
} catch (\Throwable) {
throw new NzbUploadException("Failed to read {$expectedExtension} upload", 400);
}
if ($expectedExtension === 'nzb' && ! isValidNewznabNzb($content)) {
throw new NzbUploadException('Invalid NZB payload', 400);
}
if ($expectedExtension === 'nfo' && ($content === '' || strlen($content) > self::MAX_NFO_SIZE)) {
throw new NzbUploadException('NFO upload must not be empty or exceed 65535 bytes', 400);
}
return [$filename, $basename, $content];
}
}