Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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.
10 changes: 9 additions & 1 deletion dojo/announcement/os_message.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
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__)
Expand Down Expand Up @@ -109,11 +111,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/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",
),
]
27 changes: 25 additions & 2 deletions dojo/announcement/views.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
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.forms import AnnouncementCreateForm, AnnouncementRemoveForm
from dojo.models import Announcement, UserAnnouncement
from dojo.models import Announcement, UserAnnouncement, UserContactInfo
from dojo.utils import add_breadcrumb

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -85,3 +88,23 @@ 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]
if contact.os_message_dismissed_hash != token:
contact.os_message_dismissed_hash = token
contact.save(update_fields=["os_message_dismissed_hash"])
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("/")
27 changes: 19 additions & 8 deletions dojo/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,11 @@ 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)
return contact is not None and contact.os_message_dismissed_hash == token


def bind_system_settings(request):
"""Load system settings and display warning if there's a database error."""
try:
Expand Down
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='os_message_dismissed_hash',
field=models.CharField(blank=True, default='', editable=False, help_text='Hash of the most recently dismissed open-source promo banner; the banner reappears when the message changes.', max_length=64),
),
]
1 change: 1 addition & 0 deletions dojo/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ 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."))
os_message_dismissed_hash = models.CharField(max_length=64, blank=True, default="", editable=False, help_text=_("Hash of the most recently dismissed open-source promo banner; the banner reappears when the message changes."))


class System_Settings(models.Model):
Expand Down
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
Loading
Loading