Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simplify the delegates #15

Merged
merged 17 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from 11 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
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ jobs:
uses: vapor/ci/.github/workflows/run-unit-tests.yml@main
with:
with_linting: true
test_filter: --no-parallel
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
37 changes: 21 additions & 16 deletions Sources/Orders/Orders.docc/GettingStarted.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,6 @@ struct OrderJSONData: OrderJSON.Properties {
### Implement the Delegate

Create a delegate class that implements ``OrdersDelegate``.
In the ``OrdersDelegate/sslSigningFilesDirectory`` you specify there must be the `WWDR.pem`, `ordercertificate.pem` and `orderkey.pem` files.
If they are named like that you're good to go, otherwise you have to specify the custom name.

> Tip: Obtaining the three certificates files could be a bit tricky. You could get some guidance from [this guide](https://github.com/alexandercerutti/passkit-generator/wiki/Generating-Certificates) and [this video](https://www.youtube.com/watch?v=rJZdPoXHtzI). Those guides are for Wallet passes, but the process is similar for Wallet orders.

There are other fields available which have reasonable default values. See ``OrdersDelegate``'s documentation.

Because the files for your order's template and the method of encoding might vary by order type, you'll be provided the ``Order`` for those methods.
In the ``OrdersDelegate/encode(order:db:encoder:)`` method, you'll want to encode a `struct` that conforms to ``OrderJSON``.
Expand All @@ -127,10 +121,6 @@ import Fluent
import Orders

final class OrderDelegate: OrdersDelegate {
let sslSigningFilesDirectory = URL(fileURLWithPath: "Certificates/Orders/", isDirectory: true)

let pemPrivateKeyPassword: String? = Environment.get("ORDERS_PEM_PRIVATE_KEY_PASSWORD")!

func encode<O: OrderModel>(order: O, db: Database, encoder: JSONEncoder) async throws -> Data {
// The specific OrderData class you use here may vary based on the `order.orderTypeIdentifier`
// if you have multiple different types of orders, and thus multiple types of order data.
Expand All @@ -146,19 +136,21 @@ final class OrderDelegate: OrdersDelegate {
return data
}

func template<O: OrderModel>(for order: O, db: Database) async throws -> URL {
func template<O: OrderModel>(for order: O, db: Database) async throws -> String {
// The location might vary depending on the type of order.
URL(fileURLWithPath: "Templates/Orders/", isDirectory: true)
"Templates/Orders/"
}
}
```

> Important: If you have an encrypted PEM private key, you **must** explicitly declare ``OrdersDelegate/pemPrivateKeyPassword`` as a `String?` or Swift will ignore it as it'll think it's a `String` instead.

### Initialize the Service

Next, initialize the ``OrdersService`` inside the `configure.swift` file.
This will implement all of the routes that Apple Wallet expects to exist on your server.
In the `signingFilesDirectory` you specify there must be the `WWDR.pem`, `certificate.pem` and `key.pem` files.
If they are named like that you're good to go, otherwise you have to specify the custom name.

> Tip: Obtaining the three certificates files could be a bit tricky. You could get some guidance from [this guide](https://github.com/alexandercerutti/passkit-generator/wiki/Generating-Certificates) and [this video](https://www.youtube.com/watch?v=rJZdPoXHtzI). Those guides are for Wallet passes, but the process is similar for Wallet orders.

```swift
import Fluent
Expand All @@ -169,7 +161,11 @@ let orderDelegate = OrderDelegate()

public func configure(_ app: Application) async throws {
...
let ordersService = try OrdersService(app: app, delegate: orderDelegate)
let ordersService = try OrdersService(
app: app,
delegate: orderDelegate,
signingFilesDirectory: "Certificates/Orders/"
)
}
```

Expand Down Expand Up @@ -199,7 +195,16 @@ let orderDelegate = OrderDelegate()

public func configure(_ app: Application) async throws {
...
let ordersService = try OrdersServiceCustom<MyOrderType, MyDeviceType, MyOrdersRegistrationType, MyErrorLogType>(app: app, delegate: orderDelegate)
let ordersService = try OrdersServiceCustom<
MyOrderType,
MyDeviceType,
MyOrdersRegistrationType,
MyErrorLogType
>(
app: app,
delegate: orderDelegate,
signingFilesDirectory: "Certificates/Orders/"
)
}
```

Expand Down
64 changes: 5 additions & 59 deletions Sources/Orders/OrdersDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import Foundation

/// 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.
/// Should return a URL path 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:
/// The path 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`
Expand All @@ -21,10 +22,8 @@ public protocol OrdersDelegate: AnyObject, Sendable {
/// - order: 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 order: O, db: any Database) async throws -> URL
/// - Returns: A URL path which points to the template data for the order.
func template<O: OrderModel>(for order: O, db: any Database) async throws -> String

/// Generates the SSL `signature` file.
///
Expand All @@ -51,62 +50,9 @@ public protocol OrdersDelegate: AnyObject, Sendable {
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 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 }
}

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

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

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

public var pemPrivateKeyPassword: String? {
return nil
}

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

public func generateSignatureFile(in root: URL) -> Bool {
return false
}
Expand Down
31 changes: 27 additions & 4 deletions Sources/Orders/OrdersService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,37 @@ public final class OrdersService: Sendable {
/// - Parameters:
/// - app: The `Vapor.Application` to use in route handlers and APNs.
/// - delegate: The ``OrdersDelegate`` to use for order generation.
/// - signingFilesDirectory: The path of the directory where the signing files (`wwdrCertificate`, `pemCertificate`, `pemPrivateKey`) are located.
/// - wwdrCertificate: The name of Apple's WWDR.pem certificate as contained in `signingFilesDirectory` path. Defaults to `WWDR.pem`.
/// - pemCertificate: The name of the PEM Certificate for signing the pass as contained in `signingFilesDirectory` path. Defaults to `certificate.pem`.
/// - pemPrivateKey: The name of the PEM Certificate's private key for signing the pass as contained in `signingFilesDirectory` path. Defaults to `key.pem`.
/// - pemPrivateKeyPassword: The password to the private key file. If the key is not encrypted it must be `nil`. Defaults to `nil`.
/// - sslBinary: The location of the `openssl` command as a file path.
/// - pushRoutesMiddleware: The `Middleware` to use for push notification routes. If `nil`, push routes will not be registered.
/// - logger: The `Logger` to use.
public init(
app: Application, delegate: any OrdersDelegate,
pushRoutesMiddleware: (any Middleware)? = nil, logger: Logger? = nil
app: Application,
delegate: any OrdersDelegate,
signingFilesDirectory: String,
wwdrCertificate: String = "WWDR.pem",
pemCertificate: String = "certificate.pem",
pemPrivateKey: String = "key.pem",
pemPrivateKeyPassword: String? = nil,
sslBinary: String = "/usr/bin/openssl",
pushRoutesMiddleware: (any Middleware)? = nil,
logger: Logger? = nil
) throws {
service = try .init(
app: app, delegate: delegate, pushRoutesMiddleware: pushRoutesMiddleware, logger: logger
self.service = try .init(
app: app,
delegate: delegate,
signingFilesDirectory: signingFilesDirectory,
wwdrCertificate: wwdrCertificate,
pemCertificate: pemCertificate,
pemPrivateKey: pemPrivateKey,
pemPrivateKeyPassword: pemPrivateKeyPassword,
sslBinary: sslBinary,
pushRoutesMiddleware: pushRoutesMiddleware,
logger: logger
)
}

Expand Down
75 changes: 44 additions & 31 deletions Sources/Orders/OrdersServiceCustom.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,39 +26,59 @@ public final class OrdersServiceCustom<O, D, R: OrdersRegistrationModel, E: Erro
where O == R.OrderType, D == R.DeviceType {
private unowned let app: Application
private unowned let delegate: any OrdersDelegate
private let signingFilesDirectory: URL
private let wwdrCertificate: String
private let pemCertificate: String
private let pemPrivateKey: String
private let pemPrivateKeyPassword: String?
private let sslBinary: URL
private let logger: Logger?

/// Initializes the service and registers all the routes required for Apple Wallet to work.
///
/// - Parameters:
/// - app: The `Vapor.Application` to use in route handlers and APNs.
/// - delegate: The ``OrdersDelegate`` to use for order generation.
/// - signingFilesDirectory: The path of the directory where the signing files (`wwdrCertificate`, `pemCertificate`, `pemPrivateKey`) are located.
/// - wwdrCertificate: The name of Apple's WWDR.pem certificate as contained in `signingFilesDirectory` path. Defaults to `WWDR.pem`.
/// - pemCertificate: The name of the PEM Certificate for signing the pass as contained in `signingFilesDirectory` path. Defaults to `certificate.pem`.
/// - pemPrivateKey: The name of the PEM Certificate's private key for signing the pass as contained in `signingFilesDirectory` path. Defaults to `key.pem`.
/// - pemPrivateKeyPassword: The password to the private key file. If the key is not encrypted it must be `nil`. Defaults to `nil`.
/// - sslBinary: The location of the `openssl` command as a file path.
/// - pushRoutesMiddleware: The `Middleware` to use for push notification routes. If `nil`, push routes will not be registered.
/// - logger: The `Logger` to use.
public init(
app: Application,
delegate: any OrdersDelegate,
signingFilesDirectory: String,
wwdrCertificate: String = "WWDR.pem",
pemCertificate: String = "certificate.pem",
pemPrivateKey: String = "key.pem",
pemPrivateKeyPassword: String? = nil,
sslBinary: String = "/usr/bin/openssl",
pushRoutesMiddleware: (any Middleware)? = nil,
logger: Logger? = nil
) throws {
self.app = app
self.delegate = delegate
self.signingFilesDirectory = URL(fileURLWithPath: signingFilesDirectory, isDirectory: true)
self.wwdrCertificate = wwdrCertificate
self.pemCertificate = pemCertificate
self.pemPrivateKey = pemPrivateKey
self.pemPrivateKeyPassword = pemPrivateKeyPassword
self.sslBinary = URL(fileURLWithPath: sslBinary)
self.logger = logger

let privateKeyPath = URL(
fileURLWithPath: delegate.pemPrivateKey, relativeTo: delegate.sslSigningFilesDirectory
).path
let privateKeyPath = URL(fileURLWithPath: pemPrivateKey, relativeTo: self.signingFilesDirectory).path
guard FileManager.default.fileExists(atPath: privateKeyPath) else {
throw OrdersError.pemPrivateKeyMissing
}
let pemPath = URL(
fileURLWithPath: delegate.pemCertificate, relativeTo: delegate.sslSigningFilesDirectory
).path
let pemPath = URL(fileURLWithPath: pemCertificate, relativeTo: self.signingFilesDirectory).path
guard FileManager.default.fileExists(atPath: pemPath) else {
throw OrdersError.pemCertificateMissing
}
let apnsConfig: APNSClientConfiguration
if let password = delegate.pemPrivateKeyPassword {
if let password = pemPrivateKeyPassword {
apnsConfig = APNSClientConfiguration(
authenticationMethod: try .tls(
privateKey: .privateKey(
Expand Down Expand Up @@ -351,13 +371,10 @@ extension OrdersServiceCustom {
/// - order: The order to send the notifications for.
/// - db: The `Database` to use.
public func sendPushNotifications(for order: O, on db: any Database) async throws {
try await sendPushNotificationsForOrder(
id: order.requireID(), of: order.orderTypeIdentifier, on: db)
try await sendPushNotificationsForOrder(id: order.requireID(), of: order.orderTypeIdentifier, on: db)
}

static func registrationsForOrder(
id: UUID, of orderTypeIdentifier: String, on db: any Database
) async throws -> [R] {
static func registrationsForOrder(id: UUID, of orderTypeIdentifier: String, on db: any Database) async throws -> [R] {
// This could be done by enforcing the caller to have a Siblings property wrapper,
// but there's not really any value to forcing that on them when we can just do the query ourselves like this.
try await R.query(on: db)
Expand Down Expand Up @@ -395,20 +412,20 @@ extension OrdersServiceCustom {
if delegate.generateSignatureFile(in: root) { return }

// Swift Crypto doesn't support encrypted PEM private keys, so we have to use OpenSSL for that.
if let password = delegate.pemPrivateKeyPassword {
let sslBinary = delegate.sslBinary
if let password = self.pemPrivateKeyPassword {
let sslBinary = self.sslBinary
guard FileManager.default.fileExists(atPath: sslBinary.path) else {
throw OrdersError.opensslBinaryMissing
}

let proc = Process()
proc.currentDirectoryURL = delegate.sslSigningFilesDirectory
proc.currentDirectoryURL = self.signingFilesDirectory
proc.executableURL = sslBinary
proc.arguments = [
"smime", "-binary", "-sign",
"-certfile", delegate.wwdrCertificate,
"-signer", delegate.pemCertificate,
"-inkey", delegate.pemPrivateKey,
"-certfile", self.wwdrCertificate,
"-signer", self.pemCertificate,
"-inkey", self.pemPrivateKey,
"-in", root.appendingPathComponent("manifest.json").path,
"-out", root.appendingPathComponent("signature").path,
"-outform", "DER",
Expand All @@ -425,21 +442,21 @@ extension OrdersServiceCustom {
additionalIntermediateCertificates: [
Certificate(
pemEncoded: String(
contentsOf: delegate.sslSigningFilesDirectory
.appendingPathComponent(delegate.wwdrCertificate)
contentsOf: self.signingFilesDirectory
.appendingPathComponent(self.wwdrCertificate)
)
)
],
certificate: Certificate(
pemEncoded: String(
contentsOf: delegate.sslSigningFilesDirectory
.appendingPathComponent(delegate.pemCertificate)
contentsOf: self.signingFilesDirectory
.appendingPathComponent(self.pemCertificate)
)
),
privateKey: .init(
pemEncoded: String(
contentsOf: delegate.sslSigningFilesDirectory
.appendingPathComponent(delegate.pemPrivateKey)
contentsOf: self.signingFilesDirectory
.appendingPathComponent(self.pemPrivateKey)
)
),
signingTime: Date()
Expand All @@ -454,7 +471,7 @@ extension OrdersServiceCustom {
/// - db: The `Database` to use.
/// - Returns: The generated order content as `Data`.
public func generateOrderContent(for order: O, on db: any Database) async throws -> Data {
let templateDirectory = try await delegate.template(for: order, db: db)
let templateDirectory = try await URL(fileURLWithPath: delegate.template(for: order, db: db), isDirectory: true)
guard
(try? templateDirectory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
else {
Expand All @@ -470,13 +487,9 @@ extension OrdersServiceCustom {
try await self.delegate.encode(order: order, db: db, encoder: encoder)
.write(to: root.appendingPathComponent("order.json"))

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

var files = try FileManager.default.contentsOfDirectory(
at: templateDirectory, includingPropertiesForKeys: nil)
var files = try FileManager.default.contentsOfDirectory(at: templateDirectory, includingPropertiesForKeys: nil)
files.append(URL(fileURLWithPath: "order.json", relativeTo: root))
files.append(URL(fileURLWithPath: "manifest.json", relativeTo: root))
files.append(URL(fileURLWithPath: "signature", relativeTo: root))
Expand Down
Loading
Loading