From 28d80172fb17c3a941802445477854e30d02e5e7 Mon Sep 17 00:00:00 2001 From: Eirik Botten Nicolaysen Date: Sat, 24 Oct 2026 07:19:32 +0200 Subject: [PATCH 1/2] Make Gravatar opt-in and pin the Tailwind CDN version Gravatar avatars are now behind SIMPLEAUDIT_GRAVATAR (default off); when off, no gravatar.com URL is rendered and templates show the user's initial. The visualizer sidebar uses the same helper. The Tailwind Play CDN script is pinned to 3.4.17, the version cdn.tailwindcss.com currently redirects to. --- config/settings.py | 4 ++++ docs/deployment.md | 1 + infra/context_processors.py | 24 ++++++++++++++++-------- infra/tests/test_gravatar.py | 31 +++++++++++++++++++++++++++++++ infra/visualizer.py | 12 +++--------- static/scenario_viewer.html | 2 +- static/visualizer.html | 2 +- templates/base.html | 2 +- templates/public/base_public.html | 2 +- 9 files changed, 59 insertions(+), 21 deletions(-) create mode 100644 infra/tests/test_gravatar.py diff --git a/config/settings.py b/config/settings.py index 9110863b..3e6bd9f7 100644 --- a/config/settings.py +++ b/config/settings.py @@ -314,6 +314,10 @@ def _csrf_trusted_origins() -> list[str]: WORKOS_API_KEY = os.environ.get("WORKOS_API_KEY", "") WORKOS_ENABLED = bool(WORKOS_CLIENT_ID and WORKOS_API_KEY) +# Gravatar avatars: off by default, since loading one sends the MD5 hash of the +# user's email to gravatar.com. When off, templates show the user's initial. +SIMPLEAUDIT_GRAVATAR = env_bool("SIMPLEAUDIT_GRAVATAR", False) + # Demo mode: prefill the login form with demo credentials and show a hint banner # (public demos / HF Spaces). DEMO_MODE = env_bool("DEMO_MODE", False) diff --git a/docs/deployment.md b/docs/deployment.md index bfd46ffe..75a7d85a 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -187,6 +187,7 @@ override them when you deliberately need to. | `BOOTSTRAP_USERNAME`, `BOOTSTRAP_EMAIL`, `BOOTSTRAP_PASSWORD`, `BOOTSTRAP_PROJECT_NAME` | First admin user and workspace. | | `DEMO_MODE`, `DEMO_USERNAME`, `DEMO_PASSWORD` | Prefilled demo login and the HF iframe cookie/CSRF policy. | | `WORKOS_CLIENT_ID`, `WORKOS_API_KEY` | Optional passwordless email sign-in. | +| `SIMPLEAUDIT_GRAVATAR` | `true` = show Gravatar avatars (the browser sends the MD5 hash of the user's email to gravatar.com). Default off: the user's initial is shown. | | `SENTRY_DSN`, `SENTRY_ENVIRONMENT` | Optional error tracking. | | `LOG_LEVEL`, `PORT` | Logging level; web port for the CLI. | | any name in a connection's `secret_reference` | Model API key read at execution time. | diff --git a/infra/context_processors.py b/infra/context_processors.py index 144dfdbc..eda58162 100644 --- a/infra/context_processors.py +++ b/infra/context_processors.py @@ -2,20 +2,28 @@ import hashlib +from django.conf import settings -def gravatar_url(request): - """Expose the current user's Gravatar URL to templates. - The avatar is derived from the user's email (MD5 hash), so no storage or - upload is needed. Returns ``None`` for anonymous users or users without - an email, letting templates fall back to initials. +def gravatar_for(user) -> str | None: + """The user's Gravatar URL, or ``None`` so templates fall back to initials. + + The URL carries the MD5 hash of the user's email, and the browser sends it + to gravatar.com when it loads the image. Off unless ``SIMPLEAUDIT_GRAVATAR`` + is set. Also ``None`` for anonymous users and users without an email. """ - user = getattr(request, "user", None) + if not settings.SIMPLEAUDIT_GRAVATAR: + return None if user is None or not user.is_authenticated or not user.email: - return {"gravatar_url": None} + return None email = user.email.strip().lower() md5_hash = hashlib.md5(email.encode("utf-8")).hexdigest() - return {"gravatar_url": f"https://www.gravatar.com/avatar/{md5_hash}?d=identicon&s=128"} + return f"https://www.gravatar.com/avatar/{md5_hash}?d=identicon&s=128" + + +def gravatar_url(request): + """Expose the current user's Gravatar URL (or ``None``) to templates.""" + return {"gravatar_url": gravatar_for(getattr(request, "user", None))} def admin_status(request): diff --git a/infra/tests/test_gravatar.py b/infra/tests/test_gravatar.py new file mode 100644 index 00000000..7fb80592 --- /dev/null +++ b/infra/tests/test_gravatar.py @@ -0,0 +1,31 @@ +"""Gravatar avatars are opt-in: off by default, no gravatar.com URL is rendered.""" +from django.contrib.auth.models import AnonymousUser +from django.test import Client, TestCase, override_settings + +from infra.context_processors import gravatar_for +from infra.tests.factories import UserFactory + + +class GravatarSettingTest(TestCase): + def setUp(self): + self.user = UserFactory(username="jane", email="Jane@X.com") + self.client = Client(SERVER_NAME="localhost") + self.client.force_login(self.user) + + def test_off_by_default(self): + self.assertIsNone(gravatar_for(self.user)) + content = self.client.get("/profile/").content.decode() + self.assertNotIn("gravatar.com/avatar/", content) + + @override_settings(SIMPLEAUDIT_GRAVATAR=True) + def test_on_renders_the_gravatar_url(self): + # MD5 of the trimmed, lower-cased email "jane@x.com". + url = "https://www.gravatar.com/avatar/8e747d19e83ff063fbb509e348a296d4" + self.assertTrue(gravatar_for(self.user).startswith(url)) + content = self.client.get("/profile/").content.decode() + self.assertIn(url, content) + + @override_settings(SIMPLEAUDIT_GRAVATAR=True) + def test_on_still_none_without_a_signed_in_user_with_email(self): + self.assertIsNone(gravatar_for(AnonymousUser())) + self.assertIsNone(gravatar_for(UserFactory(username="noemail", email=""))) diff --git a/infra/visualizer.py b/infra/visualizer.py index 3db036c4..c7a8f743 100644 --- a/infra/visualizer.py +++ b/infra/visualizer.py @@ -517,16 +517,10 @@ def _inject_sidebar(html: str, request) -> str: # render_to_string with a plain context does not run context processors, so # pass the values the partial needs explicitly (user, gravatar). + from infra.context_processors import gravatar_for + user = getattr(request, "user", None) - gravatar = None - if user is not None and user.is_authenticated and user.email: - import hashlib - - gravatar = ( - "https://www.gravatar.com/avatar/" - + hashlib.md5(user.email.strip().lower().encode("utf-8")).hexdigest() - + "?d=identicon&s=128" - ) + gravatar = gravatar_for(user) sidebar = render_to_string( "partials/visualizer_sidebar.html", {"request": request, "user": user, "gravatar_url": gravatar}, diff --git a/static/scenario_viewer.html b/static/scenario_viewer.html index c03e9c24..2bb5d2a1 100644 --- a/static/scenario_viewer.html +++ b/static/scenario_viewer.html @@ -7,7 +7,7 @@ SimpleAudit Scenario Viewer - + - + + + From 8c51db560fb92b0e5c2f630ddafa0773a05e88a2 Mon Sep 17 00:00:00 2001 From: Eirik Botten Nicolaysen Date: Sat, 24 Oct 2026 07:28:02 +0200 Subject: [PATCH 2/2] Hide the Gravatar text on the profile page when Gravatar is off Document SIMPLEAUDIT_GRAVATAR in both env examples. --- .env.dev.example | 5 +++++ .env.example | 4 ++++ infra/context_processors.py | 7 +++++-- infra/tests/test_gravatar.py | 5 +++++ templates/profile.html | 2 ++ 5 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.env.dev.example b/.env.dev.example index 5e186d9c..131d422c 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -44,6 +44,11 @@ VISUALIZER_RESULTS_DIR=./results # VISUALIZER_MAX_TREE_DEPTH=8 # VISUALIZER_MAX_FILE_SIZE_MB=100 +# --- Gravatar avatars ----------------------------------------------------------- +# Off by default: the user's initial is shown. Set to "true" to load avatars +# from gravatar.com (the browser sends the MD5 hash of the user's email there). +# SIMPLEAUDIT_GRAVATAR=true + # --- Chat (Open WebUI) ---------------------------------------------------------- # On by default in dev; no line needed. To force it off: # SIMPLEAUDIT_CHAT=off (env) diff --git a/.env.example b/.env.example index 5b477678..609406c7 100644 --- a/.env.example +++ b/.env.example @@ -123,6 +123,10 @@ SIMPLEAUDIT_STUDIO_URL=http://localhost:8000 # management) on or off. ON by default (preserves existing behavior). Set to # "off" to 404 the /otlp/* and /api/otlp/* routes and hide the OTLP button. #SIMPLEAUDIT_OTLP=off +# Optional: show Gravatar avatars. OFF by default (the user's initial is shown). +# Set to "true" to load them from gravatar.com; the browser then sends the MD5 +# hash of the user's email there. +#SIMPLEAUDIT_GRAVATAR=true # --- Optional observability ----------------------------------------------------- diff --git a/infra/context_processors.py b/infra/context_processors.py index eda58162..c95ba559 100644 --- a/infra/context_processors.py +++ b/infra/context_processors.py @@ -22,8 +22,11 @@ def gravatar_for(user) -> str | None: def gravatar_url(request): - """Expose the current user's Gravatar URL (or ``None``) to templates.""" - return {"gravatar_url": gravatar_for(getattr(request, "user", None))} + """Expose the current user's Gravatar URL (or ``None``) and the setting.""" + return { + "gravatar_url": gravatar_for(getattr(request, "user", None)), + "gravatar_enabled": settings.SIMPLEAUDIT_GRAVATAR, + } def admin_status(request): diff --git a/infra/tests/test_gravatar.py b/infra/tests/test_gravatar.py index 7fb80592..cffb8dfe 100644 --- a/infra/tests/test_gravatar.py +++ b/infra/tests/test_gravatar.py @@ -17,6 +17,11 @@ def test_off_by_default(self): content = self.client.get("/profile/").content.decode() self.assertNotIn("gravatar.com/avatar/", content) + def test_off_shows_no_gravatar_text_on_the_profile(self): + content = self.client.get("/profile/").content.decode() + self.assertNotIn("Add an email address to get a Gravatar avatar.", content) + self.assertNotIn("Update image on Gravatar", content) + @override_settings(SIMPLEAUDIT_GRAVATAR=True) def test_on_renders_the_gravatar_url(self): # MD5 of the trimmed, lower-cased email "jane@x.com". diff --git a/templates/profile.html b/templates/profile.html index 1ecf4fe4..1c09286c 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -18,6 +18,7 @@

Profile image

{{ profile_user.username|first|upper }}
{% endif %}
+ {% if gravatar_enabled %}

{% if gravatar_url %} Your avatar comes from Gravatar, matched to your email. @@ -29,6 +30,7 @@

Profile image

class="inline-block mt-2 px-3 py-1.5 rounded-lg text-xs bg-surface-overlay border border-gray-700 text-gray-200 hover:border-accent/40 transition-colors"> Update image on Gravatar ↗ + {% endif %}