Skip to content

Commit

Permalink
Test order content generation
Browse files Browse the repository at this point in the history
  • Loading branch information
fpseverino committed Jul 1, 2024
1 parent c358a9b commit 50db67c
Show file tree
Hide file tree
Showing 4 changed files with 306 additions and 1 deletion.
120 changes: 120 additions & 0 deletions Sources/Orders/OrdersDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//
// OrdersDelegate.swift
// PassKit
//
// Created by Francesco Paolo Severino on 01/07/24.
//

import Foundation
import FluentKit

/// The delegate which is responsible for generating the order files.
public protocol OrdersDelegate: AnyObject, Sendable {
/// Should return a `URL` which points to the template data for the order.
///
/// The URL should point to a directory containing all the images and localizations for the generated `.order` archive but should *not* contain any of these items:
/// - `manifest.json`
/// - `order.json`
/// - `signature`
///
/// - Parameters:
/// - for: The order data from the SQL server.
/// - db: The SQL database to query against.
///
/// - Returns: A `URL` which points to the template data for the order.
///
/// > Important: Be sure to use the `URL(fileURLWithPath:isDirectory:)` constructor.
func template<O: OrderModel>(for: O, db: any Database) async throws -> URL

/// Generates the SSL `signature` file.
///
/// If you need to implement custom S/Mime signing you can use this
/// method to do so. You must generate a detached DER signature of the `manifest.json` file.
///
/// - Parameter root: The location of the `manifest.json` and where to write the `signature` to.
/// - Returns: Return `true` if you generated a custom `signature`, otherwise `false`.
func generateSignatureFile(in root: URL) -> Bool

/// Encode the order into JSON.
///
/// This method should generate the entire order JSON. You are provided with
/// the order data from the SQL database and you should return a properly
/// formatted order file encoding.
///
/// - Parameters:
/// - order: The order data from the SQL server
/// - db: The SQL database to query against.
/// - encoder: The `JSONEncoder` which you should use.
/// - Returns: The encoded order JSON data.
///
/// > Tip: See the [`Order`](https://developer.apple.com/documentation/walletorders/order) object to understand the keys.
func encode<O: OrderModel>(order: O, db: any Database, encoder: JSONEncoder) async throws -> Data

/// Should return a `URL` which points to the template data for the order.
///
/// The URL should point to a directory containing the files specified by these keys:
/// - `wwdrCertificate`
/// - `pemCertificate`
/// - `pemPrivateKey`
///
/// > Important: Be sure to use the `URL(fileURLWithPath:isDirectory:)` initializer!
var sslSigningFilesDirectory: URL { get }

/// The location of the `openssl` command as a file URL.
///
/// > Important: Be sure to use the `URL(fileURLWithPath:)` constructor.
var sslBinary: URL { get }

/// The full path to the `zip` command as a file URL.
///
/// > Important: Be sure to use the `URL(fileURLWithPath:)` constructor.
var zipBinary: URL { get }

/// The name of Apple's WWDR.pem certificate as contained in `sslSigningFiles` path.
///
/// Defaults to `WWDR.pem`
var wwdrCertificate: String { get }

/// The name of the PEM Certificate for signing the order as contained in `sslSigningFiles` path.
///
/// Defaults to `ordercertificate.pem`
var pemCertificate: String { get }

/// The name of the PEM Certificate's private key for signing the order as contained in `sslSigningFiles` path.
///
/// Defaults to `orderkey.pem`
var pemPrivateKey: String { get }

/// The password to the private key file.
var pemPrivateKeyPassword: String? { get }
}

public extension OrdersDelegate {
var wwdrCertificate: String {
get { return "WWDR.pem" }
}

var pemCertificate: String {
get { return "ordercertificate.pem" }
}

var pemPrivateKey: String {
get { return "orderkey.pem" }
}

var pemPrivateKeyPassword: String? {
get { return nil }
}

var sslBinary: URL {
get { return URL(fileURLWithPath: "/usr/bin/openssl") }
}

var zipBinary: URL {
get { return URL(fileURLWithPath: "/usr/bin/zip") }
}

func generateSignatureFile(in root: URL) -> Bool {
return false
}
}
38 changes: 38 additions & 0 deletions Sources/Orders/OrdersService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// OrdersService.swift
// PassKit
//
// Created by Francesco Paolo Severino on 01/07/24.
//

import Vapor
import FluentKit

/// The main class that handles Wallet orders.
public final class OrdersService: Sendable {
private let service: OrdersServiceCustom<Order, OrdersDevice, OrdersRegistration, OrdersErrorLog>

public init(app: Application, delegate: any OrdersDelegate, logger: Logger? = nil) {
service = .init(app: app, delegate: delegate, logger: logger)
}

/// Generates the order content bundle for a given order.
///
/// - Parameters:
/// - order: The order to generate the content for.
/// - db: The `Database` to use.
/// - Returns: The generated order content.
public func generatePassContent(for order: Order, on db: any Database) async throws -> Data {
try await service.generateOrderContent(for: order, on: db)
}

/// Adds the migrations for Wallet orders models.
///
/// - Parameter migrations: The `Migrations` object to add the migrations to.
public static func register(migrations: Migrations) {
migrations.add(Order())
migrations.add(OrdersDevice())
migrations.add(OrdersRegistration())
migrations.add(OrdersErrorLog())
}
}
147 changes: 147 additions & 0 deletions Sources/Orders/OrdersServiceCustom.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
//
// OrdersServiceCustom.swift
// PassKit
//
// Created by Francesco Paolo Severino on 01/07/24.
//

import Vapor
import APNS
import VaporAPNS
@preconcurrency import APNSCore
import Fluent
import NIOSSL
import PassKit

/// Class to handle `OrdersService`.
///
/// The generics should be passed in this order:
/// - Order Type
/// - Device Type
/// - Registration Type
/// - Error Log Type
public final class OrdersServiceCustom<O, D, R: OrdersRegistrationModel, E: ErrorLogModel>: Sendable where O == R.OrderType, D == R.DeviceType {
public unowned let delegate: any OrdersDelegate
private unowned let app: Application

private let v1: FakeSendable<any RoutesBuilder>
private let logger: Logger?

public init(app: Application, delegate: any OrdersDelegate, logger: Logger? = nil) {
self.delegate = delegate
self.logger = logger
self.app = app

v1 = FakeSendable(value: app.grouped("api", "orders", "v1"))
}
}

// MARK: - order file generation
extension OrdersServiceCustom {
private static func generateManifestFile(using encoder: JSONEncoder, in root: URL) throws {
var manifest: [String: String] = [:]

let paths = try FileManager.default.subpathsOfDirectory(atPath: root.unixPath())
try paths.forEach { relativePath in
let file = URL(fileURLWithPath: relativePath, relativeTo: root)
guard !file.hasDirectoryPath else {
return
}

let data = try Data(contentsOf: file)
let hash = SHA256.hash(data: data)
manifest[relativePath] = hash.map { "0\(String($0, radix: 16))".suffix(2) }.joined()
}

let encoded = try encoder.encode(manifest)
try encoded.write(to: root.appendingPathComponent("manifest.json"))
}

private func generateSignatureFile(in root: URL) throws {
if delegate.generateSignatureFile(in: root) {
// If the caller's delegate generated a file we don't have to do it.
return
}

let sslBinary = delegate.sslBinary

guard FileManager.default.fileExists(atPath: sslBinary.unixPath()) else {
throw OrdersError.opensslBinaryMissing
}

let proc = Process()
proc.currentDirectoryURL = delegate.sslSigningFilesDirectory
proc.executableURL = sslBinary

proc.arguments = [
"smime", "-binary", "-sign",
"-certfile", delegate.wwdrCertificate,
"-signer", delegate.pemCertificate,
"-inkey", delegate.pemPrivateKey,
"-in", root.appendingPathComponent("manifest.json").unixPath(),
"-out", root.appendingPathComponent("signature").unixPath(),
"-outform", "DER"
]

if let pwd = delegate.pemPrivateKeyPassword {
proc.arguments!.append(contentsOf: ["-passin", "pass:\(pwd)"])
}

try proc.run()

proc.waitUntilExit()
}

private func zip(directory: URL, to: URL) throws {
let zipBinary = delegate.zipBinary
guard FileManager.default.fileExists(atPath: zipBinary.unixPath()) else {
throw OrdersError.zipBinaryMissing
}

let proc = Process()
proc.currentDirectoryURL = directory
proc.executableURL = zipBinary

proc.arguments = [ to.unixPath(), "-r", "-q", "." ]

try proc.run()
proc.waitUntilExit()
}

public func generateOrderContent(for order: O, on db: any Database) async throws -> Data {
let tmp = FileManager.default.temporaryDirectory
let root = tmp.appendingPathComponent(UUID().uuidString, isDirectory: true)
let zipFile = tmp.appendingPathComponent("\(UUID().uuidString).zip")
let encoder = JSONEncoder()

let src = try await delegate.template(for: order, db: db)
guard (try? src.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false else {
throw OrdersError.templateNotDirectory
}

let encoded = try await self.delegate.encode(order: order, db: db, encoder: encoder)

do {
try FileManager.default.copyItem(at: src, to: root)

defer {
_ = try? FileManager.default.removeItem(at: root)
}

try encoded.write(to: root.appendingPathComponent("order.json"))

try Self.generateManifestFile(using: encoder, in: root)
try self.generateSignatureFile(in: root)

try self.zip(directory: root, to: zipFile)

defer {
_ = try? FileManager.default.removeItem(at: zipFile)
}

return try Data(contentsOf: zipFile)
} catch {
throw error
}
}
}
2 changes: 1 addition & 1 deletion Sources/Passes/PassesDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public protocol PassesDelegate: AnyObject, Sendable {
/// - `signature`
///
/// - Parameters:
/// - pass: The pass data from the SQL server.
/// - for: The pass data from the SQL server.
/// - db: The SQL database to query against.
///
/// - Returns: A `URL` which points to the template data for the pass.
Expand Down

0 comments on commit 50db67c

Please sign in to comment.