Skip to content

Commit 2864cad

Browse files
committed
Adding CROSSORIGIN handling
1 parent 3198d9d commit 2864cad

File tree

8 files changed

+100
-26
lines changed

8 files changed

+100
-26
lines changed

.circleci/config.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ workflows:
77
matrix:
88
parameters:
99
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
10-
django-version: ["3.2", "4.2", "5.0", "5.1"]
10+
django-version: ["4.2", "5.0"]
1111
exclude:
1212
- python-version: "3.8"
1313
django-version: "5.0"

CHANGELOG.md

+8
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,18 @@ For more general information, view the [readme](README.md).
55
Releases are added to the
66
[github release page](https://github.com/ezhome/django-webpack-loader/releases).
77

8+
## --- INSERT VERSION HERE ---
9+
10+
- Automatically add `crossorigin` attributes to tags with `integrity` attributes when necessary (and enabled)
11+
812
## [3.1.1] -- 2024-08-30
913

1014
- Add support for Django 5.1
1115

16+
## [3.2.0] -- 2024-07-28
17+
18+
- Remove support for Django 3.x (LTS is EOL)
19+
1220
## [3.1.0] -- 2024-04-04
1321

1422
Support `webpack_asset` template tag to render transformed assets URL: `{% webpack_asset 'path/to/original/file' %} == "/static/assets/resource-3c9e4020d3e3c7a09c68.txt"`

README.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,9 @@ WEBPACK_LOADER = {
252252

253253
- `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
254254

255-
- `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.
255+
- `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.
256+
257+
- `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`.
256258

257259
- `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.
258260

tests/app/tests/test_webpack.py

+10-3
Original file line numberDiff line numberDiff line change
@@ -224,11 +224,18 @@ def test_integrity(self):
224224

225225
self.assertIn((
226226
'<script src="/static/django_webpack_loader_bundles/main.js" '
227-
'integrity="sha256-1wgFMxcDlOWYV727qRvWNoPHdnOGFNVMLuKd25cjR+o=" >'
227+
'integrity="sha256-1wgFMxcDlOWYV727qRvWNoPHdnOGFNVMLuKd25cjR+'
228+
'o= sha384-3RnsU3Z2OODW6qaMAPVpNC5lBb4M5I1+joXv37ACuLvCO6gQ7o'
229+
'OD7IC1zN1uAakD sha512-9nLlV4v2pWvgeavHop1wXxdP34CfYv/xUZHwVB'
230+
'N+1p+pAvHDmBw4XkvvciSGW4zQlWhaUiIi7P6nXmsLE+8Fsw==" >'
228231
'</script>'), result.rendered_content)
229232
self.assertIn((
230-
'<link href="/static/django_webpack_loader_bundles/main.css" rel="stylesheet" '
231-
'integrity="sha256-cYWwRvS04/VsttQYx4BalKYrBDuw5t8vKFhWB/LKX30=" />'),
233+
'<link href="/static/django_webpack_loader_bundles/main.css" '
234+
'rel="stylesheet" integrity="sha256-cYWwRvS04/VsttQYx4BalKYrB'
235+
'Duw5t8vKFhWB/LKX30= sha384-V/UxbrsEy8BK5nd+sBlN31Emmq/WdDDdI'
236+
'01UR8wKIFkIr6vEaT5YRaeLMfLcAQvS sha512-aigPxglXDA33t9s5i0vRa'
237+
'p5b7dFwyp7cSN6x8rOXrPpCTMubOR7qTFpmTIa8z9B0wtXxbSheBPNCEURBH'
238+
'KLQPw==" />'),
232239
result.rendered_content
233240
)
234241

webpack_loader/config.py

+4
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
'IGNORE': [r'.+\.hot-update.js', r'.+\.map'],
1717
'LOADER_CLASS': 'webpack_loader.loaders.WebpackLoader',
1818
'INTEGRITY': False,
19+
# See https://shubhamjain.co/2018/09/08/subresource-integrity-crossorigin/
20+
# See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin
21+
# type is Literal['anonymous', 'use-credentials', '']
22+
'CROSSORIGIN': '',
1923
# Whenever the global setting for SKIP_COMMON_CHUNKS is changed, please
2024
# update the fallback value in get_skip_common_chunks (utils.py).
2125
'SKIP_COMMON_CHUNKS': False,

webpack_loader/loaders.py

+59-12
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import json
2-
import time
32
import os
3+
import time
4+
from functools import lru_cache
45
from io import open
6+
from typing import Dict, Optional
7+
from urllib.parse import urlparse
8+
from warnings import warn
59

610
from django.conf import settings
711
from django.contrib.staticfiles.storage import staticfiles_storage
12+
from django.http import HttpRequest
813

914
from .exceptions import (
1015
WebpackError,
@@ -13,6 +18,21 @@
1318
WebpackBundleLookupError,
1419
)
1520

21+
_CROSSORIGIN_NO_REQUEST = (
22+
'The crossorigin attribute might be necessary but you did not pass a '
23+
'request object. django_webpack_loader needs a request object to be able '
24+
'to know when to emit the crossorigin attribute on link and script tags.')
25+
_CROSSORIGIN_NO_HOST = (
26+
'You have passed the request object but it does not have a "HTTP_HOST", '
27+
'thus django_webpack_loader can\'t know if the crossorigin header will '
28+
'be necessary or not.')
29+
30+
31+
@lru_cache(maxsize=100)
32+
def _get_netloc(url: str) -> str:
33+
'Return a cached netloc (host:port) for the passed `url`.'
34+
return urlparse(url=url).netloc
35+
1636

1737
class WebpackLoader:
1838
_assets = {}
@@ -42,19 +62,46 @@ def get_asset_by_source_filename(self, name):
4262
files = self.get_assets()["assets"].values()
4363
return next((x for x in files if x.get("sourceFilename") == name), None)
4464

45-
def get_integrity_attr(self, chunk):
46-
if not self.config.get("INTEGRITY"):
47-
return " "
48-
49-
integrity = chunk.get("integrity")
65+
def _add_crossorigin(
66+
self, request: Optional[HttpRequest], chunk_url: str,
67+
integrity: str, attrs: str) -> str:
68+
'Return an added `crossorigin` attribute if necessary.'
69+
def_value = f' integrity="{integrity}" '
70+
cfgval: str = self.config.get('CROSSORIGIN')
71+
if not request:
72+
warn(message=_CROSSORIGIN_NO_REQUEST, category=RuntimeWarning)
73+
return def_value
74+
if 'crossorigin' in attrs.lower():
75+
return def_value
76+
host: Optional[str] = request.META.get('HTTP_HOST')
77+
if not host:
78+
warn(message=_CROSSORIGIN_NO_HOST, category=RuntimeWarning)
79+
return def_value
80+
netloc = _get_netloc(url=chunk_url)
81+
if netloc == '' or netloc == host:
82+
# Crossorigin not necessary
83+
return def_value
84+
if cfgval == '':
85+
return f'{def_value}crossorigin '
86+
return f'{def_value}crossorigin="{cfgval}" '
87+
88+
def get_integrity_attr(
89+
self, chunk: Dict[str, str], request: Optional[HttpRequest],
90+
attrs: str):
91+
if not self.config.get('INTEGRITY'):
92+
# Crossorigin only necessary when integrity is used
93+
return ' '
94+
95+
integrity = chunk.get('integrity')
5096
if not integrity:
5197
raise WebpackLoaderBadStatsError(
52-
"The stats file does not contain valid data: INTEGRITY is set to True, "
53-
'but chunk does not contain "integrity" key. Maybe you forgot to add '
54-
"integrity: true in your BundleTracker configuration?"
55-
)
56-
57-
return ' integrity="{}" '.format(integrity.partition(" ")[0])
98+
'The stats file does not contain valid data: INTEGRITY is set '
99+
'to True, but chunk does not contain "integrity" key. Maybe '
100+
'you forgot to add integrity: true in your '
101+
'BundleTrackerPlugin configuration?')
102+
return self._add_crossorigin(
103+
request=request, chunk_url=chunk['url'], integrity=integrity,
104+
attrs=attrs)
58105

59106
def filter_chunks(self, chunks):
60107
filtered_chunks = []

webpack_loader/templatetags/webpack_loader.py

+6-4
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ def render_bundle(
2020
if skip_common_chunks is None:
2121
skip_common_chunks = utils.get_skip_common_chunks(config)
2222

23+
request = context.get('request')
2324
url_to_tag_dict = utils.get_as_url_to_tag_dict(
24-
bundle_name, extension=extension, config=config, suffix=suffix,
25-
attrs=attrs, is_preload=is_preload)
25+
bundle_name, request=request, extension=extension, config=config,
26+
suffix=suffix, attrs=attrs, is_preload=is_preload)
2627

27-
request = context.get('request')
2828
if request is None:
2929
if skip_common_chunks:
3030
warn(message=_WARNING_MESSAGE, category=RuntimeWarning)
@@ -35,18 +35,20 @@ def render_bundle(
3535
used_urls = request._webpack_loader_used_urls = set()
3636
if skip_common_chunks:
3737
url_to_tag_dict = {url: tag for url, tag in url_to_tag_dict.items() if url not in used_urls}
38-
used_urls.update(url_to_tag_dict.keys())
38+
used_urls.update(url_to_tag_dict)
3939
return mark_safe('\n'.join(url_to_tag_dict.values()))
4040

4141

4242
@register.simple_tag
4343
def webpack_static(asset_name, config='DEFAULT'):
4444
return utils.get_static(asset_name, config=config)
4545

46+
4647
@register.simple_tag
4748
def webpack_asset(asset_name, config='DEFAULT'):
4849
return utils.get_asset(asset_name, config=config)
4950

51+
5052
@register.simple_tag(takes_context=True)
5153
def get_files(
5254
context, bundle_name, extension=None, config='DEFAULT',

webpack_loader/utils.py

+9-5
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ def get_files(bundle_name, extension=None, config='DEFAULT'):
5757
return list(_get_bundle(loader, bundle_name, extension))
5858

5959

60-
def get_as_url_to_tag_dict(bundle_name, extension=None, config='DEFAULT', suffix='', attrs='', is_preload=False):
60+
def get_as_url_to_tag_dict(
61+
bundle_name, request=None, extension=None, config='DEFAULT', suffix='',
62+
attrs='', is_preload=False):
6163
'''
6264
Get a dict of URLs to formatted <script> & <link> tags for the assets in the
6365
named bundle.
@@ -84,7 +86,7 @@ def get_as_url_to_tag_dict(bundle_name, extension=None, config='DEFAULT', suffix
8486
).format(
8587
''.join([chunk['url'], suffix]),
8688
attrs,
87-
loader.get_integrity_attr(chunk),
89+
loader.get_integrity_attr(chunk, request, attrs),
8890
)
8991
elif chunk['name'].endswith(('.css', '.css.gz')):
9092
result[chunk['url']] = (
@@ -93,12 +95,14 @@ def get_as_url_to_tag_dict(bundle_name, extension=None, config='DEFAULT', suffix
9395
''.join([chunk['url'], suffix]),
9496
attrs,
9597
'"stylesheet"' if not is_preload else '"preload" as="style"',
96-
loader.get_integrity_attr(chunk),
98+
loader.get_integrity_attr(chunk, request, attrs),
9799
)
98100
return result
99101

100102

101-
def get_as_tags(bundle_name, extension=None, config='DEFAULT', suffix='', attrs='', is_preload=False):
103+
def get_as_tags(
104+
bundle_name, request=None, extension=None, config='DEFAULT', suffix='',
105+
attrs='', is_preload=False):
102106
'''
103107
Get a list of formatted <script> & <link> tags for the assets in the
104108
named bundle.
@@ -108,7 +112,7 @@ def get_as_tags(bundle_name, extension=None, config='DEFAULT', suffix='', attrs=
108112
:param config: (optional) the name of the configuration
109113
:return: a list of formatted tags as strings
110114
'''
111-
return list(get_as_url_to_tag_dict(bundle_name, extension, config, suffix, attrs, is_preload).values())
115+
return list(get_as_url_to_tag_dict(bundle_name, request, extension, config, suffix, attrs, is_preload).values())
112116

113117

114118
def get_static(asset_name, config='DEFAULT'):

0 commit comments

Comments
 (0)