rafalp 6 лет назад
Родитель
Сommit
69947db839

+ 14 - 16
misago/themes/admin/exporter.py

@@ -22,8 +22,8 @@ def export_theme(theme):
         export_filename = os.path.split(export_file)[-1]
 
         response = FileResponse(open(export_file, "rb"), content_type="application/zip")
-        response['Content-Length'] = os.path.getsize(export_file)
-        response['Content-Disposition'] = "inline; filename=%s" % export_filename
+        response["Content-Length"] = os.path.getsize(export_file)
+        response["Content-Disposition"] = "inline; filename=%s" % export_filename
 
         return response
 
@@ -58,15 +58,11 @@ def write_theme_css(export_dir, theme):
 
     for css in theme.css.all():
         if css.url:
-            files.append({
-                "name": css.name,
-                "url": css.url,
-            })
+            files.append({"name": css.name, "url": css.url})
         else:
-            files.append({
-                "name": css.name,
-                "path": copy_asset_file(files_dir, css.source_file),
-            })
+            files.append(
+                {"name": css.name, "path": copy_asset_file(files_dir, css.source_file)}
+            )
 
     return files
 
@@ -76,11 +72,13 @@ def write_theme_media(export_dir, theme):
     files = []
 
     for media in theme.media.all():
-        files.append({
-            "name": media.name,
-            "type": media.type,
-            "path": copy_asset_file(files_dir, media.file),
-        })
+        files.append(
+            {
+                "name": media.name,
+                "type": media.type,
+                "path": copy_asset_file(files_dir, media.file),
+            }
+        )
 
     return files
 
@@ -108,5 +106,5 @@ def write_theme_manifest(export_dir, manifest):
 
 def zip_theme_export(tmp_dir, export_dir):
     dir_name = os.path.split(export_dir)[-1]
-    zip_name = shutil.make_archive(dir_name, 'zip', tmp_dir)
+    zip_name = shutil.make_archive(dir_name, "zip", tmp_dir)
     return os.path.join(tmp_dir, zip_name)

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

@@ -1,3 +1,5 @@
+import re
+
 from django import forms
 from django.core.files.base import ContentFile
 from django.utils.translation import gettext, gettext_lazy as _
@@ -55,8 +57,7 @@ class ImportForm(forms.Form):
     )
     parent = ThemeChoiceField(label=_("Parent"), required=False)
     upload = forms.FileField(
-        label=_("Theme file"),
-        help_text=_("Theme file should be a ZIP file."),
+        label=_("Theme file"), help_text=_("Theme file should be a ZIP file.")
     )
 
     def clean_upload(self):
@@ -69,6 +70,37 @@ class ImportForm(forms.Form):
         return data
 
 
+class ThemeManifest(forms.Form):
+    name = forms.CharField(max_length=255)
+    version = forms.CharField(max_length=255, required=False)
+    author = forms.CharField(max_length=255, required=False)
+    url = forms.URLField(max_length=255, required=False)
+
+
+class ThemeCssUrlManifest(forms.Form):
+    name = forms.CharField(max_length=255)
+    url = forms.URLField(max_length=255)
+
+
+def create_css_file_manifest(allowed_path):
+    class ThemeCssFileManifest(forms.Form):
+        name = forms.CharField(max_length=255, validators=[validate_css_name])
+        path = forms.FilePathField(
+            allowed_path, match=re.compile(r"\.css$", re.IGNORECASE)
+        )
+
+    return ThemeCssFileManifest
+
+
+def create_media_file_manifest(allowed_path):
+    class ThemeMediaFileManifest(forms.Form):
+        name = forms.CharField(max_length=255)
+        type = forms.CharField(max_length=255)
+        path = forms.FilePathField(allowed_path)
+
+    return ThemeMediaFileManifest
+
+
 class UploadAssetsForm(forms.Form):
     allowed_content_types = []
     allowed_extensions = []

+ 161 - 20
misago/themes/admin/importer.py

@@ -3,31 +3,56 @@ import os
 from tempfile import TemporaryDirectory
 from zipfile import BadZipFile, ZipFile
 
-from django.utils.translation import gettext as _
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.utils.translation import gettext as _, gettext_lazy
 
 from ..models import Theme
+from .css import create_css
+from .forms import (
+    ThemeCssUrlManifest,
+    ThemeManifest,
+    create_css_file_manifest,
+    create_media_file_manifest,
+)
+from .media import create_media
+from .tasks import build_theme_css, update_remote_css_size
+
+INVALID_MANIFEST_ERROR = gettext_lazy(
+    '"manifest.json" contained by ZIP file is not a valid theme manifest file.'
+)
 
 
 class ThemeImportError(BaseException):
     pass
 
 
+class InvalidThemeManifest(ThemeImportError):
+    def __init__(self):
+        super().__init__(INVALID_MANIFEST_ERROR)
+
+
 def import_theme(name, parent, zipfile):
     with TemporaryDirectory() as tmp_dir:
         extract_zipfile_to_tmp_dir(zipfile, tmp_dir)
         validate_zipfile_contains_single_directory(tmp_dir)
         theme_dir = os.path.join(tmp_dir, os.listdir(tmp_dir)[0])
-        clean_data = clean_theme_contents(theme_dir)
-
-        # TODO: finish import
-        return Theme.objects.create(
-            name=name or clean_data["name"],
-            parent=parent,
-            version=clean_data["version"],
-            author=clean_data["author"],
-            url=clean_data["url"],
-        )
-    
+        cleaned_manifest = clean_theme_contents(theme_dir)
+
+        theme = create_theme_from_manifest(name, parent, cleaned_manifest)
+
+        try:
+            create_css_from_manifest(theme_dir, theme, cleaned_manifest["css"])
+            create_media_from_manifest(theme_dir, theme, cleaned_manifest["media"])
+        except Exception:
+            theme.delete()
+            raise InvalidThemeManifest()
+        else:
+            for css in theme.css.filter(url__isnull=False):
+                update_remote_css_size.delay(css.pk)
+            build_theme_css.delay(theme.pk)
+
+        return theme
+
 
 def extract_zipfile_to_tmp_dir(zipfile, tmp_dir):
     try:
@@ -47,19 +72,14 @@ def validate_zipfile_contains_single_directory(tmp_dir):
 
 
 def clean_theme_contents(theme_dir):
-    return read_manifest(theme_dir)
+    manifest = read_manifest(theme_dir)
+    return clean_manifest(theme_dir, manifest)
 
 
 def read_manifest(theme_dir):
     try:
         with open(os.path.join(theme_dir, "manifest.json")) as fp:
-            manifest = json.load(fp)
-        if not isinstance(manifest, dict):
-            message = _(
-                '"manifest.json" contained by ZIP file is not a valid '
-                'theme manifest file.'
-            )
-            raise ThemeImportError(message)
+            return json.load(fp)
     except FileNotFoundError:
         raise ThemeImportError(
             _('Uploaded ZIP file didn\'t contain a "manifest.json".')
@@ -70,3 +90,124 @@ def read_manifest(theme_dir):
         )
     else:
         return manifest
+
+
+def clean_manifest(theme_dir, manifest):
+    if not isinstance(manifest, dict):
+        raise InvalidThemeManifest()
+
+    form = ThemeManifest(manifest)
+    if not form.is_valid():
+        raise InvalidThemeManifest()
+
+    cleaned_manifest = form.cleaned_data.copy()
+    cleaned_manifest["css"] = clean_css_list(theme_dir, manifest.get("css"))
+    cleaned_manifest["media"] = clean_media_list(theme_dir, manifest.get("media"))
+
+    return cleaned_manifest
+
+
+def clean_css_list(theme_dir, manifest):
+    if not isinstance(manifest, list):
+        raise InvalidThemeManifest()
+
+    theme_css_dir = os.path.join(theme_dir, "css")
+    if not os.path.isdir(theme_css_dir):
+        raise InvalidThemeManifest()
+
+    cleaned_data = []
+    for item in manifest:
+        cleaned_data.append(clean_css_list_item(theme_css_dir, item))
+    return cleaned_data
+
+
+def clean_css_list_item(theme_css_dir, item):
+    if not isinstance(item, dict):
+        raise InvalidThemeManifest()
+
+    if item.get("url"):
+        return clean_css_url(item)
+    if item.get("path"):
+        return clean_css_file(theme_css_dir, item)
+
+    raise InvalidThemeManifest()
+
+
+def clean_css_url(data):
+    form = ThemeCssUrlManifest(data)
+    if not form.is_valid():
+        raise InvalidThemeManifest()
+    return form.cleaned_data
+
+
+def clean_css_file(theme_css_dir, data):
+    file_manifest = create_css_file_manifest(theme_css_dir)
+
+    if data.get("path"):
+        data["path"] = os.path.join(theme_css_dir, str(data["path"]))
+
+    form = file_manifest(data)
+    if not form.is_valid():
+        raise InvalidThemeManifest()
+    return form.cleaned_data
+
+
+def clean_media_list(theme_dir, manifest):
+    if not isinstance(manifest, list):
+        raise InvalidThemeManifest()
+
+    theme_media_dir = os.path.join(theme_dir, "media")
+    if not os.path.isdir(theme_media_dir):
+        raise InvalidThemeManifest()
+
+    cleaned_data = []
+    for item in manifest:
+        cleaned_data.append(clean_media_list_item(theme_media_dir, item))
+    return cleaned_data
+
+
+def clean_media_list_item(theme_media_dir, data):
+    if not isinstance(data, dict):
+        raise InvalidThemeManifest()
+
+    file_manifest = create_media_file_manifest(theme_media_dir)
+
+    if data.get("path"):
+        data["path"] = os.path.join(theme_media_dir, str(data["path"]))
+
+    form = file_manifest(data)
+    if not form.is_valid():
+        raise InvalidThemeManifest()
+
+    return form.cleaned_data
+
+
+def create_theme_from_manifest(name, parent, cleaned_data):
+    return Theme.objects.create(
+        name=name or cleaned_data["name"],
+        parent=parent,
+        version=cleaned_data["version"],
+        author=cleaned_data["author"],
+        url=cleaned_data["url"],
+    )
+
+
+def create_css_from_manifest(tmp_dir, theme, manifest):
+    for item in manifest:
+        if "url" in item:
+            theme.css.create(**item, order=theme.css.count())
+        else:
+            with open(item["path"], "rb") as fp:
+                file_obj = SimpleUploadedFile(
+                    item["name"], fp.read(), content_type="text/css"
+                )
+                create_css(theme, file_obj)
+
+
+def create_media_from_manifest(tmp_dir, theme, manifest):
+    for item in manifest:
+        with open(item["path"], "rb") as fp:
+            file_obj = SimpleUploadedFile(
+                item["name"], fp.read(), content_type=item["type"]
+            )
+            create_media(theme, file_obj)

+ 13 - 4
misago/themes/admin/views.py

@@ -8,7 +8,14 @@ from ..cache import clear_theme_cache
 from ..models import Theme, Css
 from .css import move_css_down, move_css_up
 from .exporter import export_theme
-from .forms import CssEditorForm, CssLinkForm, ImportForm, ThemeForm, UploadCssForm, UploadMediaForm
+from .forms import (
+    CssEditorForm,
+    CssLinkForm,
+    ImportForm,
+    ThemeForm,
+    UploadCssForm,
+    UploadMediaForm,
+)
 from .importer import ThemeImportError, import_theme
 from .tasks import build_single_theme_css, build_theme_css, update_remote_css_size
 
@@ -274,6 +281,8 @@ class MoveThemeCssDown(ThemeCssAdmin):
 
 
 class ThemeCssFormAdmin(ThemeCssAdmin, generic.ModelFormView):
+    is_atomic = False  # atomic updates cause race condition with celery tasks
+
     def real_dispatch(self, request, theme, css=None):
         form = self.initialize_form(self.form, request, theme, css)
 
@@ -364,10 +373,10 @@ class NewThemeCssLink(ThemeCssFormAdmin):
             return form(request.POST, instance=css)
         return form(instance=css)
 
-    def handle_form(self, form, *args):
-        super().handle_form(form, *args)
+    def handle_form(self, form, request, theme, css):
+        super().handle_form(form, request, theme, css)
         if "url" in form.changed_data:
-            update_remote_css_size.delay(form.instance.pk)
+            update_remote_css_size.delay(css.pk)
             clear_theme_cache()
 
     def redirect_to_edit_form(self, theme, css):