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/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..c95ba559 100644 --- a/infra/context_processors.py +++ b/infra/context_processors.py @@ -2,20 +2,31 @@ 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``) 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 new file mode 100644 index 00000000..cffb8dfe --- /dev/null +++ b/infra/tests/test_gravatar.py @@ -0,0 +1,36 @@ +"""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) + + 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". + 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 @@