Passkit-generator and SvelteKit
Closed this issue · 1 comments
alexandercerutti commented
Originally posted by @johannesmutter in #119 (comment)
Yes, works like a charm. Plug & Play.
For others who want to implement passkit-generator in Svelte/ SvelteKit, here's a comprehensive example:
From a server-side route e.g. /api/tickets/[event_id]/+server.js
the ticket generation function is called (PDF, Apple Wallet, Google Wallet):
+server.js
import { generateAppleWallet } from '$lib/generateAppleWallet';
// ... other imports
export async function GET({ fetch, params, setHeaders }) {
// ... read params
const apple_wallet_pass = await generateAppleWallet(fetch, ticketData, visitor_data, designOptions, apple_wallet_images, isPrimaryTicket);
// ... error logic
setHeaders({
'Content-Type': 'application/vnd.apple.pkpass', // MIME type for Apple Wallet pass files
'Last-Modified': new Date().toUTCString(),
'Cache-Control': 'public, max-age=600',
'Content-Disposition': `attachment; filename=${event_id}-ticket.pkpass` // File extension for Apple Wallet pass files
});
return new Response(apple_wallet_pass);
}
generateAppleWallet.js
(it's a bit messy. also the part for generating the .pass model folder on the fly is missing)
import passkit from "passkit-generator";
const PKPass = passkit.PKPass;
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { promises as fs } from 'fs';
const signerKeyPassphrase = import.meta.env.VITE_APPLE_WALLET_SIGNER_KEY_PASSPHRASE;
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/** @param {string} file */
function getFilePathForStatic(file) {
if (file.includes('..') || file.startsWith('/')) {
throw new Error('Invalid file path provided');
}
const allowedExtensions = ['.jpg', '.png', '.ttf', '.otf', '.pass'];
// Check if the file has an allowed extension
const hasValidExtension = allowedExtensions.some(extension => file.endsWith(extension));
if (!hasValidExtension) {
throw new Error('File request not allowed');
}
// Split the file path into components to construct the full path safely
const components = file.split('/');
const path_to_file = join(__dirname, '..', '..', 'static', ...components);
return path_to_file;
}
/**
* @param {string} path - Path to the file
* @returns {Promise<Buffer>} The content of the file
*/
async function readFileAsBuffer(path) {
return fs.readFile(path);
}
/**
* Formats an array of event dates into a readable string.
*
* @param {Array<{ Date: string, Start?: string, End?: string, Description?: string }>} datesArr - Array of event date objects.
* @returns {string} Formatted string representation of event dates.
*/
const formateEventDates = (datesArr) => {
return datesArr.reduce((acc, val) => {
let dateString = `${new Date(val.Date).toLocaleDateString('en-GB', {
weekday: 'long', day: '2-digit', month: 'long'
})}`;
// Check if Start and End values exist before appending
if (val.Start && val.End) {
dateString += `: ${val.Start} to ${val.End} `;
}
if (val.Description) {
dateString += `(${val.Description})`;
}
dateString += '\n\n';
acc.push(dateString);
return acc;
},[]).join('');
}
/**
* @typedef {Object} Ticket
* @property {string} event_id
* @property {string} name
* @property {string} title
* @property {string} primary_ticket_title
* @property {string} secondary_ticket_title
* @property {Array<{Label: string, Text: string}>} back_fields
* @property {string} date_range
* @property {Array<{Date: string, Start: string, End: string, Description: string}>} dates
* @property {string} location
* @property {string} organiser_name
* @property {string} organiser_website
* @property {string} organiser_email
* @property {string} organiser_phone
* @property {boolean} allow_sharing
*/
/**
* @typedef {Object} VisitorData
* @property {string} visitor_id
* @property {string} visitor_name
* @property {string[]} codes
*/
/**
* @typedef {Object} DesignOptions
* @property {string} background_color
* @property {string} body_color
* @property {string} title_color
* @property {("qr"|"barcode")} type_of_code
*/
/**
* @typedef {Object} WalletImages
* @property {string} icon
* @property {string} logo
* @property {string} thumbnail
* @property {string} strip_image
* @property {string} background_image
*/
/**
* Generates an Apple Wallet based on the given Ticket and design options.
* @param {any} fetch
* @param {Ticket} ticket - The Ticket details.
* @param {VisitorData} visitor_data
* @param {DesignOptions} designOptions - The design options for the Ticket
* @param {WalletImages} images - The design options for the Ticket
* @param {boolean} isPrimaryTicket
*/
export async function generateAppleWallet(fetch, ticket, visitor_data, designOptions,images,isPrimaryTicket) {
// data
const {
event_id,
name,
title,
primary_ticket_title,
secondary_ticket_title,
back_fields,
date_range,
dates,
location,
organiser_name,
organiser_website,
organiser_email,
organiser_phone,
allow_sharing
} = ticket;
const { visitor_id, visitor_name, codes } = visitor_data;
const { background_color, body_color, title_color, type_of_code } = designOptions;
// TODO: Generate model with image files on the fly
// const { icon, logo, thumbnail, strip_image, background_image } = images;
// const icon_file = icon && await fetch( icon ).then((res) => res.arrayBuffer());
// const logo_file = logo && await fetch( logo ).then((res) => res.arrayBuffer());
// const thumbnail_file = thumbnail && await fetch( thumbnail ).then((res) => res.arrayBuffer());
// const strip_image_file = strip_image && await fetch( strip_image ).then((res) => res.arrayBuffer());
// const background_image_file = background_image && await fetch( background_image ).then((res) => res.arrayBuffer());
const website_short = organiser_website && organiser_website.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:\/\n\?\=]+)/im)[1];
const serialNumber = isPrimaryTicket ? `${event_id}-${codes[0]}` : `${event_id}-${codes[1]}-guest`;
/**
* Each event requires a separate "Wallet model" (stored in folder static/[event_id]/[event_id].pass)
* To create a new event, clone an existing model and modify accordingly
*/
const model = getFilePathForStatic(`${event_id}/${organiser_name.toLowerCase().replace(/\s/g,'')}.pass`);
const passConfig = {
// IMPORTANT: model must have name ending in .pass and cointain only latin characters
model: model,
certificates: {
// DOCS: How to generate new certificates:
// https://github.com/alexandercerutti/passkit-generator/wiki/Generating-Certificates
wwdr: await readFileAsBuffer( join(__dirname, 'certs', 'AppleWWDRCA2030.pem') ),
signerCert: await readFileAsBuffer( join(__dirname, 'certs', 'signerCert.pem') ),
signerKey: await readFileAsBuffer( join(__dirname, 'certs', 'signerKey.pem') ),
signerKeyPassphrase: signerKeyPassphrase
},
}
const overrides = {
// keys to be added or overridden
serialNumber: serialNumber, // uniquly identifies the pass
organizationName: organiser_name,
description: `Electronic invitations by ${organiser_name}`,
backgroundColor: background_color.replace(/\s/g,''),
foregroundColor: body_color.replace(/\s/g,''),
labelColor: title_color.replace(/\s/g,''),
sharingProhibited: !allow_sharing
}
try {
let eventPass = await PKPass.from(
passConfig,
overrides
);
eventPass.type = "eventTicket";
if(isPrimaryTicket){
if(codes[0]){
eventPass.setBarcodes({
altText: codes[0] + ' (' + primary_ticket_title.replace(/:$/,'') + ')',
format: type_of_code === 'qr' ? 'PKBarcodeFormatQR' : 'PKBarcodeFormatCode128',
message: codes[0],
messageEncoding: "iso-8859-1"
});
}
} else {
if(codes[1]){
eventPass.setBarcodes({
altText: codes[1] + ' (' + secondary_ticket_title.replace(/:$/,'') + ')',
format: type_of_code === 'qr' ? 'PKBarcodeFormatQR' : 'PKBarcodeFormatCode128',
message: codes[1],
messageEncoding: "iso-8859-1"
});
}
}
// HEADER
if(title !== ''){
eventPass.headerFields.push({
key: "walletHeaderText",
label: name ? name : 'Access',
value: title,
textAlignment: "PKTextAlignmentRight"
});
}
// SECONDARY FIELDS
if(visitor_name !== ''){
eventPass.secondaryFields.push( {
key: "fullname",
label: "Visitor",
value: `${isPrimaryTicket ? 'Guest of ' : ''}${visitor_name ? visitor_name : ''}`,
textAlignment: "PKTextAlignmentLeft"
});
}
// AUXILIARY FIELDS
if(date_range){
eventPass.auxiliaryFields.push({
key: "eventduration",
label: "Dates",
value: date_range,
textAlignment: "PKTextAlignmentLeft"
});
}
if(location !== ''){
eventPass.auxiliaryFields.push({
key: "location",
label: "Location",
value: location,
textAlignment: "PKTextAlignmentLeft"
});
}
// BACK FIELDS
[
{
Label: `Dates for ${name}`,
Text: formateEventDates(dates)
},
{
Label: 'Print or download invitation as PDF',
Text: `https://XXXXXXXXX.com/api/tickets/${event_id}?pdf=${visitor_id}`,
attributedValue: `<a href=\"https://XXXXXXXXX.com/api/tickets/${event_id}?pdf=${visitor_id}\">Download your invitation (PDF)</a>`
},
// Generic Back Fields
...back_fields,
{
Label: `Visit our website`,
Text: organiser_website,
attributedValue: `<a href=\"${organiser_website}\">${website_short}</a>`
},
{
Label: `Contact us by email`,
Text: organiser_email,
attributedValue: `<a href=\"mailto:${organiser_email}\">${organiser_email}</a>`
},
{
Label: `Contact us by phone`,
Text: organiser_phone,
attributedValue: `<a href=\"tel:${organiser_phone.replace(/\s/g,'')}\">${organiser_phone}</a>`
},
].forEach( (field, index) => { // f = field
eventPass.backFields.push({
key: `back${index}`,
label: field.Label,
value: field.Text,
textAlignment: "PKTextAlignmentLeft",
...(field.attributedValue && { attributedValue: field.attributedValue })
});
});
const buffer = eventPass.getAsBuffer();
return buffer
// Alternatively return a stream
// let stream = eventPass.getAsStream();
// return stream;
} catch (err) {
console.error(err);
return {
error: true,
message: err
}
}
}
alexandercerutti commented
Might be interesting creating an example to add to the repository...