diff --git a/Dockerfile.django-alpine b/Dockerfile.django-alpine index c16c09d7a17..5a0d7af15e7 100644 --- a/Dockerfile.django-alpine +++ b/Dockerfile.django-alpine @@ -91,6 +91,14 @@ RUN \ mkdir -p dojo/migrations && \ chmod g=u dojo/migrations && \ true + +# Compile translation catalogs (.mo) into the image (the .mo files are gitignored +# and generated at build). msgfmt is run directly so no Django settings/env are +# needed; gettext is only required at build time, so install and remove it here. +RUN \ + apk add --no-cache --virtual .build-i18n gettext && \ + find dojo/locale -name 'django.po' -execdir msgfmt django.po -o django.mo \; && \ + apk del .build-i18n USER root RUN \ addgroup --gid ${gid} ${appuser} && \ diff --git a/Dockerfile.django-debian b/Dockerfile.django-debian index ac234b48b44..3b521c541ab 100644 --- a/Dockerfile.django-debian +++ b/Dockerfile.django-debian @@ -95,6 +95,17 @@ RUN \ mkdir -p dojo/migrations && \ chmod g=u dojo/migrations && \ true + +# Compile translation catalogs (.mo) into the image (the .mo files are gitignored +# and generated at build). msgfmt is run directly so no Django settings/env are +# needed; gettext is only required at build time, so install and purge it here. +RUN \ + apt-get -y update && \ + apt-get -y install --no-install-recommends gettext && \ + find dojo/locale -name 'django.po' -execdir msgfmt django.po -o django.mo \; && \ + apt-get -y purge --auto-remove gettext && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists USER root RUN \ addgroup --gid ${gid} ${appuser} && \ 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..3ea5052f7f5 100644 --- a/dojo/announcement/os_message.py +++ b/dojo/announcement/os_message.py @@ -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__) @@ -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 diff --git a/dojo/announcement/urls.py b/dojo/announcement/urls.py index 9dc91187653..07e79801045 100644 --- a/dojo/announcement/urls.py +++ b/dojo/announcement/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/views.py b/dojo/announcement/views.py index 7afe915210b..2fd0eba02b0 100644 --- a/dojo/announcement/views.py +++ b/dojo/announcement/views.py @@ -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__) @@ -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("/") diff --git a/dojo/context_processors.py b/dojo/context_processors.py index 561ae0d5791..75ce9e38021 100644 --- a/dojo/context_processors.py +++ b/dojo/context_processors.py @@ -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,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: diff --git a/dojo/db_migrations/0270_usercontactinfo_os_message_dismissed_hash.py b/dojo/db_migrations/0270_usercontactinfo_os_message_dismissed_hash.py new file mode 100644 index 00000000000..239a559be1d --- /dev/null +++ b/dojo/db_migrations/0270_usercontactinfo_os_message_dismissed_hash.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', '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), + ), + ] diff --git a/dojo/db_migrations/0271_usercontactinfo_language.py b/dojo/db_migrations/0271_usercontactinfo_language.py new file mode 100644 index 00000000000..ea0eacc27fe --- /dev/null +++ b/dojo/db_migrations/0271_usercontactinfo_language.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.14 on 2026-06-26 00:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dojo', '0270_usercontactinfo_os_message_dismissed_hash'), + ] + + operations = [ + migrations.AddField( + model_name='usercontactinfo', + name='language', + field=models.CharField(blank=True, default='', help_text='Preferred language for the DefectDojo UI. Leave blank to use the instance default.', max_length=12, verbose_name='Language'), + ), + ] diff --git a/dojo/forms.py b/dojo/forms.py index cb90d0f57de..b958273d74e 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -2378,6 +2378,12 @@ class Meta: class UserContactInfoForm(forms.ModelForm): + language = forms.ChoiceField( + required=False, + choices=[("", _("Use instance default")), *settings.LANGUAGES], + label=_("Language"), + help_text=_("Preferred language for the DefectDojo UI."), + ) reset_api_token = forms.BooleanField( required=False, label=_("Reset API token"), diff --git a/dojo/locale/pt_BR/LC_MESSAGES/django.po b/dojo/locale/pt_BR/LC_MESSAGES/django.po index 0fa06c190c8..fb477b2c0b7 100644 --- a/dojo/locale/pt_BR/LC_MESSAGES/django.po +++ b/dojo/locale/pt_BR/LC_MESSAGES/django.po @@ -3537,14 +3537,18 @@ msgstr "Vulnerabilidades de alta gravidade" msgid "" "\n" " %(name)s is affected by critical vulnerabilities." -msgstr "%(name)s é afetado por vulnerabilidades críticas." +msgstr "" +"\n" +"%(name)s é afetado por vulnerabilidades críticas." #: dojo/templates/dojo/metrics.html #, python-format msgid "" "\n" " %(name)s is affected by high severity vulnerabilities." -msgstr "%(name)s é afetado por vulnerabilidades de alta gravidade." +msgstr "" +"\n" +"%(name)s é afetado por vulnerabilidades de alta gravidade." #: dojo/templates/dojo/metrics.html msgid "Full Metrics" @@ -3789,7 +3793,9 @@ msgid "" "\n" " Showing entries %(start_index)s to %(end_index)s of %(count)s\n" " " -msgstr "Mostrando entradas %(start_index)s a %(end_index)s de %(count)s" +msgstr "" +"\n" +"Mostrando entradas %(start_index)s a %(end_index)s de %(count)s" #: dojo/templates/dojo/paging_snippet.html msgid "Page Size" diff --git a/dojo/middleware.py b/dojo/middleware.py index a576244312a..061d74582f1 100644 --- a/dojo/middleware.py +++ b/dojo/middleware.py @@ -10,6 +10,7 @@ from django.db import models from django.http import HttpResponseRedirect from django.urls import reverse +from django.utils import translation from watson.middleware import SearchContextMiddleware from watson.search import search_context_manager @@ -194,6 +195,64 @@ def __call__(self, request): return self.get_response(request) +def set_language_cookie(response, language): + """Persist (or clear) the UI-language cookie that Django's LocaleMiddleware reads.""" + if language: + response.set_cookie( + settings.LANGUAGE_COOKIE_NAME, + language, + max_age=settings.LANGUAGE_COOKIE_AGE, + path=settings.LANGUAGE_COOKIE_PATH, + domain=settings.LANGUAGE_COOKIE_DOMAIN, + secure=settings.LANGUAGE_COOKIE_SECURE, + httponly=settings.LANGUAGE_COOKIE_HTTPONLY, + samesite=settings.LANGUAGE_COOKIE_SAMESITE, + ) + else: + response.delete_cookie( + settings.LANGUAGE_COOKIE_NAME, + path=settings.LANGUAGE_COOKIE_PATH, + domain=settings.LANGUAGE_COOKIE_DOMAIN, + ) + return response + + +class LanguagePreferenceMiddleware: + + """ + Seed the language cookie from the user's stored preference when it is missing. + + UserContactInfo.language is the durable, cross-device source of truth. The DB + is read only when the django_language cookie is absent (roughly once per + browser session); thereafter Django's LocaleMiddleware serves the cookie with + no per-request DB access. API requests are skipped: they localize via + Accept-Language and their clients typically do not keep cookies, so seeding + from the DB there would add a query to every API call. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + seed = "" + user = getattr(request, "user", None) + if ( + user is not None + and getattr(user, "is_authenticated", False) + and settings.LANGUAGE_COOKIE_NAME not in request.COOKIES + and not request.path.startswith("/api/") + ): + contact = getattr(user, "usercontactinfo", None) + seed = getattr(contact, "language", "") if contact is not None else "" + if seed: + translation.activate(seed) + request.LANGUAGE_CODE = seed + response = self.get_response(request) + if seed: + set_language_cookie(response, seed) + return response + + class PgHistoryMiddleware(pghistory.middleware.HistoryMiddleware): """ diff --git a/dojo/models.py b/dojo/models.py index 7f798e23ae4..89f31385f19 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -260,6 +260,8 @@ 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.")) + language = models.CharField(max_length=12, blank=True, default="", verbose_name=_("Language"), help_text=_("Preferred language for the DefectDojo UI. Leave blank to use the instance default.")) class System_Settings(models.Model): diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index 2d92588ad70..0cbb5443426 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 @@ -330,6 +331,16 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param # to load the internationalization machinery. USE_I18N = env("DD_USE_I18N") +# Languages offered in the UI. Only languages with an audited catalog are listed; +# es/ja/de/fr are added once their translations land (their .po files are generated +# for translators). Stored DB values and serialized API values always remain English +# regardless of the selected language; only displayed text changes. +LANGUAGES = [ + ("en", "English"), + ("pt-br", "Português (Brasil)"), + ("ru", "Русский"), +] + # If you set this to False, Django will not use timezone-aware datetimes. USE_TZ = env("DD_USE_TZ") @@ -380,6 +391,9 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param DOJO_ROOT = env("DD_ROOT") +# Where Django looks for translation catalogs: dojo/locale//LC_MESSAGES/. +LOCALE_PATHS = [Path(DOJO_ROOT) / "locale"] + # Absolute filesystem path to the directory that will hold user-uploaded files. # Example: "/var/www/example.com/media/" MEDIA_ROOT = env("DD_MEDIA_ROOT") @@ -474,6 +488,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") @@ -793,10 +808,12 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param "dojo.middleware.APITrailingSlashMiddleware", "dojo.middleware.DojoSytemSettingsMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.middleware.security.SecurityMiddleware", "django_permissions_policy.PermissionsPolicyMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "dojo.middleware.LanguagePreferenceMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django_htmx.middleware.HtmxMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", 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 %}