fileExtension = $this->assetType; } /** * Generates an aggregate, given a filename. * * @param \Symfony\Component\HttpFoundation\Request $request * The request object. * @param string $file_name * The file to deliver. * * @return \Symfony\Component\HttpFoundation\BinaryFileResponse|\Symfony\Component\HttpFoundation\Response * The transferred file as response. * * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException * Thrown when the filename is invalid or an invalid query argument is * supplied. */ public function deliver(Request $request, string $file_name) { $uri = 'assets://' . $this->assetType . '/' . $file_name; // Check to see whether a file matching the $uri already exists, this can // happen if it was created while this request was in progress. if (file_exists($uri)) { return new BinaryFileResponse($uri, 200, [ 'Cache-control' => static::CACHE_CONTROL, ]); } // First validate that the request is valid enough to produce an asset group // aggregate. The theme must be passed as a query parameter, since assets // always depend on the current theme. if (!$request->query->has('theme')) { throw new BadRequestHttpException('The theme must be passed as a query argument'); } if (!$request->query->has('delta') || !is_numeric($request->query->get('delta'))) { throw new BadRequestHttpException('The numeric delta must be passed as a query argument'); } if (!$request->query->has('language')) { throw new BadRequestHttpException('The language must be passed as a query argument'); } if (!$request->query->has('include')) { throw new BadRequestHttpException('The libraries to include must be passed as a query argument'); } $file_parts = explode('_', basename($file_name, '.' . $this->fileExtension), 2); // Ensure the filename is correctly prefixed. if ($file_parts[0] !== $this->fileExtension) { throw new BadRequestHttpException('The filename prefix must match the file extension'); } // The hash is the second segment of the filename. if (!isset($file_parts[1])) { throw new BadRequestHttpException('Invalid filename'); } $received_hash = $file_parts[1]; // Now build the asset groups based on the libraries. It requires the full // set of asset groups to extract and build the aggregate for the group we // want, since libraries may be split across different asset groups. $theme = $request->query->get('theme'); $active_theme = $this->themeInitialization->initTheme($theme); $this->themeManager->setActiveTheme($active_theme); $attached_assets = new AttachedAssets(); $include_libraries = explode(',', UrlHelper::uncompressQueryParameter($request->query->get('include'))); // Check that library names are in the correct format. $validate = function ($libraries_to_check) { foreach ($libraries_to_check as $library) { if (substr_count($library, '/') === 0) { throw new BadRequestHttpException(sprintf('The "%s" library name must include at least one slash.', $library)); } } }; $validate($include_libraries); $attached_assets->setLibraries($include_libraries); if ($request->query->has('exclude')) { $exclude_libraries = explode(',', UrlHelper::uncompressQueryParameter($request->query->get('exclude'))); $validate($exclude_libraries); $attached_assets->setAlreadyLoadedLibraries($exclude_libraries); } $groups = $this->getGroups($attached_assets, $request); $group = $this->getGroup($groups, $request->query->get('delta')); // Generate a hash based on the asset group, this uses the same method as // the collection optimizer does to create the filename, so it should match. $generated_hash = $this->generateHash($group); $data = $this->optimizer->optimizeGroup($group); $response = new Response($data, 200, [ 'Cache-control' => static::CACHE_CONTROL, 'Content-Type' => $this->contentType, ]); // However, the hash from the library definitions in code may not match the // hash from the URL. This can be for three reasons: // 1. Someone has requested an outdated URL, i.e. from a cached page, which // matches a different version of the code base. // 2. Someone has requested an outdated URL during a deployment. This is // the same case as #1 but a much shorter window. // 3. Someone is attempting to craft an invalid URL in order to conduct a // denial of service attack on the site. // Dump the optimized group into an aggregate file, but only if the // received hash and generated hash match. This prevents invalid filenames // from filling the disk, while still serving aggregates that may be // referenced in cached HTML. if (hash_equals($generated_hash, $received_hash)) { $this->dumper->dumpToUri($data, $this->assetType, $uri); } else { $expected_filename = $this->fileExtension . '_' . $generated_hash . '.' . $this->fileExtension; $response = new RedirectResponse( str_replace($file_name, $expected_filename, $request->getRequestUri()), 301, ['Cache-Control' => 'public, max-age=3600, must-revalidate'], ); } return $response; } /** * Gets a group. * * @param array $groups * An array of asset groups. * @param int $group_delta * The group delta. * * @return array * The correct asset group matching $group_delta. * * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException * Thrown when the filename is invalid. */ protected function getGroup(array $groups, int $group_delta): array { if (isset($groups[$group_delta])) { return $groups[$group_delta]; } throw new BadRequestHttpException('Invalid filename.'); } /** * Get grouped assets. * * @param \Drupal\Core\Asset\AttachedAssetsInterface $attached_assets * The attached assets. * @param \Symfony\Component\HttpFoundation\Request $request * The current request. * * @return array * The grouped assets. * * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException * Thrown when the query argument is omitted. */ abstract protected function getGroups(AttachedAssetsInterface $attached_assets, Request $request): array; }