From a51c49a865ea887cfa352c7ee0071d233fb26e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Corentin=20S=C3=A9chet?= Date: Fri, 20 Oct 2023 12:06:44 +0200 Subject: [PATCH] qrcode: add get-qrcode endpoint (#82649) --- passerelle/apps/qrcode/models.py | 88 +++++++++++++++++++++++++++++- setup.py | 3 + tests/data/qrcode/test-qrcode.png | Bin 0 -> 2442 bytes tests/test_qrcode.py | 22 ++++++++ 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 tests/data/qrcode/test-qrcode.png diff --git a/passerelle/apps/qrcode/models.py b/passerelle/apps/qrcode/models.py index 13e4b17d..02ca7fdc 100644 --- a/passerelle/apps/qrcode/models.py +++ b/passerelle/apps/qrcode/models.py @@ -1,11 +1,18 @@ +import binascii import os import uuid +from io import BytesIO from django.core.validators import RegexValidator from django.db import models +from django.http import HttpResponse from django.shortcuts import get_object_or_404 +from django.urls import reverse from django.utils.dateparse import parse_datetime from django.utils.translation import gettext_lazy as _ +from nacl.signing import SigningKey +from qrcode import ERROR_CORRECT_Q, QRCode +from qrcode.image.pil import PilImage from passerelle.base.models import BaseResource from passerelle.utils.api import endpoint @@ -79,7 +86,12 @@ class QRCodeConnector(BaseResource): certificate.data = data certificate.save() - return {'data': {'uuid': certificate.uuid}} + return { + 'data': { + 'uuid': certificate.uuid, + 'qrcode_url': certificate.get_qrcode_url(request), + } + } @endpoint( name='get-certificate', @@ -102,9 +114,53 @@ class QRCodeConnector(BaseResource): 'data': certificate.data, 'validity_start': certificate.validity_start.isoformat(), 'validity_end': certificate.validity_end.isoformat(), + 'qrcode_url': certificate.get_qrcode_url(request), }, } + @endpoint( + name='get-qrcode', + description=_('Get QR Code'), + pattern=f'^{UUID_PATTERN}$', + example_pattern='{uuid}', + parameters={ + 'uuid': { + 'description': _('QRCode\'s certificate identifier'), + 'example_value': '12345678-1234-1234-1234-123456789012', + } + }, + ) + def get_qrcode(self, request, uuid): + certificate = self.certificates.get(uuid=uuid) + qr_code = certificate.generate_qr_code() + return HttpResponse(qr_code, content_type='image/png') + + +def encode_mime_like(data): + msg = '' + for key, value in data.items(): + msg += '%s: %s\n' % (key, value.replace('\n', '\n ')) + return msg.encode() + + +BASE45_CHARSET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:' +BASE45_DICT = {v: i for i, v in enumerate(BASE45_CHARSET)} + + +def b45encode(buf: bytes) -> bytes: + """Convert bytes to base45-encoded string""" + res = '' + buflen = len(buf) + for i in range(0, buflen & ~1, 2): + x = (buf[i] << 8) + buf[i + 1] + e, x = divmod(x, 45 * 45) + d, c = divmod(x, 45) + res += BASE45_CHARSET[c] + BASE45_CHARSET[d] + BASE45_CHARSET[e] + if buflen & 1: + d, c = divmod(buf[-1], 45) + res += BASE45_CHARSET[c] + BASE45_CHARSET[d] + return res.encode() + class Certificate(models.Model): uuid = models.UUIDField(verbose_name=_('UUID'), unique=True, default=uuid.uuid4) @@ -112,3 +168,33 @@ class Certificate(models.Model): validity_end = models.DateTimeField(verbose_name=_('Validity End Date')) data = models.JSONField(null=True, verbose_name='Certificate Data') resource = models.ForeignKey(QRCodeConnector, on_delete=models.CASCADE, related_name='certificates') + + def generate_qr_code(self): + data = { + 'uuid': str(self.uuid), + 'validity_start': str(self.validity_start.timestamp()), + 'validity_end': str(self.validity_end.timestamp()), + } | self.data + msg = encode_mime_like(data) + binary_key = binascii.unhexlify(self.resource.key) + signing_key = SigningKey(seed=binary_key) + signed = signing_key.sign(msg) + qr_code = QRCode(image_factory=PilImage, error_correction=ERROR_CORRECT_Q) + qr_code.add_data(b45encode(signed).decode()) + qr_code.make(fit=True) + image = qr_code.make_image(fill_color='black', back_color='white') + fd = BytesIO() + image.save(fd) + return fd.getvalue() + + def get_qrcode_url(self, request): + qrcode_relative_url = reverse( + 'generic-endpoint', + kwargs={ + 'slug': self.resource.slug, + 'connector': self.resource.get_connector_slug(), + 'endpoint': 'get-qrcode', + 'rest': str(self.uuid), + }, + ) + return request.build_absolute_uri(qrcode_relative_url) diff --git a/setup.py b/setup.py index 7160dd8b..5dae680d 100755 --- a/setup.py +++ b/setup.py @@ -175,6 +175,9 @@ setup( 'cryptography', 'xmltodict', 'phonenumbers', + 'qrcode', + 'pillow', + 'pynacl', ], cmdclass={ 'build': build, diff --git a/tests/data/qrcode/test-qrcode.png b/tests/data/qrcode/test-qrcode.png new file mode 100644 index 0000000000000000000000000000000000000000..df38f58c205bd9cf16836ae1843d814eb6919f96 GIT binary patch literal 2442 zcmZ9Oc|2768^@2~%7`nZo4VO$T+*ho4aPpQ?;%T57&6vjEH%cyjI~6T8Cgn%k$sO* zh=eR5OCsCQAX}mC4EK-U>-Wbwf1Kx>@AEvL@8@}+Z>+JQ4k!B=b^ri4brG7T0Kg1l zTuk%_z(%J&9{_+&bT!q?1F~0%1|G8rjz~crP(>!l`_xz!Ndp8THpkL zv$gm_u7A_M=&YT|Vsu>eWzSDXW(?n39n+J)SF88e1(^XJgit8U|K94Ng|?Q3lMGl! zg8+FJ!sshPOsZ8ncdLl9X2G$vb%=>!9DD;r9nd z!2EJ4@T~d;!3Fa*w+X%;wP$o)1q6sEtezj_m|!=%fVa#5y`J~;oBCC?e{M&5Lm2$I%i}> z3n=H^De%L}EUbJ%Wp$SLTD#~uV>SzQg=Rw3ZfL$X{6Itog_!*KHN`w?A4mL{ziWv_ z2D&TH1=C+_A>(0QspUJvISj!7V|`h$W;%D6(+d8pU$hKa&{UwDY2I8guQht{ia!vQ z1ZwmpE-@k6cgBl#-5Y{0a3h_RQC6{EJ(1jM`a! zv~;m5k+}Q?o-bB~Z?h-$_!4Q9d8NMag(d(?X8JiOmh8pMbZVu71H*}5z2v(cQRPIY zI^YaQ1!ZbAXIx)ke;uV1P)`WH-S6Cf&PLQ@XDJ9qjyjje*Qi!W8+=A@*feYm15uuQ zcKJ>y%J6ong_EN_F}QH@M|Nn`o8Inf%2pn+!{IBgl1K^M zJ|7x!mhJrhI7{4v&~43O=6+XYq0_j{E2Y!YyOAD$ao|Vhh6ki-UN$j%Os3ywrQ1u` z&U?GeyX<)f$V(BgbH_HHqVaG@y2|NL`%_`W=3C#^sw`=B^8JvIjHM{QU`we#Kl!aM z*XA+&@Rg-@N#<*zDD5U^qx?90tcK~+8;K;c))U)&f8T{RNIC|IB{<=_uTG zO=9Y!&}#J*y1%z-HN+UZ!ne?@WOul9YO=ec#3QDi61%I;i8UEzJ+Invw~9*nkJ_df z?Jxb^{eG`1w%%wfeMEkAlFApKk^-loBZiWs`CNJj@a~ajgt-iu_rcQ^aUR6(Z(dy9j{nTU@^Jq=y3?Kb`1v5BYlDMpxtVSe zR*HfuQJHF1#7Fut?9X+aR1b~x_M|IDSeuh>^ge$XC99-6xo6c{(%5oB+?xBu;qX>R zNHk*+b!OSexu+6ONpw-~F(=)YLo;?l=*|ojxcI{DfvJ5gzn)K|B}9D|W{8G>#U?ZY zD#o98A&B}TCH&!XT43;Xv6crKhFNs0HL`8oFU=@Y449qlw6j(}-b0Ok-6Tz{or>SJ zbCY@TI3RwgVe@J-90Ue3ad&VHhEv{rY z8MM%y$wK_@TPc_Lct%-bXq0mLd<3U`(Q5x>KZ6=RVS9rDg#sl*#Z5sfX88*KwQ4@f z2S+g(%BBqR0*LywxOn4|MJ%j)kcbSNygRMtE<&d?9poRdmzslCkNz8VeAq^P_O2=r z``^vTv$+I}5zkq@QF*3>Ti4f>3AQB}=YOz%sdL%nEmZBprt|!N?LsE(2=S5km`Nim}4I6OfAm5RM-KiGys3vNbkEJ9JCf zvKjfx17fp76Rh$%ooB6gNYJ(CbB8eGjF9G%TyO!PY<-zRno{`))4q1nhNXfxdQbMS zYUZ};T%DvPGIzs%y?XKKs+K3MQ@3rn*u2>8tu#5x2N6d+mD1La;crFnmKJe}Ggxkr z)P`YKb$Ujcw}4ut+jV2a$=M8_+%)RS(g1V`<= z8o`U+zR|ZF2^{D!^Ye438Ej@BA|mp$x2I4Tvi>+fJ|^X9ZEu(L5?kCb7IIkO@fr8Q zfkClHn0F+C;r)>6s3x<44h|u|^Rp^+zJ25+Z7&r7-^D9qTLxKGw7vxSeMMza=8s~y z86&dSU_x0Qt{mE{TegM@;-C;c8TH)MlkaTak}ZessJvAU&zF}%Z?6Bd7xe&6F<=>( nCjZd+xWUJO-v1qVP~=xk>0UMK?Wqddj6;^LmZ4^ux?|YCz=oA^ literal 0 HcmV?d00001 diff --git a/tests/test_qrcode.py b/tests/test_qrcode.py index eae6a0ca..8d50382b 100644 --- a/tests/test_qrcode.py +++ b/tests/test_qrcode.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . import datetime +import uuid from datetime import timezone import pytest @@ -50,6 +51,7 @@ def test_save_certificate(app, connector): assert result.json['err'] == 0 certificate_uuid = result.json['data']['uuid'] + assert result.json['data']['qrcode_url'] == f'http://testserver/qrcode/test/get-qrcode/{certificate_uuid}' certificate = connector.certificates.get(uuid=certificate_uuid) assert certificate.data['first_name'] == 'Georges' @@ -96,5 +98,25 @@ def test_get_certificate(app, connector): 'data': {'first_name': 'Georges', 'last_name': 'Abitbol'}, 'validity_start': '2022-01-01T10:00:00+00:00', 'validity_end': '2023-01-01T10:00:00+00:00', + 'qrcode_url': f'http://testserver/qrcode/test/get-qrcode/{certificate.uuid}', }, } + + +def test_get_qrcode(app, connector): + certificate = connector.certificates.create( + uuid=uuid.UUID('12345678-1234-5678-1234-567812345678'), + data={ + 'first_name': 'Georges', + 'last_name': 'Abitbol', + }, + validity_start=datetime.datetime(2022, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc), + validity_end=datetime.datetime(2023, 1, 1, 10, 0, 0, 0, tzinfo=timezone.utc), + ) + endpoint = generic_endpoint_url('qrcode', 'get-qrcode', slug=connector.slug) + + response = app.get(f'{endpoint}/{certificate.uuid}') + assert response.headers['Content-Type'] == 'image/png' + with open('tests/data/qrcode/test-qrcode.png', 'rb') as expected_qrcode: + # just check images are the same. Decoded content is tested javascript-side. + assert response.body == expected_qrcode.read()