Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,9 @@ All settings use the ``MINICOMPRESS_`` prefix:

``MINICOMPRESS_EXCLUDE_PATTERNS``
List of glob patterns to exclude from processing (default:
``["*.min.*", "*-min.*", "*.gz", "*.br", "*.zip"]``) Pre-compressed
files (e.g., ``.gz``, ``.br``, ``.zip``) are excluded by default to
prevent double-compression and security issues.
``["*.min.*", "*-min.*", "*swagger-ui-*", "*.gz", "*.br", "*.zip"]``)
Pre-compressed files (e.g., ``.gz``, ``.br``, ``.zip``) are excluded
by default to prevent double-compression and security issues.

Usage
-----
Expand All @@ -146,9 +146,6 @@ Supported File Types

**Compression**: CSS, JS, TXT, XML, JSON, SVG, MD, RST, HTML, HTM

Files matching ``*.min.*`` or ``*-min.*`` patterns are excluded from
processing.

Security and Performance Considerations
---------------------------------------

Expand Down
31 changes: 26 additions & 5 deletions django_minify_compress_staticfiles/conf.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured


def get_setting(name, default=None):
"""Get setting with MINICOMPRESS_ prefix."""
return getattr(settings, f"MINICOMPRESS_{name}", default)

_UNSET = object()

DEFAULT_SETTINGS = {
"ENABLED": True,
Expand Down Expand Up @@ -32,8 +29,32 @@ def get_setting(name, default=None):
"EXCLUDE_PATTERNS": [
"*.min.*",
"*-min.*",
# Fixes issues with drf-yasg
# these files are already minified
"*swagger-ui-*",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"*.gz",
"*.br",
"*.zip",
],
}


def get_setting(name):
"""Get a MINICOMPRESS_* setting, falling back to DEFAULT_SETTINGS.

An explicit None value is treated the same as unset.
"""
value = getattr(settings, f"MINICOMPRESS_{name}", _UNSET)
if value is _UNSET or value is None:
return DEFAULT_SETTINGS.get(name)
return value


def validate_settings():
"""Raise ImproperlyConfigured for settings that must be positive integers."""
for name in ("MIN_FILE_SIZE", "MAX_FILE_SIZE", "MAX_FILES_PER_RUN"):
value = get_setting(name)
if value is not None and value <= 0:
raise ImproperlyConfigured(
f"MINICOMPRESS_{name} must be a positive integer, got {value!r}."
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
80 changes: 24 additions & 56 deletions django_minify_compress_staticfiles/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from django.core.files.base import ContentFile
from django.utils.deconstruct import deconstructible

from .conf import DEFAULT_SETTINGS, get_setting
from .conf import get_setting, validate_settings
from .utils import FileManager, is_safe_path

logger = logging.getLogger(__name__)
Expand All @@ -23,11 +23,12 @@ class FileProcessorMixin:

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
validate_settings()
self.file_manager = FileManager(self)

def should_process_minification(self, path):
"""Check if file should be minified."""
if not get_setting("MINIFY_FILES", DEFAULT_SETTINGS["MINIFY_FILES"]):
if not get_setting("MINIFY_FILES"):
return False
if not self.file_manager.should_process(path):
return False
Expand All @@ -39,11 +40,8 @@ def should_process_compression(self, path, allow_min=False):
return False
if allow_min:
# When allowing min files, just check extension
ext = Path(path).suffix.lower()
supported = self.file_manager.supported_extensions
if isinstance(supported, dict):
supported = list(supported.keys())
if ext.lstrip(".") not in supported:
ext = Path(path).suffix.lower().lstrip(".")
if ext not in self.file_manager.processable_extensions:
return False
return self.file_manager.is_compression_candidate(path)

Expand All @@ -55,28 +53,18 @@ def minify_file_content(self, content, file_type):
"""Minify file content based on type."""
if file_type == "css" and rcssmin:
try:
preserve_comments = get_setting(
"PRESERVE_COMMENTS", DEFAULT_SETTINGS["PRESERVE_COMMENTS"]
)
if preserve_comments is None:
preserve_comments = True
return rcssmin.cssmin(
content,
keep_bang_comments=bool(preserve_comments),
keep_bang_comments=bool(get_setting("PRESERVE_COMMENTS")),
)
except Exception as e:
logger.error(f"CSS minification failed for {file_type}: {e}")
return content
elif file_type == "js" and rjsmin:
try:
preserve_comments = get_setting(
"PRESERVE_COMMENTS", DEFAULT_SETTINGS["PRESERVE_COMMENTS"]
)
if preserve_comments is None:
preserve_comments = True
return rjsmin.jsmin(
content,
keep_bang_comments=bool(preserve_comments),
keep_bang_comments=bool(get_setting("PRESERVE_COMMENTS")),
)
except Exception as e:
logger.error(f"JS minification failed: {e}")
Expand All @@ -89,13 +77,12 @@ class MinificationMixin(FileProcessorMixin):

def process_minification(self, paths):
"""Process minification for given paths."""
if not get_setting("MINIFY_FILES", DEFAULT_SETTINGS["MINIFY_FILES"]):
if not get_setting("ENABLED"):
return {}
if not get_setting("MINIFY_FILES"):
return {}
minified_files = {}
max_files = (
get_setting("MAX_FILES_PER_RUN", DEFAULT_SETTINGS["MAX_FILES_PER_RUN"])
or 1000
)
max_files = get_setting("MAX_FILES_PER_RUN")
processed_count = 0

for path in paths:
Expand All @@ -114,6 +101,7 @@ def process_minification(self, paths):
content = content.decode("utf-8")
except UnicodeDecodeError:
continue
processed_count += 1
file_type = self._get_file_type(path)
minified_content = self.minify_file_content(content, file_type)
# Only save if minification reduced size
Expand All @@ -131,10 +119,8 @@ def process_minification(self, paths):
minified_path = str(parent / minified_filename)
else:
minified_path = minified_filename
# Save minified content
self._write_file_content(minified_path, minified_content)
minified_files[path] = minified_path
processed_count += 1
except Exception as e:
logger.error(f"Failed to minify {path}: {e}")
continue
Expand All @@ -146,16 +132,14 @@ class CompressionMixin(FileProcessorMixin):

def process_compression(self, paths, allow_min=False):
"""Process compression for given paths."""
if not (
get_setting("GZIP_COMPRESSION", DEFAULT_SETTINGS["GZIP_COMPRESSION"])
or get_setting("BROTLI_COMPRESSION", DEFAULT_SETTINGS["BROTLI_COMPRESSION"])
):
if not get_setting("ENABLED"):
return {}
gzip_enabled = get_setting("GZIP_COMPRESSION")
brotli_enabled = get_setting("BROTLI_COMPRESSION")
if not (gzip_enabled or brotli_enabled):
return {}
compressed_files = {}
max_files = (
get_setting("MAX_FILES_PER_RUN", DEFAULT_SETTINGS["MAX_FILES_PER_RUN"])
or 1000
)
max_files = get_setting("MAX_FILES_PER_RUN")
processed_count = 0

for path in paths:
Expand All @@ -168,6 +152,7 @@ def process_compression(self, paths, allow_min=False):
content = self._read_file_content(path)
if content is None:
continue
processed_count += 1
# Get relative path for storage operations
# If path is absolute, convert to a relative path while preserving directory structure
if os.path.isabs(path):
Expand All @@ -182,24 +167,19 @@ def process_compression(self, paths, allow_min=False):
else:
relative_path = path
# Process Gzip compression
if get_setting(
"GZIP_COMPRESSION", DEFAULT_SETTINGS["GZIP_COMPRESSION"]
):
if gzip_enabled:
gzipped_path = f"{relative_path}.gz"
gzipped_content = self.gzip_compress(content)
self._write_file_content(
gzipped_path, gzipped_content, is_text=False
)
compressed_files.setdefault(path, []).append(gzipped_path)
# Process Brotli compression
if get_setting(
"BROTLI_COMPRESSION", DEFAULT_SETTINGS["BROTLI_COMPRESSION"]
):
if brotli_enabled:
brotli_path = f"{relative_path}.br"
brotli_content = self.brotli_compress(content)
self._write_file_content(brotli_path, brotli_content, is_text=False)
compressed_files.setdefault(path, []).append(brotli_path)
processed_count += 1
except Exception as e:
logger.error(f"Failed to compress {path}: {e}")
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Inconsistent processed_count placement. In process_minification (line 103), count increments after reading but before processing. Here, it increments only after successful compression.

Files that fail compression won't count toward MAX_FILES_PER_RUN. For consistent rate limiting, move processed_count += 1 to after line 157 (after content read, before compression).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be addressed

Expand All @@ -210,9 +190,7 @@ def _read_file_content(self, path):
if not is_safe_path(path):
logger.warning(f"Skipping unsafe path: {path}")
return None
max_size = (
get_setting("MAX_FILE_SIZE", DEFAULT_SETTINGS["MAX_FILE_SIZE"]) or 10485760
)
max_size = get_setting("MAX_FILE_SIZE")
# Try storage methods first
if self.exists(path):
with self.open(path) as f:
Expand Down Expand Up @@ -245,12 +223,7 @@ def _write_file_content(self, path, content, is_text=True):
def gzip_compress(self, content):
"""Compress content using gzip."""
buffer = io.BytesIO()
level = (
get_setting(
"COMPRESSION_LEVEL_GZIP", DEFAULT_SETTINGS["COMPRESSION_LEVEL_GZIP"]
)
or 6
)
level = get_setting("COMPRESSION_LEVEL_GZIP")
# Clamp level to valid range (0-9)
level = max(0, min(9, level))
with gzip.GzipFile(fileobj=buffer, mode="wb", compresslevel=level) as gz_file:
Expand All @@ -261,12 +234,7 @@ def gzip_compress(self, content):

def brotli_compress(self, content):
"""Compress content using brotli."""
level = (
get_setting(
"COMPRESSION_LEVEL_BROTLI", DEFAULT_SETTINGS["COMPRESSION_LEVEL_BROTLI"]
)
or 4
)
level = get_setting("COMPRESSION_LEVEL_BROTLI")
# Clamp level to valid range (0-11)
level = max(0, min(11, level))
if isinstance(content, str):
Expand Down
49 changes: 18 additions & 31 deletions django_minify_compress_staticfiles/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import fnmatch
import hashlib
import logging
import os
Expand All @@ -6,7 +7,7 @@

from django.utils.functional import cached_property

from .conf import DEFAULT_SETTINGS, get_setting
from .conf import get_setting

logger = logging.getLogger(__name__)

Expand All @@ -32,7 +33,7 @@ def is_safe_path(path, base_dir=None):

def validate_file_size(file_size):
"""Validate file size doesn't exceed maximum limit."""
max_size = get_setting("MAX_FILE_SIZE", DEFAULT_SETTINGS["MAX_FILE_SIZE"])
max_size = get_setting("MAX_FILE_SIZE")
return file_size <= max_size


Expand All @@ -48,10 +49,7 @@ def generate_file_hash(content_or_path, length=12):
elif isinstance(content_or_path, (str, os.PathLike)):
# File path - read and hash (supports str and pathlib.Path)
file_path = os.fspath(content_or_path)
max_size = (
get_setting("MAX_FILE_SIZE", DEFAULT_SETTINGS["MAX_FILE_SIZE"])
or 10485760
)
max_size = get_setting("MAX_FILE_SIZE")
with open(file_path, "rb") as f:
content = f.read(max_size + 1)
if len(content) > max_size:
Expand Down Expand Up @@ -100,14 +98,7 @@ def should_process_file(file_path, supported_extensions, exclude_patterns):
# Check exclude patterns
filename = path.name
for pattern in exclude_patterns or []:
# Handle simple glob patterns
if pattern == "*.min.*" and ".min." in filename:
return False
elif pattern == "*-min.*" and "-min." in filename:
return False
elif pattern.startswith("*") and filename.endswith(pattern[1:]):
return False
elif filename.endswith(pattern):
if fnmatch.fnmatch(filename, pattern):
return False

return True
Expand All @@ -131,39 +122,35 @@ def __init__(self, storage):
@cached_property
def supported_extensions(self):
"""Get supported file extensions from settings."""
result = get_setting(
"SUPPORTED_EXTENSIONS", DEFAULT_SETTINGS["SUPPORTED_EXTENSIONS"]
)
return result or {}
return get_setting("SUPPORTED_EXTENSIONS")

@cached_property
def exclude_patterns(self):
"""Get exclude patterns from settings."""
result = get_setting("EXCLUDE_PATTERNS", DEFAULT_SETTINGS["EXCLUDE_PATTERNS"])
return result or []
return get_setting("EXCLUDE_PATTERNS")

@cached_property
def min_file_size(self):
"""Get minimum file size for compression."""
result = get_setting("MIN_FILE_SIZE", DEFAULT_SETTINGS["MIN_FILE_SIZE"])
return result or 200
return get_setting("MIN_FILE_SIZE")

@cached_property
def processable_extensions(self):
"""Normalized list of enabled file extensions."""
extensions = self.supported_extensions
if isinstance(extensions, dict):
return [k for k, v in extensions.items() if v]
return list(extensions) if extensions else []

def should_process(self, file_path):
"""Check if file should be processed."""
extensions = getattr(self, "supported_extensions", None) or {}
if hasattr(extensions, "keys"):
extensions = list(extensions.keys())
elif isinstance(extensions, dict):
extensions = list(extensions.keys())
else:
extensions = extensions or []
return should_process_file(
file_path, extensions, getattr(self, "exclude_patterns", None) or []
file_path, self.processable_extensions, self.exclude_patterns
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def is_compression_candidate(self, file_path):
"""Check if file is candidate for compression (size check)."""
min_size = getattr(self, "min_file_size", None) or 200
min_size = self.min_file_size
# Try to get full path from storage first
if hasattr(self.storage, "path"):
try:
Expand Down
Loading