From cffed49bafa55dac76ae67b7582ab6081b5831eb Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 21 Apr 2026 09:57:32 +0400 Subject: [PATCH] fix(settings): resolve static asset URLs through STATIC_URL Signed-off-by: tdruez --- django_altcha/__init__.py | 2 +- django_altcha/conf.py | 35 ++++++++++++++++++---- tests/settings.py | 1 + tests/test_widget.py | 62 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 92 insertions(+), 8 deletions(-) diff --git a/django_altcha/__init__.py b/django_altcha/__init__.py index 015f00e..e37eee9 100644 --- a/django_altcha/__init__.py +++ b/django_altcha/__init__.py @@ -81,7 +81,7 @@ def get_altcha_challenge(max_number=None, expires=None): class AltchaWidget(HiddenInput): template_name = "altcha_widget.html" - def __init__(self, options, *args, **kwargs): + def __init__(self, options=None, *args, **kwargs): """Initialize the ALTCHA widget with provided options from the field.""" self.options = options or {} super().__init__(*args, **kwargs) diff --git a/django_altcha/conf.py b/django_altcha/conf.py index 52300e3..a699030 100644 --- a/django_altcha/conf.py +++ b/django_altcha/conf.py @@ -16,6 +16,7 @@ """ from django.conf import settings +from django.templatetags.static import static _DEFAULTS = { # Set to `False` to skip Altcha validation altogether. @@ -23,11 +24,17 @@ # This key is used to HMAC-sign ALTCHA challenges and must be kept secret. "ALTCHA_HMAC_KEY": None, # URL of the Altcha JavaScript file. - # Defaults to the bundled django-altcha file. - "ALTCHA_JS_URL": "/static/altcha/altcha.min.js", + # Defaults to the bundled django-altcha file, resolved through STATIC_URL. + # Accepts: + # - a relative static path (e.g. "altcha/altcha.min.js"), + # - an absolute path starting with "/", + # - or a fully-qualified URL (http:// or https://) for CDN usage. + # Relative paths are passed through Django's staticfiles storage, + # so they work with STATIC_URL customization and ManifestStaticFilesStorage. + "ALTCHA_JS_URL": "altcha/altcha.min.js", # URL of the Altcha translations JavaScript file. - # Defaults to the bundled django-altcha file. - "ALTCHA_JS_TRANSLATIONS_URL": "/static/altcha/dist_i18n/all.min.js", + # Same resolution rules as ALTCHA_JS_URL above. + "ALTCHA_JS_TRANSLATIONS_URL": "altcha/dist_i18n/all.min.js", # Whether to include Altcha translations. # https://altcha.org/docs/v2/widget-integration/#internationalization-i18n "ALTCHA_INCLUDE_TRANSLATIONS": False, @@ -41,9 +48,27 @@ "ALTCHA_CACHE_ALIAS": "default", } +# Settings whose value is a static asset path that should be resolved through +# Django's staticfiles machinery when given as a relative path. +_STATIC_ASSET_SETTINGS = {"ALTCHA_JS_URL", "ALTCHA_JS_TRANSLATIONS_URL"} + + +def _is_absolute(path): + """Return True if `path` is a full URL or an absolute server path.""" + return path.startswith(("http://", "https://", "/")) + def get_setting(name): """Look up a django-altcha setting, falling back to the default.""" if name not in _DEFAULTS: raise ValueError(f"Unknown django-altcha setting: {name}") - return getattr(settings, name, _DEFAULTS[name]) + value = getattr(settings, name, _DEFAULTS[name]) + + # Resolve relative static paths through STATIC_URL so they respect the + # project's staticfiles configuration and storage backend. Absolute paths + # and full URLs are passed through untouched, matching the convention + # used by Django's form Media class. + if name in _STATIC_ASSET_SETTINGS and value and not _is_absolute(value): + return static(value) + + return value diff --git a/tests/settings.py b/tests/settings.py index f153e28..867bdb2 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,5 +1,6 @@ INSTALLED_APPS = ["django_altcha"] DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3"}} ROOT_URLCONF = "tests.urls" +STATIC_URL = "/static/" ALTCHA_HMAC_KEY = "altcha-insecure-hmac-0123456789abcdef" CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}} diff --git a/tests/test_widget.py b/tests/test_widget.py index 1ef07b7..0e4cfe9 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -15,7 +15,7 @@ class DjangoAltchaWidgetTest(TestCase): def test_widget_initialization_with_default_options(self): - widget = AltchaWidget(options=None) + widget = AltchaWidget() self.assertNotIn("challengeurl", widget.options) self.assertNotIn("challengejson", widget.options) self.assertNotIn("auto", widget.options) @@ -56,7 +56,7 @@ def test_widget_rendering_with_complex_options(self): self.assertIn(expected, rendered_widget_html) def test_js_translation_included_if_enabled(self): - widget = AltchaWidget(options=None) + widget = AltchaWidget() expected_js = "/static/altcha/dist_i18n/all.min.js" with override_settings(ALTCHA_INCLUDE_TRANSLATIONS=True): @@ -66,3 +66,61 @@ def test_js_translation_included_if_enabled(self): with override_settings(ALTCHA_INCLUDE_TRANSLATIONS=False): rendered_widget_html = widget.render("name", "value") self.assertNotIn(expected_js, rendered_widget_html) + + def test_widget_renders_default_js_url_through_static(self): + widget = AltchaWidget() + rendered_html = widget.render("name", "value") + self.assertIn("/static/altcha/altcha.min.js", rendered_html) + + def test_widget_respects_custom_static_url(self): + widget = AltchaWidget() + with override_settings(STATIC_URL="/assets/"): + rendered_html = widget.render("name", "value") + self.assertIn("/assets/altcha/altcha.min.js", rendered_html) + self.assertNotIn("/static/altcha/altcha.min.js", rendered_html) + + def test_widget_resolves_relative_js_url_override(self): + widget = AltchaWidget() + with override_settings(ALTCHA_JS_URL="custom/altcha.js"): + rendered_html = widget.render("name", "value") + self.assertIn("/static/custom/altcha.js", rendered_html) + + def test_widget_passes_through_absolute_js_url(self): + widget = AltchaWidget() + with override_settings(ALTCHA_JS_URL="/my_static/altcha.js"): + rendered_html = widget.render("name", "value") + self.assertIn('src="/my_static/altcha.js"', rendered_html) + self.assertNotIn("/static/my_static/altcha.js", rendered_html) + + def test_widget_passes_through_http_js_url(self): + widget = AltchaWidget() + cdn_url = "http://cdn/altcha.min.js" + with override_settings(ALTCHA_JS_URL=cdn_url): + rendered_html = widget.render("name", "value") + self.assertIn(cdn_url, rendered_html) + + def test_widget_passes_through_https_js_url(self): + widget = AltchaWidget() + cdn_url = "https://cdn/altcha.min.js" + with override_settings(ALTCHA_JS_URL=cdn_url): + rendered_html = widget.render("name", "value") + self.assertIn(cdn_url, rendered_html) + + def test_widget_resolves_translations_url_through_static(self): + widget = AltchaWidget() + with override_settings( + ALTCHA_INCLUDE_TRANSLATIONS=True, + STATIC_URL="/assets/", + ): + rendered_html = widget.render("name", "value") + self.assertIn("/assets/altcha/dist_i18n/all.min.js", rendered_html) + + def test_widget_passes_through_absolute_translations_url(self): + widget = AltchaWidget() + cdn_url = "https://cdni18n/all.min.js" + with override_settings( + ALTCHA_INCLUDE_TRANSLATIONS=True, + ALTCHA_JS_TRANSLATIONS_URL=cdn_url, + ): + rendered_html = widget.render("name", "value") + self.assertIn(cdn_url, rendered_html)