Browse Source

Set rebuilding flag on css files that have urls to images

rafalp 6 years ago
parent
commit
8f74a9ac79

+ 77 - 0
misago/themes/admin/css.py

@@ -1,3 +1,8 @@
+import json
+import re
+
+from django.core.files.base import ContentFile
+
 from .utils import get_file_hash
 from .utils import get_file_hash
 
 
 
 
@@ -29,11 +34,83 @@ def save_css(theme, css, order=None):
         name=css.name,
         name=css.name,
         source_file=css,
         source_file=css,
         source_hash=get_file_hash(css),
         source_hash=get_file_hash(css),
+        source_needs_building=css_needs_rebuilding(css),
         size=css.size,
         size=css.size,
         order=order,
         order=order,
     )
     )
 
 
 
 
+def css_needs_rebuilding(css):
+    css.seek(0)
+    css_source = css.read().decode("utf-8")
+    return "url(" in css_source
+
+
+def rebuild_css(theme, css):
+    css_source = css.source_file.read()
+    build_source = change_css_source(theme, css_source)
+    if css.build_source:
+        css.build_source.delete(save=False)
+
+    build_file_name = css.name
+    if css.source_hash in build_file_name:
+        build_file_name = build_file_name.replace(".%s" % css.source_hash, "")
+    build_file = ContentFile(build_source, build_file_name)
+
+    css.build_file = build_file
+    css.build_hash = get_file_hash(build_file)
+    css.size = len(build_source.encode("utf-8"))
+    css.save()
+
+
+CSS_URL_REGEX = re.compile(r"url\((.+)\)")
+
+
+def change_css_source(theme, css_source):
+    media_map = get_theme_media_map(theme)
+    url_replacer = get_url_replacer(media_map)
+    return CSS_URL_REGEX.sub(url_replacer, css_source).strip()
+
+
+def get_theme_media_map(theme):
+    media_map = {}
+    for media in theme.media.all():
+        escaped_url = json.dumps(media.file.url)
+        media_map[media.name] = escaped_url
+
+        media_filename = str(media.file).split("/")[-1]
+        media_map[media_filename] = escaped_url
+    return media_map
+
+
+def get_url_replacer(media_map):
+    def replacer(matchobj):
+        url = matchobj.group(1).strip("\"'").strip()
+        if is_url_absolute(url):
+            return matchobj.group(0)
+
+        media_name = url.split("/")[-1]
+        if media_name in media_map:
+            return "url(%s)" % media_map[media_name]
+
+        return matchobj.group(0)
+
+    return replacer
+
+
+def is_url_absolute(url):
+    if url.startswith("//") or url.startswith("://"):
+        return True
+
+    if url.lower().startswith("https://"):
+        return True
+
+    if url.lower().startswith("http://"):
+        return True
+
+    return False
+
+
 def get_next_css_order(theme):
 def get_next_css_order(theme):
     last_css = theme.css.order_by("order").last()
     last_css = theme.css.order_by("order").last()
     if last_css:
     if last_css:

+ 2 - 1
misago/themes/admin/forms.py

@@ -4,7 +4,7 @@ from django.utils.translation import gettext, gettext_lazy as _
 from mptt.forms import TreeNodeChoiceField
 from mptt.forms import TreeNodeChoiceField
 
 
 from ..models import Theme, Css
 from ..models import Theme, Css
-from .css import create_css, get_next_css_order
+from .css import css_needs_rebuilding, create_css, get_next_css_order
 from .media import create_media
 from .media import create_media
 from .utils import get_file_hash
 from .utils import get_file_hash
 from .validators import validate_css_name, validate_css_name_is_available
 from .validators import validate_css_name, validate_css_name_is_available
@@ -150,6 +150,7 @@ class CssEditorForm(forms.ModelForm):
 
 
         self.instance.source_file = source_file
         self.instance.source_file = source_file
         self.instance.source_hash = get_file_hash(source_file)
         self.instance.source_hash = get_file_hash(source_file)
+        self.instance.source_needs_building = css_needs_rebuilding(source_file)
         self.instance.size = len(source)
         self.instance.size = len(source)
 
 
         if not self.instance.pk:
         if not self.instance.pk:

+ 39 - 0
misago/themes/admin/tests/test_css_files_creation_and_edition.py

@@ -124,6 +124,23 @@ def test_css_creation_fails_if_source_is_not_given(
     assert not theme.css.exists()
     assert not theme.css.exists()
 
 
 
 
+def test_css_file_without_url_is_created_without_rebuilding_flag(
+    theme, admin_client, create_link, data
+):
+    admin_client.post(create_link, data)
+    css = theme.css.last()
+    assert not css.source_needs_building
+
+
+def test_css_file_with_image_url_is_created_with_rebuilding_flag(
+    theme, admin_client, create_link, data, image
+):
+    data["source"] = "body { background-image: url(/static/%s); }" % image.name
+    admin_client.post(create_link, data)
+    css = theme.css.last()
+    assert css.source_needs_building
+
+
 def test_css_file_is_created_with_correct_order(
 def test_css_file_is_created_with_correct_order(
     theme, admin_client, create_link, css_link, data
     theme, admin_client, create_link, css_link, data
 ):
 ):
@@ -245,6 +262,28 @@ def test_file_is_not_updated_if_form_data_has_no_changes(
     assert original_mtime == os.path.getmtime(css.source_file.path)
     assert original_mtime == os.path.getmtime(css.source_file.path)
 
 
 
 
+def test_adding_image_url_to_edited_file_sets_rebuilding_flag(
+    theme, admin_client, edit_link, css, data, image
+):
+    data["source"] = "body { background-image: url(/static/%s); }" % image.name
+    admin_client.post(edit_link, data)
+    css.refresh_from_db()
+    assert css.source_needs_building
+
+
+def test_removing_url_from_edited_file_removes_rebuilding_flag(
+    theme, admin_client, edit_link, css, data
+):
+    css.source_needs_building = True
+    css.save()
+
+    data["source"] = "body { background-image: none; }"
+    admin_client.post(edit_link, data)
+
+    css.refresh_from_db()
+    assert not css.source_needs_building
+
+
 def test_css_order_stays_the_same_after_edit(admin_client, edit_link, css, data):
 def test_css_order_stays_the_same_after_edit(admin_client, edit_link, css, data):
     original_order = css.order
     original_order = css.order
     data["name"] = "changed.css"
     data["name"] = "changed.css"

+ 19 - 0
misago/themes/admin/tests/test_uploading_css.py

@@ -213,6 +213,25 @@ def test_newly_uploaded_css_file_reuses_replaced_file_order_if_names_are_same(
     assert updated_css.name == original_css.name
     assert updated_css.name == original_css.name
 
 
 
 
+def test_if_uploaded_css_file_contains_no_image_urls_rebuild_flag_is_not_set(
+    upload, theme, css_file
+):
+    with open(css_file) as fp:
+        upload(theme, fp)
+
+    css = theme.css.last()
+    assert not css.source_needs_building
+
+
+def test_if_uploaded_css_file_contains_image_url_it_has_rebuild_flag_set(upload, theme):
+    css_file = os.path.join(TESTS_DIR, "css", "test.buildable.css")
+    with open(css_file) as fp:
+        upload(theme, fp)
+
+    css = theme.css.last()
+    assert css.source_needs_building
+
+
 def test_error_message_is_set_if_no_css_file_was_uploaded(upload, theme):
 def test_error_message_is_set_if_no_css_file_was_uploaded(upload, theme):
     response = upload(theme)
     response = upload(theme)
     assert_has_error_message(response)
     assert_has_error_message(response)

+ 6 - 6
misago/themes/migrations/0001_initial.py

@@ -1,4 +1,4 @@
-# Generated by Django 1.11.17 on 2018-12-29 20:42
+# Generated by Django 1.11.17 on 2019-01-03 21:15
 from django.db import migrations, models
 from django.db import migrations, models
 import django.db.models.deletion
 import django.db.models.deletion
 import misago.themes.uploadto
 import misago.themes.uploadto
@@ -32,21 +32,21 @@ class Migration(migrations.Migration):
                         blank=True,
                         blank=True,
                         max_length=255,
                         max_length=255,
                         null=True,
                         null=True,
-                        upload_to=misago.themes.uploadto.upload_css_source_to,
+                        upload_to=misago.themes.uploadto.upload_source_css_to,
                     ),
                     ),
                 ),
                 ),
                 ("source_hash", models.CharField(blank=True, max_length=8, null=True)),
                 ("source_hash", models.CharField(blank=True, max_length=8, null=True)),
-                ("source_contains_urls", models.BooleanField(default=False)),
+                ("source_needs_building", models.BooleanField(default=False)),
                 (
                 (
-                    "rebuild_file",
+                    "build_file",
                     models.FileField(
                     models.FileField(
                         blank=True,
                         blank=True,
                         max_length=255,
                         max_length=255,
                         null=True,
                         null=True,
-                        upload_to=misago.themes.uploadto.upload_css_to,
+                        upload_to=misago.themes.uploadto.upload_build_css_to,
                     ),
                     ),
                 ),
                 ),
-                ("rebuild_hash", models.CharField(blank=True, max_length=8, null=True)),
+                ("build_hash", models.CharField(blank=True, max_length=8, null=True)),
                 ("size", models.PositiveIntegerField(default=0)),
                 ("size", models.PositiveIntegerField(default=0)),
                 ("order", models.IntegerField(default=0)),
                 ("order", models.IntegerField(default=0)),
                 ("modified_on", models.DateTimeField(auto_now=True)),
                 ("modified_on", models.DateTimeField(auto_now=True)),

+ 9 - 9
misago/themes/models.py

@@ -4,8 +4,8 @@ from mptt.models import MPTTModel, TreeForeignKey
 
 
 from .uploadto import (
 from .uploadto import (
     generate_theme_dirname,
     generate_theme_dirname,
-    upload_css_source_to,
-    upload_css_to,
+    upload_build_css_to,
+    upload_source_css_to,
     upload_media_to,
     upload_media_to,
     upload_media_thumbnail_to,
     upload_media_thumbnail_to,
 )
 )
@@ -43,14 +43,14 @@ class Css(models.Model):
     name = models.CharField(max_length=255)
     name = models.CharField(max_length=255)
     url = models.URLField(max_length=255, null=True, blank=True)
     url = models.URLField(max_length=255, null=True, blank=True)
     source_file = models.FileField(
     source_file = models.FileField(
-        upload_to=upload_css_source_to, max_length=255, null=True, blank=True
+        upload_to=upload_source_css_to, max_length=255, null=True, blank=True
     )
     )
     source_hash = models.CharField(max_length=8, null=True, blank=True)
     source_hash = models.CharField(max_length=8, null=True, blank=True)
-    source_contains_urls = models.BooleanField(default=False)
-    rebuild_file = models.FileField(
-        upload_to=upload_css_to, max_length=255, null=True, blank=True
+    source_needs_building = models.BooleanField(default=False)
+    build_file = models.FileField(
+        upload_to=upload_build_css_to, max_length=255, null=True, blank=True
     )
     )
-    rebuild_hash = models.CharField(max_length=8, null=True, blank=True)
+    build_hash = models.CharField(max_length=8, null=True, blank=True)
     size = models.PositiveIntegerField(default=0)
     size = models.PositiveIntegerField(default=0)
 
 
     order = models.IntegerField(default=0)
     order = models.IntegerField(default=0)
@@ -62,8 +62,8 @@ class Css(models.Model):
     def delete(self, *args, **kwargs):
     def delete(self, *args, **kwargs):
         if self.source_file:
         if self.source_file:
             self.source_file.delete(save=False)
             self.source_file.delete(save=False)
-        if self.rebuild_file:
-            self.rebuild_file.delete(save=False)
+        if self.build_file:
+            self.build_file.delete(save=False)
 
 
         super().delete(*args, **kwargs)
         super().delete(*args, **kwargs)
 
 

+ 2 - 2
misago/themes/uploadto.py

@@ -5,12 +5,12 @@ def generate_theme_dirname():
     return get_random_string(8)
     return get_random_string(8)
 
 
 
 
-def upload_css_source_to(instance, filename):
+def upload_source_css_to(instance, filename):
     filename = add_hash_to_filename(instance.source_hash, filename)
     filename = add_hash_to_filename(instance.source_hash, filename)
     return "themes/%s/css/%s" % (instance.theme.dirname, filename)
     return "themes/%s/css/%s" % (instance.theme.dirname, filename)
 
 
 
 
-def upload_css_to(instance, filename):
+def upload_build_css_to(instance, filename):
     filename = add_hash_to_filename(instance.hash, filename)
     filename = add_hash_to_filename(instance.hash, filename)
     return "themes/%s/css/%s" % (instance.theme.dirname, filename)
     return "themes/%s/css/%s" % (instance.theme.dirname, filename)
 
 

+ 1 - 0
requirements.in

@@ -18,4 +18,5 @@ pytest-mock
 pytz
 pytz
 requests<3
 requests<3
 responses
 responses
+snapshottest
 unidecode<1
 unidecode<1

+ 3 - 1
requirements.txt

@@ -43,10 +43,12 @@ redis==3.0.1              # via celery
 requests-oauthlib==1.0.0  # via social-auth-core
 requests-oauthlib==1.0.0  # via social-auth-core
 requests==2.21.0
 requests==2.21.0
 responses==0.10.5
 responses==0.10.5
-six==1.12.0               # via bleach, faker, html5lib, more-itertools, pytest, python-dateutil, responses, social-auth-app-django, social-auth-core
+six==1.12.0               # via bleach, faker, html5lib, more-itertools, pytest, python-dateutil, responses, snapshottest, social-auth-app-django, social-auth-core
+snapshottest==0.5.0
 social-auth-app-django==3.1.0
 social-auth-app-django==3.1.0
 social-auth-core==2.0.0   # via social-auth-app-django
 social-auth-core==2.0.0   # via social-auth-app-django
 sqlparse==0.2.4           # via django-debug-toolbar
 sqlparse==0.2.4           # via django-debug-toolbar
+termcolor==1.1.0          # via snapshottest
 text-unidecode==1.2       # via faker
 text-unidecode==1.2       # via faker
 unidecode==0.4.21
 unidecode==0.4.21
 urllib3==1.24.1           # via requests
 urllib3==1.24.1           # via requests