Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/content/en/open_source/upgrading/3.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 13 additions & 1 deletion dojo/announcement/os_message.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
5 changes: 5 additions & 0 deletions dojo/announcement/ui/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,9 @@
views.dismiss_announcement,
name="dismiss_announcement",
),
re_path(
r"^dismiss_os_message$",
views.dismiss_os_message,
name="dismiss_os_message",
),
]
29 changes: 28 additions & 1 deletion dojo/announcement/ui/views.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import logging
import re

from django.contrib import messages
from django.http import HttpResponseRedirect
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__)
Expand Down Expand Up @@ -85,3 +90,25 @@ 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):
contact = UserContactInfo.objects.get_or_create(user=request.user)[0]
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("/")
31 changes: 22 additions & 9 deletions dojo/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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", []):
Expand Down Expand Up @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions dojo/db_migrations/0270_usercontactinfo_user_state_details.py
Original file line number Diff line number Diff line change
@@ -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", "0269_normalize_blank_finding_components"),
]

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, ...)."),
),
]
2 changes: 2 additions & 0 deletions dojo/settings/settings.dist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -474,6 +475,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")

Expand Down
3 changes: 3 additions & 0 deletions dojo/settings/template-env
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 45 additions & 0 deletions dojo/static/dojo/css/dojo.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions dojo/static/dojo/js/classic/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
$(function () {
$('body').append('<a id="toTop" title="Back to Top" class="btn btn-primary btn-circle"><i class="fa-solid fa-arrow-up fa-fw"></i></a>');

// ---- 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();
Expand Down
22 changes: 22 additions & 0 deletions dojo/static/dojo/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
7 changes: 7 additions & 0 deletions dojo/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,13 @@
{% for banner in additional_banners %}
<div role="alert" class="announcement-banner alert alert-{{ banner.style }} show"
data-source="{{ banner.source }}">
{% if banner.dismissible %}
<form method="post" action="{% url 'dismiss_os_message' %}" class="os-message-dismiss-form">
{% csrf_token %}
<input type="hidden" name="token" value="{{ banner.dismiss_token }}">
<button type="submit" class="os-message-dismiss" aria-label="Dismiss" title="Dismiss this message">&times;</button>
</form>
{% endif %}
{{ banner.message|safe }}{% if banner.url %} <a href="{{ banner.url }}">{{ banner.link_text }}</a>{% endif %}
{% if banner.expanded_html %}
<button type="button" class="banner-toggle collapsed"
Expand Down
7 changes: 7 additions & 0 deletions dojo/templates_classic/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,13 @@
{% for banner in additional_banners %}
<div role="alert" class="announcement-banner alert alert-{{ banner.style }} show"
data-source="{{ banner.source }}">
{% if banner.dismissible %}
<form method="post" action="{% url 'dismiss_os_message' %}" class="os-message-dismiss-form">
{% csrf_token %}
<input type="hidden" name="token" value="{{ banner.dismiss_token }}">
<button type="submit" class="os-message-dismiss" aria-label="Dismiss" title="Dismiss this message">&times;</button>
</form>
{% endif %}
{{ banner.message|safe }}{% if banner.url %} <a href="{{ banner.url }}">{{ banner.link_text }}</a>{% endif %}
{% if banner.expanded_html %}
<button type="button" class="banner-toggle collapsed"
Expand Down
11 changes: 11 additions & 0 deletions dojo/user/api/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from django_filters import OrderingFilter
from django_filters import rest_framework as filters

from dojo.models import UserContactInfo

User = get_user_model()


Expand Down Expand Up @@ -42,3 +44,12 @@ class Meta:
("last_login", "last_login"),
),
)


class ApiUserContactInfoFilter(filters.FilterSet):
class Meta:
model = UserContactInfo
# user_state_details is an internal JSONField for UI state; django-filter
# cannot auto-generate a filter for it, so exclude it (everything else
# keeps the previous "__all__" auto-filter behaviour).
exclude = ["user_state_details"]
4 changes: 2 additions & 2 deletions dojo/user/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from dojo.api_v2.views import DojoModelViewSet, PrefetchDojoModelViewSet, schema_with_prefetch
from dojo.authorization import api_permissions as permissions
from dojo.models import UserContactInfo
from dojo.user.api.filters import ApiUserFilter
from dojo.user.api.filters import ApiUserContactInfoFilter, ApiUserFilter
from dojo.user.api.serializer import (
UserContactInfoSerializer,
UserProfileSerializer,
Expand Down Expand Up @@ -71,7 +71,7 @@ class UserContactInfoViewSet(
serializer_class = UserContactInfoSerializer
queryset = UserContactInfo.objects.none()
filter_backends = (DjangoFilterBackend,)
filterset_fields = "__all__"
filterset_class = ApiUserContactInfoFilter
permission_classes = (permissions.IsSuperUser, DjangoModelPermissions)

def get_queryset(self):
Expand Down
5 changes: 5 additions & 0 deletions dojo/user/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ class UserContactInfo(models.Model):
ui_use_tailwind = models.BooleanField(default=False, verbose_name=_("Use new UI (beta)"), help_text=_("Opt in to the new Tailwind-based UI. Leave off for the classic UI."))
token_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent API token reset for this user."))
password_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent password reset for this user."))
# Extensible per-user UI state: dismissed banners, "seen"/"don't show again"
# flags, and similar small ephemeral preferences. Store new flags of this kind
# as keys here instead of adding a dedicated column per flag (avoids a migration
# and a schema column for every minor toggle). Not for queryable/behavioral data.
user_state_details = models.JSONField(default=dict, blank=True, editable=False, help_text=_("Extensible per-user UI state (dismissed banners, 'don't show again' flags, ...)."))


class Contact(models.Model):
Expand Down
Loading
Loading