mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 00:01:21 +00:00
Update APIv2 endpoints
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user