diff --git a/apps/common/pkpass.py b/apps/common/pkpass.py new file mode 100644 index 000000000..cbc537e04 --- /dev/null +++ b/apps/common/pkpass.py @@ -0,0 +1,180 @@ +"""Stuff to generate Apple pkpass tickets.""" + +from datetime import datetime +import hashlib +import io +import json +from pathlib import Path +import subprocess +from typing import Any, Iterable +import zipfile +from flask import current_app as app +import pytz + +from apps.common.receipt import get_purchase_metadata +from models.user import User + +# https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/Creating.html +MAX_PASS_LOCATIONS = 10 + + +class PkPassException(Exception): + """Base pkpass generator exception.""" + + +class TooManyPassLocations(PkPassException): + """Too many pass locations specified in config.""" + + def __init__(self, count) -> None: + self.count = count + + def __str__(self) -> str: + return f"{self.__doc__} Got {self.count}, max is {MAX_PASS_LOCATIONS}" + + +class InvalidPassConfig(PkPassException): + """Pass config does not match expected schema""" + + missing: set[str] + extra: set[str] + + def __init__(self, *, missing, extra) -> None: + self.missing = missing + self.extra = extra + + def __str__(self) -> str: + out = "Invalid pass config: " + if self.missing: + out += f", missing: {self.missing}" + if self.extra: + out += f", extra: {self.extra}" + return out + + +def generate_manifest(files: dict[str, bytes]) -> dict[str, str]: + """Given a dict of filename -> contents, generate the pkpass manifest.""" + return {name: hashlib.sha1(contents).hexdigest() for name, contents in files.items()} + + +def _validate_keys( + things: Iterable[dict[str, Any]], expected_keys: set[str], optional_keys: set[str] = set() +): + for thing in things: + got_keys = set(thing.keys()) + missing = expected_keys - got_keys + extra = got_keys - (expected_keys | optional_keys) + if missing or extra: + raise InvalidPassConfig(missing=missing, extra=extra) + + +def _get_and_validate_locations(): + """Get and validate pkpass locations from config.""" + locs = app.config.get("PKPASS_LOCATIONS", []) + if len(locs) > MAX_PASS_LOCATIONS: + raise TooManyPassLocations(count=len(locs)) + _validate_keys(locs, {"latitude", "longitude"}, {"altitude", "relevantText"}) + return locs + + +def _get_beacons(): + """Get and validate pkpass beacons from config.""" + beacons = app.config.get("PKPASS_BEACONS", []) + _validate_keys(beacons, {"proximityUUID"}, {"relevantText", "major", "minor"}) + return beacons + + +def generate_pass_data(user) -> dict[str, Any]: + meta = get_purchase_metadata(user) + expire_dt = datetime.strptime(app.config["EVENT_END"], "%Y-%m-%d %H:%M:%S").replace( + tzinfo=pytz.timezone("Europe/London") + ) + return { + "passTypeIdentifier": app.config["PKPASS_IDENTIFIER"], + "teamIdentifier": app.config["PKPASS_TEAM_ID"], + "formatVersion": 1, + # Use the checkin code as a unique serial. + "serialNumber": user.checkin_code, + "organizationName": "Electromagnetic Field", + "logoText": "Electromagnetic Field", + "description": "Electromagnetic Field Entry Pass", + "locations": _get_and_validate_locations(), + "beacons": _get_beacons(), + "maxDistance": app.config.get("PKPASS_MAX_DISTANCE", 50), + "barcodes": [ + { + "format": "PKBarcodeFormatQR", + "message": app.config["CHECKIN_BASE"] + user.checkin_code, + "messageEncoding": "iso-8859-1", + } + ], + "foregroundColor": "rgb(255, 255, 255)", + "labelColor": "rgb(255, 255, 255)", + # Allow users to share passes, since they could just send the PDF anyway... + "sharingProhibited": False, + "expirationDate": expire_dt.strftime("%Y-%m-%dT%H:%M:%S%:z"), + # "expirationDate": expire_dt.isoformat(), + "eventTicket": { + "primaryFields": [ + {"key": "admissions", "value": len(meta.admissions), "label": "Admission"}, + {"key": "parking", "value": len(meta.parking_tickets), "label": "Parking"}, + {"key": "caravan", "value": len(meta.campervan_tickets), "label": "Campervan"}, + ], + "secondaryFields": [], + "backFields": [ + { + "key": "gen", + "value": f"{datetime.now().isoformat()}", + "label": "Generated at ", + } + ], + }, + "accessibilityURL": "https://emfcamp.orgabout/accessibility", + } + + +def smime_sign(data: bytes, signer_cert_file: Path, key_file: Path, cert_chain_file: Path) -> bytes: + """Call openssl smime to sign some data.""" + cmd = [ + "openssl", + "smime", + "-binary", + "-sign", + "-signer", + str(signer_cert_file), + "-inkey", + str(key_file), + "-certfile", + str(cert_chain_file), + "-outform", + "der", + ] + try: + p = subprocess.run(args=cmd, input=data, check=True, capture_output=True) + except subprocess.CalledProcessError as e: + app.logger.error("Error signing pkpass: %s", e.stderr) + raise + return p.stdout + + +def generate_pkpass(user: User) -> io.BytesIO: + """Generates a signed Apple Wallet pass for a user.""" + zip_buffer = io.BytesIO() + files = { + "pass.json": json.dumps(generate_pass_data(user)).encode(), + } + assets = Path(app.config.get("PKPASS_ASSETS_DIR", "images/pkpass")) + files |= {p.name: open(p, "rb").read() for p in assets.iterdir() if p.is_file()} + manifest = json.dumps(generate_manifest(files)).encode() + signature = smime_sign( + manifest, + app.config["PKPASS_SIGNER_CERT_FILE"], + app.config["PKPASS_KEY_FILE"], + app.config["PKPASS_CHAIN_FILE"], + ) + files["manifest.json"] = manifest + files["signature"] = signature + with zipfile.ZipFile(zip_buffer, "w") as zf: + for name, contents in files.items(): + zf.writestr(name, contents) + zip_buffer.seek(0) + return zip_buffer diff --git a/apps/common/receipt.py b/apps/common/receipt.py index 7c32c7c62..7f76c6c1b 100644 --- a/apps/common/receipt.py +++ b/apps/common/receipt.py @@ -1,3 +1,4 @@ +from collections import namedtuple import io import asyncio @@ -15,41 +16,48 @@ RECEIPT_TYPES = ["admissions", "parking", "campervan", "merchandise", "hire"] +TicketMeta = namedtuple( + "TicketMeta", + ["admissions", "parking_tickets", "campervan_tickets", "merch", "hires", "transferred_tickets"], +) -def render_receipt(user, png=False, pdf=False): + +def get_purchase_metadata(user) -> TicketMeta: purchases = ( user.owned_purchases.filter_by(is_paid_for=True) .join(PriceTier, Product, ProductGroup) .with_entities(Purchase) .order_by(Purchase.id) ) + return TicketMeta( + admissions=purchases.filter(ProductGroup.type == "admissions").all(), + parking_tickets=purchases.filter(ProductGroup.type == "parking").all(), + campervan_tickets=purchases.filter(ProductGroup.type == "campervan").all(), + merch=purchases.filter(ProductGroup.type == "merchandise").all(), + hires=purchases.filter(ProductGroup.type == "hire").all(), + transferred_tickets=( + user.transfers_from.join(Purchase) + .filter_by(state="paid") + .with_entities(PurchaseTransfer) + .order_by("timestamp") + .all() + ), + ) - admissions = purchases.filter(ProductGroup.type == "admissions").all() - - parking_tickets = purchases.filter(ProductGroup.type == "parking").all() - campervan_tickets = purchases.filter(ProductGroup.type == "campervan").all() - - merch = purchases.filter(ProductGroup.type == "merchandise").all() - hires = purchases.filter(ProductGroup.type == "hire").all() - transferred_tickets = ( - user.transfers_from.join(Purchase) - .filter_by(state="paid") - .with_entities(PurchaseTransfer) - .order_by("timestamp") - .all() - ) +def render_receipt(user, png=False, pdf=False): + meta = get_purchase_metadata(user) return render_template( "receipt.html", user=user, format_inline_qr=format_inline_qr, - admissions=admissions, - parking_tickets=parking_tickets, - campervan_tickets=campervan_tickets, - transferred_tickets=transferred_tickets, - merch=merch, - hires=hires, + admissions=meta.admissions, + parking_tickets=meta.parking_tickets, + campervan_tickets=meta.campervan_tickets, + transferred_tickets=meta.transferred_tickets, + merch=meta.merch, + hires=meta.hires, pdf=pdf, png=png, ) diff --git a/apps/payments/tasks.py b/apps/payments/tasks.py index 7cd4ed72d..df23c1fdf 100644 --- a/apps/payments/tasks.py +++ b/apps/payments/tasks.py @@ -178,3 +178,12 @@ def expire_pending_payments(yes): app.logger.info(f"Would expire payment {payment}") db.session.commit() + + +@payments.cli.command("mark_transfer_paid") +@click.argument("payment_id", type=int) +def mark_paid(payment_id: int): + """Mark a Bank transfer payment as paid. Useful for testing.""" + p = BankPayment.query.get(payment_id) + p.paid() + db.session.commit() diff --git a/apps/users/account.py b/apps/users/account.py index 25f623d58..cb09510c5 100644 --- a/apps/users/account.py +++ b/apps/users/account.py @@ -1,7 +1,18 @@ -from flask import render_template, redirect, request, flash, url_for, current_app as app +from flask import ( + abort, + render_template, + redirect, + request, + flash, + send_file, + url_for, + current_app as app, +) from flask_login import login_required, current_user from wtforms import StringField, SubmitField, BooleanField from wtforms.validators import DataRequired +from apps.common import feature_enabled +from apps.common.pkpass import generate_pkpass from main import db from models.purchase import Purchase @@ -118,3 +129,16 @@ def cancellation_refund(): ) return render_template("account/cancellation-refund.html", payments=payments) + + +@users.route("/account/pkpass") +@login_required +def pkpass_ticket(): + if not feature_enabled("ISSUE_APPLE_PKPASS_TICKETS"): + abort(403) + return send_file( + generate_pkpass(current_user), + "application/vnd.apple.pkpass", + as_attachment=True, + download_name="emf_ticket.pkpass", + ) diff --git a/config/development-example.cfg b/config/development-example.cfg index d69c07e42..a81c98cc4 100644 --- a/config/development-example.cfg +++ b/config/development-example.cfg @@ -112,3 +112,30 @@ ETHNICITY_MATCHERS = { ), "other": r"^other$", } + +# Stuff for apple pass pkpass generation +PKPASS_SIGNER_CERT_FILE = "" +PKPASS_KEY_FILE = "" +PKPASS_CHAIN_FILE = "" +# Optionally override the pkpass assets directory (default is images/pkpass so this shouldn't need to be set) +PKPASS_ASSETS_DIR = "some/other/assets/dir" +# The identifier and team id must match those used to generate the certificate above +PKPASS_IDENTIFIER = "pass.camp.emf" +PKPASS_TEAM_ID = "6S99YXW5XH" +# Optional list of locations at which the pass will be shown +PKPASS_LOCATIONS = [ + { + "latitude": 52.03942, + "longitude": -2.37930, + "relevantText": "You are near EMF" + } +] +# The distance (in m) from one of the locations below which the pass will be shown. +PKPASS_MAX_DISTANCE = 25 +# Optional list of bluetooth le beacon identifiers near which the pass will be shown +PKPASS_BEACONS = [ + { + "proximityUUID": "fda50693-a4e2-4fb1-afcf-c6eb07647825", + "relevantText": "You are near the entrance tent", + } +] \ No newline at end of file diff --git a/docs/mobile_passes.md b/docs/mobile_passes.md new file mode 100644 index 000000000..6f9adc58f --- /dev/null +++ b/docs/mobile_passes.md @@ -0,0 +1,55 @@ +The website supports generating Apple Wallet "pkpass" files of a user's checkin code and +purchase info. These are nice because they can be made to show up automatically when the +user's mobile device is near the event, and the UX for showing them on your phone is nicer +than opening a PDF attachment and zooming in on the QR. + +## You will need +- An Apple Developer Program suscription/team +- To set the following config options (see development-example.cfg) + - `PKPASS_TEAM_ID` + - `PKPASS_IDENTIFIER` + - `PKPASS_SIGNER_CERT_FILE` + - `PKPASS_KEY_FILE` + - `PKPASS_CHAIN_FILE` + - `PKPASS_ASSETS_DIR` + - `PKPASS_LOCATIONS` (optional) + - `PKPASS_MAX_DISTANCE` (optional) + - `PKPASS_BEACONS` (optional) + +## Setting up +To generate pkpasses that will be accepted by Apple devices, you will need an apple +developer account with active developer program subscription. + +1. Go to Apple developer console > Certificates, Identifiers & Profiles > Identifiers. +1. Set `PKPASS_TEAM_ID` config option to be the development team id +1. Click "+" and create a "Pass Type ID". Give it a sensible identifier, and then use this + as `PKPASS_IDENTIFIER`. +1. Generate a private key: `openssl genrsa -out pkpass.key 2048`. Point `PKPASS_KEY_FILE` to this file. +1. Generate a CSR from the private key: `openssl req -new -key pkpass.key -out pkpass.csr` + - Fill out the details - doesn't seem to matter much what you use + - Leave the challenge password blank. +1. Get Apple to generate a certifcate from the CSR: click the pass type identifier, then + create certificate, and upload the CSR. +1. You'll get a shiny `pass.cer`. Convert it to base64 (pem/crt) text format: + `openssl x509 -inform der -in pass.cer -out pkpass.crt`. Point `PKPASS_SIGNER_CERT_FILE` to this file +1. Download Apple's root and convert it to text format: + `curl -L http://developer.apple.com/certificationauthority/AppleWWDRCA.cer | openssl x509 -inform der -out applewwdrca.crt`. Point `PKPASS_CHAIN_FILE` to this file. +1. Enable pkpass generation with the `ISSUE_APPLE_PKPASS_TICKETS` feature flag. +1. Test it: go to `/account/purchases` and click the add to wallet button. This should download and show the pass on a Mac or iOS device. If it doesn't, something's wrong - most likely with the signing. You can debug by looking at console.app messages on a Mac (search for "pass"). + +## Styling +The pass can be styled with the assets in `images/pkpass`. Optionally can be overridden with `PKPASS_ASSETS_DIR`. See (somewhat out of date) docs at https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/Creating.html. +Note that our pass is an "event ticket" pass - so you should use: +- `icon.png` - shown on the lockscreen/in notifications +- `logo.png` - shown on the pass itself +- `background.png` - shown (blurred) in the background of the pass +- `strip.png` - optionally shown below the pass name as background to the ticket number information + +There should be @2x and @3x variants of all the above too. + +## Locations/beacons +The pass can be automatically shown on iOS devices when the device is near a GPS location or bluetooth le beacon. These are configured in +- `PKPASS_LOCATIONS` (with the distance threshold below which it's shown being `PKPASS_MAX_DISTANCE`) +- `PKPASS_BEACONS` +See the docs for the schema: https://developer.apple.com/documentation/walletpasses/pass +The `relevantText` fields are optional but recommended given the user sees them directly on the lockscreen notification. \ No newline at end of file diff --git a/images/pkpass/background.png b/images/pkpass/background.png new file mode 100644 index 000000000..d384c1508 Binary files /dev/null and b/images/pkpass/background.png differ diff --git a/images/pkpass/background@2x.png b/images/pkpass/background@2x.png new file mode 100644 index 000000000..3f6c7cfcc Binary files /dev/null and b/images/pkpass/background@2x.png differ diff --git a/images/pkpass/background@3x.png b/images/pkpass/background@3x.png new file mode 100644 index 000000000..09f1af9ec Binary files /dev/null and b/images/pkpass/background@3x.png differ diff --git a/images/pkpass/icon.png b/images/pkpass/icon.png new file mode 100644 index 000000000..fbdfc97d7 Binary files /dev/null and b/images/pkpass/icon.png differ diff --git a/images/pkpass/icon@2x.png b/images/pkpass/icon@2x.png new file mode 100644 index 000000000..151df81be Binary files /dev/null and b/images/pkpass/icon@2x.png differ diff --git a/images/pkpass/icon@3x.png b/images/pkpass/icon@3x.png new file mode 100644 index 000000000..35835c1e9 Binary files /dev/null and b/images/pkpass/icon@3x.png differ diff --git a/images/pkpass/logo.png b/images/pkpass/logo.png new file mode 100644 index 000000000..1266a5ec8 Binary files /dev/null and b/images/pkpass/logo.png differ diff --git a/images/pkpass/logo@2x.png b/images/pkpass/logo@2x.png new file mode 100644 index 000000000..d7987f8cf Binary files /dev/null and b/images/pkpass/logo@2x.png differ diff --git a/images/pkpass/logo@3x.png b/images/pkpass/logo@3x.png new file mode 100644 index 000000000..39ce8ef77 Binary files /dev/null and b/images/pkpass/logo@3x.png differ diff --git a/models/feature_flag.py b/models/feature_flag.py index 5a64117c0..57d176b96 100644 --- a/models/feature_flag.py +++ b/models/feature_flag.py @@ -10,6 +10,7 @@ "CFP_CLOSED", "CFP_FINALISE", "ISSUE_TICKETS", + "ISSUE_APPLE_PKPASS_TICKETS", "LINE_UP", "LIGHTNING_TALKS", "SCHEDULE", diff --git a/templates/account/purchases.html b/templates/account/purchases.html index 46414efee..f241b87b5 100644 --- a/templates/account/purchases.html +++ b/templates/account/purchases.html @@ -8,7 +8,7 @@

Your tickets

{% if not config['ISSUE_TICKETS'] %} -

We'll send you a scannable ticket by email shortly before the event. +

We'll send you a scannable entry pass by email shortly before the event. This will include any parking tickets, which you need to print out and leave on the dashboard.

diff --git a/templates/account/purchases/ticket-list.html b/templates/account/purchases/ticket-list.html index 6e90f582c..f833b7edc 100644 --- a/templates/account/purchases/ticket-list.html +++ b/templates/account/purchases/ticket-list.html @@ -6,6 +6,11 @@

Printable ticket Download PDF +{% if feature_enabled("ISSUE_APPLE_PKPASS_TICKETS") %} + + Add to Apple wallet + +{% endif %}

{% endif %}