diff --git a/docs/configuration.rst b/docs/configuration.rst
index 13c807c9..a1ac1b24 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -132,6 +132,25 @@ A dictionary passed to compiler's ``compile_file`` method as kwargs. None of def
Defaults to ``{}``.
+``crossorigin``
+...............
+
+**Optional**
+
+Indicate if you want to add to the group this attribute that provides support for CORS, defining how the element handles cross-origin requests, thereby enabling the configuration of the CORS requests for the element's fetched data. .
+
+Missing by default (the attribute is not added), the only valid values currently are ``anonymous`` and ``use-credentials``.
+
+``integrity``
+.............
+
+**Optional**
+
+Indicate if you want to add the sub-resource integrity (SRI) attribute to the group.
+This attribute contains inline metadata that a user agent can use to verify that a fetched resource has been delivered free of unexpected manipulation
+
+Missing by default, and only valid values are ``"sha256"``, ``"sha384"`` and ``"sha512"``.
+
Other settings
--------------
diff --git a/pipeline/jinja2/__init__.py b/pipeline/jinja2/__init__.py
index 827003a2..e954784e 100644
--- a/pipeline/jinja2/__init__.py
+++ b/pipeline/jinja2/__init__.py
@@ -42,7 +42,12 @@ def render_css(self, package, path):
template_name = package.template_name or "pipeline/css.jinja"
context = package.extra_context
context.update(
- {"type": guess_type(path, "text/css"), "url": staticfiles_storage.url(path)}
+ {
+ "type": guess_type(path, "text/css"),
+ "url": staticfiles_storage.url(path),
+ "crossorigin": package.config.get("crossorigin"),
+ "integrity": package.get_sri(path),
+ }
)
template = self.environment.get_template(template_name)
return template.render(context)
@@ -66,6 +71,8 @@ def render_js(self, package, path):
{
"type": guess_type(path, "text/javascript"),
"url": staticfiles_storage.url(path),
+ "crossorigin": package.config.get("crossorigin"),
+ "integrity": package.get_sri(path),
}
)
template = self.environment.get_template(template_name)
diff --git a/pipeline/jinja2/pipeline/css.jinja b/pipeline/jinja2/pipeline/css.jinja
index d40e7dc0..508f2add 100644
--- a/pipeline/jinja2/pipeline/css.jinja
+++ b/pipeline/jinja2/pipeline/css.jinja
@@ -1 +1 @@
-
+
diff --git a/pipeline/jinja2/pipeline/js.jinja b/pipeline/jinja2/pipeline/js.jinja
index 6d3c8d49..7e8e4003 100644
--- a/pipeline/jinja2/pipeline/js.jinja
+++ b/pipeline/jinja2/pipeline/js.jinja
@@ -1 +1 @@
-
+
diff --git a/pipeline/packager.py b/pipeline/packager.py
index 2dbccb33..7028babe 100644
--- a/pipeline/packager.py
+++ b/pipeline/packager.py
@@ -1,3 +1,7 @@
+import base64
+import hashlib
+from functools import lru_cache
+
from django.contrib.staticfiles.finders import find, get_finders
from django.contrib.staticfiles.storage import staticfiles_storage
from django.core.files.base import ContentFile
@@ -61,6 +65,20 @@ def manifest(self):
def compiler_options(self):
return self.config.get("compiler_options", {})
+ @lru_cache
+ def get_sri(self, path):
+ method = self.config.get("integrity")
+ if method not in {"sha256", "sha384", "sha512"}:
+ return None
+ if staticfiles_storage.exists(path):
+ with staticfiles_storage.open(path) as fd:
+ h = getattr(hashlib, method)()
+ for data in iter(lambda: fd.read(16384), b""):
+ h.update(data)
+ digest = base64.b64encode(h.digest()).decode()
+ return f"{method}-{digest}"
+ return None
+
class Packager:
def __init__(
diff --git a/pipeline/templates/pipeline/css.html b/pipeline/templates/pipeline/css.html
index 4321d63e..8e3a558b 100644
--- a/pipeline/templates/pipeline/css.html
+++ b/pipeline/templates/pipeline/css.html
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/pipeline/templates/pipeline/css.jinja b/pipeline/templates/pipeline/css.jinja
index d40e7dc0..508f2add 100644
--- a/pipeline/templates/pipeline/css.jinja
+++ b/pipeline/templates/pipeline/css.jinja
@@ -1 +1 @@
-
+
diff --git a/pipeline/templates/pipeline/js.html b/pipeline/templates/pipeline/js.html
index 29263f72..ff11b246 100644
--- a/pipeline/templates/pipeline/js.html
+++ b/pipeline/templates/pipeline/js.html
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/pipeline/templates/pipeline/js.jinja b/pipeline/templates/pipeline/js.jinja
index 6d3c8d49..7e8e4003 100644
--- a/pipeline/templates/pipeline/js.jinja
+++ b/pipeline/templates/pipeline/js.jinja
@@ -1 +1 @@
-
+
diff --git a/pipeline/templatetags/pipeline.py b/pipeline/templatetags/pipeline.py
index 3f38cca1..6116d6a9 100644
--- a/pipeline/templatetags/pipeline.py
+++ b/pipeline/templatetags/pipeline.py
@@ -152,6 +152,8 @@ def render_css(self, package, path):
{
"type": guess_type(path, "text/css"),
"url": mark_safe(staticfiles_storage.url(path)),
+ "crossorigin": package.config.get("crossorigin"),
+ "integrity": package.get_sri(path),
}
)
return render_to_string(template_name, context)
@@ -188,6 +190,8 @@ def render_js(self, package, path):
{
"type": guess_type(path, "text/javascript"),
"url": mark_safe(staticfiles_storage.url(path)),
+ "crossorigin": package.config.get("crossorigin"),
+ "integrity": package.get_sri(path),
}
)
return render_to_string(template_name, context)
diff --git a/tests/settings.py b/tests/settings.py
index fb54d7e7..e1334076 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -2,6 +2,8 @@
import os
import shutil
+from django.utils import version
+
def local_path(path):
return os.path.join(os.path.dirname(__file__), path)
@@ -42,7 +44,19 @@ def local_path(path):
MEDIA_ROOT = local_path("media")
-STATICFILES_STORAGE = "pipeline.storage.PipelineStorage"
+django_version = version.get_complete_version()
+if django_version >= (4, 2):
+
+ STORAGES = {
+ "default": {
+ "BACKEND": "pipeline.storage.PipelineStorage",
+ },
+ "staticfiles": {
+ "BACKEND": "pipeline.storage.PipelineStorage",
+ },
+ }
+else:
+ STATICFILES_STORAGE = "pipeline.storage.PipelineStorage"
STATIC_ROOT = local_path("static/")
STATIC_URL = "/static/"
STATICFILES_DIRS = (("pipeline", local_path("assets/")),)
@@ -89,6 +103,42 @@ def local_path(path):
"title": "Default Style",
},
},
+ "screen_crossorigin": {
+ "source_filenames": (
+ "pipeline/css/first.css",
+ "pipeline/css/second.css",
+ "pipeline/css/urls.css",
+ ),
+ "output_filename": "screen_crossorigin.css",
+ "crossorigin": "anonymous",
+ },
+ "screen_sri_sha256": {
+ "source_filenames": (
+ "pipeline/css/first.css",
+ "pipeline/css/second.css",
+ "pipeline/css/urls.css",
+ ),
+ "output_filename": "screen_sri_sha256.css",
+ "integrity": "sha256",
+ },
+ "screen_sri_sha384": {
+ "source_filenames": (
+ "pipeline/css/first.css",
+ "pipeline/css/second.css",
+ "pipeline/css/urls.css",
+ ),
+ "output_filename": "screen_sri_sha384.css",
+ "integrity": "sha384",
+ },
+ "screen_sri_sha512": {
+ "source_filenames": (
+ "pipeline/css/first.css",
+ "pipeline/css/second.css",
+ "pipeline/css/urls.css",
+ ),
+ "output_filename": "screen_sri_sha512.css",
+ "integrity": "sha512",
+ },
},
"JAVASCRIPT": {
"scripts": {
@@ -137,6 +187,46 @@ def local_path(path):
"defer": True,
},
},
+ "scripts_crossorigin": {
+ "source_filenames": (
+ "pipeline/js/first.js",
+ "pipeline/js/second.js",
+ "pipeline/js/application.js",
+ "pipeline/templates/**/*.jst",
+ ),
+ "output_filename": "scripts_crossorigin.js",
+ "crossorigin": "anonymous",
+ },
+ "scripts_sri_sha256": {
+ "source_filenames": (
+ "pipeline/js/first.js",
+ "pipeline/js/second.js",
+ "pipeline/js/application.js",
+ "pipeline/templates/**/*.jst",
+ ),
+ "output_filename": "scripts_sha256.js",
+ "integrity": "sha256",
+ },
+ "scripts_sri_sha384": {
+ "source_filenames": (
+ "pipeline/js/first.js",
+ "pipeline/js/second.js",
+ "pipeline/js/application.js",
+ "pipeline/templates/**/*.jst",
+ ),
+ "output_filename": "scripts_sha384.js",
+ "integrity": "sha384",
+ },
+ "scripts_sri_sha512": {
+ "source_filenames": (
+ "pipeline/js/first.js",
+ "pipeline/js/second.js",
+ "pipeline/js/application.js",
+ "pipeline/templates/**/*.jst",
+ ),
+ "output_filename": "scripts_sha512.js",
+ "integrity": "sha512",
+ },
},
}
diff --git a/tests/tests/test_template.py b/tests/tests/test_template.py
index 0bb17669..1ec55c8e 100644
--- a/tests/tests/test_template.py
+++ b/tests/tests/test_template.py
@@ -1,3 +1,8 @@
+import base64
+import hashlib
+
+from django.contrib.staticfiles.storage import staticfiles_storage
+from django.core.management import call_command
from django.template import Context, Template
from django.test import TestCase
from jinja2 import Environment, PackageLoader
@@ -8,6 +13,8 @@
class JinjaTest(TestCase):
def setUp(self):
+ staticfiles_storage._setup()
+ call_command("collectstatic", verbosity=0, interactive=False)
self.env = Environment(
extensions=[PipelineExtension],
loader=PackageLoader("pipeline", "templates"),
@@ -64,8 +71,99 @@ def test_package_js_async_defer(self):
template.render(),
)
+ def test_crossorigin(self):
+ template = self.env.from_string("""{% javascript "scripts_crossorigin" %}""")
+ self.assertEqual(
+ (
+ ''
+ ),
+ template.render(),
+ ) # noqa
+ template = self.env.from_string("""{% stylesheet "screen_crossorigin" %}""")
+ self.assertEqual(
+ (
+ ''
+ ),
+ template.render(),
+ ) # noqa
+
+ def test_sri_sha256(self):
+ template = self.env.from_string("""{% javascript "scripts_sri_sha256" %}""")
+ hash_ = self.get_integrity("scripts_sha256.js", "sha256")
+ self.assertEqual(
+ (
+ ''
+ ),
+ template.render(),
+ ) # noqa
+ template = self.env.from_string("""{% stylesheet "screen_sri_sha256" %}""")
+ hash_ = self.get_integrity("screen_sri_sha256.css", "sha256")
+ self.assertEqual(
+ (
+ f''
+ ),
+ template.render(),
+ ) # noqa
+
+ def test_sri_sha384(self):
+ template = self.env.from_string("""{% javascript "scripts_sri_sha384" %}""")
+ hash_ = self.get_integrity("scripts_sha384.js", "sha384")
+ self.assertEqual(
+ (
+ ''
+ ),
+ template.render(),
+ ) # noqa
+ template = self.env.from_string("""{% stylesheet "screen_sri_sha384" %}""")
+ hash_ = self.get_integrity("screen_sri_sha384.css", "sha384")
+ self.assertEqual(
+ (
+ f''
+ ),
+ template.render(),
+ ) # noqa
+
+ def test_sri_sha512(self):
+ template = self.env.from_string("""{% javascript "scripts_sri_sha512" %}""")
+ hash_ = self.get_integrity("scripts_sha512.js", "sha512")
+ self.assertEqual(
+ (
+ ''
+ ),
+ template.render(),
+ ) # noqa
+ template = self.env.from_string("""{% stylesheet "screen_sri_sha512" %}""")
+ hash_ = self.get_integrity("screen_sri_sha512.css", "sha512")
+ self.assertEqual(
+ (
+ f''
+ ),
+ template.render(),
+ ) # noqa
+
+ @staticmethod
+ def get_integrity(path, method):
+ with staticfiles_storage.open(path) as fd:
+ h = getattr(hashlib, method)(fd.read())
+ digest = base64.b64encode(h.digest()).decode()
+ return f"{method}-{digest}"
+
class DjangoTest(TestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ staticfiles_storage._setup()
+ call_command("collectstatic", verbosity=0, interactive=False)
+
def render_template(self, template):
return Template(template).render(Context())
@@ -137,3 +235,97 @@ def test_compressed_js_async_defer(self):
'', # noqa
rendered,
)
+
+ def test_crossorigin(self):
+ rendered = self.render_template(
+ """{% load pipeline %}{% javascript "scripts_crossorigin" %}"""
+ ) # noqa
+ self.assertEqual(
+ (
+ ''
+ ),
+ rendered,
+ ) # noqa
+ rendered = self.render_template(
+ """{% load pipeline %}{% stylesheet "screen_crossorigin" %}"""
+ ) # noqa
+ self.assertEqual(
+ (
+ ''
+ ),
+ rendered,
+ ) # noqa
+
+ def test_sri_sha256(self):
+ rendered = self.render_template(
+ """{% load pipeline %}{% javascript "scripts_sri_sha256" %}"""
+ ) # noqa
+ hash_ = JinjaTest.get_integrity("scripts_sha256.js", "sha256")
+ self.assertEqual(
+ (
+ ''
+ ),
+ rendered,
+ ) # noqa
+ rendered = self.render_template(
+ """{% load pipeline %}{% stylesheet "screen_sri_sha256" %}"""
+ ) # noqa
+ hash_ = JinjaTest.get_integrity("screen_sri_sha256.css", "sha256")
+ self.assertEqual(
+ (
+ f''
+ ),
+ rendered,
+ ) # noqa
+
+ def test_sri_sha384(self):
+ rendered = self.render_template(
+ """{% load pipeline %}{% javascript "scripts_sri_sha384" %}"""
+ ) # noqa
+ hash_ = JinjaTest.get_integrity("scripts_sha384.js", "sha384")
+ self.assertEqual(
+ (
+ ''
+ ),
+ rendered,
+ ) # noqa
+ rendered = self.render_template(
+ """{% load pipeline %}{% stylesheet "screen_sri_sha384" %}"""
+ ) # noqa
+ hash_ = JinjaTest.get_integrity("screen_sri_sha384.css", "sha384")
+ self.assertEqual(
+ (
+ f''
+ ),
+ rendered,
+ ) # noqa
+
+ def test_sri_sha512(self):
+ rendered = self.render_template(
+ """{% load pipeline %}{% javascript "scripts_sri_sha512" %}"""
+ ) # noqa
+ hash_ = JinjaTest.get_integrity("scripts_sha512.js", "sha512")
+ self.assertEqual(
+ (
+ ''
+ ),
+ rendered,
+ ) # noqa
+ rendered = self.render_template(
+ """{% load pipeline %}{% stylesheet "screen_sri_sha512" %}"""
+ ) # noqa
+ hash_ = JinjaTest.get_integrity("screen_sri_sha512.css", "sha512")
+ self.assertEqual(
+ (
+ f''
+ ),
+ rendered,
+ ) # noqa
diff --git a/tox.ini b/tox.ini
index 261bed83..ab1d7944 100644
--- a/tox.ini
+++ b/tox.ini
@@ -47,6 +47,7 @@ deps =
setenv =
DJANGO_SETTINGS_MODULE = tests.settings
PYTHONPATH = {toxinidir}
+ JAVA_HOME = /usr/local/Cellar/openjdk/22.0.1
commands =
npm install
{envbindir}/coverage run --source pipeline {envbindir}/django-admin test {posargs:tests}