Verified Commit 6d62cf23 authored by Alex Pott's avatar Alex Pott
Browse files

Issue #3303067 by catch, nod_, Wim Leers, olli, alexpott: Compress aggregate URL query strings

parent d6689fcb
Loading
Loading
Loading
Loading
+43 −0
Original line number Diff line number Diff line
@@ -62,6 +62,49 @@ public static function buildQuery(array $query, $parent = '') {
    return implode('&', $params);
  }

  /**
   * Compresses a string for use in a query parameter.
   *
   * While RFC 1738 doesn't specify a maximum length for query strings,
   * browsers or server configurations may restrict URLs and/or query strings to
   * a certain length, often 1000 or 2000 characters. This method can be used to
   * compress a string into a URL-safe query parameter which will be shorter
   * than if it was used directly.
   *
   * @see \Drupal\Component\Utility\UrlHelper::uncompressQueryParameter()
   *
   * @param string $data
   *   The data to compress.
   *
   * @return string
   *   The data compressed into a URL-safe string.
   */
  public static function compressQueryParameter(string $data): string {
    // Use 'base64url' encoding. Note that the '=' sign is only used for padding
    // on the right of the string, and is otherwise not part of the data.
    // @see https://datatracker.ietf.org/doc/html/rfc4648#section-5
    // @see https://www.php.net/manual/en/function.base64-encode.php#123098
    return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode(gzcompress($data)));
  }

  /**
   * Takes a compressed parameter and converts it back to the original.
   *
   * @see \Drupal\Component\Utility\UrlHelper::compressQueryParameter()
   *
   * @param string $compressed
   *   A string as compressed by
   *   \Drupal\Component\Utility\UrlHelper::compressQueryParameter().
   *
   * @return string|bool
   *   The uncompressed data or FALSE on failure.
   */
  public static function uncompressQueryParameter(string $compressed): string|bool {
    // Because this comes from user data, suppress the PHP warning that
    // gzcompress() throws if the base64-encoded string is invalid.
    return @gzuncompress(base64_decode(str_replace(['-', '_'], ['+', '/'], $compressed)));
  }

  /**
   * Filters a URL query parameter array to remove unwanted elements.
   *
+10 −6
Original line number Diff line number Diff line
@@ -102,18 +102,22 @@ public function optimize(array $css_assets, array $libraries) {
        $css_assets[$order]['data'] = $uri;
      }
    }
    // Generate a URL for each group of assets, but do not process them inline,
    // this is done using optimizeGroup() when the asset path is requested.
    $ajax_page_state = $this->requestStack->getCurrentRequest()->get('ajax_page_state');
    $already_loaded = isset($ajax_page_state) ? explode(',', $ajax_page_state['libraries']) : [];

    // All asset group URLs will have exactly the same query arguments, except
    // for the delta, so prepare them in advance.
    $query_args = [
      'language' => $this->languageManager->getCurrentLanguage()->getId(),
      'theme' => $this->themeManager->getActiveTheme()->getName(),
      'include' => implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($libraries)),
      'include' => UrlHelper::compressQueryParameter(implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($libraries))),
    ];
    $ajax_page_state = $this->requestStack->getCurrentRequest()->get('ajax_page_state');
    $already_loaded = isset($ajax_page_state) ? explode(',', $ajax_page_state['libraries']) : [];
    if ($already_loaded) {
      $query_args['exclude'] = implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($already_loaded));
      $query_args['exclude'] = UrlHelper::compressQueryParameter(implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($already_loaded)));
    }

    // Generate a URL for each group of assets, but do not process them inline,
    // this is done using optimizeGroup() when the asset path is requested.
    foreach ($css_assets as $order => $css_asset) {
      if (!empty($css_asset['preprocessed'])) {
        $query = ['delta' => "$order"] + $query_args;
+10 −7
Original line number Diff line number Diff line
@@ -110,20 +110,23 @@ public function optimize(array $js_assets, array $libraries) {
      }
    }
    if ($libraries) {
      // Generate a URL for the group, but do not process it inline, this is
      // done by \Drupal\system\controller\JsAssetController.
      $ajax_page_state = $this->requestStack->getCurrentRequest()
        ->get('ajax_page_state');
      $already_loaded = isset($ajax_page_state) ? explode(',', $ajax_page_state['libraries']) : [];
      // All group URLs have the same query arguments apart from the delta and
      // scope, so prepare them in advance.
      $language = $this->languageManager->getCurrentLanguage()->getId();
      $query_args = [
        'language' => $language,
        'theme' => $this->themeManager->getActiveTheme()->getName(),
        'include' => implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($libraries)),
        'include' => UrlHelper::compressQueryParameter(implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($libraries))),
      ];
      $ajax_page_state = $this->requestStack->getCurrentRequest()
        ->get('ajax_page_state');
      $already_loaded = isset($ajax_page_state) ? explode(',', $ajax_page_state['libraries']) : [];
      if ($already_loaded) {
        $query_args['exclude'] = implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($already_loaded));
        $query_args['exclude'] = UrlHelper::compressQueryParameter(implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($already_loaded)));
      }

      // Generate a URL for the group, but do not process it inline, this is
      // done by \Drupal\system\controller\JsAssetController.
      foreach ($js_assets as $order => $js_asset) {
        if (!empty($js_asset['preprocessed'])) {
          $query = [
+16 −2
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@

namespace Drupal\system\Controller;

use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Asset\AssetCollectionGrouperInterface;
use Drupal\Core\Asset\AssetCollectionOptimizerInterface;
use Drupal\Core\Asset\AssetDumperUriInterface;
@@ -131,6 +132,9 @@ public function deliver(Request $request, string $file_name) {
    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);

    // The hash is the second segment of the filename.
@@ -147,9 +151,19 @@ public function deliver(Request $request, string $file_name) {
    $this->themeManager->setActiveTheme($active_theme);

    $attached_assets = new AttachedAssets();
    $attached_assets->setLibraries(explode(',', $request->query->get('include')));
    $include_string = UrlHelper::uncompressQueryParameter($request->query->get('include'));

    if (!$include_string) {
      throw new BadRequestHttpException('The libraries to include are encoded incorrectly.');
    }
    $attached_assets->setLibraries(explode(',', $include_string));

    if ($request->query->has('exclude')) {
      $attached_assets->setAlreadyLoadedLibraries(explode(',', $request->query->get('exclude')));
      $exclude_string = UrlHelper::uncompressQueryParameter($request->query->get('exclude'));
      if (!$exclude_string) {
        throw new BadRequestHttpException('The libraries to exclude are encoded incorrectly.');
      }
      $attached_assets->setAlreadyLoadedLibraries(explode(',', $exclude_string));
    }
    $groups = $this->getGroups($attached_assets, $request);

+68 −3
Original line number Diff line number Diff line
@@ -116,6 +116,15 @@ protected function assertInvalidAggregates(string $url): void {
    $session->visit($this->omitTheme($url));
    $this->assertSession()->statusCodeEquals(400);

    $session->visit($this->omitInclude($url));
    $this->assertSession()->statusCodeEquals(400);

    $session->visit($this->invalidInclude($url));
    $this->assertSession()->statusCodeEquals(400);

    $session->visit($this->invalidExclude($url));
    $this->assertSession()->statusCodeEquals(400);

    $session->visit($this->setInvalidLibrary($url));
    $this->assertSession()->statusCodeEquals(200);

@@ -164,19 +173,21 @@ protected function replaceGroupHash(string $url): string {
  }

  /**
   * Replaces the 'libraries' entry in the given URL with an invalid value.
   * Replaces the 'include' entry in the given URL with an invalid value.
   *
   * @param string $url
   *   The source URL.
   *
   * @return string
   *   The URL with the 'library' query set to an invalid value.
   *   The URL with the 'include' query set to an invalid value.
   */
  protected function setInvalidLibrary(string $url): string {
    // First replace the hash, so we don't get served the actual file on disk.
    $url = $this->replaceGroupHash($url);
    $parts = UrlHelper::parse($url);
    $parts['query']['libraries'] = ['system/llama'];
    $include = explode(',', UrlHelper::uncompressQueryParameter($parts['query']['include']));
    $include[] = 'system/llama';
    $parts['query']['include'] = UrlHelper::compressQueryParameter(implode(',', $include));

    $query = UrlHelper::buildQuery($parts['query']);
    return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']);
@@ -200,4 +211,58 @@ protected function omitTheme(string $url): string {
    return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']);
  }

  /**
   * Removes the 'include' query parameter from the given URL.
   *
   * @param string $url
   *   The source URL.
   *
   * @return string
   *   The URL with the 'include' parameter omitted.
   */
  protected function omitInclude(string $url): string {
    // First replace the hash, so we don't get served the actual file on disk.
    $url = $this->replaceGroupHash($url);
    $parts = UrlHelper::parse($url);
    unset($parts['query']['include']);
    $query = UrlHelper::buildQuery($parts['query']);
    return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']);
  }

  /**
   * Replaces the 'include' query parameter with an invalid value.
   *
   * @param string $url
   *   The source URL.
   *
   * @return string
   *   The URL with 'include' set to an arbitrary string.
   */
  protected function invalidInclude(string $url): string {
    // First replace the hash, so we don't get served the actual file on disk.
    $url = $this->replaceGroupHash($url);
    $parts = UrlHelper::parse($url);
    $parts['query']['include'] = 'abcdefghijklmnop';
    $query = UrlHelper::buildQuery($parts['query']);
    return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']);
  }

  /**
   * Adds an invalid 'exclude' query parameter with an invalid value.
   *
   * @param string $url
   *   The source URL.
   *
   * @return string
   *   The URL with 'exclude' set to an arbitrary string.
   */
  protected function invalidExclude(string $url): string {
    // First replace the hash, so we don't get served the actual file on disk.
    $url = $this->replaceGroupHash($url);
    $parts = UrlHelper::parse($url);
    $parts['query']['exclude'] = 'abcdefghijklmnop';
    $query = UrlHelper::buildQuery($parts['query']);
    return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']);
  }

}
Loading