From eae08e81f9a23e7eb8d9044fa904be4938b49000 Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Wed, 10 Mar 2021 17:50:22 +0100 Subject: [PATCH 1/2] Shorten the form URL by compressing and b64 encoding it. In a real-world example, the baseline URL goes from 1086 characters to 620. This is good for the resulting QR barcode. --- cara/apps/calculator/__init__.py | 11 +++++++++++ cara/apps/calculator/report_generator.py | 22 +++++++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/cara/apps/calculator/__init__.py b/cara/apps/calculator/__init__.py index d22e8846..c31ca372 100644 --- a/cara/apps/calculator/__init__.py +++ b/cara/apps/calculator/__init__.py @@ -1,10 +1,12 @@ import datetime +import base64 import html import json import os from pathlib import Path import traceback import uuid +import zlib import jinja2 from tornado.web import Application, RequestHandler, StaticFileHandler @@ -131,6 +133,14 @@ class CalculatorForm(BaseRequestHandler): self.finish(report) +class CompressedCalculatorFormInputs(BaseRequestHandler): + def get(self, compressed_args: str): + # Convert a base64 zlib encoded shortened URL into a non compressed + # URL, and redirect. + args = zlib.decompress(base64.b64decode(compressed_args)).decode() + self.redirect(f'/calculator?{args}') + + class ReadmeHandler(BaseRequestHandler): def get(self): template = self.settings['template_environment'].get_template("userguide.html.j2") @@ -146,6 +156,7 @@ def make_app(debug=False, prefix='/calculator'): calculator_static_dir = Path(__file__).absolute().parent / 'static' urls = [ (r'/?', LandingPage), + (r'/c/(.*)', CompressedCalculatorFormInputs), (r'/static/(.*)', StaticFileHandler, {'path': static_dir}), (prefix + r'/?', CalculatorForm), (prefix + r'/report', ConcentrationModel), diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py index 3eb65193..a96571e6 100644 --- a/cara/apps/calculator/report_generator.py +++ b/cara/apps/calculator/report_generator.py @@ -4,6 +4,7 @@ from datetime import datetime import io from pathlib import Path import typing +import zlib import qrcode import urllib @@ -70,7 +71,22 @@ def calculate_report_data(model: models.ExposureModel): def generate_qr_code(prefix, form: FormData): form_dict = FormData.to_dict(form) - url = prefix + "?" + urllib.parse.urlencode(form_dict) + + # Generate the calculator URL arguments that would be needed to re-create this + # form. + args = urllib.parse.urlencode(form_dict) + + # Then zlib compress + base64 encode the string. To be inverted by the + # /c/ endpoint. + qr_url = prefix + "/c/" + base64.b64encode( + zlib.compress(args.encode()) + ).decode() + + # We show the human-friendly URL when hovering over the link, but for now + # let's keep it the same as the QR URL to ensure everything continues to work + # as expected (it takes more effort to validate QR barcodes than it does links). + # url = prefix + "/calculator/?" + args + url = qr_url qr = qrcode.QRCode( version=1, @@ -78,7 +94,7 @@ def generate_qr_code(prefix, form: FormData): box_size=10, border=4, ) - qr.add_data(url) + qr.add_data(qr_url) qr.make(fit=True) img = qr.make_image(fill_color="black", back_color="white").convert('RGB') @@ -272,7 +288,7 @@ def build_report(base_url: str, model: models.ExposureModel, form: FormData): context.update(calculate_report_data(model)) alternative_scenarios = manufacture_alternative_scenarios(form) context['alternative_scenarios'] = comparison_report(alternative_scenarios) - context['qr_code'] = generate_qr_code(f'{base_url}/calculator', form) + context['qr_code'] = generate_qr_code(base_url, form) cara_templates = Path(__file__).parent.parent / "templates" calculator_templates = Path(__file__).parent / "templates" From e6fb1b937443438a9dd90af144a67e8973d8029f Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Thu, 25 Mar 2021 11:36:56 +0100 Subject: [PATCH 2/2] Use the compressed URL only for the QR code, and always present the full URL to the user/browser. --- cara/apps/calculator/__init__.py | 10 ++-- cara/apps/calculator/report_generator.py | 12 ++--- cara/tests/apps/calculator/conftest.py | 13 +++++ .../apps/calculator/test_model_generator.py | 9 ---- .../apps/calculator/test_report_generator.py | 13 +---- cara/tests/apps/calculator/test_webapp.py | 54 +++++++++++++++++++ setup.py | 2 + 7 files changed, 80 insertions(+), 33 deletions(-) create mode 100644 cara/tests/apps/calculator/conftest.py create mode 100644 cara/tests/apps/calculator/test_webapp.py diff --git a/cara/apps/calculator/__init__.py b/cara/apps/calculator/__init__.py index c31ca372..9690fed7 100644 --- a/cara/apps/calculator/__init__.py +++ b/cara/apps/calculator/__init__.py @@ -88,8 +88,6 @@ class ConcentrationModel(BaseRequestHandler): try: form = model_generator.FormData.from_dict(requested_model_config) - except (KeyboardInterrupt, SystemExit): - raise except Exception as err: if self.settings.get("debug", False): import traceback @@ -137,7 +135,11 @@ class CompressedCalculatorFormInputs(BaseRequestHandler): def get(self, compressed_args: str): # Convert a base64 zlib encoded shortened URL into a non compressed # URL, and redirect. - args = zlib.decompress(base64.b64decode(compressed_args)).decode() + try: + args = zlib.decompress(base64.b64decode(compressed_args)).decode() + except Exception as err: # noqa + self.set_status(400) + return self.finish("Invalid calculator data: it seems incomplete. Was there an error copying & pasting the URL?") self.redirect(f'/calculator?{args}') @@ -156,7 +158,7 @@ def make_app(debug=False, prefix='/calculator'): calculator_static_dir = Path(__file__).absolute().parent / 'static' urls = [ (r'/?', LandingPage), - (r'/c/(.*)', CompressedCalculatorFormInputs), + (r'/_c/(.*)', CompressedCalculatorFormInputs), (r'/static/(.*)', StaticFileHandler, {'path': static_dir}), (prefix + r'/?', CalculatorForm), (prefix + r'/report', ConcentrationModel), diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py index a96571e6..8effd90c 100644 --- a/cara/apps/calculator/report_generator.py +++ b/cara/apps/calculator/report_generator.py @@ -77,16 +77,11 @@ def generate_qr_code(prefix, form: FormData): args = urllib.parse.urlencode(form_dict) # Then zlib compress + base64 encode the string. To be inverted by the - # /c/ endpoint. - qr_url = prefix + "/c/" + base64.b64encode( + # /_c/ endpoint. + qr_url = prefix + "/_c/" + base64.b64encode( zlib.compress(args.encode()) ).decode() - - # We show the human-friendly URL when hovering over the link, but for now - # let's keep it the same as the QR URL to ensure everything continues to work - # as expected (it takes more effort to validate QR barcodes than it does links). - # url = prefix + "/calculator/?" + args - url = qr_url + url = prefix + "/calculator?" + args qr = qrcode.QRCode( version=1, @@ -101,6 +96,7 @@ def generate_qr_code(prefix, form: FormData): return { 'image': img2base64(_img2bytes(img)), 'link': url, + 'qr_url': qr_url, } diff --git a/cara/tests/apps/calculator/conftest.py b/cara/tests/apps/calculator/conftest.py new file mode 100644 index 00000000..6e9c4074 --- /dev/null +++ b/cara/tests/apps/calculator/conftest.py @@ -0,0 +1,13 @@ +import pytest + +from cara.apps.calculator import model_generator + + +@pytest.fixture +def baseline_form_data(): + return model_generator.baseline_raw_form_data() + + +@pytest.fixture +def baseline_form(baseline_form_data): + return model_generator.FormData.from_dict(baseline_form_data) diff --git a/cara/tests/apps/calculator/test_model_generator.py b/cara/tests/apps/calculator/test_model_generator.py index 64ecb0d4..89a3c6e0 100644 --- a/cara/tests/apps/calculator/test_model_generator.py +++ b/cara/tests/apps/calculator/test_model_generator.py @@ -6,15 +6,6 @@ from cara import models from cara import data import numpy as np -@pytest.fixture -def baseline_form_data(): - return model_generator.baseline_raw_form_data() - - -@pytest.fixture -def baseline_form(baseline_form_data): - return model_generator.FormData.from_dict(baseline_form_data) - def test_model_from_dict(baseline_form_data): form = model_generator.FormData.from_dict(baseline_form_data) diff --git a/cara/tests/apps/calculator/test_report_generator.py b/cara/tests/apps/calculator/test_report_generator.py index 14beade5..fb70d70b 100644 --- a/cara/tests/apps/calculator/test_report_generator.py +++ b/cara/tests/apps/calculator/test_report_generator.py @@ -1,19 +1,8 @@ import pytest -from cara.apps.calculator import model_generator from cara.apps.calculator import report_generator -@pytest.fixture -def baseline_form_data(): - return model_generator.baseline_raw_form_data() - - -@pytest.fixture -def baseline_form(baseline_form_data): - return model_generator.FormData.from_dict(baseline_form_data) - - def test_generate_report(baseline_form): model = baseline_form.build_model() @@ -32,4 +21,4 @@ def test_generate_report(baseline_form): ], ) def test_readable_minutes(test_input, expected): - assert report_generator.readable_minutes(test_input) == expected \ No newline at end of file + assert report_generator.readable_minutes(test_input) == expected diff --git a/cara/tests/apps/calculator/test_webapp.py b/cara/tests/apps/calculator/test_webapp.py new file mode 100644 index 00000000..24cb0819 --- /dev/null +++ b/cara/tests/apps/calculator/test_webapp.py @@ -0,0 +1,54 @@ +import pytest + +from cara.apps.calculator import make_app +from cara.apps.calculator.report_generator import generate_qr_code + + +@pytest.fixture +def app(): + return make_app() + + +async def test_homepage(http_server_client): + response = await http_server_client.fetch('/') + assert response.code == 200 + + +async def test_calculator(http_server_client): + # Both with and without a trailing slash. + response = await http_server_client.fetch('/calculator') + assert response.code == 200 + + response = await http_server_client.fetch('/calculator/') + assert response.code == 200 + + +async def test_qrcode_urls(http_server_client, baseline_form): + prefix = 'proto://hostname/prefix' + qr_data = generate_qr_code(prefix, baseline_form) + expected = f'{prefix}/calculator?activity_type=office&air_changes=0.0' + assert qr_data['link'].startswith(expected) + + # We should get a 200 for the link. + response = await http_server_client.fetch(qr_data['link'].replace(prefix, '')) + assert response.code == 200 + + # And a 302 for the QR url itself. The redirected URL should be the same as + # in the link. + assert qr_data['qr_url'].startswith(prefix) + response = await http_server_client.fetch( + qr_data['qr_url'].replace(prefix, ''), + max_redirects=0, + raise_error=False, + ) + assert response.code == 302 + assert response.headers['Location'] == qr_data['link'].replace(prefix, '') + + +async def test_invalid_compressed_url(http_server_client, baseline_form): + response = await http_server_client.fetch( + '/_c/invalid-data', + max_redirects=0, + raise_error=False, + ) + assert response.code == 400 diff --git a/setup.py b/setup.py index 08afec18..0487cf14 100644 --- a/setup.py +++ b/setup.py @@ -71,6 +71,8 @@ setup( 'all': [req for reqs in REQUIREMENTS.values() for req in reqs], }, package_data={'cara': [ + 'apps/templates/*.j2', + 'apps/calculator/templates/*.j2', 'apps/calculator/*', 'apps/calculator/*/*', 'apps/calculator/*/*/*'