Files
pretix_cgo/src/pretix/plugins/wallet/styles/apple.py
Kara Engelhardt f6f4c1c56c WIP
2026-05-18 17:27:23 +02:00

257 lines
9.5 KiB
Python

from .base import (
FieldEntryType,
ImageFieldGroup,
PlaceholderFieldGroup,
PredefinedFieldGroup,
TextFieldGroup,
WalletPlatform,
PassStyle,
PlaceholderFieldEntry,
)
from django.utils.translation import gettext as _
from i18nfield.strings import LazyI18nString
import io
import hashlib
import zipfile
import cryptography
import cryptography.hazmat.primitives.serialization.pkcs7
import json
from django.contrib.staticfiles import finders
class ApplePlatform(WalletPlatform):
identifier = "apple"
name = _("Apple")
class StringResource:
# mapping string in default event locale -> LazyI18nString
entries: dict[str, LazyI18nString]
locales: set[str]
def __init__(self, locales):
self.entries = {}
self.locales = set(locales)
def add_entry(self, key: str, value: LazyI18nString):
if key in self.entries:
raise ValueError(f"{key} already exists in this StringResource")
self.entries[key] = value
def escape(self, string):
return string.translate(
str.maketrans({'"': '\\"', "\r": "\\r", "\n": "\\n", "\\": "\\\\"})
)
def generate_resource(self, language):
output = ""
for key, entry in self.entries.items():
output += (
f'"{self.escape(key)}" = "{self.escape(entry.localize(language))}";\n'
)
return output.strip()
def generate(self):
return {language: self.generate_resource(language) for language in self.locales}
class SignedZipFile:
"""Generates a zip-file with manifest and signature as apple expects a pkpass file to be"""
def __init__(self, ca_certificate, certificate, key, password):
self.ca_certificate = cryptography.x509.load_pem_x509_certificate(
ca_certificate
)
self.certificate = cryptography.x509.load_pem_x509_certificate(certificate)
self.key = cryptography.hazmat.primitives.serialization.load_pem_private_key(
key, password
)
self.password = password
self.file = io.BytesIO()
self.zip_file = zipfile.ZipFile(self.file, "w")
self.manifest = {}
def sign(self, data: bytes):
return (
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7SignatureBuilder()
.set_data(data)
.add_signer(
self.certificate,
self.key,
cryptography.hazmat.primitives.hashes.SHA256(),
)
.add_certificate(self.ca_certificate)
.sign(
cryptography.hazmat.primitives.serialization.Encoding.DER,
[
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7Options.Binary,
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7Options.DetachedSignature,
],
)
)
def finish(self):
manifest = json.dumps(self.manifest).encode()
signature = self.sign(manifest)
self.add_file("manifest.json", manifest)
self.add_file("signature", signature)
self.zip_file.close()
return self.file.getvalue()
def add_file(self, filename: str, content: str | bytes):
if isinstance(content, str):
content = content.encode()
with self.zip_file.open(filename, "w") as f:
f.write(content)
self.manifest[filename] = hashlib.sha1(content).hexdigest()
class AppleWalletStyle(PassStyle):
platform = ApplePlatform
def pass_content(self, layout, context, strings):
raise NotImplementedError()
def generate_pass_json(self, layout, context, strings):
def add_from_context(key):
value = context.get(key)
if not value:
raise ValueError(f"{key} must be set to a truthy value")
return value
pass_json = {
"formatVersion": 1,
"description": add_from_context("description"),
"organizationName": add_from_context("organizationName"),
"passTypeIdentifier": add_from_context("passTypeIdentifier"),
"teamIdentifier": add_from_context("teamIdentifier"),
"serialNumber": add_from_context("serialNumber"),
**self.pass_content(layout, context, strings),
}
return pass_json
def generate(self, layout, context):
for key in ["ca_certificate", "certificate", "key", "password", "locales"]:
if key not in context:
raise ValueError(f"{key} missing from context")
pkpass = SignedZipFile(
context["ca_certificate"],
context["certificate"],
context["key"],
context["password"],
)
strings = StringResource(locales=context['locales'])
pass_json = self.generate_pass_json(layout, context, strings)
print(pass_json)
pkpass.add_file(
"icon.png", open(finders.find("pretix_passbook/icon.png"), "rb").read()
)
pkpass.add_file("pass.json", json.dumps(pass_json))
return pkpass.finish()
class AppleWalletEventTicket(AppleWalletStyle):
identifier = "event_1"
name = _("Event Ticket Layout 1")
fieldgroups = [
ImageFieldGroup(
identifier="logo",
name=_("Logo"),
min_entries=0,
max_entries=1,
labels=False,
default_entries=[
PlaceholderFieldEntry(
content="poweredby",
)
],
),
TextFieldGroup(
identifier="primary",
name=_("Primary"),
min_entries=1,
max_entries=1,
default_entries=[
PlaceholderFieldEntry(
label=LazyI18nString({"de": "Tickettyp", "en": "Ticket type"}),
content="item",
)
], # TODO: support Lazyi18nproxy here
description=_("These fields appear prominently featured on the pass."),
),
TextFieldGroup(
identifier="secondary", name=_("Secondary"), max_entries=4
), # TODO: validation of max field count if combined "Coupons, store cards, and generic passes with a square barcode can have a total of up to four secondary and auxiliary fields, combined."
TextFieldGroup(
identifier="headers", name=_("Header"), max_entries=3
), # TODO: header image
TextFieldGroup(identifier="auxillary", name=_("Auxillary"), max_entries=4),
TextFieldGroup(identifier="back", name=_("Back")),
]
# preview_image = "apple/event_ticket.svg"
def get_pass_fields(self, layout, context):
fields = {}
for group in self.fieldgroups:
if isinstance(group, PredefinedFieldGroup):
pass
elif isinstance(group, PlaceholderFieldGroup):
group_fields = []
if group.identifier in layout["fieldgroups"]:
for field in layout["fieldgroups"][group.identifier]["entries"]:
field_entry = {}
if group.labels:
field_entry["label"] = LazyI18nString(field["label"])
if field["type"] == FieldEntryType.PLACEHOLDER.value:
placeholder = (
context.get("placeholders")
.get(group.content_type.value, {})
.get(field["content"])
)
if placeholder:
placeholder_value = placeholder["evaluate"](
*context.get("evaluation_context", [])
)
if placeholder_value:
field_entry["value"] = placeholder_value
elif field["type"] == FieldEntryType.TEXT.value:
placeholder_value = LazyI18nString(field["content"])
elif field["type"] == FieldEntryType.IMAGE.value:
raise NotImplementedError(
"Image placeholders not implemented"
)
if "value" in field_entry and field_entry["value"]:
group_fields.append(field_entry)
if group.min_entries and len(group_fields) < group.min_entries:
raise ValueError(
f"Group {group.identifier} needs at least {group.min_entries} entries, but only {len(group_fields)} were provided"
)
fields[group.identifier] = group_fields[: group.max_entries]
else:
raise ValueError("Unknown field group")
return fields
def convert_fields(self, strings, fields):
converted = []
for i,f in enumerate(fields):
converted_field = {**f, "key": f"primary-{i}"}
if "label" in converted_field and isinstance(converted_field['label'], LazyI18nString):
strings.add_entry(f"primary-{i}-label", converted_field['label'])
converted_field['label'] = f"primary-{i}-label"
converted.append(converted_field)
return converted
def pass_content(self, layout, context, strings):
fields = self.get_pass_fields(layout, context)
return {
"eventTicket": {
"primaryFields": self.convert_fields(strings, fields['primary'])
}
}