Adding CROSSORIGIN handling

This commit is contained in:
László Károlyi 2024-11-09 16:46:17 +01:00
parent 3198d9d836
commit 2864cad3d6
Signed by: karolyi
GPG key ID: 2DCAF25E55735BFE
8 changed files with 100 additions and 26 deletions

View file

@ -7,7 +7,7 @@ workflows:
matrix:
parameters:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
django-version: ["3.2", "4.2", "5.0", "5.1"]
django-version: ["4.2", "5.0"]
exclude:
- python-version: "3.8"
django-version: "5.0"

View file

@ -5,10 +5,18 @@ For more general information, view the [readme](README.md).
Releases are added to the
[github release page](https://github.com/ezhome/django-webpack-loader/releases).
## --- INSERT VERSION HERE ---
- Automatically add `crossorigin` attributes to tags with `integrity` attributes when necessary (and enabled)
## [3.1.1] -- 2024-08-30
- Add support for Django 5.1
## [3.2.0] -- 2024-07-28
- Remove support for Django 3.x (LTS is EOL)
## [3.1.0] -- 2024-04-04
Support `webpack_asset` template tag to render transformed assets URL: `{% webpack_asset 'path/to/original/file' %} == "/static/assets/resource-3c9e4020d3e3c7a09c68.txt"`

View file

@ -252,7 +252,9 @@ WEBPACK_LOADER = {
- `TIMEOUT` is the number of seconds webpack_loader should wait for Webpack to finish compiling before raising an exception. `0`, `None` or leaving the value out of settings disables timeouts
- `INTEGRITY` is flag enabling [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) on rendered `<script>` and `<link>` tags. Integrity hash is get from stats file and configuration on side of `BundleTracker`, where [configuration option](https://github.com/django-webpack/webpack-bundle-tracker#options) `integrity: true` is required.
- `INTEGRITY` is a flag enabling [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) on rendered `<script>` and `<link>` tags. Integrity hash is fetched from the stats of `BundleTrackerPlugin`. The [configuration option](https://github.com/django-webpack/webpack-bundle-tracker#options) `integrity: true` is required.
- `CROSSORIGIN`: If you use the `integrity` attribute in your tags and you load your webpack generated assets from another origin (that is not the same `host:port` as the one you load the webpage from), you can configure the `CROSSORIGIN` configuration option. The default value is `''` (empty string), where an empty `crossorigin` attribute will be emitted when necessary. Valid values are: `''` (empty string), `'anonymous'` (functionally same as the empty string) and `use-credentials`. For an explanation, see https://shubhamjain.co/2018/09/08/subresource-integrity-crossorigin/. A typical case for this scenario is when you develop locally and your webpack-dev-server runs with hot-reload on a local host/port other than that of django's `runserver`.
- `LOADER_CLASS` is the fully qualified name of a python class as a string that holds the custom Webpack loader. This is where behavior can be customized as to how the stats file is loaded. Examples include loading the stats file from a database, cache, external URL, etc. For convenience, `webpack_loader.loaders.WebpackLoader` can be extended. The `load_assets` method is likely where custom behavior will be added. This should return the stats file as an object.

View file

@ -224,11 +224,18 @@ class LoaderTestCase(TestCase):
self.assertIn((
'<script src="/static/django_webpack_loader_bundles/main.js" '
'integrity="sha256-1wgFMxcDlOWYV727qRvWNoPHdnOGFNVMLuKd25cjR+o=" >'
'integrity="sha256-1wgFMxcDlOWYV727qRvWNoPHdnOGFNVMLuKd25cjR+'
'o= sha384-3RnsU3Z2OODW6qaMAPVpNC5lBb4M5I1+joXv37ACuLvCO6gQ7o'
'OD7IC1zN1uAakD sha512-9nLlV4v2pWvgeavHop1wXxdP34CfYv/xUZHwVB'
'N+1p+pAvHDmBw4XkvvciSGW4zQlWhaUiIi7P6nXmsLE+8Fsw==" >'
'</script>'), result.rendered_content)
self.assertIn((
'<link href="/static/django_webpack_loader_bundles/main.css" rel="stylesheet" '
'integrity="sha256-cYWwRvS04/VsttQYx4BalKYrBDuw5t8vKFhWB/LKX30=" />'),
'<link href="/static/django_webpack_loader_bundles/main.css" '
'rel="stylesheet" integrity="sha256-cYWwRvS04/VsttQYx4BalKYrB'
'Duw5t8vKFhWB/LKX30= sha384-V/UxbrsEy8BK5nd+sBlN31Emmq/WdDDdI'
'01UR8wKIFkIr6vEaT5YRaeLMfLcAQvS sha512-aigPxglXDA33t9s5i0vRa'
'p5b7dFwyp7cSN6x8rOXrPpCTMubOR7qTFpmTIa8z9B0wtXxbSheBPNCEURBH'
'KLQPw==" />'),
result.rendered_content
)

View file

@ -16,6 +16,10 @@ DEFAULT_CONFIG = {
'IGNORE': [r'.+\.hot-update.js', r'.+\.map'],
'LOADER_CLASS': 'webpack_loader.loaders.WebpackLoader',
'INTEGRITY': False,
# See https://shubhamjain.co/2018/09/08/subresource-integrity-crossorigin/
# See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin
# type is Literal['anonymous', 'use-credentials', '']
'CROSSORIGIN': '',
# Whenever the global setting for SKIP_COMMON_CHUNKS is changed, please
# update the fallback value in get_skip_common_chunks (utils.py).
'SKIP_COMMON_CHUNKS': False,

View file

@ -1,10 +1,15 @@
import json
import time
import os
import time
from functools import lru_cache
from io import open
from typing import Dict, Optional
from urllib.parse import urlparse
from warnings import warn
from django.conf import settings
from django.contrib.staticfiles.storage import staticfiles_storage
from django.http import HttpRequest
from .exceptions import (
WebpackError,
@ -13,6 +18,21 @@ from .exceptions import (
WebpackBundleLookupError,
)
_CROSSORIGIN_NO_REQUEST = (
'The crossorigin attribute might be necessary but you did not pass a '
'request object. django_webpack_loader needs a request object to be able '
'to know when to emit the crossorigin attribute on link and script tags.')
_CROSSORIGIN_NO_HOST = (
'You have passed the request object but it does not have a "HTTP_HOST", '
'thus django_webpack_loader can\'t know if the crossorigin header will '
'be necessary or not.')
@lru_cache(maxsize=100)
def _get_netloc(url: str) -> str:
'Return a cached netloc (host:port) for the passed `url`.'
return urlparse(url=url).netloc
class WebpackLoader:
_assets = {}
@ -42,19 +62,46 @@ class WebpackLoader:
files = self.get_assets()["assets"].values()
return next((x for x in files if x.get("sourceFilename") == name), None)
def get_integrity_attr(self, chunk):
if not self.config.get("INTEGRITY"):
return " "
def _add_crossorigin(
self, request: Optional[HttpRequest], chunk_url: str,
integrity: str, attrs: str) -> str:
'Return an added `crossorigin` attribute if necessary.'
def_value = f' integrity="{integrity}" '
cfgval: str = self.config.get('CROSSORIGIN')
if not request:
warn(message=_CROSSORIGIN_NO_REQUEST, category=RuntimeWarning)
return def_value
if 'crossorigin' in attrs.lower():
return def_value
host: Optional[str] = request.META.get('HTTP_HOST')
if not host:
warn(message=_CROSSORIGIN_NO_HOST, category=RuntimeWarning)
return def_value
netloc = _get_netloc(url=chunk_url)
if netloc == '' or netloc == host:
# Crossorigin not necessary
return def_value
if cfgval == '':
return f'{def_value}crossorigin '
return f'{def_value}crossorigin="{cfgval}" '
integrity = chunk.get("integrity")
def get_integrity_attr(
self, chunk: Dict[str, str], request: Optional[HttpRequest],
attrs: str):
if not self.config.get('INTEGRITY'):
# Crossorigin only necessary when integrity is used
return ' '
integrity = chunk.get('integrity')
if not integrity:
raise WebpackLoaderBadStatsError(
"The stats file does not contain valid data: INTEGRITY is set to True, "
'but chunk does not contain "integrity" key. Maybe you forgot to add '
"integrity: true in your BundleTracker configuration?"
)
return ' integrity="{}" '.format(integrity.partition(" ")[0])
'The stats file does not contain valid data: INTEGRITY is set '
'to True, but chunk does not contain "integrity" key. Maybe '
'you forgot to add integrity: true in your '
'BundleTrackerPlugin configuration?')
return self._add_crossorigin(
request=request, chunk_url=chunk['url'], integrity=integrity,
attrs=attrs)
def filter_chunks(self, chunks):
filtered_chunks = []

View file

@ -20,11 +20,11 @@ def render_bundle(
if skip_common_chunks is None:
skip_common_chunks = utils.get_skip_common_chunks(config)
url_to_tag_dict = utils.get_as_url_to_tag_dict(
bundle_name, extension=extension, config=config, suffix=suffix,
attrs=attrs, is_preload=is_preload)
request = context.get('request')
url_to_tag_dict = utils.get_as_url_to_tag_dict(
bundle_name, request=request, extension=extension, config=config,
suffix=suffix, attrs=attrs, is_preload=is_preload)
if request is None:
if skip_common_chunks:
warn(message=_WARNING_MESSAGE, category=RuntimeWarning)
@ -35,7 +35,7 @@ def render_bundle(
used_urls = request._webpack_loader_used_urls = set()
if skip_common_chunks:
url_to_tag_dict = {url: tag for url, tag in url_to_tag_dict.items() if url not in used_urls}
used_urls.update(url_to_tag_dict.keys())
used_urls.update(url_to_tag_dict)
return mark_safe('\n'.join(url_to_tag_dict.values()))
@ -43,10 +43,12 @@ def render_bundle(
def webpack_static(asset_name, config='DEFAULT'):
return utils.get_static(asset_name, config=config)
@register.simple_tag
def webpack_asset(asset_name, config='DEFAULT'):
return utils.get_asset(asset_name, config=config)
@register.simple_tag(takes_context=True)
def get_files(
context, bundle_name, extension=None, config='DEFAULT',

View file

@ -57,7 +57,9 @@ def get_files(bundle_name, extension=None, config='DEFAULT'):
return list(_get_bundle(loader, bundle_name, extension))
def get_as_url_to_tag_dict(bundle_name, extension=None, config='DEFAULT', suffix='', attrs='', is_preload=False):
def get_as_url_to_tag_dict(
bundle_name, request=None, extension=None, config='DEFAULT', suffix='',
attrs='', is_preload=False):
'''
Get a dict of URLs to formatted <script> & <link> tags for the assets in the
named bundle.
@ -84,7 +86,7 @@ def get_as_url_to_tag_dict(bundle_name, extension=None, config='DEFAULT', suffix
).format(
''.join([chunk['url'], suffix]),
attrs,
loader.get_integrity_attr(chunk),
loader.get_integrity_attr(chunk, request, attrs),
)
elif chunk['name'].endswith(('.css', '.css.gz')):
result[chunk['url']] = (
@ -93,12 +95,14 @@ def get_as_url_to_tag_dict(bundle_name, extension=None, config='DEFAULT', suffix
''.join([chunk['url'], suffix]),
attrs,
'"stylesheet"' if not is_preload else '"preload" as="style"',
loader.get_integrity_attr(chunk),
loader.get_integrity_attr(chunk, request, attrs),
)
return result
def get_as_tags(bundle_name, extension=None, config='DEFAULT', suffix='', attrs='', is_preload=False):
def get_as_tags(
bundle_name, request=None, extension=None, config='DEFAULT', suffix='',
attrs='', is_preload=False):
'''
Get a list of formatted <script> & <link> tags for the assets in the
named bundle.
@ -108,7 +112,7 @@ def get_as_tags(bundle_name, extension=None, config='DEFAULT', suffix='', attrs=
:param config: (optional) the name of the configuration
:return: a list of formatted tags as strings
'''
return list(get_as_url_to_tag_dict(bundle_name, extension, config, suffix, attrs, is_preload).values())
return list(get_as_url_to_tag_dict(bundle_name, request, extension, config, suffix, attrs, is_preload).values())
def get_static(asset_name, config='DEFAULT'):