diff --git a/README.md b/README.md index 3c58dbed..67fcbad4 100644 --- a/README.md +++ b/README.md @@ -542,6 +542,7 @@ A rollback leaves the clone on a detached HEAD at the restored commit; the updat - On a host whose libvirt cannot change network interfaces (the udev backend: RHEL 9 and later, Debian 12), the interface pages offer no create, start, stop or delete; they only failed there. - On a running VM the disk list, its edit form, the QoS table, the title, description, CPU, boot order, video and console fields show the settings as they will be at the next start, so saving an edit no longer reverts another pending change. A boot order that names a device missing from that definition is refused instead of clearing the order. The settings tab of a running VM says so. A QoS edit keeps the inbound floor. Buttons shown as disabled while the VM runs can no longer be submitted with Enter. - A new disk or CD-ROM on a running VM gets a device name that neither the running VM nor its next-start settings use; it could take the name of a pending disk and stay attached until the next start only. +- The OTP e-mail's QR code is drawn with segno; django-qr-code is no longer required. A `settings.py` kept from an older version still lists `"qr_code"` in `INSTALLED_APPS`: delete that line, or the app does not start in a venv without django-qr-code. - A disk, NIC or guest agent added to a running VM is requested for both the running VM and its next-start settings in one libvirt call: a disk name or guest agent already pending is refused before anything changes, and a NIC added without a MAC gets the same MAC in both. A failure after the device is plugged can still leave it in the running VM only. - The VM page shows each NIC's own QoS and each CD-ROM's own pool (they showed the previous device's), and a NIC without a source or model no longer breaks it. - A volume of an LVM or iSCSI pool can be attached to a VM, and bridge and direct NICs can be boot devices. The NIC model `default` lets libvirt choose, and `rt18139` is `rtl8139`: a VM with either did not start; a migration corrects the default NIC type setting. diff --git a/accounts/templates/account.html b/accounts/templates/account.html index ff902104..b7df91ed 100644 --- a/accounts/templates/account.html +++ b/accounts/templates/account.html @@ -2,7 +2,6 @@ {% load i18n %} {% load bootstrap_icons %} -{% load qr_code %} {% block title %}{% trans "User Profile" %} - {{ user }}{% endblock %} {% block page_heading %}{% trans "User Profile" %}: {{ user }}{% endblock page_heading %} diff --git a/accounts/templates/accounts/email/otp.html b/accounts/templates/accounts/email/otp.html index c80ec147..c41a01b4 100644 --- a/accounts/templates/accounts/email/otp.html +++ b/accounts/templates/accounts/email/otp.html @@ -1,9 +1,8 @@ {% load i18n %} -{% load qr_code %} {% blocktrans %} Scan this QR code to get OTP for account '{{ user }}' {% endblocktrans %}
-{% qr_from_text totp_url %} +{{ qr_svg }}

{% trans 'Some e-mail clients does not render SVG, also generating PNG.' %}

-{% qr_from_text totp_url size="s" image_format="png" error_correction="M" %} +{{ totp_url }} diff --git a/accounts/test_utils.py b/accounts/test_utils.py index 08d3b260..78f9870f 100644 --- a/accounts/test_utils.py +++ b/accounts/test_utils.py @@ -1,10 +1,16 @@ import base64 +import hashlib +import re import struct +import zlib +from types import SimpleNamespace +from unittest.mock import patch from django.contrib.auth import get_user_model from django.core import mail from django.test import TestCase from django_otp.plugins.otp_totp.models import TOTPDevice +from lxml import html from accounts.utils import (get_existing_totp_device, get_user_totp_device, send_email_with_otp, validate_ssh_key) @@ -78,3 +84,26 @@ class AccountsUtilsTestCase(TestCase): self.assertEqual(len(mail.outbox), 1) self.assertIn("OTP QR Code", mail.outbox[0].subject) self.assertEqual(mail.outbox[0].to, ["user@example.com"]) + # the QR code as inline SVG and, for clients without SVG, as a PNG data URI + doc = html.fromstring(mail.outbox[0].alternatives[0][0]) + self.assertEqual(len(doc.xpath("//svg")), 1) + (img,) = doc.xpath("//img") + self.assertEqual(img.get("alt"), device.config_url) + self.assertTrue(base64.b64decode(img.get("src").removeprefix("data:image/png;base64,")).startswith(b"\x89PNG")) + + def test_otp_email_qr_codes_are_what_django_qr_code_drew(self): + # sha256 of the SVG and the PNG django-qr-code 4.2.0 made for this URL + url = "otpauth://totp/WebVirtCloud:alice?secret=JBSWY3DPEHPK3PXP&issuer=WebVirtCloud" + with patch("accounts.utils.send_mail") as send: + send_email_with_otp(self.user, SimpleNamespace(config_url=url)) + page = send.call_args.kwargs["html_message"] + svg = re.search(r"", page, re.S).group(0) + png = base64.b64decode(re.search(r'I4s", png[pos:pos + 8]) + chunks[kind] = chunks.get(kind, b"") + png[pos + 8:pos + 8 + size] + pos += 12 + size + self.assertEqual(hashlib.sha256(chunks[b"IHDR"] + zlib.decompress(chunks[b"IDAT"])).hexdigest(), "cca16de611c73468827f70001f69dc2e48633e6aae2b570a9391eb40a1f8ae88") diff --git a/accounts/utils.py b/accounts/utils.py index 80fe2ec9..290c1111 100644 --- a/accounts/utils.py +++ b/accounts/utils.py @@ -1,9 +1,12 @@ import base64 import binascii import struct +from decimal import Decimal +import segno from django.core.mail import send_mail from django.template.loader import render_to_string +from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ from django_otp import devices_for_user from django_otp.plugins.otp_totp.models import TOTPDevice @@ -54,6 +57,10 @@ def validate_ssh_key(key): def send_email_with_otp(user, device): + # An inline SVG (1.8 mm modules) and, for mail clients that show no SVG, + # a PNG (12 px modules); error correction M, 4-module border. + qr = segno.make(device.config_url, error="m", mode="byte", encoding="utf-8", micro=False) + style = {"border": 4, "dark": "black", "light": "white"} send_mail( _("OTP QR Code"), _("Please view HTML version of this message."), @@ -64,6 +71,8 @@ def send_email_with_otp(user, device): { "totp_url": device.config_url, "user": user, + "qr_svg": mark_safe(qr.svg_inline(scale=Decimal("1.8"), unit="mm", **style)), + "qr_png": qr.png_data_uri(scale=12, **style), }, ), fail_silently=False, diff --git a/conf/requirements.txt b/conf/requirements.txt index 325e8d4f..4b9fb359 100644 --- a/conf/requirements.txt +++ b/conf/requirements.txt @@ -2,7 +2,6 @@ Django==5.2.18 django_bootstrap5==26.3 django-bootstrap-icons==0.9.0 django-otp==1.7.4 -django-qr-code==4.2.0 django-auth-ldap==5.3.0 # the installer's venv can otherwise keep older distro copies (openSUSE 15.6) python-ldap>=3.4.5 @@ -25,6 +24,7 @@ paramiko==5.0.0 psycopg[binary]==3.3.6 python-engineio==4.14.0 python-socketio==5.17.0 +segno==1.6.6 websockify==0.13.0 whitenoise==6.12.0 crypt-r>=3.13.0; python_version >= "3.13" diff --git a/webvirtcloud/settings.py.template b/webvirtcloud/settings.py.template index c7a51f6c..6c39cf97 100644 --- a/webvirtcloud/settings.py.template +++ b/webvirtcloud/settings.py.template @@ -79,7 +79,6 @@ INSTALLED_APPS = [ "storages", "virtsecrets", "logs", - "qr_code", "rest_framework", "axes", ]