Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions apps/common/pkpass.py
Original file line number Diff line number Diff line change
@@ -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
50 changes: 29 additions & 21 deletions apps/common/receipt.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from collections import namedtuple
import io
import asyncio

Expand All @@ -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,
)
Expand Down
9 changes: 9 additions & 0 deletions apps/payments/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
26 changes: 25 additions & 1 deletion apps/users/account.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
)
27 changes: 27 additions & 0 deletions config/development-example.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
]
55 changes: 55 additions & 0 deletions docs/mobile_passes.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added images/pkpass/background.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/pkpass/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/pkpass/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/pkpass/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/pkpass/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/pkpass/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/pkpass/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/pkpass/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/pkpass/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading