alexandercerutti / passkit-generator

The easiest way to generate custom Apple Wallet passes in Node.js
MIT License
893 stars 109 forks source link

Passkit-generator and SvelteKit #171

Closed alexandercerutti closed 1 year ago

alexandercerutti commented 1 year ago

Originally posted by @johannesmutter in https://github.com/alexandercerutti/passkit-generator/issues/119#issuecomment-1756288658

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 1 year ago

Might be interesting creating an example to add to the repository...