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}