diff --git a/docs/content/en/open_source/upgrading/3.1.md b/docs/content/en/open_source/upgrading/3.1.md index bc1265d40c2..540140c7aba 100644 --- a/docs/content/en/open_source/upgrading/3.1.md +++ b/docs/content/en/open_source/upgrading/3.1.md @@ -2,6 +2,10 @@ title: 'Upgrading to DefectDojo Version 3.1.x' toc_hide: true weight: -20260615 -description: No special instructions. +description: New optional setting DD_OS_MESSAGE_ENABLED to control the open-source promo banner. --- -There are no special instructions for upgrading to 3.1.x. Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.1.0) for the contents of the release. +There are no breaking changes when upgrading to 3.1.x. Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.1.0) for the contents of the release. + +### New setting: `DD_OS_MESSAGE_ENABLED` + +This release adds the `DD_OS_MESSAGE_ENABLED` setting (default `True`), which controls the open-source promotional ("Upgrade to Pro") banner. The default preserves the existing behavior. Set `DD_OS_MESSAGE_ENABLED=False` to hide the banner; when disabled, DefectDojo skips the outbound request that fetches the message. diff --git a/dojo/announcement/os_message.py b/dojo/announcement/os_message.py index dfdb9288710..e14cbfc840c 100644 --- a/dojo/announcement/os_message.py +++ b/dojo/announcement/os_message.py @@ -1,12 +1,18 @@ +import hashlib import logging import bleach import markdown import requests +from django.conf import settings from django.core.cache import cache logger = logging.getLogger(__name__) +# Key under UserContactInfo.user_state_details holding the hash of the most +# recently dismissed open-source promo banner. +OS_MESSAGE_DISMISSED_KEY = "os_message_dismissed_hash" + BUCKET_URL = "https://storage.googleapis.com/defectdojo-os-messages-prod/open_source_message.md" CACHE_SECONDS = 3600 HTTP_TIMEOUT_SECONDS = 2 @@ -109,11 +115,17 @@ def parse_os_message(text): def get_os_banner(): + if not settings.OS_MESSAGE_ENABLED: + return None try: text = fetch_os_message() if not text: return None - return parse_os_message(text) + banner = parse_os_message(text) except Exception: logger.debug("os_message: get_os_banner failed", exc_info=True) return None + else: + if banner: + banner["dismiss_token"] = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16] + return banner diff --git a/dojo/announcement/ui/urls.py b/dojo/announcement/ui/urls.py index 2ac2f2b8af0..d3b7dada753 100644 --- a/dojo/announcement/ui/urls.py +++ b/dojo/announcement/ui/urls.py @@ -13,4 +13,9 @@ views.dismiss_announcement, name="dismiss_announcement", ), + re_path( + r"^dismiss_os_message$", + views.dismiss_os_message, + name="dismiss_os_message", + ), ] diff --git a/dojo/announcement/ui/views.py b/dojo/announcement/ui/views.py index f668901e86f..383bb6f16bd 100644 --- a/dojo/announcement/ui/views.py +++ b/dojo/announcement/ui/views.py @@ -1,14 +1,20 @@ import logging +import re from django.contrib import messages -from django.http import HttpResponseRedirect +from django.db import transaction +from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect from django.shortcuts import render from django.urls import reverse +from django.utils.http import url_has_allowed_host_and_scheme from django.utils.translation import gettext from django.utils.translation import gettext_lazy as _ +from django.views.decorators.http import require_POST from dojo.announcement.models import Announcement, UserAnnouncement +from dojo.announcement.os_message import OS_MESSAGE_DISMISSED_KEY from dojo.announcement.ui.forms import AnnouncementCreateForm, AnnouncementRemoveForm +from dojo.user.models import UserContactInfo from dojo.utils import add_breadcrumb logger = logging.getLogger(__name__) @@ -85,3 +91,30 @@ def dismiss_announcement(request): ) return render(request, "dojo/dismiss_announcement.html") return render(request, "dojo/dismiss_announcement.html") + + +@require_POST +def dismiss_os_message(request): + if not request.user.is_authenticated: + return HttpResponseForbidden() + token = request.POST.get("token", "").strip() + if token and re.fullmatch(r"[0-9a-f]{1,64}", token): + # user_state_details is a shared JSON blob for many per-user flags, so + # read-modify-write it under a row lock to avoid clobbering a concurrent + # write to a different key (lost update). + with transaction.atomic(): + contact, _ = UserContactInfo.objects.get_or_create(user=request.user) + contact = UserContactInfo.objects.select_for_update().get(pk=contact.pk) + state = contact.user_state_details or {} + if state.get(OS_MESSAGE_DISMISSED_KEY) != token: + state[OS_MESSAGE_DISMISSED_KEY] = token + contact.user_state_details = state + contact.save(update_fields=["user_state_details"]) + if request.headers.get("x-requested-with") == "XMLHttpRequest": + return HttpResponse(status=204) + referer = request.META.get("HTTP_REFERER") + if referer and url_has_allowed_host_and_scheme( + referer, allowed_hosts={request.get_host()}, require_https=request.is_secure(), + ): + return HttpResponseRedirect(referer) + return HttpResponseRedirect("/") diff --git a/dojo/context_processors.py b/dojo/context_processors.py index 561ae0d5791..cf9b154cf7d 100644 --- a/dojo/context_processors.py +++ b/dojo/context_processors.py @@ -5,7 +5,7 @@ from django.contrib import messages from django.urls import NoReverseMatch, reverse -from dojo.announcement.os_message import get_os_banner +from dojo.announcement.os_message import OS_MESSAGE_DISMISSED_KEY, get_os_banner from dojo.labels import get_labels from dojo.models import System_Settings, UserAnnouncement @@ -28,14 +28,20 @@ def globalize_vars(request): additional_banners = [] if (os_banner := get_os_banner()) is not None: - additional_banners.append({ - "source": "os", - "message": os_banner["message"], - "style": "info", - "url": "", - "link_text": "", - "expanded_html": os_banner["expanded_html"], - }) + token = os_banner.get("dismiss_token", "") + user = getattr(request, "user", None) + dismissible = bool(token and getattr(user, "is_authenticated", False)) + if not (dismissible and _os_message_dismissed(user, token)): + additional_banners.append({ + "source": "os", + "message": os_banner["message"], + "style": "info", + "url": "", + "link_text": "", + "expanded_html": os_banner["expanded_html"], + "dismissible": dismissible, + "dismiss_token": token, + }) if hasattr(request, "session"): for banner in request.session.pop("_product_banners", []): @@ -72,6 +78,13 @@ def _should_show_ui_toggle_banner(request): return not (contact is not None and getattr(contact, "ui_use_tailwind", False)) +def _os_message_dismissed(user, token): + contact = getattr(user, "usercontactinfo", None) + if contact is None: + return False + return (contact.user_state_details or {}).get(OS_MESSAGE_DISMISSED_KEY) == token + + def bind_system_settings(request): """Load system settings and display warning if there's a database error.""" try: diff --git a/dojo/db_migrations/0275_usercontactinfo_user_state_details.py b/dojo/db_migrations/0275_usercontactinfo_user_state_details.py new file mode 100644 index 00000000000..4492d69580e --- /dev/null +++ b/dojo/db_migrations/0275_usercontactinfo_user_state_details.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.14 on 2026-06-25 00:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("dojo", "0274_finding_sla_expiration_index"), + ] + + operations = [ + migrations.AddField( + model_name="usercontactinfo", + name="user_state_details", + field=models.JSONField(blank=True, default=dict, editable=False, help_text="Extensible per-user UI state (dismissed banners, 'don't show again' flags, ...)."), + ), + ] diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index 3fcf12b78d4..4c0f161768a 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -147,6 +147,7 @@ DD_FORGOT_PASSWORD=(bool, True), # do we show link "I forgot my password" on login screen DD_PASSWORD_RESET_TIMEOUT=(int, 259200), # 3 days, in seconds (the deafult) DD_FORGOT_USERNAME=(bool, True), # do we show link "I forgot my username" on login screen + DD_OS_MESSAGE_ENABLED=(bool, True), # show the open-source "Upgrade to Pro" / OS message promo banner # Some security policies require allowing users to have only one active session DD_SINGLE_USER_SESSION=(bool, False), # if somebody is using own documentation how to use DefectDojo in his own company @@ -477,6 +478,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param REQUIRE_PASSWORD_ON_USER = env("DD_REQUIRE_PASSWORD_ON_USER") FORGOT_USERNAME = env("DD_FORGOT_USERNAME") PASSWORD_RESET_TIMEOUT = env("DD_PASSWORD_RESET_TIMEOUT") +OS_MESSAGE_ENABLED = env("DD_OS_MESSAGE_ENABLED") DOCUMENTATION_URL = env("DD_DOCUMENTATION_URL") diff --git a/dojo/settings/template-env b/dojo/settings/template-env index 0263dc941a7..2f633c769b4 100644 --- a/dojo/settings/template-env +++ b/dojo/settings/template-env @@ -47,6 +47,9 @@ DD_WHITENOISE=True # If True, the SecurityMiddleware sets the X-Content-Type-Options: nosniff; # DD_SECURE_CONTENT_TYPE_NOSNIFF=True +# Show the open-source promo ("Upgrade to Pro") banner. Set to False to disable. +# DD_OS_MESSAGE_ENABLED=True + # Change the default language set # DD_LANG=en-us diff --git a/dojo/static/dojo/css/dojo.css b/dojo/static/dojo/css/dojo.css index 8871ec262be..a7f0eb4b3cf 100644 --- a/dojo/static/dojo/css/dojo.css +++ b/dojo/static/dojo/css/dojo.css @@ -1025,6 +1025,51 @@ span.endpoint_product { margin-top: 8px; } +/* OS message banner dismiss (×): inline, grouped with the headline/caret + (not floated into the empty right side). Scoped so it only affects the + OS promo banner, which is the only banner carrying these classes. */ +/* Lay out the OS promo banner as a single centered row so the dismiss button, + headline, and expand caret line up vertically. Scoped to data-source="os" + so the other banners are untouched. */ +.announcement-banner[data-source="os"] { + display: flex; + align-items: center; + flex-wrap: wrap; +} + +.announcement-banner[data-source="os"] .banner-expanded { + flex-basis: 100%; /* expanded text drops to its own row */ +} + +.announcement-banner .os-message-dismiss-form { + display: inline-flex; + margin-right: 10px; +} + +.announcement-banner .os-message-dismiss { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + background: transparent; + border: 1px solid rgba(0, 0, 0, 0.3); + border-radius: 3px; + color: inherit; + cursor: pointer; + font-size: 13px; + line-height: 1; + opacity: 0.7; +} + +.announcement-banner .os-message-dismiss:hover, +.announcement-banner .os-message-dismiss:focus { + opacity: 1; + background: rgba(0, 0, 0, 0.06); + outline: none; +} + /* Removed: custom-search-form media queries — old topbar search sizing */ /* Removed: Old sidebar/layout media queries for #page-wrapper, #footer-wrapper, diff --git a/dojo/static/dojo/js/classic/index.js b/dojo/static/dojo/js/classic/index.js index 4de7c38f63b..694f62dec1b 100644 --- a/dojo/static/dojo/js/classic/index.js +++ b/dojo/static/dojo/js/classic/index.js @@ -1,5 +1,18 @@ $(function () { $('body').append(''); + + // ---- OS promo banner dismiss: persist per-user (form carries CSRF) + hide instantly ---- + $(document).on('submit', '.os-message-dismiss-form', function (e) { + e.preventDefault(); + var form = this; + $(form).closest('.announcement-banner').fadeOut(200, function () { $(this).remove(); }); + fetch(form.action, { + method: 'POST', + body: new FormData(form), + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + credentials: 'same-origin', + }); + }); $(window).scroll(function () { if ($(this).scrollTop() > 300) { $('#toTop').fadeIn(); diff --git a/dojo/static/dojo/js/index.js b/dojo/static/dojo/js/index.js index d9986d7b975..4f29c973685 100644 --- a/dojo/static/dojo/js/index.js +++ b/dojo/static/dojo/js/index.js @@ -65,6 +65,28 @@ }); })(); +/* ---- OS promo banner dismiss ---- + Persist the dismissal per-user (the form carries csrfmiddlewaretoken) and + hide the banner instantly. Degrades to a normal form POST when JS is off. +*/ +document.addEventListener('submit', function (e) { + var form = e.target.closest('.os-message-dismiss-form'); + if (!form) return; + e.preventDefault(); + var banner = form.closest('.announcement-banner'); + if (banner) { + banner.style.transition = 'opacity 0.2s'; + banner.style.opacity = '0'; + setTimeout(function () { banner.remove(); }, 200); + } + fetch(form.action, { + method: 'POST', + body: new FormData(form), + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + credentials: 'same-origin', + }); +}); + /* ---- Collapse shim ---- Handles [data-toggle="collapse"] by toggling .in on the target element. CSS in tailwind.css: .collapse { display:none } .collapse.in { display:block } diff --git a/dojo/templates/base.html b/dojo/templates/base.html index 1929f8370f8..05620ae6e77 100644 --- a/dojo/templates/base.html +++ b/dojo/templates/base.html @@ -503,6 +503,13 @@ {% for banner in additional_banners %}