Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace MiniShop3\Processors\Api;

use MiniShop3\Router\Response;
use MiniShop3\Router\Router as ApiRouter;

/**
Expand Down Expand Up @@ -65,14 +66,9 @@ public function process()

http_response_code($statusCode);

if ($statusCode >= 200 && $statusCode < 300) {
return $this->success('', $responseData['data'] ?? $responseData);
}

return $this->failure(
$responseData['message'] ?? 'API request failed',
$responseData
);
// Sanitize before Processor success()/failure() → modConnectorResponse → xPDO::toJSON()
// which has no UTF-8 fallback (#671 / #654).
return $this->respondFromRouter($responseData, $statusCode);
} catch (\Throwable $e) {
// TypeError/Error must stay JSON — display_errors HTML breaks Vue request.json() (#531/#532).
$this->modx->log(\MODX\Revolution\modX::LOG_LEVEL_ERROR, '[MiniShop3 API] ' . $e->getMessage());
Expand All @@ -86,4 +82,34 @@ public function process()
);
}
}

/**
* Map router Response payload to MODX Processor success()/failure() after UTF-8 sanitize (#671).
*
* @param mixed $responseData Router Response::getData()
* @internal Exposed for unit tests of the manager connector reshape path.
*/
protected function respondFromRouter(mixed $responseData, int $statusCode): mixed
{
$payload = is_array($responseData) ? $responseData : ['code' => $statusCode];
$clean = Response::sanitizeUtf8ForJson($payload, $this->modx);
if (!is_array($clean)) {
return $this->connectorJsonEncodeFailure();
}

if ($statusCode >= 200 && $statusCode < 300) {
return $this->success('', $clean['data'] ?? $clean);
}

$message = (string) ($clean['message'] ?? 'API request failed');

return $this->failure($message !== '' ? $message : 'API request failed', $clean);
}

private function connectorJsonEncodeFailure(): mixed
{
http_response_code(500);

return $this->failure('Internal server error', ['code' => 500]);
}
}
72 changes: 60 additions & 12 deletions core/components/minishop3/src/Router/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ class Response
/** @var string|null HTTP redirect target (Location) */
protected ?string $redirectUrl = null;

private const JSON_ENCODE_FLAGS = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;

public function __construct($data, int $statusCode = HttpStatus::OK, array $headers = [])
{
$this->data = $data;
Expand Down Expand Up @@ -304,20 +306,11 @@ public function send(): void
*/
public function encodeJsonBody(?object $modx = null): string
{
$flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
$json = json_encode($this->data, $flags);
if ($json !== false) {
return $json;
}

self::logJsonEncodeFailure($modx, json_last_error_msg(), $this->data);

$json = json_encode($this->data, $flags | JSON_INVALID_UTF8_SUBSTITUTE);
if ($json !== false) {
$json = self::encodeJsonUtf8Safe($this->data, $modx);
if ($json !== null) {
return $json;
}

self::logJsonEncodeFailure($modx, json_last_error_msg(), $this->data, true);
$this->statusCode = HttpStatus::INTERNAL_SERVER_ERROR;
$this->data = [
'success' => false,
Expand All @@ -327,10 +320,65 @@ public function encodeJsonBody(?object $modx = null): string
'error_code' => ApiErrorCode::INTERNAL_ERROR,
];

$fallback = json_encode($this->data, $flags);
$fallback = json_encode($this->data, self::JSON_ENCODE_FLAGS);
return $fallback !== false ? $fallback : '{"success":false,"message":"Internal server error","code":500,"errors":null,"error_code":"internal_error"}';
}

/**
* Return a JSON-encodable copy of $data for MODX connector toJSON() (#671).
*
* Same UTF-8 handling as {@see encodeJsonBody()}: log invalid paths (hex snippets only),
* then substitute invalid sequences. Returns $data unchanged when already encodable.
* When encoding still fails (e.g. NAN), returns null so the caller can fail closed.
*/
public static function sanitizeUtf8ForJson(mixed $data, ?object $modx = null): mixed
{
if (json_encode($data, self::JSON_ENCODE_FLAGS) !== false) {
return $data;
}

$json = self::encodeJsonSubstitutingUtf8($data, $modx);
if ($json === null) {
return null;
}

$decoded = json_decode($json, true);

return json_last_error() === JSON_ERROR_NONE ? $decoded : null;
}

/**
* json_encode with UTF-8 substitute fallback and MODX logging (#654 / #671).
*
* @return string|null JSON string, or null when unencodable even after substitute.
*/
private static function encodeJsonUtf8Safe(mixed $data, ?object $modx): ?string
{
$json = json_encode($data, self::JSON_ENCODE_FLAGS);
if ($json !== false) {
return $json;
}

return self::encodeJsonSubstitutingUtf8($data, $modx);
}

/**
* Log + re-encode with JSON_INVALID_UTF8_SUBSTITUTE after a failed first encode.
*/
private static function encodeJsonSubstitutingUtf8(mixed $data, ?object $modx): ?string
{
self::logJsonEncodeFailure($modx, json_last_error_msg(), $data);

$json = json_encode($data, self::JSON_ENCODE_FLAGS | JSON_INVALID_UTF8_SUBSTITUTE);
if ($json === false) {
self::logJsonEncodeFailure($modx, json_last_error_msg(), $data, true);

return null;
}

return $json;
}

/**
* Encode an arbitrary payload with the same UTF-8 safety as {@see encodeJsonBody()} (#654).
*
Expand Down
3 changes: 3 additions & 0 deletions core/components/minishop3/tests/RouterRoutePlansTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@
if (!str_contains($traitSrc, 'catch (\\Throwable')) {
$fail('trait must catch \\Throwable so TypeError stays JSON (#531/#532)');
}
if (!str_contains($traitSrc, 'respondFromRouter') || !str_contains($traitSrc, 'sanitizeUtf8ForJson')) {
$fail('trait must sanitize UTF-8 via respondFromRouter before success()/failure() (#671)');
}
if (str_contains($traitSrc, 'loadWebRoutes') || str_contains($traitSrc, 'ManagerConnectorRouteLoader')) {
$fail('trait must not load web routes or use deleted ManagerConnectorRouteLoader');
}
Expand Down
113 changes: 113 additions & 0 deletions core/components/minishop3/tests/Unit/Router/ResponseJsonEncodeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace MiniShop3\Tests\Unit\Router;

use MiniShop3\Processors\Api\ProcessesManagerConnectorRouteTrait;
use MiniShop3\Router\ApiErrorCode;
use MiniShop3\Router\HttpStatus;
use MiniShop3\Router\Response;
Expand Down Expand Up @@ -55,6 +56,73 @@ public function testStaticEncodeJsonMatchesInstanceHelper(): void
);
}

public function testSanitizeUtf8ForJsonSubstitutesInvalidBytes(): void
{
$modx = $this->makeLogger();
$clean = Response::sanitizeUtf8ForJson(['label' => "ab\xC3\x28cd"], $modx);

self::assertIsArray($clean);
self::assertIsString($clean['label']);
self::assertStringContainsString("\u{FFFD}", $clean['label']);
self::assertNotFalse(json_encode($clean));
self::assertNotEmpty($modx->logs);
self::assertStringContainsString('label', $modx->logs[0]);
self::assertStringContainsString('hex=', $modx->logs[0]);
}

public function testSanitizeUtf8ForJsonLeavesValidDataUnchanged(): void
{
$payload = ['title' => 'Товар', 'n' => 1];
self::assertSame($payload, Response::sanitizeUtf8ForJson($payload));
}

public function testSanitizeUtf8ForJsonReturnsNullWhenUnencodable(): void
{
$modx = $this->makeLogger();
self::assertNull(Response::sanitizeUtf8ForJson(['value' => NAN], $modx));
self::assertNotEmpty($modx->logs);
}

/**
* Manager connector path: trait respondFromRouter → Processor success → xPDO-style json_encode (#671).
*/
public function testManagerConnectorRespondFromRouterSurvivesBareJsonEncode(): void
{
$modx = $this->makeLogger();
$harness = $this->makeConnectorHarness($modx);
$apiData = [
'success' => true,
'message' => null,
'data' => ['fields' => [['label' => "bad\xC3\x28"]]],
];

$result = $harness->exposeRespond($apiData, 200);

self::assertTrue($result['success']);
self::assertArrayHasKey('object', $result);
// Mirrors xPDO::toJSON / modConnectorResponse (no flags, no false check).
$encoded = json_encode($result);
self::assertNotFalse($encoded);
self::assertNotSame('', $encoded);
$decoded = json_decode($encoded, true);
self::assertStringContainsString("\u{FFFD}", $decoded['object']['fields'][0]['label']);
self::assertNotEmpty($modx->logs);
}

public function testManagerConnectorRespondFromRouterFailsClosedOnNan(): void
{
$modx = $this->makeLogger();
$harness = $this->makeConnectorHarness($modx);
$result = $harness->exposeRespond(['success' => false, 'message' => 'x', 'value' => NAN], 400);

self::assertFalse($result['success']);
self::assertSame('Internal server error', $result['message']);
self::assertSame(['code' => 500], $result['object']);
$encoded = json_encode($result);
self::assertNotFalse($encoded);
self::assertNotSame('', $encoded);
}

public function testNonUtf8EncodeFailureFallsBackTo500Envelope(): void
{
$modx = $this->makeLogger();
Expand Down Expand Up @@ -82,4 +150,49 @@ public function log($level, $message): void
}
};
}

/**
* Minimal Processor stand-in that exercises trait respondFromRouter (#671).
*/
private function makeConnectorHarness(object $modx): object
{
return new class ($modx) {
use ProcessesManagerConnectorRouteTrait;

public object $modx;

public function __construct(object $modx)
{
$this->modx = $modx;
}

public function exposeRespond(mixed $responseData, int $statusCode): mixed
{
return $this->respondFromRouter($responseData, $statusCode);
}

public function success($message = '', $object = null): array
{
return [
'success' => true,
'message' => $message,
'object' => $object,
];
}

public function failure($message = '', $object = null): array
{
return [
'success' => false,
'message' => $message,
'object' => $object,
];
}

public function getProperty($key, $default = null): mixed
{
return $default;
}
};
}
}