<?php
namespace App\EventSubscriber\Api;
use App\Controller\Api\AbstractApiController;
use App\Helper\Api\Constants\Entity\Properties\CoreModule\File as PropertyNamesFile;
use App\Helper\Api\Constants\FrontEnd\AbstractFrontEndParameterNames;
use App\Helper\Api\JsonFormatter\Throwable\Formatter;
use App\Helper\Api\Translator\ApiTranslator;
use Exception;
use Symfony\Component\Form\Util\ServerParams;
use Symfony\Component\HttpFoundation\Exception\JsonException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Subscriber for kernel controller event to decode JSON POST data to array values.
*
* @package API
* @internal
*/
class JsonDecodeSubscriber extends AbstractSubscriber
{
/**
* ServerParams instance to test for exceeding the post_max_size from PHP ini.
*
* @var ServerParams
*/
private ServerParams $serverParams;
/**
* Pre-parsed uploaded files from multipart form data.
*
* @var array
*/
public array $files = [];
/**
* Pre-parsed inputs from multipart form data.
*
* @var array
*/
public array $inputs = [];
/**
* Returns an array of event names this subscriber wants to listen to.
*
* The array keys are event names and the value can be:
*
* * The method name to call (priority defaults to 0)
* * An array composed of the method name to call and the priority
* * An array of arrays composed of the method names to call and respective
* priorities, or 0 if unset
*
* For instance:
*
* * ['eventName' => 'methodName']
* * ['eventName' => ['methodName', $priority]]
* * ['eventName' => [['methodName1', $priority], ['methodName2']]]
*
* The code must not depend on runtime state as it will only be called at compile time.
* All logic depending on runtime state must be put into the individual methods handling the events.
*
* @noinspection PhpArrayShapeAttributeCanBeAddedInspection
* @noinspection PhpUnused
*/
public static function getSubscribedEvents(): array
{
return [
KernelEvents::CONTROLLER => [
['jsonDecode', 8]
]
];
}
/**
* Constructor.
*
* @param Formatter $formatter
* @param ApiTranslator $translator
* @param RequestStack $requestStack
*/
public function __construct(Formatter $formatter, ApiTranslator $translator, RequestStack $requestStack)
{
parent::__construct($formatter, $translator);
// create ServerParams instance because it is no Symfony service class
$this->serverParams = new ServerParams($requestStack);
}
/**
* Returns an array with file information based on the given uploaded file.
*
* @param UploadedFile $file
* @return array
*/
protected function parseFile(UploadedFile $file): array
{
return [
PropertyNamesFile::NAME => $file->getClientOriginalName(),
PropertyNamesFile::MIME_TYPE => $file->getClientMimeType(),
PropertyNamesFile::EXTENSION => $file->getClientOriginalExtension(),
PropertyNamesFile::SIZE => $file->getSize(),
PropertyNamesFile::OBJECT => $file
];
}
/**
* Decode JSON data to associative array and replace the request body with it.
*
* @param Request $request Request containing the data.
* @return void
*/
protected function parseJson(Request $request): void
{
try {
$jsonData = json_decode(
$request->getContent(),
true,
512,
JSON_THROW_ON_ERROR
);
} catch (Exception) {
throw new JsonException('Invalid JSON.');
}
$request->request->replace(is_array($jsonData) ? $jsonData : array());
}
/**
* For requests using multipart/form-data for sending JSON data alongside files to upload, decode JSON data to
* associative array, replace the request body with it and append the uploaded files to the body.
*
* @param Request $request Request containing the data.
* @return void
*/
protected function parseMultipartFormData(Request $request): void
{
try {
$jsonData = json_decode(
$request->request->get(AbstractFrontEndParameterNames::JSON_DATA),
true,
512,
JSON_THROW_ON_ERROR
);
} catch (Exception) {
throw new JsonException('Invalid JSON.');
}
$request->request->remove(AbstractFrontEndParameterNames::JSON_DATA);
$request->request->add($jsonData);
foreach ($request->files as $parameterName => $fileData) {
if (is_array($fileData)) {
$files = [];
foreach ($fileData as $file) {
/* @var UploadedFile $file */
$files[] = $this->parseFile($file);
}
$request->request->add([$parameterName => $files]);
} else {
/* @var UploadedFile $fileData */
$request->request->add([
$parameterName => $this->parseFile($fileData)
]);
}
}
}
/**
* Parses the content of the part from the request content separated by the boundary string.
*
* @param array $parsedPartHeaders The parsed headers from the part of the request content.
* @param string $rawPartContent The raw content from the part of the request content.
* @return void
*/
protected function parseRawPartContent(array $parsedPartHeaders, string $rawPartContent): void
{
// remove final CRLF
$rawPartContent = substr($rawPartContent, 0, strlen($rawPartContent) - 2);
// get field name and optional the original file name on the client side from the content disposition header
preg_match(
'/^form-data; name="([^"]+)"(; filename="([^"]+)")?/',
$parsedPartHeaders['content-disposition'],
$matches
);
$fieldName = trim(trim($matches[1], ']'), '[');
$fileName = $matches[3] ?? null;
// handle data and files separately
if (is_null($fileName)) {
// append data to the local input list, leave the content raw, because we parse JSON later
$this->inputs = array_merge($this->inputs, [$fieldName => $rawPartContent]);
} else {
// store temporary file and append to local files list
$file = $this->storeTmpFile($fileName, $parsedPartHeaders['content-type'], $rawPartContent);
if (isset($this->files[$fieldName])) {
$this->files[$fieldName][] = $file;
} else {
$this->files[$fieldName] = [$file];
}
}
}
/**
* Parses the headers of the part from the request content separated by the boundary string.
*
* @param string $rawPartHeaders The raw headers from the part of the request content.
* @return array The parsed headers from the part of the request content.
*/
protected function parseRawPartHeaders(string $rawPartHeaders): array
{
$parsedPartHeaders = [];
// headers are separated by CRLF
$partHeaders = explode("\r\n", $rawPartHeaders);
foreach ($partHeaders as $header) {
// header name and value are separated by a colon followed by a blank
[$name, $value] = explode(': ', $header, 2);
// normalize case of header name
$parsedPartHeaders[strtolower($name)] = $value;
}
return $parsedPartHeaders;
}
/**
* Processes a part of the request content separated by the boundary string.
*
* @param string $contentPart A part of the request content.
* @return void
*/
protected function processContentParts(string $contentPart): void
{
// remove initial CRLF
$contentPart = ltrim($contentPart, "\r\n");
// separate part headers from part content by (first) double CRLF
[$rawPartHeaders, $rawPartContent] = explode("\r\n\r\n", $contentPart, 2);
$parsedPartHeaders = $this->parseRawPartHeaders($rawPartHeaders);
if (isset($parsedPartHeaders['content-disposition'])) {
$this->parseRawPartContent($parsedPartHeaders, $rawPartContent);
} else {
// we only accept content parts with a content disposition header in this workaround
throw new HttpException(Response::HTTP_BAD_REQUEST, 'Content disposition header not present');
}
}
/**
* Parse the multipart form data and populate the local input and file properties.
*
* @param string $content The request content.
* @return void
*/
protected function requestParseBody(string $content): void
{
// search initial CRLF to identify the boundary string
$firstCrlfPosition = strpos($content, "\r\n");
// if found, identify string before as boundary string
$boundary = $firstCrlfPosition ? substr($content, 0, $firstCrlfPosition) : null;
if (is_null($boundary)) {
// we only accept multipart form data with boundary strings in this workaround
throw new HttpException(Response::HTTP_BAD_REQUEST, 'Multipart boundary not identified');
}
// split content at the boundaries
$parts = explode($boundary, $content);
// remove empty parts or closing string --CRLF
$parts = array_filter($parts, function (string $part): bool {
return mb_strlen($part) > 0 && $part !== "--\r\n";
});
foreach ($parts as $part) {
$this->processContentParts($part);
}
}
/**
* Stores given file data as temporary file and returns an UploadedFile instance.
*
* @param string $originalFileName The original file name on the client side.
* @param string $mimeType The MIME type of the file.
* @param string $fileData The file data as string.
* @return UploadedFile UploadedFile instance containing the temporary file reference.
*/
protected function storeTmpFile(string $originalFileName, string $mimeType, string $fileData): UploadedFile
{
// create temporary file name in the system tmp directory
$tmpFilePath = tempnam(sys_get_temp_dir(), '');
// write file data to temporary file
file_put_contents($tmpFilePath, $fileData);
// remove temporary file after processing
register_shutdown_function(function () use ($tmpFilePath): void {
if (file_exists($tmpFilePath)) {
unlink($tmpFilePath);
}
});
return new UploadedFile(
$tmpFilePath,
$originalFileName,
$mimeType,
UPLOAD_ERR_OK,
true
/**
* Set this to true, otherwise PHP considers file as not uploaded by HTTP
*
* @see UploadedFile::isValid()
* @see is_uploaded_file()
*/
);
}
/**
* Decodes JSON to array in request content.
*
* Allows to query for parameters with {@link Request::get()}.
*
* @throws JsonException when invalid JSON was sent
* @noinspection PhpUnused
*/
public function jsonDecode(ControllerEvent $event): void
{
$controller = $event->getController();
// when a controller class defines multiple action methods, the controller
// is returned as [$controllerInstance, 'methodName']
if (is_array($controller)) {
$controller = $controller[0];
}
// limit to API controllers
if (!$controller instanceof AbstractApiController) {
return;
}
// Exceeding the post_max_size from PHP ini results probably just in a warning and lead to empty
// content arrays without further notice. Use built in Symfony detection method to ensure raising a proper
// error message to the client.
// See https://github.com/symfony/symfony/issues/19311
if ($this->serverParams->hasPostMaxSizeBeenExceeded()) {
throw new HttpException(
Response::HTTP_REQUEST_ENTITY_TOO_LARGE,
'PHP post max size has been exceeded',
);
}
$request = $event->getRequest();
// do nothing on unhandled contents
if (
($request->getContentType() !== 'json' || !$request->getContent()) &&
$request->getContentType() !== 'form'
) {
return;
}
if ($request->getContentType() === 'json') {
$this->parseJson($request);
return;
}
if ($request->getContentType() === 'form') {
/**
* TODO
* PHP pre 8.4 ignores files and input data from HTTP PUT requests when using content type
* multipart/form-data and doesn't populate the respective super globals. Since we decided
* a) to use this content type for update requests including uploaded files and
* b) to use HTTP PUT to update a specific ressource to provide a REST API
* we need some workaround until we upgrade to PHP 8.4.
*
* Possible workarounds:
* - use POST in these cases instead of PUT, but this will break the pattern to use PUT on updates in
* general (discarded, front end would need to change)
* - use the PECL extension apfd, but there could be issues building the extension on some local dev systems
* - parse the multipart form data for our use case, but this is not completely reliable and efficient
*
* @link https://bugs.php.net/bug.php?id=55815
* @link https://wiki.php.net/rfc/rfc1867-non-post
* @link https://pecl.php.net/package/apfd
*
* So we parse the multipart form data for ourselves as temporary workaround if we're still using
* PHP < 8.4 and the apdf extension is not installed in case of an HTTP PUT request.
*/
if ($request->getMethod() === Request::METHOD_PUT && !in_array('apfd', get_loaded_extensions())) {
// using the content as a string could be problematic for big files, but acceptable for now
// until we move to PHP 8.4 and apdf is not available, big files are also prevented by PHP ini
// setting post_max_size
$this->requestParseBody($request->getContent());
// copy the parsed data to the request bags
$request->request->replace($this->inputs);
$request->files->replace($this->files);
}
$this->parseMultipartFormData($request);
}
}
}