Repository: learningequality/kolibri

Issue:
Hashi no hash-y
### Observed behavior
Currently we build hashi without a build hash. This means that in the future if we upgrade our Hashi functionality, this may not get cache busted.

### Expected behavior
New versions of Hashi should cache bust old versions!

### User-facing consequences
Weird, unpredictable Hashi behaviour, depending on previous use.
Not possible to 'complete' an HTML5 App
### Observed behavior
In introducing Hashi, the 15s insta-points was removed from HTML5 apps. Now impossible to register any progress.

### Expected behavior
What should progress look like for an arbitrary HTML5 app?

### User-facing consequences
Confusion?


Discussion:

Resolving patch:
diff --git a/kolibri/core/content/views.py b/kolibri/core/content/views.py
--- a/kolibri/core/content/views.py
+++ b/kolibri/core/content/views.py
@@ -1,15 +1,20 @@
+import hashlib
+import io
 import mimetypes
 import os
 import zipfile
 
+from django.conf import settings
 from django.core.cache import cache
 from django.http import Http404
 from django.http import HttpResponse
 from django.http.response import FileResponse
 from django.http.response import HttpResponseNotModified
-from django.urls import reverse
 from django.template import loader
+from django.urls import reverse
+from django.utils.decorators import method_decorator
 from django.views.decorators.clickjacking import xframe_options_exempt
+from django.views.decorators.http import etag
 from django.views.decorators.vary import vary_on_headers
 from django.views.generic.base import View
 from le_utils.constants import exercises
@@ -24,6 +29,16 @@
 # https://www.thecodingforums.com/threads/mimetypes-guess_type-broken-in-windows-on-py2-7-and-python-3-x.952693/
 mimetypes.init([os.path.join(os.path.dirname(__file__), 'constants', 'mime.types')])
 
+HASHI_FILENAME = None
+
+
+def get_hashi_filename():
+    global HASHI_FILENAME
+    if HASHI_FILENAME is None or getattr(settings, 'DEVELOPER_MODE', None):
+        with io.open(os.path.join(os.path.dirname(__file__), './build/hashi_filename'), mode='r', encoding='utf-8') as f:
+            HASHI_FILENAME = f.read().strip()
+    return HASHI_FILENAME
+
 
 def _add_access_control_headers(request, response):
     response["Access-Control-Allow-Origin"] = "*"
@@ -71,6 +86,21 @@ def _add_content_security_policy_header(request, response):
     response["Content-Security-Policy"] = "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: " + host
 
 
+def calculate_zip_content_etag(request, zipped_filename, embedded_filepath):
+    zipped_path = get_content_storage_file_path(zipped_filename)
+
+    # if no path, or a directory, is being referenced, look for an index.html file
+    if not embedded_filepath or embedded_filepath.endswith("/"):
+        embedded_filepath += "index.html"
+
+    # Are we returning the Hashi bootstrap html? In which case the etag should change
+    # along with the built file asset of the Hashi client library.
+    if (not request.is_ajax()) and zipped_path.endswith('zip') and (embedded_filepath.endswith('htm') or embedded_filepath.endswith('html')):
+        return hashlib.md5(get_hashi_filename().encode('utf-8')).hexdigest()
+
+    return hashlib.md5((zipped_filename + embedded_filepath).encode('utf-8')).hexdigest()
+
+
 class ZipContentView(View):
 
     @xframe_options_exempt
@@ -85,6 +115,7 @@ def options(self, request, *args, **kwargs):
     @vary_on_headers('X-Requested-With')
     @cache_forever
     @xframe_options_exempt
+    @method_decorator(etag(calculate_zip_content_etag))
     def get(self, request, zipped_filename, embedded_filepath):
         """
         Handles GET requests and serves a static file from within the zip file.
@@ -127,7 +158,8 @@ def get(self, request, zipped_filename, embedded_filepath):
                 bootstrap_content = cache.get(cache_key)
                 if bootstrap_content is None:
                     template = loader.get_template('content/hashi.html')
-                    bootstrap_content = template.render({}, None)
+                    hashi_path = "content/{filename}".format(filename=get_hashi_filename())
+                    bootstrap_content = template.render({"hashi_path": hashi_path}, None)
                     cache.set(cache_key, bootstrap_content)
                 response = HttpResponse(bootstrap_content)
                 _add_access_control_headers(request, response)


Test patch:
diff --git a/kolibri/core/content/test/test_zipcontent.py b/kolibri/core/content/test/test_zipcontent.py
--- a/kolibri/core/content/test/test_zipcontent.py
+++ b/kolibri/core/content/test/test_zipcontent.py
@@ -7,6 +7,7 @@
 from django.test import Client
 from django.test import TestCase
 from le_utils.constants import exercises
+from mock import patch
 
 from ..models import LocalFile
 from ..utils.paths import get_content_storage_file_path
@@ -14,6 +15,9 @@
 from kolibri.utils.tests.helpers import override_option
 
 
+DUMMY_FILENAME = 'hashi123.js'
+
+
 @override_option("Paths", "CONTENT_DIR", tempfile.mkdtemp())
 class ZipContentTestCase(TestCase):
     """
@@ -130,52 +134,62 @@ def test_json_image_replacement_no_http_referer_header(self):
             response.content.decode('utf-8'),
             self.test_str_3.replace("$" + exercises.IMG_PLACEHOLDER, (server_name.replace('http:', '') + self.zip_file_base_url)).strip("/"))
 
-    def test_non_xhr_request_for_html_return_hashi(self):
+    @patch('kolibri.core.content.views.get_hashi_filename', return_value=DUMMY_FILENAME)
+    def test_non_xhr_request_for_html_return_hashi(self, filename_patch):
+        hashi_path = "content/{filename}".format(filename=DUMMY_FILENAME)
         client = Client()
         response = client.get(self.zip_file_base_url)
         template = loader.get_template('content/hashi.html')
-        hashi_snippet = template.render({}, None)
+        hashi_snippet = template.render({'hashi_path': hashi_path}, None)
         self.assertEqual(response.content.decode(), hashi_snippet)
 
     def test_xhr_request_for_html_return_html(self):
         response = self.client.get(self.zip_file_base_url)
         self.assertEqual(b"".join(response.streaming_content).decode(), self.index_str)
 
-    def test_non_xhr_nonexistent_zip_file_access(self):
+    @patch('kolibri.core.content.views.get_hashi_filename', return_value=DUMMY_FILENAME)
+    def test_non_xhr_nonexistent_zip_file_access(self, filename_patch):
         client = Client()
         bad_base_url = self.zip_file_base_url.replace(self.zip_file_base_url[20:25], "aaaaa")
         response = client.get(bad_base_url + self.test_name_1)
         self.assertEqual(response.status_code, 404)
 
-    def test_non_xhr_zip_file_nonexistent_internal_file_access(self):
+    @patch('kolibri.core.content.views.get_hashi_filename', return_value=DUMMY_FILENAME)
+    def test_non_xhr_zip_file_nonexistent_internal_file_access(self, filename_patch):
         response = self.non_xhr_client.get(self.zip_file_base_url + "qqq" + self.index_name)
         self.assertEqual(response.status_code, 404)
 
-    def test_non_xhr_not_modified_response_when_if_modified_since_header_set_index_file(self):
+    @patch('kolibri.core.content.views.get_hashi_filename', return_value=DUMMY_FILENAME)
+    def test_non_xhr_not_modified_response_when_if_modified_since_header_set_index_file(self, filename_patch):
         caching_client = Client(HTTP_IF_MODIFIED_SINCE="Sat, 10-Sep-2016 19:14:07 GMT")
         response = caching_client.get(self.zip_file_base_url)
         self.assertEqual(response.status_code, 304)
 
-    def test_non_xhr_not_modified_response_when_if_modified_since_header_set_other_html_file(self):
+    @patch('kolibri.core.content.views.get_hashi_filename', return_value=DUMMY_FILENAME)
+    def test_non_xhr_not_modified_response_when_if_modified_since_header_set_other_html_file(self, filename_patch):
         caching_client = Client(HTTP_IF_MODIFIED_SINCE="Sat, 10-Sep-2016 19:14:07 GMT")
         response = caching_client.get(self.zip_file_base_url + self.other_name)
         self.assertEqual(response.status_code, 304)
 
-    def test_non_xhr_content_security_policy_header(self):
+    @patch('kolibri.core.content.views.get_hashi_filename', return_value=DUMMY_FILENAME)
+    def test_non_xhr_content_security_policy_header(self, filename_patch):
         response = self.non_xhr_client.get(self.zip_file_base_url + self.index_name)
         self.assertEqual(response.get("Content-Security-Policy"), "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: http://testserver")
 
-    def test_non_xhr_access_control_allow_origin_header(self):
+    @patch('kolibri.core.content.views.get_hashi_filename', return_value=DUMMY_FILENAME)
+    def test_non_xhr_access_control_allow_origin_header(self, filename_patch):
         response = self.non_xhr_client.get(self.zip_file_base_url + self.index_name)
         self.assertEqual(response.get("Access-Control-Allow-Origin"), "*")
         response = self.non_xhr_client.options(self.zip_file_base_url + self.index_name)
         self.assertEqual(response.get("Access-Control-Allow-Origin"), "*")
 
-    def test_non_xhr_x_frame_options_header(self):
+    @patch('kolibri.core.content.views.get_hashi_filename', return_value=DUMMY_FILENAME)
+    def test_non_xhr_x_frame_options_header(self, filename_patch):
         response = self.non_xhr_client.get(self.zip_file_base_url + self.index_name)
         self.assertEqual(response.get("X-Frame-Options", ""), "")
 
-    def test_non_xhr_access_control_allow_headers(self):
+    @patch('kolibri.core.content.views.get_hashi_filename', return_value=DUMMY_FILENAME)
+    def test_non_xhr_access_control_allow_headers(self, filename_patch):
         headerval = "X-Penguin-Dance-Party"
         response = self.non_xhr_client.options(self.zip_file_base_url + self.index_name, HTTP_ACCESS_CONTROL_REQUEST_HEADERS=headerval)
         self.assertEqual(response.get("Access-Control-Allow-Headers", ""), headerval)