diff --git a/.circleci/config.yml b/.circleci/config.yml
index 30a7f7db..85aaecfe 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -6,13 +6,21 @@ workflows:
- base-test:
matrix:
parameters:
- python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
- django-version: ["3.2", "4.2", "5.0"]
+ python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
+ django-version: ["4.2", "5.0", "5.1", "5.2"]
exclude:
- python-version: "3.8"
django-version: "5.0"
- python-version: "3.9"
django-version: "5.0"
+ - python-version: "3.8"
+ django-version: "5.1"
+ - python-version: "3.9"
+ django-version: "5.1"
+ - python-version: "3.8"
+ django-version: "5.2"
+ - python-version: "3.9"
+ django-version: "5.2"
- coverall:
requires:
- base-test
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..6828d463
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,19 @@
+version: 2
+updates:
+ # Ignore all updates to the `tests` directory (old webpack 4 tests)
+ - package-ecosystem: "npm"
+ directory: "/tests"
+ schedule:
+ interval: "weekly"
+ ignore:
+ - dependency-name: "*"
+
+ - package-ecosystem: "npm"
+ directory: "/tests_webpack"
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "npm"
+ directory: "/examples"
+ schedule:
+ interval: "weekly"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a2086425..29bdb807 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,16 @@ 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).
+## [3.2.0] -- 2025-05-19
+
+- Automatically add `crossorigin` attributes to tags with `integrity` attributes when necessary
+- Use `request.csp_nonce` from [django-csp](https://github.com/mozilla/django-csp) if available and configured
+- Remove support for Django 3.x (LTS is EOL)
+
+## [3.1.1] -- 2024-08-30
+
+- Add support for Django 5.1
+
## [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"`
diff --git a/README.md b/README.md
index 3f9bf559..7131c80e 100644
--- a/README.md
+++ b/README.md
@@ -252,7 +252,11 @@ 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 `'), result.rendered_content)
self.assertIn((
- ''),
+ ''),
+ result.rendered_content
+ )
+
+ def test_integrity_with_crossorigin_empty(self):
+ self.compile_bundles('webpack.config.integrity.js')
+
+ loader = get_loader(DEFAULT_CONFIG)
+ with patch.dict(loader.config, {'INTEGRITY': True, 'CROSSORIGIN': '', 'CACHE': False}):
+ view = TemplateView.as_view(template_name='single.html')
+ request = self.factory.get('/')
+ request.META['HTTP_HOST'] = 'crossorigin-custom-static-host.com'
+ result = view(request)
+
+ self.assertIn((
+ ''
+ ), result.rendered_content)
+ self.assertIn((
+ ''),
+ result.rendered_content
+ )
+
+ def test_integrity_with_crossorigin_anonymous(self):
+ self.compile_bundles('webpack.config.integrity.js')
+
+ loader = get_loader(DEFAULT_CONFIG)
+ with patch.dict(loader.config, {'INTEGRITY': True, 'CROSSORIGIN': 'anonymous', 'CACHE': False}):
+ view = TemplateView.as_view(template_name='single.html')
+ request = self.factory.get('/')
+ request.META['HTTP_HOST'] = 'crossorigin-custom-static-host.com'
+ result = view(request)
+
+ self.assertIn((
+ ''
+ ), result.rendered_content)
+ self.assertIn((
+ ''),
+ result.rendered_content
+ )
+
+ def test_integrity_with_crossorigin_use_credentials(self):
+ self.compile_bundles('webpack.config.integrity.js')
+
+ loader = get_loader(DEFAULT_CONFIG)
+ with patch.dict(loader.config, {'INTEGRITY': True, 'CROSSORIGIN': 'use-credentials', 'CACHE': False}):
+ view = TemplateView.as_view(template_name='single.html')
+ request = self.factory.get('/')
+ request.META['HTTP_HOST'] = 'crossorigin-custom-static-host.com'
+ result = view(request)
+
+ self.assertIn((
+ ''
+ ), result.rendered_content)
+ self.assertIn((
+ ''),
result.rendered_content
)
@@ -244,11 +340,11 @@ def test_integrity_missing_config(self):
result = view(request)
self.assertIn((
- ''), result.rendered_content
)
self.assertIn((
- ''),
+ ''),
result.rendered_content
)
@@ -850,3 +946,76 @@ def test_get_as_tags_direct_usage(self):
self.assertEqual(tags[0], asset_vendor)
self.assertEqual(tags[1], asset_app1)
self.assertEqual(tags[2], asset_app2)
+
+ def test_get_url_to_tag_dict_with_nonce(self):
+ """Test the get_as_url_to_tag_dict function with nonce attribute handling."""
+
+ self.compile_bundles('webpack.config.simple.js')
+
+ loader = get_loader(DEFAULT_CONFIG)
+ with patch.dict(loader.config, {"CSP_NONCE": True, 'CACHE': False}):
+ # Create a request with csp_nonce
+ request = self.factory.get('/')
+ request.csp_nonce = "test-nonce-123"
+
+ # Get tag dict with nonce enabled
+ tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=request)
+ # Verify nonce is in the tag
+ self.assertIn('nonce="test-nonce-123"', tag_dict['/static/django_webpack_loader_bundles/main.js'])
+
+ # Test with existing nonce in attrs - should not duplicate
+ tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='nonce="existing-nonce"', request=request)
+ self.assertIn('nonce="existing-nonce"', tag_dict['/static/django_webpack_loader_bundles/main.js'])
+ self.assertNotIn('nonce="test-nonce-123"', tag_dict['/static/django_webpack_loader_bundles/main.js'])
+
+ # Test without request - should not have nonce
+ tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=None)
+ self.assertNotIn('nonce=', tag_dict['/static/django_webpack_loader_bundles/main.js'])
+
+ # Test with request but no csp_nonce attribute - should not have nonce
+ request_without_nonce = self.factory.get('/')
+ tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=request_without_nonce)
+ self.assertNotIn('nonce=', tag_dict['/static/django_webpack_loader_bundles/main.js'])
+
+ def test_get_url_to_tag_dict_with_nonce_disabled(self):
+ self.compile_bundles('webpack.config.simple.js')
+
+ loader = get_loader(DEFAULT_CONFIG)
+ with patch.dict(loader.config, {"CSP_NONCE": False, 'CACHE': False}):
+ # Create a request without csp_nonce
+ request = self.factory.get('/')
+
+ # should not have nonce
+ tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=request)
+ self.assertNotIn('nonce=', tag_dict['/static/django_webpack_loader_bundles/main.js'])
+
+ # Create a request with csp_nonce
+ request_with_nonce = self.factory.get('/')
+ request_with_nonce.csp_nonce = "test-nonce-123"
+
+ # Test with CSP_NONCE disabled - should not have nonce
+ tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=request_with_nonce)
+ self.assertNotIn('nonce=', tag_dict['/static/django_webpack_loader_bundles/main.js'])
+
+ def test_get_url_to_tag_dict_with_different_extensions(self):
+ """Test the get_as_url_to_tag_dict function with different file extensions."""
+
+ self.compile_bundles('webpack.config.simple.js')
+
+ loader = get_loader(DEFAULT_CONFIG)
+ with patch.dict(loader.config, {"CSP_NONCE": True, 'CACHE': False}):
+ # Create a request with csp_nonce
+ request = self.factory.get('/')
+ request.csp_nonce = "test-nonce-123"
+
+ # JavaScript file
+ tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=request)
+ self.assertIn(''), result.rendered_content)
+
+ def test_integrity_with_crossorigin_empty(self):
+ self.compile_bundles('webpack.config.integrity.js')
+
+ loader = get_loader(DEFAULT_CONFIG)
+ with patch.dict(loader.config, {'INTEGRITY': True, 'CROSSORIGIN': '', 'CACHE': False}):
+ view = TemplateView.as_view(template_name='home.html')
+ request = self.factory.get('/')
+ request.META['HTTP_HOST'] = 'crossorigin-custom-static-host.com'
+ result = view(request)
+
+ self.assertIn((
+ ''
+ ), result.rendered_content)
+
+ def test_integrity_with_crossorigin_anonymous(self):
+ self.compile_bundles('webpack.config.integrity.js')
+
+ loader = get_loader(DEFAULT_CONFIG)
+ with patch.dict(loader.config, {'INTEGRITY': True, 'CROSSORIGIN': 'anonymous', 'CACHE': False}):
+ view = TemplateView.as_view(template_name='home.html')
+ request = self.factory.get('/')
+ request.META['HTTP_HOST'] = 'crossorigin-custom-static-host.com'
+ result = view(request)
+
+ self.assertIn((
+ ''
+ ), result.rendered_content)
+
+ def test_integrity_with_crossorigin_use_credentials(self):
+ self.compile_bundles('webpack.config.integrity.js')
+
+ loader = get_loader(DEFAULT_CONFIG)
+ with patch.dict(loader.config, {'INTEGRITY': True, 'CROSSORIGIN': 'use-credentials', 'CACHE': False}):
+ view = TemplateView.as_view(template_name='home.html')
+ request = self.factory.get('/')
+ request.META['HTTP_HOST'] = 'crossorigin-custom-static-host.com'
+ result = view(request)
+
+ self.assertIn((
+ ''
+ ), result.rendered_content)
+
+ def test_get_url_to_tag_dict_with_nonce(self):
+ """Test the get_as_url_to_tag_dict function with nonce attribute handling."""
+
+ self.compile_bundles('webpack.config.js')
+
+ loader = get_loader(DEFAULT_CONFIG)
+ with patch.dict(loader.config, {"CSP_NONCE": True, 'CACHE': False}):
+ # Create a request with csp_nonce
+ request = self.factory.get('/')
+ request.csp_nonce = "test-nonce-123"
+
+ # Get tag dict with nonce enabled
+ tag_dict = get_as_url_to_tag_dict('resources', extension='js', attrs='', request=request)
+ # Verify nonce is in the tag
+ self.assertIn('nonce="test-nonce-123"', tag_dict['/static/django_webpack_loader_bundles/resources.js'])
+
+ # Test with existing nonce in attrs - should not duplicate
+ tag_dict = get_as_url_to_tag_dict('resources', extension='js', attrs='nonce="existing-nonce"', request=request)
+ self.assertIn('nonce="existing-nonce"', tag_dict['/static/django_webpack_loader_bundles/resources.js'])
+ self.assertNotIn('nonce="test-nonce-123"', tag_dict['/static/django_webpack_loader_bundles/resources.js'])
+
+ # Test without request - should not have nonce
+ tag_dict = get_as_url_to_tag_dict('resources', extension='js', attrs='', request=None)
+ self.assertNotIn('nonce=', tag_dict['/static/django_webpack_loader_bundles/resources.js'])
+
+ # Test with request but no csp_nonce attribute - should not have nonce
+ request_without_nonce = self.factory.get('/')
+ tag_dict = get_as_url_to_tag_dict('resources', extension='js', attrs='', request=request_without_nonce)
+ self.assertNotIn('nonce=', tag_dict['/static/django_webpack_loader_bundles/resources.js'])
+
+ def test_get_url_to_tag_dict_with_nonce_disabled(self):
+ self.compile_bundles('webpack.config.js')
+
+ loader = get_loader(DEFAULT_CONFIG)
+ with patch.dict(loader.config, {"CSP_NONCE": False, 'CACHE': False}):
+ # Create a request without csp_nonce
+ request = self.factory.get('/')
+
+ # should not have nonce
+ tag_dict = get_as_url_to_tag_dict('resources', extension='js', attrs='', request=request)
+ self.assertNotIn('nonce=', tag_dict['/static/django_webpack_loader_bundles/resources.js'])
+
+ # Create a request with csp_nonce
+ request_with_nonce = self.factory.get('/')
+ request_with_nonce.csp_nonce = "test-nonce-123"
+
+ # Test with CSP_NONCE disabled - should not have nonce
+ tag_dict = get_as_url_to_tag_dict('resources', extension='js', attrs='', request=request_with_nonce)
+ self.assertNotIn('nonce=', tag_dict['/static/django_webpack_loader_bundles/resources.js'])
diff --git a/tests_webpack5/package-lock.json b/tests_webpack5/package-lock.json
index dab2aee9..e380978e 100644
--- a/tests_webpack5/package-lock.json
+++ b/tests_webpack5/package-lock.json
@@ -9,8 +9,8 @@
"version": "0.0.0",
"license": "MIT",
"devDependencies": {
- "webpack": "^5.91.0",
- "webpack-bundle-tracker": "3.1.0",
+ "webpack": "^5.92.0",
+ "webpack-bundle-tracker": "3.2.0",
"webpack-cli": "^5.1.4"
}
},
@@ -81,26 +81,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
- "node_modules/@types/eslint": {
- "version": "8.56.6",
- "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.6.tgz",
- "integrity": "sha512-ymwc+qb1XkjT/gfoQwxIeHZ6ixH23A+tCT2ADSA/DPVKzAjwYkTXBMCQ/f6fe4wEa85Lhp26VPeUxI7wMhAi7A==",
- "dev": true,
- "dependencies": {
- "@types/estree": "*",
- "@types/json-schema": "*"
- }
- },
- "node_modules/@types/eslint-scope": {
- "version": "3.7.7",
- "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz",
- "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
- "dev": true,
- "dependencies": {
- "@types/eslint": "*",
- "@types/estree": "*"
- }
- },
"node_modules/@types/estree": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
@@ -336,10 +316,10 @@
"node": ">=0.4.0"
}
},
- "node_modules/acorn-import-assertions": {
- "version": "1.9.0",
- "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz",
- "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==",
+ "node_modules/acorn-import-attributes": {
+ "version": "1.9.5",
+ "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz",
+ "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==",
"dev": true,
"peerDependencies": {
"acorn": "^8"
@@ -484,9 +464,9 @@
"dev": true
},
"node_modules/enhanced-resolve": {
- "version": "5.16.0",
- "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.16.0.tgz",
- "integrity": "sha512-O+QWCviPNSSLAD9Ucn8Awv+poAkqn3T1XY5/N7kR7rQO9yfSGWkYZDwpJ+iKF7B8rxaQKWngSqACpgzeapSyoA==",
+ "version": "5.17.1",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz",
+ "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==",
"dev": true,
"dependencies": {
"graceful-fs": "^4.2.4",
@@ -807,12 +787,6 @@
"integrity": "sha512-dvqe2I+cO5MzXCMhUnfYFa9MD+/760yx2aTAN1lqEcEkf896TxgrX373igVdqSJj6tQd0jnSLE1UMuKufqqxFw==",
"dev": true
},
- "node_modules/lodash.get": {
- "version": "4.4.2",
- "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
- "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
- "dev": true
- },
"node_modules/lodash.topairs": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.topairs/-/lodash.topairs-4.3.0.tgz",
@@ -1250,21 +1224,20 @@
}
},
"node_modules/webpack": {
- "version": "5.91.0",
- "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.91.0.tgz",
- "integrity": "sha512-rzVwlLeBWHJbmgTC/8TvAcu5vpJNII+MelQpylD4jNERPwpBJOE2lEcko1zJX3QJeLjTTAnQxn/OJ8bjDzVQaw==",
+ "version": "5.94.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz",
+ "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==",
"dev": true,
"dependencies": {
- "@types/eslint-scope": "^3.7.3",
"@types/estree": "^1.0.5",
"@webassemblyjs/ast": "^1.12.1",
"@webassemblyjs/wasm-edit": "^1.12.1",
"@webassemblyjs/wasm-parser": "^1.12.1",
"acorn": "^8.7.1",
- "acorn-import-assertions": "^1.9.0",
+ "acorn-import-attributes": "^1.9.5",
"browserslist": "^4.21.10",
"chrome-trace-event": "^1.0.2",
- "enhanced-resolve": "^5.16.0",
+ "enhanced-resolve": "^5.17.1",
"es-module-lexer": "^1.2.1",
"eslint-scope": "5.1.1",
"events": "^3.2.0",
@@ -1297,16 +1270,15 @@
}
},
"node_modules/webpack-bundle-tracker": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/webpack-bundle-tracker/-/webpack-bundle-tracker-3.1.0.tgz",
- "integrity": "sha512-OKiN3UhInZgGTLIevl5IGKfwjUwQTxdwDZqI3H6PbHXEHRHw+aQLbntzFLJcx3BQzj/lfovQw+45jFvcrNelKg==",
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/webpack-bundle-tracker/-/webpack-bundle-tracker-3.2.0.tgz",
+ "integrity": "sha512-/CV+Mre7jC+lSTjF7nYv36FpdwIQR15URKGHpLmdgGxxdYFQ858MV13J0qqR7Rn3wke3O41yX0tNntPv3uo1QA==",
"dev": true,
"dependencies": {
"lodash.assign": "^4.2.0",
"lodash.defaults": "^4.2.0",
"lodash.foreach": "^4.5.0",
"lodash.frompairs": "^4.0.1",
- "lodash.get": "^4.4.2",
"lodash.topairs": "^4.3.0"
}
},
diff --git a/tests_webpack5/package.json b/tests_webpack5/package.json
index 47322efa..7cf0d8f7 100644
--- a/tests_webpack5/package.json
+++ b/tests_webpack5/package.json
@@ -5,8 +5,8 @@
"main": "index.js",
"license": "MIT",
"devDependencies": {
- "webpack": "^5.91.0",
- "webpack-bundle-tracker": "3.1.0",
+ "webpack": "^5.92.0",
+ "webpack-bundle-tracker": "3.2.0",
"webpack-cli": "^5.1.4"
}
}
diff --git a/tests_webpack5/webpack.config.integrity.js b/tests_webpack5/webpack.config.integrity.js
new file mode 100644
index 00000000..c4af8761
--- /dev/null
+++ b/tests_webpack5/webpack.config.integrity.js
@@ -0,0 +1,27 @@
+var path = require("path");
+var BundleTracker = require('webpack-bundle-tracker');
+
+module.exports = {
+ entry: {
+ resources: './assets/js/resources'
+ },
+
+ output: {
+ assetModuleFilename: 'assets/[name]-[contenthash][ext]',
+ path: path.resolve('./assets/django_webpack_loader_bundles/'),
+ publicPath: 'http://custom-static-host.com/',
+ },
+
+ module: {
+ rules: [{ test: /\.txt$/, type: 'asset/resource' }]
+ },
+
+ plugins: [
+ new BundleTracker({path: __dirname, integrity: true})
+ ],
+
+ resolve: {
+ extensions: ['.js', '.jsx']
+ },
+}
+
diff --git a/webpack_loader/__init__.py b/webpack_loader/__init__.py
index 6ef5a1b3..30031b94 100644
--- a/webpack_loader/__init__.py
+++ b/webpack_loader/__init__.py
@@ -1,5 +1,5 @@
__author__ = "Vinta Software"
-__version__ = "3.1.0"
+__version__ = "3.2.0"
import django
diff --git a/webpack_loader/config.py b/webpack_loader/config.py
index 7045224d..e8263e75 100644
--- a/webpack_loader/config.py
+++ b/webpack_loader/config.py
@@ -16,9 +16,15 @@
'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,
+ # Use nonces from django-csp when available
+ 'CSP_NONCE': False
}
}
diff --git a/webpack_loader/loaders.py b/webpack_loader/loaders.py
index 9e6c52aa..2fde9310 100644
--- a/webpack_loader/loaders.py
+++ b/webpack_loader/loaders.py
@@ -1,18 +1,47 @@
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.request import HttpRequest
from .exceptions import (
+ WebpackBundleLookupError,
WebpackError,
WebpackLoaderBadStatsError,
WebpackLoaderTimeoutError,
- 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. '
+ 'Chunk name: {chunk_name}')
+_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. Chunk name: {chunk_name}')
+_NONCE_NO_REQUEST = (
+ 'You have enabled the adding of nonce attributes to generated tags via '
+ 'django_webpack_loader, but haven\'t passed a request. '
+ 'Chunk name: {chunk_name}')
+_NONCE_NO_CSPNONCE = (
+ 'django_webpack_loader can\'t generate a nonce tag for a bundle, '
+ 'because the passed request doesn\'t contain a "csp_nonce". '
+ 'Chunk name: {chunk_name}')
+
+
+@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 +71,69 @@ def get_asset_by_source_filename(self, name):
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 " "
-
- integrity = chunk.get("integrity")
+ def _add_crossorigin(
+ self, request: Optional[HttpRequest], chunk: Dict[str, str],
+ integrity: str, attrs_l: str) -> str:
+ 'Return an added `crossorigin` attribute if necessary.'
+ def_value = f' integrity="{integrity}" '
+ if not request:
+ message = _CROSSORIGIN_NO_REQUEST.format(chunk_name=chunk['name'])
+ warn(message=message, category=RuntimeWarning)
+ return def_value
+ if 'crossorigin' in attrs_l:
+ return def_value
+ host: Optional[str] = request.META.get('HTTP_HOST')
+ if not host:
+ message = _CROSSORIGIN_NO_HOST.format(chunk_name=chunk['name'])
+ warn(message=message, category=RuntimeWarning)
+ return def_value
+ netloc = _get_netloc(url=chunk['url'])
+ if netloc == '' or netloc == host:
+ # Crossorigin not necessary
+ return def_value
+ cfgval: str = self.config.get('CROSSORIGIN')
+ if cfgval == '':
+ return f'{def_value}crossorigin '
+ return f'{def_value}crossorigin="{cfgval}" '
+
+ def get_integrity_attr(
+ self, chunk: Dict[str, str], request: Optional[HttpRequest],
+ attrs_l: str) -> 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?"
+ '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=chunk,
+ integrity=integrity,
+ attrs_l=attrs_l,
+ )
- return ' integrity="{}" '.format(integrity.partition(" ")[0])
+ def get_nonce_attr(self, chunk: Dict[str, str], request: Optional[HttpRequest], attrs: str) -> str:
+ 'Return an added nonce for CSP when available.'
+ if not self.config.get('CSP_NONCE'):
+ return ''
+ if request is None:
+ message = _NONCE_NO_REQUEST.format(chunk_name=chunk['name'])
+ warn(message=message, category=RuntimeWarning)
+ return ''
+ nonce = getattr(request, 'csp_nonce', None)
+ if nonce is None:
+ message = _NONCE_NO_CSPNONCE.format(chunk_name=chunk['name'])
+ warn(message=message, category=RuntimeWarning)
+ return ''
+ if 'nonce=' in attrs.lower():
+ return ''
+ return f'nonce="{nonce}" '
def filter_chunks(self, chunks):
filtered_chunks = []
diff --git a/webpack_loader/templatetags/webpack_loader.py b/webpack_loader/templatetags/webpack_loader.py
index e70c6dd7..5b838a7a 100644
--- a/webpack_loader/templatetags/webpack_loader.py
+++ b/webpack_loader/templatetags/webpack_loader.py
@@ -1,5 +1,7 @@
+from typing import Optional
from warnings import warn
+from django.http.request import HttpRequest
from django.template import Library
from django.utils.safestring import mark_safe
@@ -20,33 +22,36 @@ 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: Optional[HttpRequest] = context.get('request')
+ tags = utils.get_as_url_to_tag_dict(
+ bundle_name, request=request, extension=extension, config=config,
+ suffix=suffix, attrs=attrs, is_preload=is_preload)
- request = context.get('request')
if request is None:
if skip_common_chunks:
warn(message=_WARNING_MESSAGE, category=RuntimeWarning)
- return mark_safe('\n'.join(url_to_tag_dict.values()))
+ return mark_safe('\n'.join(tags.values()))
used_urls = getattr(request, '_webpack_loader_used_urls', None)
- if not used_urls:
- used_urls = request._webpack_loader_used_urls = set()
+ if used_urls is None:
+ used_urls = set()
+ setattr(request, '_webpack_loader_used_urls', used_urls)
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())
- return mark_safe('\n'.join(url_to_tag_dict.values()))
+ tags = {url: tag for url, tag in tags.items() if url not in used_urls}
+ used_urls.update(tags)
+ return mark_safe('\n'.join(tags.values()))
@register.simple_tag
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',
diff --git a/webpack_loader/utils.py b/webpack_loader/utils.py
index 25423c02..e789f70b 100644
--- a/webpack_loader/utils.py
+++ b/webpack_loader/utils.py
@@ -1,9 +1,12 @@
-from collections import OrderedDict
+from functools import lru_cache
from importlib import import_module
+from typing import Optional, OrderedDict
+
from django.conf import settings
-from .config import load_config
+from django.http.request import HttpRequest
-_loaders = {}
+from .config import load_config
+from .loaders import WebpackLoader
def import_string(dotted_path):
@@ -21,12 +24,11 @@ def import_string(dotted_path):
raise ImportError('%s doesn\'t look like a valid module path' % dotted_path)
-def get_loader(config_name):
- if config_name not in _loaders:
- config = load_config(config_name)
- loader_class = import_string(config['LOADER_CLASS'])
- _loaders[config_name] = loader_class(config_name, config)
- return _loaders[config_name]
+@lru_cache(maxsize=None)
+def get_loader(config_name) -> WebpackLoader:
+ config = load_config(config_name)
+ loader_class = import_string(config['LOADER_CLASS'])
+ return loader_class(config_name, config)
def get_skip_common_chunks(config_name):
@@ -57,7 +59,10 @@ 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: Optional[HttpRequest] = None, extension=None,
+ config='DEFAULT', suffix='', attrs='', is_preload=False
+) -> OrderedDict[str, str]:
'''
Get a dict of URLs to formatted '
+ ''
).format(
''.join([chunk['url'], suffix]),
attrs,
- loader.get_integrity_attr(chunk),
+ loader.get_integrity_attr(chunk, request, attrs_l),
+ loader.get_nonce_attr(chunk, request, attrs_l),
)
elif chunk['name'].endswith(('.css', '.css.gz')):
result[chunk['url']] = (
- ''
+ ''
).format(
''.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_l),
+ loader.get_nonce_attr(chunk, request, attrs_l),
)
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