src/EventSubscriber/Api/JsonDecodeSubscriber.php line 320

Open in your IDE?
  1. <?php
  2. namespace App\EventSubscriber\Api;
  3. use App\Controller\Api\AbstractApiController;
  4. use App\Helper\Api\Constants\Entity\Properties\CoreModule\File as PropertyNamesFile;
  5. use App\Helper\Api\Constants\FrontEnd\AbstractFrontEndParameterNames;
  6. use App\Helper\Api\JsonFormatter\Throwable\Formatter;
  7. use App\Helper\Api\Translator\ApiTranslator;
  8. use Exception;
  9. use Symfony\Component\Form\Util\ServerParams;
  10. use Symfony\Component\HttpFoundation\Exception\JsonException;
  11. use Symfony\Component\HttpFoundation\File\UploadedFile;
  12. use Symfony\Component\HttpFoundation\Request;
  13. use Symfony\Component\HttpFoundation\RequestStack;
  14. use Symfony\Component\HttpFoundation\Response;
  15. use Symfony\Component\HttpKernel\Event\ControllerEvent;
  16. use Symfony\Component\HttpKernel\Exception\HttpException;
  17. use Symfony\Component\HttpKernel\KernelEvents;
  18. /**
  19.  * Subscriber for kernel controller event to decode JSON POST data to array values.
  20.  *
  21.  * @package API
  22.  * @internal
  23.  */
  24. class JsonDecodeSubscriber extends AbstractSubscriber
  25. {
  26.     /**
  27.      * ServerParams instance to test for exceeding the post_max_size from PHP ini.
  28.      *
  29.      * @var ServerParams
  30.      */
  31.     private ServerParams $serverParams;
  32.     /**
  33.      * Pre-parsed uploaded files from multipart form data.
  34.      *
  35.      * @var array
  36.      */
  37.     public array $files = [];
  38.     /**
  39.      * Pre-parsed inputs from multipart form data.
  40.      *
  41.      * @var array
  42.      */
  43.     public array $inputs = [];
  44.     /**
  45.      * Returns an array of event names this subscriber wants to listen to.
  46.      *
  47.      * The array keys are event names and the value can be:
  48.      *
  49.      *  * The method name to call (priority defaults to 0)
  50.      *  * An array composed of the method name to call and the priority
  51.      *  * An array of arrays composed of the method names to call and respective
  52.      *    priorities, or 0 if unset
  53.      *
  54.      * For instance:
  55.      *
  56.      *  * ['eventName' => 'methodName']
  57.      *  * ['eventName' => ['methodName', $priority]]
  58.      *  * ['eventName' => [['methodName1', $priority], ['methodName2']]]
  59.      *
  60.      * The code must not depend on runtime state as it will only be called at compile time.
  61.      * All logic depending on runtime state must be put into the individual methods handling the events.
  62.      *
  63.      * @noinspection PhpArrayShapeAttributeCanBeAddedInspection
  64.      * @noinspection PhpUnused
  65.      */
  66.     public static function getSubscribedEvents(): array
  67.     {
  68.         return [
  69.             KernelEvents::CONTROLLER => [
  70.                 ['jsonDecode'8]
  71.             ]
  72.         ];
  73.     }
  74.     /**
  75.      * Constructor.
  76.      *
  77.      * @param Formatter $formatter
  78.      * @param ApiTranslator $translator
  79.      * @param RequestStack $requestStack
  80.      */
  81.     public function __construct(Formatter $formatterApiTranslator $translatorRequestStack $requestStack)
  82.     {
  83.         parent::__construct($formatter$translator);
  84.         // create ServerParams instance because it is no Symfony service class
  85.         $this->serverParams = new ServerParams($requestStack);
  86.     }
  87.     /**
  88.      * Returns an array with file information based on the given uploaded file.
  89.      *
  90.      * @param UploadedFile $file
  91.      * @return array
  92.      */
  93.     protected function parseFile(UploadedFile $file): array
  94.     {
  95.         return [
  96.             PropertyNamesFile::NAME => $file->getClientOriginalName(),
  97.             PropertyNamesFile::MIME_TYPE => $file->getClientMimeType(),
  98.             PropertyNamesFile::EXTENSION => $file->getClientOriginalExtension(),
  99.             PropertyNamesFile::SIZE => $file->getSize(),
  100.             PropertyNamesFile::OBJECT => $file
  101.         ];
  102.     }
  103.     /**
  104.      * Decode JSON data to associative array and replace the request body with it.
  105.      *
  106.      * @param Request $request Request containing the data.
  107.      * @return void
  108.      */
  109.     protected function parseJson(Request $request): void
  110.     {
  111.         try {
  112.             $jsonData json_decode(
  113.                 $request->getContent(),
  114.                 true,
  115.                 512,
  116.                 JSON_THROW_ON_ERROR
  117.             );
  118.         } catch (Exception) {
  119.             throw new JsonException('Invalid JSON.');
  120.         }
  121.         $request->request->replace(is_array($jsonData) ? $jsonData : array());
  122.     }
  123.     /**
  124.      * For requests using multipart/form-data for sending JSON data alongside files to upload, decode JSON data to
  125.      * associative array, replace the request body with it and append the uploaded files to the body.
  126.      *
  127.      * @param Request $request Request containing the data.
  128.      * @return void
  129.      */
  130.     protected function parseMultipartFormData(Request $request): void
  131.     {
  132.         try {
  133.             $jsonData json_decode(
  134.                 $request->request->get(AbstractFrontEndParameterNames::JSON_DATA),
  135.                 true,
  136.                 512,
  137.                 JSON_THROW_ON_ERROR
  138.             );
  139.         } catch (Exception) {
  140.             throw new JsonException('Invalid JSON.');
  141.         }
  142.         $request->request->remove(AbstractFrontEndParameterNames::JSON_DATA);
  143.         $request->request->add($jsonData);
  144.         foreach ($request->files as $parameterName => $fileData) {
  145.             if (is_array($fileData)) {
  146.                 $files = [];
  147.                 foreach ($fileData as $file) {
  148.                     /* @var UploadedFile $file */
  149.                     $files[] = $this->parseFile($file);
  150.                 }
  151.                 $request->request->add([$parameterName => $files]);
  152.             } else {
  153.                 /* @var UploadedFile $fileData */
  154.                 $request->request->add([
  155.                     $parameterName => $this->parseFile($fileData)
  156.                 ]);
  157.             }
  158.         }
  159.     }
  160.     /**
  161.      * Parses the content of the part from the request content separated by the boundary string.
  162.      *
  163.      * @param array $parsedPartHeaders The parsed headers from the part of the request content.
  164.      * @param string $rawPartContent The raw content from the part of the request content.
  165.      * @return void
  166.      */
  167.     protected function parseRawPartContent(array $parsedPartHeadersstring $rawPartContent): void
  168.     {
  169.         // remove final CRLF
  170.         $rawPartContent substr($rawPartContent0strlen($rawPartContent) - 2);
  171.         // get field name and optional the original file name on the client side from the content disposition header
  172.         preg_match(
  173.             '/^form-data; name="([^"]+)"(; filename="([^"]+)")?/',
  174.             $parsedPartHeaders['content-disposition'],
  175.             $matches
  176.         );
  177.         $fieldName trim(trim($matches[1], ']'), '[');
  178.         $fileName $matches[3] ?? null;
  179.         // handle data and files separately
  180.         if (is_null($fileName)) {
  181.             // append data to the local input list, leave the content raw, because we parse JSON later
  182.             $this->inputs array_merge($this->inputs, [$fieldName => $rawPartContent]);
  183.         } else {
  184.             // store temporary file and append to local files list
  185.             $file $this->storeTmpFile($fileName$parsedPartHeaders['content-type'], $rawPartContent);
  186.             if (isset($this->files[$fieldName])) {
  187.                 $this->files[$fieldName][] = $file;
  188.             } else {
  189.                 $this->files[$fieldName] = [$file];
  190.             }
  191.         }
  192.     }
  193.     /**
  194.      * Parses the headers of the part from the request content separated by the boundary string.
  195.      *
  196.      * @param string $rawPartHeaders The raw headers from the part of the request content.
  197.      * @return array The parsed headers from the part of the request content.
  198.      */
  199.     protected function parseRawPartHeaders(string $rawPartHeaders): array
  200.     {
  201.         $parsedPartHeaders = [];
  202.         // headers are separated by CRLF
  203.         $partHeaders explode("\r\n"$rawPartHeaders);
  204.         foreach ($partHeaders as $header) {
  205.             // header name and value are separated by a colon followed by a blank
  206.             [$name$value] = explode(': '$header2);
  207.             // normalize case of header name
  208.             $parsedPartHeaders[strtolower($name)] = $value;
  209.         }
  210.         return $parsedPartHeaders;
  211.     }
  212.     /**
  213.      * Processes a part of the request content separated by the boundary string.
  214.      *
  215.      * @param string $contentPart A part of the request content.
  216.      * @return void
  217.      */
  218.     protected function processContentParts(string $contentPart): void
  219.     {
  220.         // remove initial CRLF
  221.         $contentPart ltrim($contentPart"\r\n");
  222.         // separate part headers from part content by (first) double CRLF
  223.         [$rawPartHeaders$rawPartContent] = explode("\r\n\r\n"$contentPart2);
  224.         $parsedPartHeaders $this->parseRawPartHeaders($rawPartHeaders);
  225.         if (isset($parsedPartHeaders['content-disposition'])) {
  226.             $this->parseRawPartContent($parsedPartHeaders$rawPartContent);
  227.         } else {
  228.             // we only accept content parts with a content disposition header in this workaround
  229.             throw new HttpException(Response::HTTP_BAD_REQUEST'Content disposition header not present');
  230.         }
  231.     }
  232.     /**
  233.      * Parse the multipart form data and populate the local input and file properties.
  234.      *
  235.      * @param string $content The request content.
  236.      * @return void
  237.      */
  238.     protected function requestParseBody(string $content): void
  239.     {
  240.         // search initial CRLF to identify the boundary string
  241.         $firstCrlfPosition strpos($content"\r\n");
  242.         // if found, identify string before as boundary string
  243.         $boundary $firstCrlfPosition substr($content0$firstCrlfPosition) : null;
  244.         if (is_null($boundary)) {
  245.             // we only accept multipart form data with boundary strings in this workaround
  246.             throw new HttpException(Response::HTTP_BAD_REQUEST'Multipart boundary not identified');
  247.         }
  248.         // split content at the boundaries
  249.         $parts explode($boundary$content);
  250.         // remove empty parts or closing string --CRLF
  251.         $parts array_filter($parts, function (string $part): bool {
  252.             return mb_strlen($part) > && $part !== "--\r\n";
  253.         });
  254.         foreach ($parts as $part) {
  255.             $this->processContentParts($part);
  256.         }
  257.     }
  258.     /**
  259.      * Stores given file data as temporary file and returns an UploadedFile instance.
  260.      *
  261.      * @param string $originalFileName The original file name on the client side.
  262.      * @param string $mimeType The MIME type of the file.
  263.      * @param string $fileData The file data as string.
  264.      * @return UploadedFile UploadedFile instance containing the temporary file reference.
  265.      */
  266.     protected function storeTmpFile(string $originalFileNamestring $mimeTypestring $fileData): UploadedFile
  267.     {
  268.         // create temporary file name in the system tmp directory
  269.         $tmpFilePath tempnam(sys_get_temp_dir(), '');
  270.         // write file data to temporary file
  271.         file_put_contents($tmpFilePath$fileData);
  272.         // remove temporary file after processing
  273.         register_shutdown_function(function () use ($tmpFilePath): void {
  274.             if (file_exists($tmpFilePath)) {
  275.                 unlink($tmpFilePath);
  276.             }
  277.         });
  278.         return new UploadedFile(
  279.             $tmpFilePath,
  280.             $originalFileName,
  281.             $mimeType,
  282.             UPLOAD_ERR_OK,
  283.             true
  284.         /**
  285.          * Set this to true, otherwise PHP considers file as not uploaded by HTTP
  286.          *
  287.          * @see UploadedFile::isValid()
  288.          * @see is_uploaded_file()
  289.          */
  290.         );
  291.     }
  292.     /**
  293.      * Decodes JSON to array in request content.
  294.      *
  295.      * Allows to query for parameters with {@link Request::get()}.
  296.      *
  297.      * @throws JsonException when invalid JSON was sent
  298.      * @noinspection PhpUnused
  299.      */
  300.     public function jsonDecode(ControllerEvent $event): void
  301.     {
  302.         $controller $event->getController();
  303.         // when a controller class defines multiple action methods, the controller
  304.         // is returned as [$controllerInstance, 'methodName']
  305.         if (is_array($controller)) {
  306.             $controller $controller[0];
  307.         }
  308.         // limit to API controllers
  309.         if (!$controller instanceof AbstractApiController) {
  310.             return;
  311.         }
  312.         // Exceeding the post_max_size from PHP ini results probably just in a warning and lead to empty
  313.         // content arrays without further notice. Use built in Symfony detection method to ensure raising a proper
  314.         // error message to the client.
  315.         // See https://github.com/symfony/symfony/issues/19311
  316.         if ($this->serverParams->hasPostMaxSizeBeenExceeded()) {
  317.             throw new HttpException(
  318.                 Response::HTTP_REQUEST_ENTITY_TOO_LARGE,
  319.                 'PHP post max size has been exceeded',
  320.             );
  321.         }
  322.         $request $event->getRequest();
  323.         // do nothing on unhandled contents
  324.         if (
  325.             ($request->getContentType() !== 'json' || !$request->getContent()) &&
  326.             $request->getContentType() !== 'form'
  327.         ) {
  328.             return;
  329.         }
  330.         if ($request->getContentType() === 'json') {
  331.             $this->parseJson($request);
  332.             return;
  333.         }
  334.         if ($request->getContentType() === 'form') {
  335.             /**
  336.              * TODO
  337.              * PHP pre 8.4 ignores files and input data from HTTP PUT requests when using content type
  338.              * multipart/form-data and doesn't populate the respective super globals. Since we decided
  339.              * a) to use this content type for update requests including uploaded files and
  340.              * b) to use HTTP PUT to update a specific ressource to provide a REST API
  341.              * we need some workaround until we upgrade to PHP 8.4.
  342.              *
  343.              * Possible workarounds:
  344.              * - use POST in these cases instead of PUT, but this will break the pattern to use PUT on updates in
  345.              *   general (discarded, front end would need to change)
  346.              * - use the PECL extension apfd, but there could be issues building the extension on some local dev systems
  347.              * - parse the multipart form data for our use case, but this is not completely reliable and efficient
  348.              *
  349.              * @link https://bugs.php.net/bug.php?id=55815
  350.              * @link https://wiki.php.net/rfc/rfc1867-non-post
  351.              * @link https://pecl.php.net/package/apfd
  352.              *
  353.              * So we parse the multipart form data for ourselves as temporary workaround if we're still using
  354.              * PHP < 8.4 and the apdf extension is not installed in case of an HTTP PUT request.
  355.              */
  356.             if ($request->getMethod() === Request::METHOD_PUT && !in_array('apfd'get_loaded_extensions())) {
  357.                 // using the content as a string could be problematic for big files, but acceptable for now
  358.                 // until we move to PHP 8.4 and apdf is not available, big files are also prevented by PHP ini
  359.                 // setting post_max_size
  360.                 $this->requestParseBody($request->getContent());
  361.                 // copy the parsed data to the request bags
  362.                 $request->request->replace($this->inputs);
  363.                 $request->files->replace($this->files);
  364.             }
  365.             $this->parseMultipartFormData($request);
  366.         }
  367.     }
  368. }