From 052d6bcfa332c1d2b3310f5780f07e6a531479b5 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:42:59 -0700 Subject: [PATCH 01/13] Fix collectstatic crash if static file URL contains question mark (Windows) --- CHANGELOG.md | 3 +-- README.md | 2 +- src/servestatic/storage.py | 8 ++++++-- tests/test_django_servestatic.py | 13 +++++++++++++ tests/test_files/static/styles_with_query.css | 3 +++ 5 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 tests/test_files/static/styles_with_query.css diff --git a/CHANGELOG.md b/CHANGELOG.md index eceaee8..0527beb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,11 +19,10 @@ Don't forget to remove deprecated code on each major release! ### Added -- Added new Django setting `SERVESTATIC_USE_STATIC_ROOT` to allow users to opt in to having `ServeStatic` scan all files within `STATIC_ROOT` at start-up. This is now enabled by default. +- Added new Django setting `SERVESTATIC_USE_STATIC_ROOT` to allow users to opt in to having `ServeStatic` scan all files within `STATIC_ROOT` at start-up. - Add JavaScript and CSS minification support to the `servestatic` CLI command. - Add JavaScript and CSS minification support to the `servestatic` the Django storage backend. - ## [4.1.0] - 2026-03-07 !!! tip diff --git a/README.md b/README.md index ce639b4..71b45bb 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ _This project is a [fork](https://github.com/evansd/whitenoise/pull/359#issuecom `ServeStatic` simplifies static file serving with minimal lines of configuration. It enables you to create a self-contained unit without requiring external services like Nginx or Amazon S3. This can simplify any production deployment, but is especially useful for platforms like Heroku, OpenShift, and other PaaS providers. -It is designed to work seamlessly with CDNs to ensure high performance for traffic-intensive sites. It can be run in "standalone" mode, or alongside any preexisting ASGI/WSGI app. A command-line interface is provided to perform common tasks such as multi-format compression, file-name hashing, and manifest generation. Extra features and auto-configuration are available for [Django](https://www.djangoproject.com/) users. +This project is designed to work seamlessly with CDNs to ensure high performance for traffic-intensive sites. It can be run in "standalone" mode, or alongside any preexisting ASGI/WSGI app. A command-line interface is provided to perform common tasks such as multi-format compression, file-name hashing, and manifest generation. Extra features and auto-configuration are available for [Django](https://www.djangoproject.com/) users. When using `ServeStatic`, best practices are automatically handled such as: diff --git a/src/servestatic/storage.py b/src/servestatic/storage.py index 049a77f..9127d36 100644 --- a/src/servestatic/storage.py +++ b/src/servestatic/storage.py @@ -9,6 +9,7 @@ from collections.abc import Iterator from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any +from urllib.parse import unquote, urlsplit from django.conf import settings from django.contrib.staticfiles.storage import ( @@ -152,7 +153,9 @@ def post_process_with_compression(self, files: _PostProcessT) -> _PostProcessT: self.start_tracking_new_files(new_files) for name, hashed_name, processed in files: if hashed_name and not isinstance(processed, Exception): - hashed_names[self.clean_name(name)] = hashed_name + clean_name = self.clean_name(name) + clean_hashed_name = self.clean_name(unquote(urlsplit(hashed_name).path)) + hashed_names[clean_name] = clean_hashed_name yield name, hashed_name, processed self.stop_tracking_new_files() original_files = set(hashed_names.keys()) @@ -170,7 +173,8 @@ def post_process_with_compression(self, files: _PostProcessT) -> _PostProcessT: def hashed_name(self, *args: Any, **kwargs: Any) -> str: name = super().hashed_name(*args, **kwargs) if self._new_files is not None: - self._new_files.add(self.clean_name(name)) + clean_name = self.clean_name(unquote(urlsplit(name).path)) + self._new_files.add(clean_name) return name def start_tracking_new_files(self, new_files: set[str]) -> None: diff --git a/tests/test_django_servestatic.py b/tests/test_django_servestatic.py index 2ec0b71..deb00d3 100644 --- a/tests/test_django_servestatic.py +++ b/tests/test_django_servestatic.py @@ -996,6 +996,19 @@ def test_manifest_with_keep_only_hashed(static_files): shutil.rmtree(static_root, ignore_errors=True) +@pytest.mark.usefixtures("static_files") +def test_manifest_with_keep_only_hashed_query_string(): + with override_settings(SERVESTATIC_USE_MANIFEST=True, SERVESTATIC_KEEP_ONLY_HASHED_FILES=True): + try: + # Collect static files + reset_lazy_object(storage.staticfiles_storage) + # This should not raise an OSError (like [WinError 123] on Windows) due to `?` query strings in `hashed_name` mappings. + call_command("collectstatic", verbosity=0, interactive=False) + finally: + static_root = settings.STATIC_ROOT + shutil.rmtree(static_root, ignore_errors=True) + + @pytest.mark.skipif(django.VERSION < (5, 0), reason="Django 5.0+ only") @pytest.mark.usefixtures("static_files") def test_manifest_with_keep_only_hashed_2(): diff --git a/tests/test_files/static/styles_with_query.css b/tests/test_files/static/styles_with_query.css new file mode 100644 index 0000000..f48e5f8 --- /dev/null +++ b/tests/test_files/static/styles_with_query.css @@ -0,0 +1,3 @@ +.question_mark { + background-image: url("directory/pixel.gif?v=4.0.3"); +} From c7a936334f42ef059597993de005eae36ff2ce5e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:50:22 -0700 Subject: [PATCH 02/13] v4.2.1 --- CHANGELOG.md | 9 ++++++++- src/servestatic/__init__.py | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0527beb..85e9182 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,12 @@ Don't forget to remove deprecated code on each major release! - Nothing (yet) +## [4.2.1] - 2026-04-03 + +### Fixed + +- Fix a bug where the Django `collectstatic` command could crash when encountering static files with query parameters in their URLs. + ## [4.2.0] - 2026-04-01 ### Added @@ -169,7 +175,8 @@ Don't forget to remove deprecated code on each major release! - Forked from [`whitenoise`](https://github.com/evansd/whitenoise) to add ASGI support. -[Unreleased]: https://github.com/Archmonger/ServeStatic/compare/4.2.0...HEAD +[Unreleased]: https://github.com/Archmonger/ServeStatic/compare/4.2.1...HEAD +[4.2.1]: https://github.com/Archmonger/ServeStatic/compare/4.2.0...4.2.1 [4.2.0]: https://github.com/Archmonger/ServeStatic/compare/4.1.0...4.2.0 [4.1.0]: https://github.com/Archmonger/ServeStatic/compare/4.0.0...4.1.0 [4.0.0]: https://github.com/Archmonger/ServeStatic/compare/3.1.0...4.0.0 diff --git a/src/servestatic/__init__.py b/src/servestatic/__init__.py index 6fddb9f..90258bd 100644 --- a/src/servestatic/__init__.py +++ b/src/servestatic/__init__.py @@ -3,6 +3,6 @@ from servestatic.asgi import ServeStaticASGI from servestatic.wsgi import ServeStatic -__version__ = "4.2.0" +__version__ = "4.2.1" __all__ = ["ServeStatic", "ServeStaticASGI"] From e8394fa5a6f5b198d8b9aa2eefa272696631760a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:20:53 -0700 Subject: [PATCH 03/13] misc docs cleanup --- CHANGELOG.md | 2 +- docs/src/django-settings.md | 34 +++++++++++++++------------------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85e9182..b13352f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Don't forget to remove deprecated code on each major release! ### Fixed -- Fix a bug where the Django `collectstatic` command could crash when encountering static files with query parameters in their URLs. +- Fix a bug where the Django `collectstatic` command could crash when encountering static files that reference a URL containing query parameters. ## [4.2.0] - 2026-04-01 diff --git a/docs/src/django-settings.md b/docs/src/django-settings.md index d554208..a1a830d 100644 --- a/docs/src/django-settings.md +++ b/docs/src/django-settings.md @@ -16,39 +16,41 @@ Don't use this for the bulk of your static files because you won't benefit from ## `SERVESTATIC_AUTOREFRESH` -**Default:** `settings.py:DEBUG` +**Default:** `DEBUG` -Recheck the filesystem to see if any files have changed before responding. This is designed to be used in development where it can be convenient to pick up changes to static files without restarting the server. For both performance and security reasons, this setting should not be used in production. +Always check the filesystem to see if any files have changed before responding. This is designed to be used in development where it can be convenient to pick up changes to static files without restarting the server. + +When running under ASGI, ServeStatic performs these checks asynchronously. Regardless, this setting adds significant overhead making it far less efficient than the default of not checking for changes. Additionally, it is [not recommended](https://www.redfoxsec.com/blog/understanding-file-upload-vulnerabilities) to use this setting to serve user-uploaded media files unless you have full confidence in your ability to validate and sanitize these files. --- ## `SERVESTATIC_USE_MANIFEST` -**Default:** `not settings.py:DEBUG and isinstance(staticfiles_storage, ManifestStaticFilesStorage)` +**Default:** `not DEBUG and isinstance(staticfiles_storage, ManifestStaticFilesStorage)` Find and serve files using Django's manifest file. This is the most efficient way to determine what files are available, but it requires that you are using a [manifest-compatible](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#manifeststaticfilesstorage) storage backend. -When using ServeStatic's [`CompressedManifestStaticFilesStorage`](./django.md#step-3-add-compression-and-caching-support) storage backend, ServeStatic will no longer need to call `os.stat` on each file during startup. +When using ServeStatic's [`CompressedManifestStaticFilesStorage`](./django.md#step-3-add-compression-and-caching-support) storage backend, ServeStatic will no longer need to call `os.stat` on each file during startup. This will significantly reduce startup time, especially when you have a large number of static files. --- ## `SERVESTATIC_USE_FINDERS` -**Default:** `settings.py:DEBUG` +**Default:** `DEBUG` -Find and serve files using Django's [`finders`](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#finders-module) API. +Find and serve files using Django's [`finders`](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#finders-module) API. Defaults to `True` if Django's `DEBUG` setting is enabled. -It's possible to use this setting in production, but be mindful of the [`settings.py:STATICFILES_DIRS`](https://docs.djangoproject.com/en/stable/ref/settings/#staticfiles-dirs) and [`settings.py:STATICFILE_FINDERS`](https://docs.djangoproject.com/en/stable/ref/settings/#staticfiles-finders) settings. By default, the finders API only searches the `'static'` directory in each app, which are not the copies post-processed by ServeStatic. +It's possible to use this setting in production, but it will be less efficient than the other methods. Also, be mindful of the [`STATICFILES_DIRS`](https://docs.djangoproject.com/en/stable/ref/settings/#staticfiles-dirs) and [`STATICFILE_FINDERS`](https://docs.djangoproject.com/en/stable/ref/settings/#staticfiles-finders) settings. -Note that `STATICFILES_DIRS` cannot equal `STATIC_ROOT` while running the `collectstatic` management command. +By default, the finders API only searches the `'static'` directory in each Django app, which are not the copies post-processed by ServeStatic. Additionally, `STATICFILES_DIRS` cannot equal `STATIC_ROOT` while running the `collectstatic` management command. --- ## `SERVESTATIC_USE_STATIC_ROOT` -**Default:** `not (settings.py:SERVESTATIC_USE_MANIFEST or settings.py:SERVESTATIC_USE_FINDERS)` +**Default:** `not (SERVESTATIC_USE_MANIFEST or SERVESTATIC_USE_FINDERS)` Find and serve all files within Django's `STATIC_ROOT` (file scan is only run during startup). This defaults to `True` if you do not have no other method configured. @@ -58,7 +60,7 @@ This allows users to have their `STATIC_ROOT` directory contain files which are ## `SERVESTATIC_MAX_AGE` -**Default:** `60 if not settings.py:DEBUG else 0` +**Default:** `60 if not DEBUG else 0` Time (in seconds) for which browsers and proxies should cache **non-versioned** files. @@ -74,7 +76,7 @@ Set to `None` to disable setting any `Cache-Control` header on non-versioned fil **Default:** `False` -If `True` enable index file serving. If set to a non-empty string, enable index files and use that string as the index file name. +If `True`, serve an index file when a directory is requested. When set to `True`, ServeStatic will assume your index files are named `index.html`. However, if this value is set to a string, it will use that as the index file name. --- @@ -259,7 +261,7 @@ SERVESTATIC_IMMUTABLE_FILE_TEST = immutable_file_test ## `SERVESTATIC_STATIC_PREFIX` -**Default:** `settings.py:STATIC_URL` +**Default:** `STATIC_URL` The URL prefix under which static files will be served. @@ -289,10 +291,4 @@ This setting is only effective if the `ServeStatic` storage backend is being use Set to `False` to prevent Django throwing an error if you reference a static file which doesn't exist in the manifest. -This works by setting the [`manifest_strict`](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#django.contrib.staticfiles.storage.ManifestStaticFilesStorage.manifest_strict) option on the underlying Django storage instance, as described in the Django documentation. - -This setting is only effective if the `ServeStatic` storage backend is being used. - -!!! Note - - If a file isn't found in the `staticfiles.json` manifest at runtime, a `ValueError` is raised. This behavior can be disabled by subclassing `ManifestStaticFilesStorage` and setting the `manifest_strict` attribute to `False` -- nonexistent paths will remain unchanged. +This setting only takes effect when using `CompressedManifestStaticFilesStorage`, and it works by setting the [`manifest_strict`](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#django.contrib.staticfiles.storage.ManifestStaticFilesStorage.manifest_strict) option on the underlying Django storage instance, as described in the Django documentation. From 8bd46e084e959fa9f360eadd451726525dbe7e08 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:45:00 -0700 Subject: [PATCH 04/13] autorefresh_cache_timeout --- docs/src/django-settings.md | 10 ++++ docs/src/servestatic.md | 12 ++++- src/servestatic/base.py | 39 ++++++++++---- src/servestatic/checks.py | 10 ++++ src/servestatic/middleware.py | 20 +++++++ tests/test_django_servestatic.py | 93 +++++++++++++++++++++++++++++++- tests/test_servestatic.py | 31 +++++++++++ 7 files changed, 201 insertions(+), 14 deletions(-) diff --git a/docs/src/django-settings.md b/docs/src/django-settings.md index a1a830d..08a3815 100644 --- a/docs/src/django-settings.md +++ b/docs/src/django-settings.md @@ -24,6 +24,16 @@ When running under ASGI, ServeStatic performs these checks asynchronously. Regar --- +## `SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT` + +**Default:** `0` + +Determine how long to cache file scanning results (in seconds) when `SERVESTATIC_AUTOREFRESH` is enabled. This is designed to allow users to utilize `AUTOREFRESH` in production while minimizing I/O overhead. + +This can significantly improve performance when you need to use `SERVESTATIC_AUTOREFRESH` in production. Note that the cache is on a per-process basis. When using Django, the `servestatic` cache backend will be used if it exists. + +--- + ## `SERVESTATIC_USE_MANIFEST` **Default:** `not DEBUG and isinstance(staticfiles_storage, ManifestStaticFilesStorage)` diff --git a/docs/src/servestatic.md b/docs/src/servestatic.md index 0a40f03..2e23a94 100644 --- a/docs/src/servestatic.md +++ b/docs/src/servestatic.md @@ -19,7 +19,17 @@ These can be set by passing keyword arguments to the constructor, or by sub-clas **Default:** `False` -Recheck the filesystem to see if any files have changed before responding. This is designed to be used in development where it can be convenient to pick up changes to static files without restarting the server. For both performance and security reasons, this setting should not be used in production. +Recheck the filesystem to see if any files have changed before responding. This is designed to be used in development where it can be convenient to pick up changes to static files without restarting the server. Use `autorefresh_cache_timeout` to improve performance whenever this setting is used in production. + +--- + +### `autorefresh_cache_timeout` + +**Default:** `0` + +Determine how long to cache file scanning results (in seconds) when `autorefresh` is enabled. This is designed to allow users to utilize `autorefresh` in production while minimizing I/O overhead. + +This can significantly improve performance when you need to use `autorefresh` in production. When using ASGI or WSGI, the cache is on a per-process basis. When using Django, the `servestatic` cache backend will be used if it exists, otherwise Django's `default` cache will be used. --- diff --git a/src/servestatic/base.py b/src/servestatic/base.py index c3b3368..1c862c1 100644 --- a/src/servestatic/base.py +++ b/src/servestatic/base.py @@ -3,6 +3,7 @@ import contextlib import os import re +import time import warnings from posixpath import normpath from typing import TYPE_CHECKING @@ -36,17 +37,9 @@ def __init__( application: Callable | None, root: Path | str | None = None, prefix: str | None = None, - *, - # Re-check the filesystem on every request so that any changes are - # automatically picked up. NOTE: For use in development only, not supported - # in production autorefresh: bool = False, + autorefresh_cache_timeout: int = 0, max_age: int | None = 60, # seconds - # Set 'Access-Control-Allow-Origin: *' header on all files. - # As these are all public static files this is safe (See - # https://www.w3.org/TR/cors/#security) and ensures that things (e.g - # webfonts in Firefox) still work as expected when your static files are - # served from a CDN, rather than your primary domain. allow_all_origins: bool = True, charset: str = "utf-8", mimetypes: dict[str, str] | None = None, @@ -56,6 +49,7 @@ def __init__( allow_unsafe_symlinks: bool = False, ) -> None: self.autorefresh = autorefresh + self.autorefresh_cache_timeout = autorefresh_cache_timeout self.max_age = max_age self.allow_all_origins = allow_all_origins self.charset = charset @@ -67,6 +61,7 @@ def __init__( self.application = application self.files: dict[str, StaticFile | Redirect] = {} self.directories: list[tuple[str, str]] = [] + self._autorefresh_cache: dict[str, tuple[float, StaticFile | Redirect | None]] = {} if index_file is True: self.index_file: str | None = "index.html" @@ -146,9 +141,31 @@ def add_file_to_dictionary( def find_file(self, url: str) -> StaticFile | Redirect | None: # Optimization: bail early if the URL can never match a file if self.index_file is None and url.endswith("/"): - return + return None if not self.url_is_canonical(url): - return + return None + + if self.autorefresh_cache_timeout <= 0: + return self._get_file_from_path(url) + + now = time.time() + if url in self._autorefresh_cache: + timestamp, cached_file = self._autorefresh_cache[url] + if now - timestamp < self.autorefresh_cache_timeout: + return cached_file + + file = self._get_file_from_path(url) + + self._autorefresh_cache[url] = (now, file) + # Basic cleanup of old entries to prevent infinite growth + if len(self._autorefresh_cache) > 10000: + self._autorefresh_cache = { + k: v for k, v in self._autorefresh_cache.items() if now - v[0] < self.autorefresh_cache_timeout + } + + return file + + def _get_file_from_path(self, url: str) -> StaticFile | Redirect | None: for path in self.candidate_paths_for_url(url): with contextlib.suppress(MissingFileError): return self.find_file_at_path(path, url) diff --git a/src/servestatic/checks.py b/src/servestatic/checks.py index f60982a..9382a94 100644 --- a/src/servestatic/checks.py +++ b/src/servestatic/checks.py @@ -72,6 +72,15 @@ def _validate_servestatic_max_age() -> list[Error]: return [Error("SERVESTATIC_MAX_AGE must be a non-negative integer or None.", id="servestatic.E014")] +def _validate_servestatic_autorefresh_cache_timeout() -> list[Error]: + value = _get_setting("SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT") + if value is None or _is_non_negative_int(value): + return [] + return [ + Error("SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT must be a non-negative integer or None.", id="servestatic.E034") + ] + + def _validate_servestatic_index_file() -> list[Error]: value = _get_setting("SERVESTATIC_INDEX_FILE") if value is None or isinstance(value, bool): @@ -227,6 +236,7 @@ def check_setting_configuration( errors.extend(_validate_servestatic_root()) errors.extend(_validate_bool_setting("SERVESTATIC_AUTOREFRESH", "servestatic.E011")) + errors.extend(_validate_servestatic_autorefresh_cache_timeout()) errors.extend(_validate_bool_setting("SERVESTATIC_USE_MANIFEST", "servestatic.E012")) errors.extend(_validate_bool_setting("SERVESTATIC_USE_FINDERS", "servestatic.E013")) errors.extend(_validate_servestatic_max_age()) diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index e1e5bc0..cd64f93 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -19,6 +19,7 @@ ManifestStaticFilesStorage, staticfiles_storage, ) +from django.core.cache import DEFAULT_CACHE_ALIAS, caches from django.http import FileResponse, HttpRequest, HttpResponseBase from servestatic.responders import AsyncSlicedFile, MissingFileError, Redirect, StaticFile @@ -88,6 +89,8 @@ def __init__( self.get_response = cast("GetResponseCallable", get_response) autorefresh = getattr(settings, "SERVESTATIC_AUTOREFRESH", debug) + self.cache_timeout = getattr(settings, "SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT", 0) + self.cache_alias = "servestatic" if "servestatic" in getattr(settings, "CACHES", {}) else DEFAULT_CACHE_ALIAS max_age = getattr(settings, "SERVESTATIC_MAX_AGE", 0 if debug else 60) allow_all_origins = getattr(settings, "SERVESTATIC_ALLOW_ALL_ORIGINS", True) charset = getattr(settings, "SERVESTATIC_CHARSET", "utf-8") @@ -113,6 +116,7 @@ def __init__( super().__init__( application=lambda *_: None, autorefresh=autorefresh, + autorefresh_cache_timeout=0, # Disable our base caching algorithm when using Django max_age=max_age, allow_all_origins=allow_all_origins, charset=charset, @@ -151,6 +155,22 @@ def __init__( if root: self.add_files(root) + def find_file(self, url: str) -> StaticFile | Redirect | None: + if self.cache_timeout <= 0: + return super().find_file(url) + + cache = caches[self.cache_alias] + cache_key = f"servestatic_find_file_{url}" + + cached_val = cache.get(cache_key) + if cached_val is not None: + return None if cached_val == "NOT_FOUND" else cached_val + + file = super().find_file(url) + + cache.set(cache_key, file if file is not None else "NOT_FOUND", timeout=self.cache_timeout) + return file + async def __call__(self, request: HttpRequest) -> HttpResponseBase: """If the URL contains a static file, serve it. Otherwise, continue to the next middleware.""" diff --git a/tests/test_django_servestatic.py b/tests/test_django_servestatic.py index deb00d3..a141228 100644 --- a/tests/test_django_servestatic.py +++ b/tests/test_django_servestatic.py @@ -387,8 +387,6 @@ def test_django_check_accepts_correct_gzip_middleware_order(): ({"SERVESTATIC_CHARSET": ""}, "servestatic.E017"), ({"SERVESTATIC_ALLOW_ALL_ORIGINS": "yes"}, "servestatic.E018"), ({"SERVESTATIC_SKIP_COMPRESS_EXTENSIONS": "jpg,png"}, "servestatic.E019"), - ({"SERVESTATIC_USE_GZIP": "yes"}, "servestatic.E032"), - ({"SERVESTATIC_USE_BROTLI": "yes"}, "servestatic.E033"), ({"SERVESTATIC_USE_ZSTD": "yes"}, "servestatic.E020"), ({"SERVESTATIC_ZSTD_DICTIONARY": 123}, "servestatic.E021"), ({"SERVESTATIC_ZSTD_DICTIONARY_IS_RAW": "yes"}, "servestatic.E022"), @@ -400,6 +398,9 @@ def test_django_check_accepts_correct_gzip_middleware_order(): ({"SERVESTATIC_MANIFEST_STRICT": "yes"}, "servestatic.E028"), ({"SERVESTATIC_ALLOW_UNSAFE_SYMLINKS": "yes"}, "servestatic.E029"), ({"SERVESTATIC_MINIFY": "yes"}, "servestatic.E031"), + ({"SERVESTATIC_USE_GZIP": "yes"}, "servestatic.E032"), + ({"SERVESTATIC_USE_BROTLI": "yes"}, "servestatic.E033"), + ({"SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT": -1}, "servestatic.E034"), ], ) def test_django_check_reports_invalid_setting_types(overrides, error_id): @@ -1170,3 +1171,91 @@ def test_get_static_url_value_error(): with mock.patch("servestatic.middleware.staticfiles_storage.url", side_effect=ValueError): assert ServeStaticMiddleware.get_static_url("foo") is None + + +@override_settings( + SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT=1, + CACHES={ + "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}, + "servestatic": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "servestatic-test-cache", + }, + }, + SERVESTATIC_USE_MANIFEST=False, +) +def test_middleware_autorefresh_cache_timeout(tmp_path, async_middleware_response): + from django.core.cache import caches + + from servestatic.middleware import ServeStaticMiddleware + + with override_settings(SERVESTATIC_ROOT=str(tmp_path), SERVESTATIC_AUTOREFRESH=True): + m = ServeStaticMiddleware(async_middleware_response) + + # Clear cache before test + cache = caches["servestatic"] + cache.clear() + + # Create a test file + test_file = tmp_path / "test.txt" + test_file.write_text("initial") + + # 1. The first lookup hits the filesystem and caches + file_obj1 = m.find_file("/test.txt") + assert file_obj1 is not None + assert file_obj1.alternatives[0][1] == str(test_file) + + # 2. Delete the file. The second lookup should hit the Django cache + test_file.unlink() + file_obj2 = m.find_file("/test.txt") + assert file_obj2 is not None + assert file_obj2.alternatives[0][1] == file_obj1.alternatives[0][1] + + # 3. Clear cache and try again. It should be gone. + cache.clear() + file_obj3 = m.find_file("/test.txt") + assert file_obj3 is None + + +@override_settings( + SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT=60, + CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}, + SERVESTATIC_USE_MANIFEST=False, +) +def test_middleware_autorefresh_cache_timeout_fallback_to_default(tmp_path, async_middleware_response): + from django.core.cache import caches + + from servestatic.middleware import ServeStaticMiddleware + + with override_settings( + SERVESTATIC_ROOT=str(tmp_path), SERVESTATIC_AUTOREFRESH=True, SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT=60 + ): + m = ServeStaticMiddleware(async_middleware_response) + + # Check properties to ensure fallback is used + assert getattr(m, "cache_alias", None) == "default" + assert getattr(m, "cache_timeout", 0) == 60 + + # Clear cache before test + cache = caches["default"] + cache.clear() + + # Create a test file + test_file = tmp_path / "test2.txt" + test_file.write_text("initial") + + # 1. The first lookup hits the filesystem and caches + file_obj1 = m.find_file("/test2.txt") + assert file_obj1 is not None + assert file_obj1.alternatives[0][1] == str(test_file) + + # 2. Delete the file. The second lookup should hit the Django cache + test_file.unlink() + file_obj2 = m.find_file("/test2.txt") + assert file_obj2 is not None + assert file_obj2.alternatives[0][1] == file_obj1.alternatives[0][1] + + # 3. Clear cache and try again. It should be gone. + cache.clear() + file_obj3 = m.find_file("/test2.txt") + assert file_obj3 is None diff --git a/tests/test_servestatic.py b/tests/test_servestatic.py index e6f4cbd..ebd5124 100644 --- a/tests/test_servestatic.py +++ b/tests/test_servestatic.py @@ -671,3 +671,34 @@ def start_response(status, headers): # Check if the response is a 404 Not Found assert result["status"] == "404 Not Found" assert b"Not Found" in response + + +def test_autorefresh_cache_timeout(): + """Test that find_file caches the result based on autorefresh_cache_timeout.""" + import time + + with tempfile.TemporaryDirectory() as tmp_dir: + test_file = os.path.join(tmp_dir, "test.txt") + with open(test_file, "w") as f: + f.write("initial content") + + app = DummyServeStaticBase( + application=None, + root=tmp_dir, + autorefresh=True, + autorefresh_cache_timeout=1, + ) + + # 1. First lookup, hits filesystem and caches + file_obj1 = app.find_file("/test.txt") + assert file_obj1 is not None + + # 2. Delete the file, second lookup should return cached object + os.remove(test_file) + file_obj2 = app.find_file("/test.txt") + assert file_obj2 is file_obj1 + + # 3. Wait for cache to expire, lookup should return None (file deleted) + time.sleep(1.1) + file_obj3 = app.find_file("/test.txt") + assert file_obj3 is None From e7c3151f6bb2d213d3f8cae668d80e53fd6c5a4c Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:45:15 -0700 Subject: [PATCH 05/13] Bump to 4.3.0 --- CHANGELOG.md | 10 +++++++--- src/servestatic/__init__.py | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b13352f..ed1ba01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,11 @@ Don't forget to remove deprecated code on each major release! - Nothing (yet) -## [4.2.1] - 2026-04-03 +## [4.3.0] - 2026-04-03 + +### Added + +- Added `autorefresh_cache_timeout` argument (and equivalent `SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT` Django setting) to allow users to set a custom cache timeout for the `ServeStaticMiddleware` when `autorefresh` is enabled. This allows users to utilize `autorefresh` in production while minimizing I/O overhead. ### Fixed @@ -175,8 +179,8 @@ Don't forget to remove deprecated code on each major release! - Forked from [`whitenoise`](https://github.com/evansd/whitenoise) to add ASGI support. -[Unreleased]: https://github.com/Archmonger/ServeStatic/compare/4.2.1...HEAD -[4.2.1]: https://github.com/Archmonger/ServeStatic/compare/4.2.0...4.2.1 +[Unreleased]: https://github.com/Archmonger/ServeStatic/compare/4.3.0...HEAD +[4.3.0]: https://github.com/Archmonger/ServeStatic/compare/4.2.0...4.3.0 [4.2.0]: https://github.com/Archmonger/ServeStatic/compare/4.1.0...4.2.0 [4.1.0]: https://github.com/Archmonger/ServeStatic/compare/4.0.0...4.1.0 [4.0.0]: https://github.com/Archmonger/ServeStatic/compare/3.1.0...4.0.0 diff --git a/src/servestatic/__init__.py b/src/servestatic/__init__.py index 90258bd..e2c3890 100644 --- a/src/servestatic/__init__.py +++ b/src/servestatic/__init__.py @@ -3,6 +3,6 @@ from servestatic.asgi import ServeStaticASGI from servestatic.wsgi import ServeStatic -__version__ = "4.2.1" +__version__ = "4.3.0" __all__ = ["ServeStatic", "ServeStaticASGI"] From 931d66fc8b0db1de33c888298d96030463ea9718 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:46:40 -0700 Subject: [PATCH 06/13] fix lint error --- tests/test_servestatic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_servestatic.py b/tests/test_servestatic.py index ebd5124..df99f86 100644 --- a/tests/test_servestatic.py +++ b/tests/test_servestatic.py @@ -679,7 +679,7 @@ def test_autorefresh_cache_timeout(): with tempfile.TemporaryDirectory() as tmp_dir: test_file = os.path.join(tmp_dir, "test.txt") - with open(test_file, "w") as f: + with open(test_file, "w", encoding="utf-8") as f: f.write("initial content") app = DummyServeStaticBase( From c1e1558579bf35b7e70d9cae3faa0fefe034ce90 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 3 Apr 2026 19:16:05 -0700 Subject: [PATCH 07/13] self review --- .gitignore | 1 + CHANGELOG.md | 2 +- docs/src/django-settings.md | 10 ++++++---- src/servestatic/middleware.py | 4 +++- tests/test_django_servestatic.py | 4 ++-- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 7841886..ea9d325 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ logs *.log *.pot *.pyc +.django_cache .dccachea __pycache__ *.sqlite3 diff --git a/CHANGELOG.md b/CHANGELOG.md index ed1ba01..582cdae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Don't forget to remove deprecated code on each major release! ### Added -- Added `autorefresh_cache_timeout` argument (and equivalent `SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT` Django setting) to allow users to set a custom cache timeout for the `ServeStaticMiddleware` when `autorefresh` is enabled. This allows users to utilize `autorefresh` in production while minimizing I/O overhead. +- Added `autorefresh_cache_timeout` argument (and equivalent `SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT` Django setting) to allow users to set a custom cache timeout for the `ServeStaticMiddleware` when `autorefresh` is enabled. This allows users to reduce performance overhead when utilizing `autorefresh` in production. ### Fixed diff --git a/docs/src/django-settings.md b/docs/src/django-settings.md index 08a3815..1c46fb1 100644 --- a/docs/src/django-settings.md +++ b/docs/src/django-settings.md @@ -18,9 +18,9 @@ Don't use this for the bulk of your static files because you won't benefit from **Default:** `DEBUG` -Always check the filesystem to see if any files have changed before responding. This is designed to be used in development where it can be convenient to pick up changes to static files without restarting the server. +Always check the filesystem to see if any files have changed before responding. This is especially useful in development environments to pick up changes to static files without restarting the server. -When running under ASGI, ServeStatic performs these checks asynchronously. Regardless, this setting adds significant overhead making it far less efficient than the default of not checking for changes. Additionally, it is [not recommended](https://www.redfoxsec.com/blog/understanding-file-upload-vulnerabilities) to use this setting to serve user-uploaded media files unless you have full confidence in your ability to validate and sanitize these files. +When running under ASGI, ServeStatic performs these checks asynchronously. Regardless, keep in mind that this setting adds performance overhead. Additionally, it is [not recommended](https://www.redfoxsec.com/blog/understanding-file-upload-vulnerabilities) to use this setting to serve user-uploaded media files unless you have full confidence in your ability to validate and sanitize user data. --- @@ -28,9 +28,11 @@ When running under ASGI, ServeStatic performs these checks asynchronously. Regar **Default:** `0` -Determine how long to cache file scanning results (in seconds) when `SERVESTATIC_AUTOREFRESH` is enabled. This is designed to allow users to utilize `AUTOREFRESH` in production while minimizing I/O overhead. +Determine how long (in seconds) to cache file metadata when `SERVESTATIC_AUTOREFRESH` is enabled. This is designed to allow users to reduce performance overhead when utilizing `AUTOREFRESH`. -This can significantly improve performance when you need to use `SERVESTATIC_AUTOREFRESH` in production. Note that the cache is on a per-process basis. When using Django, the `servestatic` cache backend will be used if it exists. +This setting can significantly improve performance when you need to use `SERVESTATIC_AUTOREFRESH` in production. Note that the cache is on a per-process basis. When using Django, the `servestatic` cache backend will be used if it exists. + +In production, it is recommended to use this setting alongside a HTTP rate limiter to prevent malicious users from causing excessive disk I/O by making many requests for uncached files. --- diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index cd64f93..391d997 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -2,6 +2,7 @@ import asyncio import contextlib +import hashlib import os import warnings from collections.abc import Awaitable, Callable, Iterable, Iterator @@ -160,7 +161,8 @@ def find_file(self, url: str) -> StaticFile | Redirect | None: return super().find_file(url) cache = caches[self.cache_alias] - cache_key = f"servestatic_find_file_{url}" + url_hash = hashlib.md5(url.encode("utf-8"), usedforsecurity=False).hexdigest() + cache_key = f"servestatic_find_file_{url_hash}" cached_val = cache.get(cache_key) if cached_val is not None: diff --git a/tests/test_django_servestatic.py b/tests/test_django_servestatic.py index a141228..53026fb 100644 --- a/tests/test_django_servestatic.py +++ b/tests/test_django_servestatic.py @@ -1178,8 +1178,8 @@ def test_get_static_url_value_error(): CACHES={ "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}, "servestatic": { - "BACKEND": "django.core.cache.backends.locmem.LocMemCache", - "LOCATION": "servestatic-test-cache", + "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", + "LOCATION": ".django_cache", }, }, SERVESTATIC_USE_MANIFEST=False, From 1e06f49574da33446d34e558f5863e8a9973dc1c Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 4 Apr 2026 01:35:15 -0700 Subject: [PATCH 08/13] self review 2 --- docs/src/dictionary.txt | 1 + docs/src/django-settings.md | 6 +++--- docs/src/servestatic.md | 10 +++++++--- src/servestatic/middleware.py | 2 +- tests/test_servestatic.py | 28 ++++++++++++++++++++++++++++ 5 files changed, 40 insertions(+), 7 deletions(-) diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt index 2523d4f..3d6687f 100644 --- a/docs/src/dictionary.txt +++ b/docs/src/dictionary.txt @@ -43,3 +43,4 @@ symlink symlinks minification minify +uncached diff --git a/docs/src/django-settings.md b/docs/src/django-settings.md index 1c46fb1..699b3e8 100644 --- a/docs/src/django-settings.md +++ b/docs/src/django-settings.md @@ -26,11 +26,11 @@ When running under ASGI, ServeStatic performs these checks asynchronously. Regar ## `SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT` -**Default:** `0` +**Default:** `0 if DEBUG else 1` Determine how long (in seconds) to cache file metadata when `SERVESTATIC_AUTOREFRESH` is enabled. This is designed to allow users to reduce performance overhead when utilizing `AUTOREFRESH`. -This setting can significantly improve performance when you need to use `SERVESTATIC_AUTOREFRESH` in production. Note that the cache is on a per-process basis. When using Django, the `servestatic` cache backend will be used if it exists. +This setting can significantly improve performance when you need to use `SERVESTATIC_AUTOREFRESH`. The `servestatic` cache backend will be used if it exists, otherwise it will fall back to the `default` cache backend. In production, it is recommended to use this setting alongside a HTTP rate limiter to prevent malicious users from causing excessive disk I/O by making many requests for uncached files. @@ -54,7 +54,7 @@ When using ServeStatic's [`CompressedManifestStaticFilesStorage`](./django.md#st Find and serve files using Django's [`finders`](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#finders-module) API. Defaults to `True` if Django's `DEBUG` setting is enabled. -It's possible to use this setting in production, but it will be less efficient than the other methods. Also, be mindful of the [`STATICFILES_DIRS`](https://docs.djangoproject.com/en/stable/ref/settings/#staticfiles-dirs) and [`STATICFILE_FINDERS`](https://docs.djangoproject.com/en/stable/ref/settings/#staticfiles-finders) settings. +It's possible to use this setting in production, but it will be less efficient than other methods. Also, be mindful of the [`STATICFILES_DIRS`](https://docs.djangoproject.com/en/stable/ref/settings/#staticfiles-dirs) and [`STATICFILE_FINDERS`](https://docs.djangoproject.com/en/stable/ref/settings/#staticfiles-finders) settings. By default, the finders API only searches the `'static'` directory in each Django app, which are not the copies post-processed by ServeStatic. Additionally, `STATICFILES_DIRS` cannot equal `STATIC_ROOT` while running the `collectstatic` management command. diff --git a/docs/src/servestatic.md b/docs/src/servestatic.md index 2e23a94..f419293 100644 --- a/docs/src/servestatic.md +++ b/docs/src/servestatic.md @@ -19,7 +19,9 @@ These can be set by passing keyword arguments to the constructor, or by sub-clas **Default:** `False` -Recheck the filesystem to see if any files have changed before responding. This is designed to be used in development where it can be convenient to pick up changes to static files without restarting the server. Use `autorefresh_cache_timeout` to improve performance whenever this setting is used in production. +Always check the filesystem to see if any files have changed before responding. This is especially useful in development environments to pick up changes to static files without restarting the server. + +Keep in mind that this setting adds performance overhead. Additionally, it is [not recommended](https://www.redfoxsec.com/blog/understanding-file-upload-vulnerabilities) to use this setting to serve user-uploaded media files unless you have full confidence in your ability to validate and sanitize user data. --- @@ -27,9 +29,11 @@ Recheck the filesystem to see if any files have changed before responding. This **Default:** `0` -Determine how long to cache file scanning results (in seconds) when `autorefresh` is enabled. This is designed to allow users to utilize `autorefresh` in production while minimizing I/O overhead. +Determine how long (in seconds) to cache file metadata when `autorefresh` is enabled. This is designed to allow users to reduce performance overhead when utilizing `autorefresh`. + +This setting can significantly improve performance when you need to use `autorefresh` in production. Note that the cache is on a per-process basis. -This can significantly improve performance when you need to use `autorefresh` in production. When using ASGI or WSGI, the cache is on a per-process basis. When using Django, the `servestatic` cache backend will be used if it exists, otherwise Django's `default` cache will be used. +If using this setting in production, it is recommended to use a HTTP rate limiter to prevent malicious users from creating excessive disk I/O by making many requests for uncached files. --- diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index 391d997..1347e7c 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -90,7 +90,7 @@ def __init__( self.get_response = cast("GetResponseCallable", get_response) autorefresh = getattr(settings, "SERVESTATIC_AUTOREFRESH", debug) - self.cache_timeout = getattr(settings, "SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT", 0) + self.cache_timeout = getattr(settings, "SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT", 0 if debug else 1) self.cache_alias = "servestatic" if "servestatic" in getattr(settings, "CACHES", {}) else DEFAULT_CACHE_ALIAS max_age = getattr(settings, "SERVESTATIC_MAX_AGE", 0 if debug else 60) allow_all_origins = getattr(settings, "SERVESTATIC_ALLOW_ALL_ORIGINS", True) diff --git a/tests/test_servestatic.py b/tests/test_servestatic.py index df99f86..4e88ed6 100644 --- a/tests/test_servestatic.py +++ b/tests/test_servestatic.py @@ -702,3 +702,31 @@ def test_autorefresh_cache_timeout(): time.sleep(1.1) file_obj3 = app.find_file("/test.txt") assert file_obj3 is None + + +def test_autorefresh_cache_size_limit(): + app = DummyServeStaticBase( + application=None, + autorefresh=True, + autorefresh_cache_timeout=10, + ) + + import time + + now = time.time() + + # Add 10,000 expired items + for i in range(10000): + app._autorefresh_cache[f"/expired_{i}.txt"] = (now - 20, None) + + # Add 1 valid item + app._autorefresh_cache["/valid.txt"] = (now, None) + + # Adding one more item via find_file will trigger cleanup + # The length becomes 10002 inside find_file, triggering the condition + app.find_file("/new.txt") + + # Only the valid items (and the new one just added) should remain + assert "/valid.txt" in app._autorefresh_cache + assert "/new.txt" in app._autorefresh_cache + assert len(app._autorefresh_cache) == 2 From 72cccbb56b67a20be177fa16fb5bed6b4ca5074e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 4 Apr 2026 01:44:46 -0700 Subject: [PATCH 09/13] Revert "autorefresh_cache_timeout" This reverts commit 8bd46e084e959fa9f360eadd451726525dbe7e08. --- CHANGELOG.md | 4 - docs/src/django-settings.md | 146 ------------------------------- docs/src/servestatic.md | 16 +--- src/servestatic/base.py | 33 +------ src/servestatic/checks.py | 10 --- src/servestatic/middleware.py | 21 ----- tests/test_django_servestatic.py | 89 ------------------- tests/test_servestatic.py | 59 ------------- 8 files changed, 5 insertions(+), 373 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 582cdae..ded3a34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,10 +17,6 @@ Don't forget to remove deprecated code on each major release! ## [4.3.0] - 2026-04-03 -### Added - -- Added `autorefresh_cache_timeout` argument (and equivalent `SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT` Django setting) to allow users to set a custom cache timeout for the `ServeStaticMiddleware` when `autorefresh` is enabled. This allows users to reduce performance overhead when utilizing `autorefresh` in production. - ### Fixed - Fix a bug where the Django `collectstatic` command could crash when encountering static files that reference a URL containing query parameters. diff --git a/docs/src/django-settings.md b/docs/src/django-settings.md index 699b3e8..f039172 100644 --- a/docs/src/django-settings.md +++ b/docs/src/django-settings.md @@ -1,228 +1,113 @@ !!! Note - The `ServeStaticMiddleware` class can take the same configuration options as the `ServeStatic` base class, but rather than accepting keyword arguments to its constructor it uses Django settings. The setting names are just the keyword arguments upper-cased with a `SERVESTATIC_` prefix. - --- - ## `SERVESTATIC_ROOT` - **Default:** `None` - Absolute path to a directory of files which will be served at the root of your application (ignored if not set). - Don't use this for the bulk of your static files because you won't benefit from cache versioning, but it can be convenient for files like `robots.txt` or `favicon.ico` which you want to serve at a specific URL. - --- - ## `SERVESTATIC_AUTOREFRESH` - **Default:** `DEBUG` - Always check the filesystem to see if any files have changed before responding. This is especially useful in development environments to pick up changes to static files without restarting the server. - When running under ASGI, ServeStatic performs these checks asynchronously. Regardless, keep in mind that this setting adds performance overhead. Additionally, it is [not recommended](https://www.redfoxsec.com/blog/understanding-file-upload-vulnerabilities) to use this setting to serve user-uploaded media files unless you have full confidence in your ability to validate and sanitize user data. - --- - -## `SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT` - -**Default:** `0 if DEBUG else 1` - -Determine how long (in seconds) to cache file metadata when `SERVESTATIC_AUTOREFRESH` is enabled. This is designed to allow users to reduce performance overhead when utilizing `AUTOREFRESH`. - -This setting can significantly improve performance when you need to use `SERVESTATIC_AUTOREFRESH`. The `servestatic` cache backend will be used if it exists, otherwise it will fall back to the `default` cache backend. - -In production, it is recommended to use this setting alongside a HTTP rate limiter to prevent malicious users from causing excessive disk I/O by making many requests for uncached files. - --- - ## `SERVESTATIC_USE_MANIFEST` - **Default:** `not DEBUG and isinstance(staticfiles_storage, ManifestStaticFilesStorage)` - Find and serve files using Django's manifest file. - This is the most efficient way to determine what files are available, but it requires that you are using a [manifest-compatible](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#manifeststaticfilesstorage) storage backend. - When using ServeStatic's [`CompressedManifestStaticFilesStorage`](./django.md#step-3-add-compression-and-caching-support) storage backend, ServeStatic will no longer need to call `os.stat` on each file during startup. This will significantly reduce startup time, especially when you have a large number of static files. - --- - ## `SERVESTATIC_USE_FINDERS` - **Default:** `DEBUG` - Find and serve files using Django's [`finders`](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#finders-module) API. Defaults to `True` if Django's `DEBUG` setting is enabled. - It's possible to use this setting in production, but it will be less efficient than other methods. Also, be mindful of the [`STATICFILES_DIRS`](https://docs.djangoproject.com/en/stable/ref/settings/#staticfiles-dirs) and [`STATICFILE_FINDERS`](https://docs.djangoproject.com/en/stable/ref/settings/#staticfiles-finders) settings. - By default, the finders API only searches the `'static'` directory in each Django app, which are not the copies post-processed by ServeStatic. Additionally, `STATICFILES_DIRS` cannot equal `STATIC_ROOT` while running the `collectstatic` management command. - --- - ## `SERVESTATIC_USE_STATIC_ROOT` - **Default:** `not (SERVESTATIC_USE_MANIFEST or SERVESTATIC_USE_FINDERS)` - Find and serve all files within Django's `STATIC_ROOT` (file scan is only run during startup). This defaults to `True` if you do not have no other method configured. - This allows users to have their `STATIC_ROOT` directory contain files which are created _after_ `manage.py collectstatic` is ran (e.g. by `django-compressor`). - --- - ## `SERVESTATIC_MAX_AGE` - **Default:** `60 if not DEBUG else 0` - Time (in seconds) for which browsers and proxies should cache **non-versioned** files. - Versioned files (i.e. files which have been given a unique name like `base.a4ef2389.css` by including a hash of their contents in the name) are detected automatically and set to be cached forever. - The default is chosen to be short enough not to cause problems with stale versions but long enough that, if you're running `ServeStatic` behind a CDN, the CDN will still take the majority of the strain during times of heavy load. - Set to `None` to disable setting any `Cache-Control` header on non-versioned files. - --- - ## `SERVESTATIC_INDEX_FILE` - **Default:** `False` - If `True`, serve an index file when a directory is requested. When set to `True`, ServeStatic will assume your index files are named `index.html`. However, if this value is set to a string, it will use that as the index file name. - --- - ## `SERVESTATIC_MIMETYPES` - **Default:** `None` - A dictionary mapping file extensions (lowercase) to the mimetype for that extension. For example: : - ```json linenums="0" { ".foo": "application/x-foo" } ``` - Note that `ServeStatic` ships with its own default set of mimetypes and does not use the system-supplied ones (e.g. `/etc/mime.types`). This ensures that it behaves consistently regardless of the environment in which it's run. View the defaults in ServeStatic's `media_types.py` file. - In addition to file extensions, mimetypes can be specified by supplying the entire filename, for example: : - ```json linenums="0" { "some-special-file": "application/x-custom-type" } ``` - --- - ## `SERVESTATIC_CHARSET` - **Default:** `#!python 'utf-8'` - Charset to add as part of the `Content-Type` header for all files whose mimetype allows a charset. - --- - ## `SERVESTATIC_ALLOW_ALL_ORIGINS` - **Default:** `True` - Toggles whether to send an `Access-Control-Allow-Origin: *` header for all static files. - This allows cross-origin requests for static files which means your static files will continue to work as expected even if they are served via a CDN and therefore on a different domain. Without this your static files will _mostly_ work, but you may have problems with fonts loading in Firefox, or accessing images in canvas elements, or other mysterious things. - The W3C [explicitly state](https://www.w3.org/TR/cors/#security) that this behaviour is safe for publicly accessible files. - --- - ## `SERVESTATIC_ALLOW_UNSAFE_SYMLINKS` - **Default:** `False` - Controls whether symlinks that resolve outside configured static roots are allowed. - By default, ServeStatic blocks symlink breakout so requests cannot escape the configured static directory tree. Set this to `True` only if you intentionally depend on symlinks that point outside your static roots and you trust those links. - --- - ## `SERVESTATIC_SKIP_COMPRESS_EXTENSIONS` - **Default:** `('jpg', 'jpeg', 'png', 'gif', 'webp','zip', 'gz', 'tgz', 'bz2', 'tbz', 'xz', 'br', 'zstd', 'swf', 'flv', 'woff', 'woff2')` - File extensions to skip when compressing. - Because the compression process will only create compressed files where this results in an actual size saving, it would be safe to leave this list empty and attempt to compress all files. However, for files which we're confident won't benefit from compression, it speeds up the process if we just skip over them. - --- - ## `SERVESTATIC_MINIFY` - **Default:** `False` - If set to `True`, ServeStatic will minify CSS and JS files during the `post_process` step before compressing. This feature requires the optional `rcssmin` and `rjsmin` packages to be installed, which can be done via `pip install servestatic[minify]`. If enabled without the required packages, it will raise an `ImportError`. - --- - ## `SERVESTATIC_USE_GZIP` - **Default:** `True` - Enable or disable gzip output generation (`.gz`). - --- - ## `SERVESTATIC_USE_BROTLI` - **Default:** `True` - Enable or disable brotli output generation (`.br`) when `brotli` is available. - --- - ## `SERVESTATIC_USE_ZSTD` - **Default:** `True` - Enable or disable zstd output generation when `compression.zstd` is available (Python 3.14+). - --- - ## `SERVESTATIC_ZSTD_DICTIONARY` - **Default:** `None` - Optional zstd dictionary to improve compression ratio for your asset corpus. - This setting can be either: - - a filesystem path to a trained dictionary file, or - raw dictionary bytes / a prebuilt zstd dictionary object supplied by custom storage subclass logic. - --- - ## `SERVESTATIC_ZSTD_DICTIONARY_IS_RAW` - **Default:** `False` - Set to `True` if `SERVESTATIC_ZSTD_DICTIONARY` points to a raw-content dictionary. - --- - ## `SERVESTATIC_ZSTD_LEVEL` - **Default:** `None` - Optional zstd compression level. - --- - ## `SERVESTATIC_ADD_HEADERS_FUNCTION` - **Default:** `None` - Reference to a function which is passed the headers object for each static file, allowing it to modify them. - The function should not return anything; changes should be made by modifying the headers dictionary directly. - For example: - ```python def force_download_pdfs(headers, path, url): """ @@ -231,29 +116,18 @@ def force_download_pdfs(headers, path, url): instance (which you can treat just as a dict) containing the headers for the current file path: The absolute path to the local file url: The host-relative URL of the file e.g. `/static/styles/app.css` - """ if path.endswith(".pdf"): headers["Content-Disposition"] = "attachment" - - SERVESTATIC_ADD_HEADERS_FUNCTION = force_download_pdfs ``` - --- - ## `SERVESTATIC_IMMUTABLE_FILE_TEST` - **Default:** See [`immutable_file_test`](./servestatic.md#immutable_file_test) in source - Reference to function, or string. - If a reference to a function, this is passed the path and URL for each static file and should return whether that file is immutable, i.e. guaranteed not to change, and so can be safely cached forever. The default is designed to work with Django's `ManifestStaticFilesStorage` backend, and any derivatives of that, so you should only need to change this if you are using a different system for versioning your static files. - If a string, this is treated as a regular expression and each file's URL is matched against it. - Example: - ```python def immutable_file_test(path, url): """ @@ -264,43 +138,23 @@ def immutable_file_test(path, url): # Match filename with 12 hex digits before the extension # e.g. app.db8f2edc0c8a.js return re.match(r"^.+\.[0-9a-f]{12}\..+$", url) - - SERVESTATIC_IMMUTABLE_FILE_TEST = immutable_file_test ``` - --- - ## `SERVESTATIC_STATIC_PREFIX` - **Default:** `STATIC_URL` - The URL prefix under which static files will be served. - If this setting is unset, this value will automatically determined by analysing your `STATIC_URL` setting. For example, if `STATIC_URL = 'https://example.com/static/'` then `SERVESTATIC_STATIC_PREFIX` will be `/static/`. - Note that `FORCE_SCRIPT_NAME` is also taken into account when automatically determining this value. For example, if `FORCE_SCRIPT_NAME = 'subdir/'` and `STATIC_URL = 'subdir/static/'` then `SERVESTATIC_STATIC_PREFIX` will be `/static/`. - If your deployment is more complicated than this (for instance, if you are using a CDN which is doing [path rewriting](https://blog.nginx.org/blog/creating-nginx-rewrite-rules)) then you may need to configure this value directly. - --- - ## `SERVESTATIC_KEEP_ONLY_HASHED_FILES` - **Default:** `False` - Stores only files with hashed names in `STATIC_ROOT`. - This setting removes the "unhashed" version of the file (which should be not be referenced in any case) which should reduce the space required for static files. In some deployment scenarios it can be important to reduce the size of the build artifact as much as possible. - This setting is only effective if the `ServeStatic` storage backend is being used. - --- - ## `SERVESTATIC_MANIFEST_STRICT` - **Default:** `True` - Set to `False` to prevent Django throwing an error if you reference a static file which doesn't exist in the manifest. - This setting only takes effect when using `CompressedManifestStaticFilesStorage`, and it works by setting the [`manifest_strict`](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#django.contrib.staticfiles.storage.ManifestStaticFilesStorage.manifest_strict) option on the underlying Django storage instance, as described in the Django documentation. diff --git a/docs/src/servestatic.md b/docs/src/servestatic.md index f419293..0a40f03 100644 --- a/docs/src/servestatic.md +++ b/docs/src/servestatic.md @@ -19,21 +19,7 @@ These can be set by passing keyword arguments to the constructor, or by sub-clas **Default:** `False` -Always check the filesystem to see if any files have changed before responding. This is especially useful in development environments to pick up changes to static files without restarting the server. - -Keep in mind that this setting adds performance overhead. Additionally, it is [not recommended](https://www.redfoxsec.com/blog/understanding-file-upload-vulnerabilities) to use this setting to serve user-uploaded media files unless you have full confidence in your ability to validate and sanitize user data. - ---- - -### `autorefresh_cache_timeout` - -**Default:** `0` - -Determine how long (in seconds) to cache file metadata when `autorefresh` is enabled. This is designed to allow users to reduce performance overhead when utilizing `autorefresh`. - -This setting can significantly improve performance when you need to use `autorefresh` in production. Note that the cache is on a per-process basis. - -If using this setting in production, it is recommended to use a HTTP rate limiter to prevent malicious users from creating excessive disk I/O by making many requests for uncached files. +Recheck the filesystem to see if any files have changed before responding. This is designed to be used in development where it can be convenient to pick up changes to static files without restarting the server. For both performance and security reasons, this setting should not be used in production. --- diff --git a/src/servestatic/base.py b/src/servestatic/base.py index 1c862c1..7ad7c11 100644 --- a/src/servestatic/base.py +++ b/src/servestatic/base.py @@ -3,7 +3,6 @@ import contextlib import os import re -import time import warnings from posixpath import normpath from typing import TYPE_CHECKING @@ -37,9 +36,9 @@ def __init__( application: Callable | None, root: Path | str | None = None, prefix: str | None = None, + *, autorefresh: bool = False, - autorefresh_cache_timeout: int = 0, - max_age: int | None = 60, # seconds + max_age: int | None = 60, allow_all_origins: bool = True, charset: str = "utf-8", mimetypes: dict[str, str] | None = None, @@ -49,7 +48,6 @@ def __init__( allow_unsafe_symlinks: bool = False, ) -> None: self.autorefresh = autorefresh - self.autorefresh_cache_timeout = autorefresh_cache_timeout self.max_age = max_age self.allow_all_origins = allow_all_origins self.charset = charset @@ -61,7 +59,6 @@ def __init__( self.application = application self.files: dict[str, StaticFile | Redirect] = {} self.directories: list[tuple[str, str]] = [] - self._autorefresh_cache: dict[str, tuple[float, StaticFile | Redirect | None]] = {} if index_file is True: self.index_file: str | None = "index.html" @@ -141,31 +138,9 @@ def add_file_to_dictionary( def find_file(self, url: str) -> StaticFile | Redirect | None: # Optimization: bail early if the URL can never match a file if self.index_file is None and url.endswith("/"): - return None + return if not self.url_is_canonical(url): - return None - - if self.autorefresh_cache_timeout <= 0: - return self._get_file_from_path(url) - - now = time.time() - if url in self._autorefresh_cache: - timestamp, cached_file = self._autorefresh_cache[url] - if now - timestamp < self.autorefresh_cache_timeout: - return cached_file - - file = self._get_file_from_path(url) - - self._autorefresh_cache[url] = (now, file) - # Basic cleanup of old entries to prevent infinite growth - if len(self._autorefresh_cache) > 10000: - self._autorefresh_cache = { - k: v for k, v in self._autorefresh_cache.items() if now - v[0] < self.autorefresh_cache_timeout - } - - return file - - def _get_file_from_path(self, url: str) -> StaticFile | Redirect | None: + return for path in self.candidate_paths_for_url(url): with contextlib.suppress(MissingFileError): return self.find_file_at_path(path, url) diff --git a/src/servestatic/checks.py b/src/servestatic/checks.py index 9382a94..f60982a 100644 --- a/src/servestatic/checks.py +++ b/src/servestatic/checks.py @@ -72,15 +72,6 @@ def _validate_servestatic_max_age() -> list[Error]: return [Error("SERVESTATIC_MAX_AGE must be a non-negative integer or None.", id="servestatic.E014")] -def _validate_servestatic_autorefresh_cache_timeout() -> list[Error]: - value = _get_setting("SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT") - if value is None or _is_non_negative_int(value): - return [] - return [ - Error("SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT must be a non-negative integer or None.", id="servestatic.E034") - ] - - def _validate_servestatic_index_file() -> list[Error]: value = _get_setting("SERVESTATIC_INDEX_FILE") if value is None or isinstance(value, bool): @@ -236,7 +227,6 @@ def check_setting_configuration( errors.extend(_validate_servestatic_root()) errors.extend(_validate_bool_setting("SERVESTATIC_AUTOREFRESH", "servestatic.E011")) - errors.extend(_validate_servestatic_autorefresh_cache_timeout()) errors.extend(_validate_bool_setting("SERVESTATIC_USE_MANIFEST", "servestatic.E012")) errors.extend(_validate_bool_setting("SERVESTATIC_USE_FINDERS", "servestatic.E013")) errors.extend(_validate_servestatic_max_age()) diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index 1347e7c..9824304 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -20,7 +20,6 @@ ManifestStaticFilesStorage, staticfiles_storage, ) -from django.core.cache import DEFAULT_CACHE_ALIAS, caches from django.http import FileResponse, HttpRequest, HttpResponseBase from servestatic.responders import AsyncSlicedFile, MissingFileError, Redirect, StaticFile @@ -90,8 +89,6 @@ def __init__( self.get_response = cast("GetResponseCallable", get_response) autorefresh = getattr(settings, "SERVESTATIC_AUTOREFRESH", debug) - self.cache_timeout = getattr(settings, "SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT", 0 if debug else 1) - self.cache_alias = "servestatic" if "servestatic" in getattr(settings, "CACHES", {}) else DEFAULT_CACHE_ALIAS max_age = getattr(settings, "SERVESTATIC_MAX_AGE", 0 if debug else 60) allow_all_origins = getattr(settings, "SERVESTATIC_ALLOW_ALL_ORIGINS", True) charset = getattr(settings, "SERVESTATIC_CHARSET", "utf-8") @@ -117,7 +114,6 @@ def __init__( super().__init__( application=lambda *_: None, autorefresh=autorefresh, - autorefresh_cache_timeout=0, # Disable our base caching algorithm when using Django max_age=max_age, allow_all_origins=allow_all_origins, charset=charset, @@ -156,23 +152,6 @@ def __init__( if root: self.add_files(root) - def find_file(self, url: str) -> StaticFile | Redirect | None: - if self.cache_timeout <= 0: - return super().find_file(url) - - cache = caches[self.cache_alias] - url_hash = hashlib.md5(url.encode("utf-8"), usedforsecurity=False).hexdigest() - cache_key = f"servestatic_find_file_{url_hash}" - - cached_val = cache.get(cache_key) - if cached_val is not None: - return None if cached_val == "NOT_FOUND" else cached_val - - file = super().find_file(url) - - cache.set(cache_key, file if file is not None else "NOT_FOUND", timeout=self.cache_timeout) - return file - async def __call__(self, request: HttpRequest) -> HttpResponseBase: """If the URL contains a static file, serve it. Otherwise, continue to the next middleware.""" diff --git a/tests/test_django_servestatic.py b/tests/test_django_servestatic.py index 53026fb..6b8c81b 100644 --- a/tests/test_django_servestatic.py +++ b/tests/test_django_servestatic.py @@ -400,7 +400,6 @@ def test_django_check_accepts_correct_gzip_middleware_order(): ({"SERVESTATIC_MINIFY": "yes"}, "servestatic.E031"), ({"SERVESTATIC_USE_GZIP": "yes"}, "servestatic.E032"), ({"SERVESTATIC_USE_BROTLI": "yes"}, "servestatic.E033"), - ({"SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT": -1}, "servestatic.E034"), ], ) def test_django_check_reports_invalid_setting_types(overrides, error_id): @@ -1171,91 +1170,3 @@ def test_get_static_url_value_error(): with mock.patch("servestatic.middleware.staticfiles_storage.url", side_effect=ValueError): assert ServeStaticMiddleware.get_static_url("foo") is None - - -@override_settings( - SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT=1, - CACHES={ - "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}, - "servestatic": { - "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", - "LOCATION": ".django_cache", - }, - }, - SERVESTATIC_USE_MANIFEST=False, -) -def test_middleware_autorefresh_cache_timeout(tmp_path, async_middleware_response): - from django.core.cache import caches - - from servestatic.middleware import ServeStaticMiddleware - - with override_settings(SERVESTATIC_ROOT=str(tmp_path), SERVESTATIC_AUTOREFRESH=True): - m = ServeStaticMiddleware(async_middleware_response) - - # Clear cache before test - cache = caches["servestatic"] - cache.clear() - - # Create a test file - test_file = tmp_path / "test.txt" - test_file.write_text("initial") - - # 1. The first lookup hits the filesystem and caches - file_obj1 = m.find_file("/test.txt") - assert file_obj1 is not None - assert file_obj1.alternatives[0][1] == str(test_file) - - # 2. Delete the file. The second lookup should hit the Django cache - test_file.unlink() - file_obj2 = m.find_file("/test.txt") - assert file_obj2 is not None - assert file_obj2.alternatives[0][1] == file_obj1.alternatives[0][1] - - # 3. Clear cache and try again. It should be gone. - cache.clear() - file_obj3 = m.find_file("/test.txt") - assert file_obj3 is None - - -@override_settings( - SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT=60, - CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}, - SERVESTATIC_USE_MANIFEST=False, -) -def test_middleware_autorefresh_cache_timeout_fallback_to_default(tmp_path, async_middleware_response): - from django.core.cache import caches - - from servestatic.middleware import ServeStaticMiddleware - - with override_settings( - SERVESTATIC_ROOT=str(tmp_path), SERVESTATIC_AUTOREFRESH=True, SERVESTATIC_AUTOREFRESH_CACHE_TIMEOUT=60 - ): - m = ServeStaticMiddleware(async_middleware_response) - - # Check properties to ensure fallback is used - assert getattr(m, "cache_alias", None) == "default" - assert getattr(m, "cache_timeout", 0) == 60 - - # Clear cache before test - cache = caches["default"] - cache.clear() - - # Create a test file - test_file = tmp_path / "test2.txt" - test_file.write_text("initial") - - # 1. The first lookup hits the filesystem and caches - file_obj1 = m.find_file("/test2.txt") - assert file_obj1 is not None - assert file_obj1.alternatives[0][1] == str(test_file) - - # 2. Delete the file. The second lookup should hit the Django cache - test_file.unlink() - file_obj2 = m.find_file("/test2.txt") - assert file_obj2 is not None - assert file_obj2.alternatives[0][1] == file_obj1.alternatives[0][1] - - # 3. Clear cache and try again. It should be gone. - cache.clear() - file_obj3 = m.find_file("/test2.txt") - assert file_obj3 is None diff --git a/tests/test_servestatic.py b/tests/test_servestatic.py index 4e88ed6..e6f4cbd 100644 --- a/tests/test_servestatic.py +++ b/tests/test_servestatic.py @@ -671,62 +671,3 @@ def start_response(status, headers): # Check if the response is a 404 Not Found assert result["status"] == "404 Not Found" assert b"Not Found" in response - - -def test_autorefresh_cache_timeout(): - """Test that find_file caches the result based on autorefresh_cache_timeout.""" - import time - - with tempfile.TemporaryDirectory() as tmp_dir: - test_file = os.path.join(tmp_dir, "test.txt") - with open(test_file, "w", encoding="utf-8") as f: - f.write("initial content") - - app = DummyServeStaticBase( - application=None, - root=tmp_dir, - autorefresh=True, - autorefresh_cache_timeout=1, - ) - - # 1. First lookup, hits filesystem and caches - file_obj1 = app.find_file("/test.txt") - assert file_obj1 is not None - - # 2. Delete the file, second lookup should return cached object - os.remove(test_file) - file_obj2 = app.find_file("/test.txt") - assert file_obj2 is file_obj1 - - # 3. Wait for cache to expire, lookup should return None (file deleted) - time.sleep(1.1) - file_obj3 = app.find_file("/test.txt") - assert file_obj3 is None - - -def test_autorefresh_cache_size_limit(): - app = DummyServeStaticBase( - application=None, - autorefresh=True, - autorefresh_cache_timeout=10, - ) - - import time - - now = time.time() - - # Add 10,000 expired items - for i in range(10000): - app._autorefresh_cache[f"/expired_{i}.txt"] = (now - 20, None) - - # Add 1 valid item - app._autorefresh_cache["/valid.txt"] = (now, None) - - # Adding one more item via find_file will trigger cleanup - # The length becomes 10002 inside find_file, triggering the condition - app.find_file("/new.txt") - - # Only the valid items (and the new one just added) should remain - assert "/valid.txt" in app._autorefresh_cache - assert "/new.txt" in app._autorefresh_cache - assert len(app._autorefresh_cache) == 2 From 33a5f745daba0dce36a59fe24f18bbec2a8b74ab Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 4 Apr 2026 08:44:56 +0000 Subject: [PATCH 10/13] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/servestatic/middleware.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index 9824304..e1e5bc0 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -2,7 +2,6 @@ import asyncio import contextlib -import hashlib import os import warnings from collections.abc import Awaitable, Callable, Iterable, Iterator From b0a0a803c7684b3916f7c692df5b3e255a1a3626 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 4 Apr 2026 01:52:04 -0700 Subject: [PATCH 11/13] format markdown --- docs/src/django-settings.md | 136 +++++++++++++++++++++++++++++++++++- 1 file changed, 135 insertions(+), 1 deletion(-) diff --git a/docs/src/django-settings.md b/docs/src/django-settings.md index f039172..8e28b2b 100644 --- a/docs/src/django-settings.md +++ b/docs/src/django-settings.md @@ -1,113 +1,216 @@ !!! Note + The `ServeStaticMiddleware` class can take the same configuration options as the `ServeStatic` base class, but rather than accepting keyword arguments to its constructor it uses Django settings. The setting names are just the keyword arguments upper-cased with a `SERVESTATIC_` prefix. + --- + ## `SERVESTATIC_ROOT` + **Default:** `None` + Absolute path to a directory of files which will be served at the root of your application (ignored if not set). + Don't use this for the bulk of your static files because you won't benefit from cache versioning, but it can be convenient for files like `robots.txt` or `favicon.ico` which you want to serve at a specific URL. + --- + ## `SERVESTATIC_AUTOREFRESH` + **Default:** `DEBUG` + Always check the filesystem to see if any files have changed before responding. This is especially useful in development environments to pick up changes to static files without restarting the server. + When running under ASGI, ServeStatic performs these checks asynchronously. Regardless, keep in mind that this setting adds performance overhead. Additionally, it is [not recommended](https://www.redfoxsec.com/blog/understanding-file-upload-vulnerabilities) to use this setting to serve user-uploaded media files unless you have full confidence in your ability to validate and sanitize user data. + --- ---- + ## `SERVESTATIC_USE_MANIFEST` + **Default:** `not DEBUG and isinstance(staticfiles_storage, ManifestStaticFilesStorage)` + Find and serve files using Django's manifest file. + This is the most efficient way to determine what files are available, but it requires that you are using a [manifest-compatible](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#manifeststaticfilesstorage) storage backend. + When using ServeStatic's [`CompressedManifestStaticFilesStorage`](./django.md#step-3-add-compression-and-caching-support) storage backend, ServeStatic will no longer need to call `os.stat` on each file during startup. This will significantly reduce startup time, especially when you have a large number of static files. + --- + ## `SERVESTATIC_USE_FINDERS` + **Default:** `DEBUG` + Find and serve files using Django's [`finders`](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#finders-module) API. Defaults to `True` if Django's `DEBUG` setting is enabled. + It's possible to use this setting in production, but it will be less efficient than other methods. Also, be mindful of the [`STATICFILES_DIRS`](https://docs.djangoproject.com/en/stable/ref/settings/#staticfiles-dirs) and [`STATICFILE_FINDERS`](https://docs.djangoproject.com/en/stable/ref/settings/#staticfiles-finders) settings. + By default, the finders API only searches the `'static'` directory in each Django app, which are not the copies post-processed by ServeStatic. Additionally, `STATICFILES_DIRS` cannot equal `STATIC_ROOT` while running the `collectstatic` management command. + --- + ## `SERVESTATIC_USE_STATIC_ROOT` + **Default:** `not (SERVESTATIC_USE_MANIFEST or SERVESTATIC_USE_FINDERS)` + Find and serve all files within Django's `STATIC_ROOT` (file scan is only run during startup). This defaults to `True` if you do not have no other method configured. + This allows users to have their `STATIC_ROOT` directory contain files which are created _after_ `manage.py collectstatic` is ran (e.g. by `django-compressor`). + --- + ## `SERVESTATIC_MAX_AGE` + **Default:** `60 if not DEBUG else 0` + Time (in seconds) for which browsers and proxies should cache **non-versioned** files. + Versioned files (i.e. files which have been given a unique name like `base.a4ef2389.css` by including a hash of their contents in the name) are detected automatically and set to be cached forever. + The default is chosen to be short enough not to cause problems with stale versions but long enough that, if you're running `ServeStatic` behind a CDN, the CDN will still take the majority of the strain during times of heavy load. + Set to `None` to disable setting any `Cache-Control` header on non-versioned files. + --- + ## `SERVESTATIC_INDEX_FILE` + **Default:** `False` + If `True`, serve an index file when a directory is requested. When set to `True`, ServeStatic will assume your index files are named `index.html`. However, if this value is set to a string, it will use that as the index file name. + --- + ## `SERVESTATIC_MIMETYPES` + **Default:** `None` + A dictionary mapping file extensions (lowercase) to the mimetype for that extension. For example: : + ```json linenums="0" { ".foo": "application/x-foo" } ``` + Note that `ServeStatic` ships with its own default set of mimetypes and does not use the system-supplied ones (e.g. `/etc/mime.types`). This ensures that it behaves consistently regardless of the environment in which it's run. View the defaults in ServeStatic's `media_types.py` file. + In addition to file extensions, mimetypes can be specified by supplying the entire filename, for example: : + ```json linenums="0" { "some-special-file": "application/x-custom-type" } ``` + --- + ## `SERVESTATIC_CHARSET` + **Default:** `#!python 'utf-8'` + Charset to add as part of the `Content-Type` header for all files whose mimetype allows a charset. + --- + ## `SERVESTATIC_ALLOW_ALL_ORIGINS` + **Default:** `True` + Toggles whether to send an `Access-Control-Allow-Origin: *` header for all static files. + This allows cross-origin requests for static files which means your static files will continue to work as expected even if they are served via a CDN and therefore on a different domain. Without this your static files will _mostly_ work, but you may have problems with fonts loading in Firefox, or accessing images in canvas elements, or other mysterious things. + The W3C [explicitly state](https://www.w3.org/TR/cors/#security) that this behaviour is safe for publicly accessible files. + --- + ## `SERVESTATIC_ALLOW_UNSAFE_SYMLINKS` + **Default:** `False` + Controls whether symlinks that resolve outside configured static roots are allowed. + By default, ServeStatic blocks symlink breakout so requests cannot escape the configured static directory tree. Set this to `True` only if you intentionally depend on symlinks that point outside your static roots and you trust those links. + --- + ## `SERVESTATIC_SKIP_COMPRESS_EXTENSIONS` + **Default:** `('jpg', 'jpeg', 'png', 'gif', 'webp','zip', 'gz', 'tgz', 'bz2', 'tbz', 'xz', 'br', 'zstd', 'swf', 'flv', 'woff', 'woff2')` + File extensions to skip when compressing. + Because the compression process will only create compressed files where this results in an actual size saving, it would be safe to leave this list empty and attempt to compress all files. However, for files which we're confident won't benefit from compression, it speeds up the process if we just skip over them. + --- + ## `SERVESTATIC_MINIFY` + **Default:** `False` + If set to `True`, ServeStatic will minify CSS and JS files during the `post_process` step before compressing. This feature requires the optional `rcssmin` and `rjsmin` packages to be installed, which can be done via `pip install servestatic[minify]`. If enabled without the required packages, it will raise an `ImportError`. + --- + ## `SERVESTATIC_USE_GZIP` + **Default:** `True` + Enable or disable gzip output generation (`.gz`). + --- + ## `SERVESTATIC_USE_BROTLI` + **Default:** `True` + Enable or disable brotli output generation (`.br`) when `brotli` is available. + --- + ## `SERVESTATIC_USE_ZSTD` + **Default:** `True` + Enable or disable zstd output generation when `compression.zstd` is available (Python 3.14+). + --- + ## `SERVESTATIC_ZSTD_DICTIONARY` + **Default:** `None` + Optional zstd dictionary to improve compression ratio for your asset corpus. + This setting can be either: + - a filesystem path to a trained dictionary file, or - raw dictionary bytes / a prebuilt zstd dictionary object supplied by custom storage subclass logic. + --- + ## `SERVESTATIC_ZSTD_DICTIONARY_IS_RAW` + **Default:** `False` + Set to `True` if `SERVESTATIC_ZSTD_DICTIONARY` points to a raw-content dictionary. + --- + ## `SERVESTATIC_ZSTD_LEVEL` + **Default:** `None` + Optional zstd compression level. + --- + ## `SERVESTATIC_ADD_HEADERS_FUNCTION` + **Default:** `None` + Reference to a function which is passed the headers object for each static file, allowing it to modify them. + The function should not return anything; changes should be made by modifying the headers dictionary directly. + For example: + ```python def force_download_pdfs(headers, path, url): """ @@ -116,18 +219,29 @@ def force_download_pdfs(headers, path, url): instance (which you can treat just as a dict) containing the headers for the current file path: The absolute path to the local file url: The host-relative URL of the file e.g. `/static/styles/app.css` + """ if path.endswith(".pdf"): headers["Content-Disposition"] = "attachment" + + SERVESTATIC_ADD_HEADERS_FUNCTION = force_download_pdfs ``` + --- + ## `SERVESTATIC_IMMUTABLE_FILE_TEST` + **Default:** See [`immutable_file_test`](./servestatic.md#immutable_file_test) in source + Reference to function, or string. + If a reference to a function, this is passed the path and URL for each static file and should return whether that file is immutable, i.e. guaranteed not to change, and so can be safely cached forever. The default is designed to work with Django's `ManifestStaticFilesStorage` backend, and any derivatives of that, so you should only need to change this if you are using a different system for versioning your static files. + If a string, this is treated as a regular expression and each file's URL is matched against it. + Example: + ```python def immutable_file_test(path, url): """ @@ -138,23 +252,43 @@ def immutable_file_test(path, url): # Match filename with 12 hex digits before the extension # e.g. app.db8f2edc0c8a.js return re.match(r"^.+\.[0-9a-f]{12}\..+$", url) + + SERVESTATIC_IMMUTABLE_FILE_TEST = immutable_file_test ``` + --- + ## `SERVESTATIC_STATIC_PREFIX` + **Default:** `STATIC_URL` + The URL prefix under which static files will be served. + If this setting is unset, this value will automatically determined by analysing your `STATIC_URL` setting. For example, if `STATIC_URL = 'https://example.com/static/'` then `SERVESTATIC_STATIC_PREFIX` will be `/static/`. + Note that `FORCE_SCRIPT_NAME` is also taken into account when automatically determining this value. For example, if `FORCE_SCRIPT_NAME = 'subdir/'` and `STATIC_URL = 'subdir/static/'` then `SERVESTATIC_STATIC_PREFIX` will be `/static/`. + If your deployment is more complicated than this (for instance, if you are using a CDN which is doing [path rewriting](https://blog.nginx.org/blog/creating-nginx-rewrite-rules)) then you may need to configure this value directly. + --- + ## `SERVESTATIC_KEEP_ONLY_HASHED_FILES` + **Default:** `False` + Stores only files with hashed names in `STATIC_ROOT`. + This setting removes the "unhashed" version of the file (which should be not be referenced in any case) which should reduce the space required for static files. In some deployment scenarios it can be important to reduce the size of the build artifact as much as possible. + This setting is only effective if the `ServeStatic` storage backend is being used. + --- + ## `SERVESTATIC_MANIFEST_STRICT` + **Default:** `True` + Set to `False` to prevent Django throwing an error if you reference a static file which doesn't exist in the manifest. + This setting only takes effect when using `CompressedManifestStaticFilesStorage`, and it works by setting the [`manifest_strict`](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#django.contrib.staticfiles.storage.ManifestStaticFilesStorage.manifest_strict) option on the underlying Django storage instance, as described in the Django documentation. From b6dc8367a6542527a3b9cd4a726f2e8726fc5255 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:49:57 -0700 Subject: [PATCH 12/13] Update autorefresh docs --- docs/src/servestatic.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/src/servestatic.md b/docs/src/servestatic.md index 0a40f03..a05dc84 100644 --- a/docs/src/servestatic.md +++ b/docs/src/servestatic.md @@ -19,7 +19,9 @@ These can be set by passing keyword arguments to the constructor, or by sub-clas **Default:** `False` -Recheck the filesystem to see if any files have changed before responding. This is designed to be used in development where it can be convenient to pick up changes to static files without restarting the server. For both performance and security reasons, this setting should not be used in production. +Always check the filesystem to see if any files have changed before responding. This is especially useful in development environments to pick up changes to static files without restarting the server. + +Keep in mind that this setting adds performance overhead. Additionally, it is [not recommended](https://www.redfoxsec.com/blog/understanding-file-upload-vulnerabilities) to use this to serve user-uploaded media files unless you have full confidence in your ability to validate and sanitize user data. --- From 002de989e3ceaf3f507c781b7a80fcf21cd043f6 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:50:36 -0700 Subject: [PATCH 13/13] bump date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ded3a34..c8e70f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Don't forget to remove deprecated code on each major release! - Nothing (yet) -## [4.3.0] - 2026-04-03 +## [4.3.0] - 2026-04-09 ### Fixed