Repository: django-compressor/django-compressor

Issue:
Allow separate storage of offline manifest
By default the manifest file is stored in the same storage as the compressed files. Usually this is `static/CACHE/manifest.json`.
The problem with that is, that this gives an easy access to all used files, which can be an issue if some of them are used in protected areas.
Currently, you cannot change that by configuration. Using a relative path for `COMPRESS_OFFLINE_MANIFEST`, e.g. `../../../manifest.json`, won't work, because that will fail due to django's protection against path traversal.
In our projects we're using a subclass of `CompressorFileStorage` which bypasses those checks only for the manifest file, but that's not a clean solution.

**Solution:**
Similar to how django has fixed that same issue for the manifest files of any `ManifestStaticFilesStorage` with https://github.com/django/django/pull/12187 in 4.0, we should also allow to configure a separate file storage for the manifest file.
With that it would be possible to store the file in a private directory.


Discussion:
The linked PR in Django was relatively painless... It's not up to me but, I'd be inclined to think it reasonable enough here (yes). 

Would you be prepared to make a PR? 
I'll be happy to review and merge a PR with tests & documentation implementing this.
I haven't contributed to django-compressor yet, but I could try a PR, just I cannot promise that for the near future. So if someone else wants to implement that, I'm ok with it ;)
@th3hamm0r — No problem. No rush 😃 — The Django PR you linked should be a nice guide. 

Welcome aboard! ⛵ 

Resolving patch:
diff --git a/compressor/cache.py b/compressor/cache.py
--- a/compressor/cache.py
+++ b/compressor/cache.py
@@ -11,7 +11,7 @@
 from django.utils.functional import SimpleLazyObject
 
 from compressor.conf import settings
-from compressor.storage import default_storage
+from compressor.storage import default_offline_manifest_storage
 from compressor.utils import get_mod_func
 
 _cachekey_func = None
@@ -66,20 +66,15 @@ def get_offline_cachekey(source):
     return get_cachekey("offline.%s" % get_offline_hexdigest(source))
 
 
-def get_offline_manifest_filename():
-    output_dir = settings.COMPRESS_OUTPUT_DIR.strip('/')
-    return os.path.join(output_dir, settings.COMPRESS_OFFLINE_MANIFEST)
-
-
 _offline_manifest = None
 
 
 def get_offline_manifest():
     global _offline_manifest
     if _offline_manifest is None:
-        filename = get_offline_manifest_filename()
-        if default_storage.exists(filename):
-            with default_storage.open(filename) as fp:
+        filename = settings.COMPRESS_OFFLINE_MANIFEST
+        if default_offline_manifest_storage.exists(filename):
+            with default_offline_manifest_storage.open(filename) as fp:
                 _offline_manifest = json.loads(fp.read().decode('utf8'))
         else:
             _offline_manifest = {}
@@ -92,9 +87,8 @@ def flush_offline_manifest():
 
 
 def write_offline_manifest(manifest):
-    filename = get_offline_manifest_filename()
     content = json.dumps(manifest, indent=2).encode('utf8')
-    default_storage.save(filename, ContentFile(content))
+    default_offline_manifest_storage.save(settings.COMPRESS_OFFLINE_MANIFEST, ContentFile(content))
     flush_offline_manifest()
 
 
diff --git a/compressor/conf.py b/compressor/conf.py
--- a/compressor/conf.py
+++ b/compressor/conf.py
@@ -75,6 +75,7 @@ class CompressorConf(AppConf):
     OFFLINE_CONTEXT = {}
     # The name of the manifest file (e.g. filename.ext)
     OFFLINE_MANIFEST = 'manifest.json'
+    OFFLINE_MANIFEST_STORAGE = 'compressor.storage.OfflineManifestFileStorage'
     # The Context to be used when TemplateFilter is used
     TEMPLATE_FILTER_CONTEXT = {}
     # Placeholder to be used instead of settings.COMPRESS_URL during offline compression.
diff --git a/compressor/storage.py b/compressor/storage.py
--- a/compressor/storage.py
+++ b/compressor/storage.py
@@ -2,6 +2,7 @@
 import os
 from datetime import datetime
 import time
+from urllib.parse import urljoin
 
 from django.core.files.storage import FileSystemStorage, get_storage_class
 from django.utils.functional import LazyObject, SimpleLazyObject
@@ -110,3 +111,20 @@ def _setup(self):
 
 
 default_storage = DefaultStorage()
+
+
+class OfflineManifestFileStorage(CompressorFileStorage):
+    def __init__(self, location=None, base_url=None, *args, **kwargs):
+        if location is None:
+            location = os.path.join(settings.COMPRESS_ROOT, settings.COMPRESS_OUTPUT_DIR)
+        if base_url is None:
+            base_url = urljoin(settings.COMPRESS_URL, settings.COMPRESS_OUTPUT_DIR)
+        super().__init__(location, base_url, *args, **kwargs)
+
+
+class DefaultOfflineManifestStorage(LazyObject):
+    def _setup(self):
+        self._wrapped = get_storage_class(settings.COMPRESS_OFFLINE_MANIFEST_STORAGE)()
+
+
+default_offline_manifest_storage = DefaultOfflineManifestStorage()


Test patch:
diff --git a/compressor/tests/test_offline.py b/compressor/tests/test_offline.py
--- a/compressor/tests/test_offline.py
+++ b/compressor/tests/test_offline.py
@@ -15,7 +15,7 @@
 from compressor.cache import flush_offline_manifest, get_offline_manifest
 from compressor.exceptions import OfflineGenerationError
 from compressor.management.commands.compress import Command as CompressCommand
-from compressor.storage import default_storage
+from compressor.storage import default_offline_manifest_storage
 from compressor.utils import get_mod_func
 
 
@@ -154,9 +154,9 @@ def setUp(self):
     def tearDown(self):
         self.override_settings.__exit__(None, None, None)
 
-        manifest_path = os.path.join('CACHE', 'manifest.json')
-        if default_storage.exists(manifest_path):
-            default_storage.delete(manifest_path)
+        manifest_filename = 'manifest.json'
+        if default_offline_manifest_storage.exists(manifest_filename):
+            default_offline_manifest_storage.delete(manifest_filename)
 
     def _prepare_contexts(self, engine):
         contexts = settings.COMPRESS_OFFLINE_CONTEXT
@@ -311,9 +311,9 @@ def test_rendering_without_manifest_raises_exception_jinja2(self):
     def _test_deleting_manifest_does_not_affect_rendering(self, engine):
         count, result = CompressCommand().handle_inner(engines=[engine], verbosity=0)
         get_offline_manifest()
-        manifest_path = os.path.join('CACHE', 'manifest.json')
-        if default_storage.exists(manifest_path):
-            default_storage.delete(manifest_path)
+        manifest_filename = 'manifest.json'
+        if default_offline_manifest_storage.exists(manifest_filename):
+            default_offline_manifest_storage.delete(manifest_filename)
         self.assertEqual(1, count)
         self.assertEqual([self._render_script(self.expected_hash)], result)
         rendered_template = self._render_template(engine)
diff --git a/compressor/tests/test_storages.py b/compressor/tests/test_storages.py
--- a/compressor/tests/test_storages.py
+++ b/compressor/tests/test_storages.py
@@ -67,3 +67,9 @@ def test_duplicate_save_overwrites_same_file(self):
         filename2 = self.default_storage.save('test.txt', ContentFile('yeah yeah'))
         self.assertEqual(filename1, filename2)
         self.assertNotIn("_", filename2)
+
+    def test_offline_manifest_storage(self):
+        storage.default_offline_manifest_storage.save('test.txt', ContentFile('yeah yeah'))
+        self.assertTrue(os.path.exists(os.path.join(settings.COMPRESS_ROOT, 'CACHE', 'test.txt')))
+        # Check that the file is stored at the same default location as before the new manifest storage.
+        self.assertTrue(self.default_storage.exists(os.path.join('CACHE', 'test.txt')))