Repository: openfun/marsha

Issue:
statics js chunks are not served from cloud-front when AWS is configured to managed statics
## Bug Report

**Expected behavior/code**

In environment allowing to manage statics with AWS (staging, preproduction and production), every statics files should be fetched on the cloud-front CDN

**Actual Behavior**

First the `index.js` file is fetched on cloud-front, then every chunks are fetched on the current domain name:

![Screenshot from 2019-04-24 18-17-12](https://user-images.githubusercontent.com/767834/56675797-468cde80-66bd-11e9-84b3-526bdfc74a6d.png)

**Steps to Reproduce**
1. Deploy marsha in an environment listed before using AWS to manage statics
2. Monitor networks in your browser and you will se that every chunk are in 404

**Possible Solution**

In webpack we use the [output.publicpath](https://webpack.js.org/configuration/output/#outputpublicpath) option to change the static files paths but in the documentation it is also written that when you use a CDN, the CDN URL should be use, but in our case it's not possible.

I don't really know if it's a webpack config issue or if it is related to the manifest generated by the backend during the collecstatic job. 



Discussion:

Resolving patch:
diff --git a/src/backend/marsha/core/storage.py b/src/backend/marsha/core/storage.py
--- a/src/backend/marsha/core/storage.py
+++ b/src/backend/marsha/core/storage.py
@@ -1,15 +1,49 @@
 """Customizing Django storage backends to enable blue/green deployments."""
+from collections import OrderedDict
+import re
+
 from django.conf import settings
 from django.contrib.staticfiles.storage import ManifestFilesMixin, StaticFilesStorage
 
 from storages.backends.s3boto3 import S3Boto3Storage
 
 
+STATIC_POSTPROCESS_IGNORE_REGEX = re.compile(
+    getattr(settings, "STATIC_POSTPROCESS_IGNORE_REGEX", "^$")
+)
+
+
 class ConfigurableManifestFilesMixin(ManifestFilesMixin):
     """Allow configuring the name of the manifest file via Django settings."""
 
     manifest_name = settings.STATICFILES_MANIFEST_NAME
 
+    # pylint: disable=arguments-differ,unused-argument,missing-yield-doc,missing-yield-type-doc
+    def post_process(self, paths, dry_run=False, **options):
+        """Remove paths from file to post process.
+
+        Some js static files generated by webpack already have a unique name per build
+        and may be referenced from within the js applications. We therefore don't want
+        to hash their name and include them in the manifest file.
+        We use a regex configurable via settings to decide which files to ignore.
+
+        Parameters
+        ----------
+        paths : OrderedDict
+            List of files to post process
+        dry_run: boolean
+            Like canada dry, it tastes alcohol but it's not
+        options: kwargs
+            See HashedFilesMixin.post_process
+
+        """
+        filtered_paths = OrderedDict()
+        for path in paths:
+            if not STATIC_POSTPROCESS_IGNORE_REGEX.match(path):
+                filtered_paths[path] = paths[path]
+
+        yield from super().post_process(filtered_paths, dry_run=dry_run, **options)
+
 
 class ConfigurableManifestStaticFilesStorage(
     ConfigurableManifestFilesMixin, StaticFilesStorage
diff --git a/src/backend/marsha/core/views.py b/src/backend/marsha/core/views.py
--- a/src/backend/marsha/core/views.py
+++ b/src/backend/marsha/core/views.py
@@ -3,6 +3,7 @@
 from logging import getLogger
 import uuid
 
+from django.conf import settings
 from django.core.exceptions import ImproperlyConfigured
 from django.utils.decorators import method_decorator
 from django.views.decorators.clickjacking import xframe_options_exempt
@@ -101,6 +102,9 @@ def get_context_data(self):
             VideoSerializer(video).data if video else None
         )
 
+        if getattr(settings, "STATICFILES_AWS_ENABLED", False):
+            context["cloudfront_domain"] = settings.CLOUDFRONT_DOMAIN
+
         return context
 
     # pylint: disable=unused-argument
diff --git a/src/backend/marsha/settings.py b/src/backend/marsha/settings.py
--- a/src/backend/marsha/settings.py
+++ b/src/backend/marsha/settings.py
@@ -211,6 +211,9 @@ class Base(Configuration):
     LRS_URL = values.Value("")
     LRS_XAPI_VERSION = values.Value("1.0.3")
 
+    # Helper to easily know if static files in AWS is activated
+    STATICFILES_AWS_ENABLED = False
+
     # pylint: disable=invalid-name
     @property
     def SIMPLE_JWT(self):
@@ -369,6 +372,9 @@ class Production(Base):
     ALLOWED_HOSTS = values.ListValue(None)
     AWS_SOURCE_BUCKET_NAME = values.Value("production-marsha-source")
 
+    # Helper to easily know if static files in AWS is activated
+    STATICFILES_AWS_ENABLED = True
+
     # For static files in production, we want to use a backend that includes a hash in
     # the filename, that is calculated from the file content, so that browsers always
     # get the updated version of each file.
@@ -393,6 +399,10 @@ class Production(Base):
     # in the cloudfront configuration.
     AWS_LOCATION = Base.STATIC_URL
 
+    # pattern matching files to ignore when hashing file names and exclude from the
+    # static files manifest
+    STATIC_POSTPROCESS_IGNORE_REGEX = values.Value(r"^js\/[0-9]*\..*\.index\.js$")
+
 
 class Staging(Production):
     """Staging environment settings."""


Test patch:
diff --git a/src/backend/marsha/core/tests/test_views_lti_video.py b/src/backend/marsha/core/tests/test_views_lti_video.py
--- a/src/backend/marsha/core/tests/test_views_lti_video.py
+++ b/src/backend/marsha/core/tests/test_views_lti_video.py
@@ -355,6 +355,50 @@ def test_views_video_lti_with_timed_text(self):
         self.assertEqual(jwt_token.payload["roles"], [data["roles"]])
         self.assertEqual(jwt_token.payload["locale"], "en_US")
 
+    @override_settings(STATICFILES_AWS_ENABLED=False)
+    @override_settings(CLOUDFRONT_DOMAIN="abcd.cloudfront.net")
+    def test_views_video_staticfiles_aws_disabled(self):
+        """Meta tag public-path should'nt be in the response when staticfiles on AWS is diabled."""
+        passport = ConsumerSiteLTIPassportFactory()
+        video = VideoFactory(playlist__consumer_site=passport.consumer_site)
+        data = {
+            "resource_link_id": video.lti_id,
+            "context_id": video.playlist.lti_id,
+            "roles": "instructor",
+            "oauth_consumer_key": passport.oauth_consumer_key,
+            "user_id": "56255f3807599c377bf0e5bf072359fd",
+            "launch_presentation_locale": "fr",
+        }
+        with mock.patch.object(LTI, "verify", return_value=passport.consumer_site):
+            response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "<html>")
+        self.assertNotContains(
+            response, '<meta name="public-path" value="abcd.cloudfront.net" />'
+        )
+
+    @override_settings(STATICFILES_AWS_ENABLED=True)
+    @override_settings(CLOUDFRONT_DOMAIN="abcd.cloudfront.net")
+    def test_views_video_staticfiles_aws_enabled(self):
+        """Meta tag public-path should be in the response when staticfiles on AWS is enabled."""
+        passport = ConsumerSiteLTIPassportFactory()
+        video = VideoFactory(playlist__consumer_site=passport.consumer_site)
+        data = {
+            "resource_link_id": video.lti_id,
+            "context_id": video.playlist.lti_id,
+            "roles": "instructor",
+            "oauth_consumer_key": passport.oauth_consumer_key,
+            "user_id": "56255f3807599c377bf0e5bf072359fd",
+            "launch_presentation_locale": "fr",
+        }
+        with mock.patch.object(LTI, "verify", return_value=passport.consumer_site):
+            response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "<html>")
+        self.assertContains(
+            response, '<meta name="public-path" value="abcd.cloudfront.net" />'
+        )
+
 
 class DevelopmentViewsTestCase(TestCase):
     """Test the views in the ``core`` app of the Marsha project for development use cases.