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 00000000..df38f58c Binary files /dev/null and b/tests/data/qrcode/test-qrcode.png differ 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()