diff --git a/app/Services/Api/V1/ApiV1Presenter.php b/app/Services/Api/V1/ApiV1Presenter.php index d91352dcb..85e2299bc 100644 --- a/app/Services/Api/V1/ApiV1Presenter.php +++ b/app/Services/Api/V1/ApiV1Presenter.php @@ -10,6 +10,9 @@ use Illuminate\Http\Response; final readonly class ApiV1Presenter { + // JSON_HEX_TAG would turn category separators such as ">" into "\u003E". + private const JSON_ENCODING_OPTIONS = JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; + public function __construct(private ApiCapabilitiesService $capabilities) {} /** @@ -40,7 +43,7 @@ final readonly class ApiV1Presenter if ($array === false) { return showApiError(201); } - $body = json_encode($array, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + $body = json_encode($array, self::JSON_ENCODING_OPTIONS); $contentType = 'application/json'; } diff --git a/app/Services/Api/V2/ApiV2Presenter.php b/app/Services/Api/V2/ApiV2Presenter.php index 2d4bf3a9f..dcc68f089 100644 --- a/app/Services/Api/V2/ApiV2Presenter.php +++ b/app/Services/Api/V2/ApiV2Presenter.php @@ -13,6 +13,7 @@ use Illuminate\Support\Carbon; final class ApiV2Presenter { + // JSON_HEX_TAG would turn category separators such as ">" into "\u003E". private const JSON_ENCODING_OPTIONS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; /** @param array $data */ diff --git a/tests/Feature/ApiRequestMatrixTest.php b/tests/Feature/ApiRequestMatrixTest.php index bf6db6973..951f38202 100644 --- a/tests/Feature/ApiRequestMatrixTest.php +++ b/tests/Feature/ApiRequestMatrixTest.php @@ -189,7 +189,7 @@ class ApiRequestMatrixTest extends TestCase $releaseSearchService = Mockery::mock(ReleaseSearchService::class); $releaseSearchService->shouldReceive('apiSearch') ->once() - ->with('ubuntu', -1, 0, 100, -1, [5030], [-1], 0, 'posted_desc') + ->with('ubuntu', -1, 0, 100, -1, [5030], [-1], 0, 'posted_desc', null) ->andReturn(collect()); $releaseBrowseService = Mockery::mock(ReleaseBrowseService::class); @@ -217,7 +217,7 @@ class ApiRequestMatrixTest extends TestCase $releaseSearchService = Mockery::mock(ReleaseSearchService::class); $releaseSearchService->shouldReceive('apiSearch') ->once() - ->with('ubuntu', -1, 0, 100, -1, [5030], [-1], 0, 'posted_desc') + ->with('ubuntu', -1, 0, 100, -1, [5030], [-1], 0, 'posted_desc', null) ->andReturn(collect([ (object) [ '_totalrows' => 1, @@ -250,6 +250,51 @@ class ApiRequestMatrixTest extends TestCase $this->assertStringNotContainsString('\u003E', $content); } + public function test_v1_search_keeps_category_separator_unescaped_in_json_body(): void + { + $token = (string) DB::table('users')->value('api_token'); + $request = Request::create('/api/v1/api', 'GET', [ + 't' => 'search', + 'o' => 'json', + 'apikey' => $token, + 'q' => 'ubuntu', + ]); + + $releaseSearchService = Mockery::mock(ReleaseSearchService::class); + $releaseSearchService->shouldReceive('apiSearch') + ->once() + ->with('ubuntu', -1, 0, 100, -1, [5030], [-1], 0, 'posted_desc') + ->andReturn(collect([ + (object) [ + '_totalrows' => 1, + 'searchname' => 'Ubuntu.Movie.Release', + 'guid' => 'movie-release-guid', + 'categories_id' => 2040, + 'category_name' => 'Movies > WEBDL', + 'adddate' => '2026-01-03 00:00:00', + 'size' => 123456, + 'totalpart' => 10, + 'grabs' => 2, + 'comments' => 1, + 'passwordstatus' => 0, + 'postdate' => '2026-01-02 00:00:00', + ], + ])); + + $releaseBrowseService = Mockery::mock(ReleaseBrowseService::class); + $releaseBrowseService->shouldNotReceive('getBrowseRangeForApi'); + + $controller = new ApiController($releaseSearchService, $releaseBrowseService); + $response = $controller->api($request); + + $this->assertNotNull($response); + $content = $response->getContent(); + + $this->assertIsString($content); + $this->assertStringContainsString('"category":"Movies > WEBDL"', $content); + $this->assertStringNotContainsString('\u003E', $content); + } + public function test_v1_search_reuses_cached_release_rows(): void { $token = (string) DB::table('users')->value('api_token');