diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a8cca0d..91b08d5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,4 +8,4 @@ on: jobs: unit-tests: - uses: vapor/ci/.github/workflows/run-unit-tests.yml@main \ No newline at end of file + uses: vapor/ci/.github/workflows/run-unit-tests.yml@main diff --git a/Package.swift b/Package.swift index 7a8c575..af782f7 100644 --- a/Package.swift +++ b/Package.swift @@ -11,9 +11,13 @@ let package = Package( .library(name: "Orders", targets: ["Orders"]), ], dependencies: [ - .package(url: "https://github.com/vapor/vapor.git", from: "4.102.0"), + .package(url: "https://github.com/vapor/vapor.git", from: "4.103.1"), .package(url: "https://github.com/vapor/fluent.git", from: "4.11.0"), - .package(url: "https://github.com/vapor/apns.git", from: "4.1.0"), + .package(url: "https://github.com/vapor/apns.git", from: "4.2.0"), + .package(url: "https://github.com/vapor-community/Zip.git", from: "2.2.0"), + .package(url: "https://github.com/apple/swift-certificates.git", from: "1.5.0"), + // used in tests + .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.7.4"), ], targets: [ .target( @@ -22,6 +26,8 @@ let package = Package( .product(name: "Fluent", package: "fluent"), .product(name: "Vapor", package: "vapor"), .product(name: "VaporAPNS", package: "apns"), + .product(name: "Zip", package: "zip"), + .product(name: "X509", package: "swift-certificates"), ], swiftSettings: swiftSettings ), @@ -43,7 +49,12 @@ let package = Package( name: "PassesTests", dependencies: [ .target(name: "Passes"), + .target(name: "PassKit"), .product(name: "XCTVapor", package: "vapor"), + .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"), + ], + resources: [ + .copy("Templates"), ], swiftSettings: swiftSettings ), @@ -51,7 +62,12 @@ let package = Package( name: "OrdersTests", dependencies: [ .target(name: "Orders"), + .target(name: "PassKit"), .product(name: "XCTVapor", package: "vapor"), + .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"), + ], + resources: [ + .copy("Templates"), ], swiftSettings: swiftSettings ), diff --git a/README.md b/README.md index 5b5aab1..6f978d7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
avatar

PassKit

- + Documentation Team Chat @@ -20,21 +20,10 @@ 🎟️ 📦 Create, distribute, and update passes and orders for the Apple Wallet app with Vapor. -### Major Releases - -The table below shows a list of PassKit major releases alongside their compatible Swift versions. - -|Version|Swift|SPM| -|---|---|---| -|0.5.0|5.10+|`from: "0.5.0"`| -|0.4.0|5.10+|`from: "0.4.0"`| -|0.2.0|5.9+|`from: "0.2.0"`| -|0.1.0|5.9+|`from: "0.1.0"`| - Use the SPM string to easily include the dependendency in your `Package.swift` file. ```swift -.package(url: "https://github.com/vapor-community/PassKit.git", from: "0.5.0") +.package(url: "https://github.com/vapor-community/PassKit.git", from: "0.6.0") ``` > Note: This package is made for Vapor 4. @@ -50,7 +39,7 @@ Add the `Passes` product to your target's dependencies: .product(name: "Passes", package: "PassKit") ``` -See the framework's [documentation](https://swiftpackageindex.com/vapor-community/PassKit/0.5.0/documentation/passes) for information on how to use it. +See the framework's [documentation](https://swiftpackageindex.com/vapor-community/PassKit/documentation/passes) for information and guides on how to use it. For information on Apple Wallet passes, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletpasses). @@ -65,6 +54,6 @@ Add the `Orders` product to your target's dependencies: .product(name: "Orders", package: "PassKit") ``` -See the framework's [documentation](https://swiftpackageindex.com/vapor-community/PassKit/0.5.0/documentation/orders) for information on how to use it. +See the framework's [documentation](https://swiftpackageindex.com/vapor-community/PassKit/documentation/orders) for information and guides on how to use it. For information on Apple Wallet orders, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletorders). diff --git a/Sources/Orders/DTOs/OrderJSON.swift b/Sources/Orders/DTOs/OrderJSON.swift index 31a12a4..33dafd1 100644 --- a/Sources/Orders/DTOs/OrderJSON.swift +++ b/Sources/Orders/DTOs/OrderJSON.swift @@ -51,6 +51,40 @@ public struct OrderJSON { } } +extension OrderJSON { + /// A protocol that represents the merchant associated with the order. + /// + /// > Tip: See the [`Order.Merchant`](https://developer.apple.com/documentation/walletorders/merchant) object to understand the keys. + public protocol Merchant: Encodable { + /// The localized display name of the merchant. + var displayName: String { get } + + /// The Apple Merchant Identifier for this merchant, generated at `developer.apple.com`. + var merchantIdentifier: String { get } + + /// The URL for the merchant’s website or landing page. + var url: String { get } + } +} + +extension OrderJSON { + /// A protocol that represents the details of a barcode for an order. + /// + /// > Tip: See the [`Order.Barcode`](https://developer.apple.com/documentation/walletorders/barcode) object to understand the keys. + public protocol Barcode: Encodable { + /// The format of the barcode. + var format: BarcodeFormat { get } + + /// The contents of the barcode. + var message: String { get } + + /// The text encoding of the barcode message. + /// + /// Typically this is `iso-8859-1`, but you may specify an alternative encoding if required. + var messageEncoding: String { get } + } +} + extension OrderJSON { /// The type of order this bundle represents. public enum OrderType: String, Encodable { @@ -68,20 +102,12 @@ extension OrderJSON { public enum SchemaVersion: Int, Encodable { case v1 = 1 } -} -extension OrderJSON { - /// A protocol that represents the merchant associated with the order. - /// - /// > Tip: See the [`Order.Merchant`](https://developer.apple.com/documentation/walletorders/merchant) object to understand the keys. - public protocol Merchant: Encodable { - /// The localized display name of the merchant. - var displayName: String { get } - - /// The Apple Merchant Identifier for this merchant, generated at `developer.apple.com`. - var merchantIdentifier: String { get } - - /// The URL for the merchant’s website or landing page. - var url: String { get } + /// The format of the barcode. + public enum BarcodeFormat: String, Encodable { + case pdf417 + case qr + case aztec + case code128 } } diff --git a/Sources/Orders/DTOs/OrdersForDeviceDTO.swift b/Sources/Orders/DTOs/OrdersForDeviceDTO.swift index ef07b22..6ef7f64 100644 --- a/Sources/Orders/DTOs/OrdersForDeviceDTO.swift +++ b/Sources/Orders/DTOs/OrdersForDeviceDTO.swift @@ -13,10 +13,6 @@ struct OrdersForDeviceDTO: Content { init(with orderIdentifiers: [String], maxDate: Date) { self.orderIdentifiers = orderIdentifiers - self.lastModified = ISO8601DateFormatter.string( - from: maxDate, - timeZone: .init(secondsFromGMT: 0)!, - formatOptions: .withInternetDateTime - ) + self.lastModified = String(maxDate.timeIntervalSince1970) } } diff --git a/Sources/Orders/Models/Concrete Models/Order.swift b/Sources/Orders/Models/Concrete Models/Order.swift index cced3f2..9c0352c 100644 --- a/Sources/Orders/Models/Concrete Models/Order.swift +++ b/Sources/Orders/Models/Concrete Models/Order.swift @@ -9,7 +9,7 @@ import Foundation import FluentKit /// The `Model` that stores Wallet orders. -open class Order: OrderModel, @unchecked Sendable { +final public class Order: OrderModel, @unchecked Sendable { /// The schema name of the order model. public static let schema = Order.FieldKeys.schemaName diff --git a/Sources/Orders/Models/OrderModel.swift b/Sources/Orders/Models/OrderModel.swift index 227e2ac..056307e 100644 --- a/Sources/Orders/Models/OrderModel.swift +++ b/Sources/Orders/Models/OrderModel.swift @@ -43,15 +43,6 @@ internal extension OrderModel { return orderTypeIdentifier } - - var _$createdAt: Timestamp { - guard let mirror = Mirror(reflecting: self).descendant("_createdAt"), - let createdAt = mirror as? Timestamp else { - fatalError("createdAt property must be declared using @Timestamp(on: .create)") - } - - return createdAt - } var _$updatedAt: Timestamp { guard let mirror = Mirror(reflecting: self).descendant("_updatedAt"), diff --git a/Sources/Orders/Orders.docc/DistributeUpdate.md b/Sources/Orders/Orders.docc/DistributeUpdate.md deleted file mode 100644 index 1146042..0000000 --- a/Sources/Orders/Orders.docc/DistributeUpdate.md +++ /dev/null @@ -1,181 +0,0 @@ -# Building, Distributing and Updating an Order - -Build a distributable order and distribute it to your users or update an existing order. - -## Overview - -The order you distribute to a user is a signed bundle that contains the JSON description of the order, images, and optional localizations. -The Orders framework provides the ``OrdersService`` class that handles the creation of the order JSON file and the signing of the order bundle, using an ``OrdersDelegate`` that you must implement. -The ``OrdersService`` class also provides methods to send push notifications to all devices registered to an order when it's updated and all the routes that Apple Wallet expects to get and update orders. - -### Implement the Delegate - -Create a delegate file 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``. - -```swift -import Vapor -import Fluent -import Orders - -final class OrderDelegate: OrdersDelegate { - let sslSigningFilesDirectory = URL(fileURLWithPath: "Certificates/Orders/", isDirectory: true) - - let pemPrivateKeyPassword: String? = Environment.get("ORDER_PEM_PRIVATE_KEY_PASSWORD")! - - func encode(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. - guard let orderData = try await OrderData.query(on: db) - .filter(\.$order.$id == order.id!) - .first() - else { - throw Abort(.internalServerError) - } - guard let data = try? encoder.encode(OrderJSONData(data: orderData, order: order)) else { - throw Abort(.internalServerError) - } - return data - } - - func template(for: O, db: Database) async throws -> URL { - // The location might vary depending on the type of order. - return URL(fileURLWithPath: "Templates/Orders/", isDirectory: true) - } -} -``` - -> Important: You **must** explicitly declare ``OrdersDelegate/pemPrivateKeyPassword`` as a `String?` or Swift will ignore it as it'll think it's a `String` instead. - -### Register the Routes - -Next, register the routes in `routes.swift`. -This will implement all of the routes that Apple Wallet expects to exist on your server for you. - -```swift -import Vapor -import Orders - -let orderDelegate = OrderDelegate() - -func routes(_ app: Application) throws { - let ordersService = OrdersService(app: app, delegate: orderDelegate) - ordersService.registerRoutes() -} -``` - -> Note: Notice how the ``OrdersDelegate`` is created as a global variable. You need to ensure that the delegate doesn't go out of scope as soon as the `routes(_:)` method exits. - -### Push Notifications - -If you wish to include routes specifically for sending push notifications to updated orders you can also include this line in your `routes(_:)` method. You'll need to pass in whatever `Middleware` you want Vapor to use to authenticate the two routes. - -> Important: If you don't include this line, you have to configure an APNS container yourself. - -```swift -try ordersService.registerPushRoutes(middleware: SecretMiddleware(secret: "foo")) -``` - -That will add two routes, the first one sends notifications and the second one retrieves a list of push tokens which would be sent a notification. - -```http -POST https://example.com/api/orders/v1/push/{orderTypeIdentifier}/{orderIdentifier} HTTP/2 -``` - -```http -GET https://example.com/api/orders/v1/push/{orderTypeIdentifier}/{orderIdentifier} HTTP/2 -``` - -### Order Data Model Middleware - -Whether you include the routes or not, you'll want to add a model middleware that sends push notifications and updates the ``Order/updatedAt`` field when your order data updates. The model middleware could also create and link the ``Order`` during the creation of the order data, depending on your requirements. - -See for more information. - -### Apple Push Notification service - -If you did not include the push notification routes, remember to configure APNs yourself. - -> Important: Apple Wallet *only* works with the APNs production environment. You can't pass in the `.sandbox` environment. - -```swift -let apnsConfig: APNSClientConfiguration -if let pemPrivateKeyPassword { - apnsConfig = APNSClientConfiguration( - authenticationMethod: try .tls( - privateKey: .privateKey( - NIOSSLPrivateKey(file: privateKeyPath, format: .pem) { closure in - closure(pemPrivateKeyPassword.utf8) - }), - certificateChain: NIOSSLCertificate.fromPEMFile(pemPath).map { .certificate($0) } - ), - environment: .production - ) -} else { - apnsConfig = APNSClientConfiguration( - authenticationMethod: try .tls( - privateKey: .file(privateKeyPath), - certificateChain: NIOSSLCertificate.fromPEMFile(pemPath).map { .certificate($0) } - ), - environment: .production - ) -} -app.apns.containers.use( - apnsConfig, - eventLoopGroupProvider: .shared(app.eventLoopGroup), - responseDecoder: JSONDecoder(), - requestEncoder: JSONEncoder(), - as: .init(string: "orders"), - isDefault: false -) -``` - -### Generate the Order Content - -To generate and distribute the `.order` bundle, pass the ``OrdersService`` object to your `RouteCollection`. - -```swift -import Fluent -import Vapor -import Orders - -struct OrdersController: RouteCollection { - let ordersService: OrdersService - - func boot(routes: RoutesBuilder) throws { - ... - } -} -``` - -Then use the object inside your route handlers to generate and distribute the order bundle. - -```swift -fileprivate func passHandler(_ req: Request) async throws -> Response { - ... - guard let orderData = try await OrderData.query(on: req.db) - .filter(...) - .with(\.$order) - .first() - else { - throw Abort(.notFound) - } - - let bundle = try await ordersService.generateOrderContent(for: orderData.order, on: req.db) - let body = Response.Body(data: bundle) - var headers = HTTPHeaders() - headers.add(name: .contentType, value: "application/vnd.apple.order") - headers.add(name: .contentDisposition, value: "attachment; filename=name.order") // Add this header only if you are serving the order in a web page - headers.add(name: .lastModified, value: String(orderData.order.updatedAt?.timeIntervalSince1970 ?? 0)) - headers.add(name: .contentTransferEncoding, value: "binary") - return Response(status: .ok, headers: headers, body: body) -} -``` diff --git a/Sources/Orders/Orders.docc/Extensions/OrderJSON.md b/Sources/Orders/Orders.docc/Extensions/OrderJSON.md index 3a99a9c..9dbc792 100644 --- a/Sources/Orders/Orders.docc/Extensions/OrderJSON.md +++ b/Sources/Orders/Orders.docc/Extensions/OrderJSON.md @@ -11,4 +11,9 @@ ### Merchants -- ``Merchant`` \ No newline at end of file +- ``Merchant`` + +### Barcode + +- ``Barcode`` +- ``BarcodeFormat`` diff --git a/Sources/Orders/Orders.docc/Extensions/OrdersService.md b/Sources/Orders/Orders.docc/Extensions/OrdersService.md index ebaa346..d59b09c 100644 --- a/Sources/Orders/Orders.docc/Extensions/OrdersService.md +++ b/Sources/Orders/Orders.docc/Extensions/OrdersService.md @@ -6,11 +6,8 @@ - ``generateOrderContent(for:on:)`` - ``register(migrations:)`` -- ``registerRoutes()`` ### Push Notifications -- ``registerPushRoutes(middleware:)`` -- ``sendPushNotifications(for:on:app:)-wkeu`` -- ``sendPushNotifications(for:on:app:)-4hxhb`` -- ``sendPushNotificationsForOrder(id:of:on:app:)`` +- ``sendPushNotifications(for:on:)`` +- ``sendPushNotificationsForOrder(id:of:on:)`` diff --git a/Sources/Orders/Orders.docc/GettingStarted.md b/Sources/Orders/Orders.docc/GettingStarted.md new file mode 100644 index 0000000..ce70e86 --- /dev/null +++ b/Sources/Orders/Orders.docc/GettingStarted.md @@ -0,0 +1,304 @@ +# Getting Started with Orders + +Create the order data model, build an order for Apple Wallet and distribute it with a Vapor server. + +## Overview + +The Orders framework provides models to save all the basic information for orders, user devices and their registration to each order. +For all the other custom data needed to generate the order, such as the barcodes, merchant info, etc., you have to create your own model and its model middleware to handle the creation and update of order. +The order data model will be used to generate the `order.json` file contents. + +The order you distribute to a user is a signed bundle that contains the `order.json` file, images, and optional localizations. +The Orders framework provides the ``OrdersService`` class that handles the creation of the order JSON file and the signing of the order bundle, using an ``OrdersDelegate`` that you must implement. +The ``OrdersService`` class also provides methods to send push notifications to all devices registered when you update an order, and all the routes that Apple Wallet uses to retrieve orders. + +### Implement the Order Data Model + +Your data model should contain all the fields that you store for your order, as well as a foreign key to ``Order``, the order model offered by the Orders framework. + +```swift +import Fluent +import struct Foundation.UUID +import Orders + +final class OrderData: OrderDataModel, @unchecked Sendable { + static let schema = "order_data" + + @ID + var id: UUID? + + @Parent(key: "order_id") + var order: Order + + // Example of other extra fields: + @Field(key: "merchant_name") + var merchantName: String + + // Add any other field relative to your app, such as an identifier, the order status, etc. + + init() { } +} + +struct CreateOrderData: AsyncMigration { + public func prepare(on database: Database) async throws { + try await database.schema(OrderData.schema) + .id() + .field("order_id", .uuid, .required, .references(Order.schema, .id, onDelete: .cascade)) + .field("merchant_name", .string, .required) + .create() + } + + public func revert(on database: Database) async throws { + try await database.schema(OrderData.schema).delete() + } +} +``` + +### Handle Cleanup + +Depending on your implementation details, you may want to automatically clean out the orders and devices table when a registration is deleted. +The implementation will be based on your type of SQL database, as there's not yet a Fluent way to implement something like SQL's `NOT EXISTS` call with a `DELETE` statement. + +> Warning: Be careful with SQL triggers, as they can have unintended consequences if not properly implemented. + +### Model the order.json contents + +Create a `struct` that implements ``OrderJSON/Properties`` which will contain all the fields for the generated `order.json` file. +Create an initializer that takes your custom order data, the ``Order`` and everything else you may need. + +> Tip: For information on the various keys available see the [documentation](https://developer.apple.com/documentation/walletorders/order). + +```swift +import Orders + +struct OrderJSONData: OrderJSON.Properties { + let schemaVersion = OrderJSON.SchemaVersion.v1 + let orderTypeIdentifier = Environment.get("ORDER_TYPE_IDENTIFIER")! + let orderIdentifier: String + let orderType = OrderJSON.OrderType.ecommerce + let orderNumber = "HM090772020864" + let createdAt: String + let updatedAt: String + let status = OrderJSON.OrderStatus.open + let merchant: MerchantData + let orderManagementURL = "https://www.example.com/" + let authenticationToken: String + + private let webServiceURL = "https://example.com/api/orders/" + + struct MerchantData: OrderJSON.Merchant { + let merchantIdentifier = "com.example.pet-store" + let displayName: String + let url = "https://www.example.com/" + let logo = "pet_store_logo.png" + } + + init(data: OrderData, order: Order) { + self.orderIdentifier = order.id!.uuidString + self.authenticationToken = order.authenticationToken + self.merchant = MerchantData(displayName: data.title) + + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = .withInternetDateTime + self.createdAt = dateFormatter.string(from: order.createdAt!) + self.updatedAt = dateFormatter.string(from: order.updatedAt!) + } +} +``` + +> Important: You **must** add `api/orders/` to your `webServiceURL`, as shown in the example above. + +### 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``. + +```swift +import Vapor +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(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. + guard let orderData = try await OrderData.query(on: db) + .filter(\.$order.$id == order.requireID()) + .first() + else { + throw Abort(.internalServerError) + } + guard let data = try? encoder.encode(OrderJSONData(data: orderData, order: order)) else { + throw Abort(.internalServerError) + } + return data + } + + func template(for order: O, db: Database) async throws -> URL { + // The location might vary depending on the type of order. + URL(fileURLWithPath: "Templates/Orders/", isDirectory: true) + } +} +``` + +> 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. + +```swift +import Fluent +import Vapor +import Orders + +let orderDelegate = OrderDelegate() + +public func configure(_ app: Application) async throws { + ... + let ordersService = try OrdersService(app: app, delegate: orderDelegate) +} +``` + +> Note: Notice how the ``OrdersDelegate`` is created as a global variable. You need to ensure that the delegate doesn't go out of scope as soon as the `configure(_:)` method exits. + +If you wish to include routes specifically for sending push notifications to updated orders, you can also pass to the ``OrdersService`` initializer whatever `Middleware` you want Vapor to use to authenticate the two routes. Doing so will add two routes, the first one sends notifications and the second one retrieves a list of push tokens which would be sent a notification. + +```http +POST https://example.com/api/orders/v1/push/{orderTypeIdentifier}/{orderIdentifier} HTTP/2 +``` + +```http +GET https://example.com/api/orders/v1/push/{orderTypeIdentifier}/{orderIdentifier} HTTP/2 +``` + +### Custom Implementation of OrdersService + +If you don't like the schema names provided by default, you can create your own models conforming to ``OrderModel``, `DeviceModel`, ``OrdersRegistrationModel`` and `ErrorLogModel` and instantiate the generic ``OrdersServiceCustom``, providing it your model types. + +```swift +import Fluent +import Vapor +import PassKit +import Orders + +let orderDelegate = OrderDelegate() + +public func configure(_ app: Application) async throws { + ... + let ordersService = try OrdersServiceCustom(app: app, delegate: orderDelegate) +} +``` + +### Register Migrations + +If you're using the default schemas provided by this framework, you can register the default models in your `configure(_:)` method: + +```swift +OrdersService.register(migrations: app.migrations) +``` + +> Important: Register the default models before the migration of your order data model. + +### Order Data Model Middleware + +You'll want to create a model middleware to handle the creation and update of the order data model. +This middleware could be responsible for creating and linking an ``Order`` to the order data model, depending on your requirements. +When your order data changes, it should also update the ``Order/updatedAt`` field of the ``Order`` and send a push notification to all devices registered to that order. + +```swift +import Vapor +import Fluent +import Orders + +struct OrderDataMiddleware: AsyncModelMiddleware { + private unowned let service: OrdersService + + init(service: OrdersService) { + self.service = service + } + + // Create the `Order` and add it to the `OrderData` automatically at creation + func create(model: OrderData, on db: Database, next: AnyAsyncModelResponder) async throws { + let order = Order( + orderTypeIdentifier: Environment.get("ORDER_TYPE_IDENTIFIER")!, + authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString()) + try await order.save(on: db) + model.$order.id = try order.requireID() + try await next.create(model, on: db) + } + + func update(model: OrderData, on db: Database, next: AnyAsyncModelResponder) async throws { + let order = try await model.$order.get(on: db) + order.updatedAt = Date() + try await order.save(on: db) + try await next.update(model, on: db) + try await service.sendPushNotifications(for: order, on: db) + } +} +``` + +You could register it in the `configure.swift` file. + +```swift +app.databases.middleware.use(OrderDataMiddleware(service: ordersService), on: .psql) +``` + +> Important: Whenever your order data changes, you must update the ``Order/updatedAt`` time of the linked ``Order`` so that Wallet knows to retrieve a new order. + +### Generate the Order Content + +To generate and distribute the `.order` bundle, pass the ``OrdersService`` object to your `RouteCollection`. + +```swift +import Fluent +import Vapor +import Orders + +struct OrdersController: RouteCollection { + let ordersService: OrdersService + + func boot(routes: RoutesBuilder) throws { + ... + } +} +``` + +> Note: You'll have to register the `OrdersController` in the `configure.swift` file, in order to pass it the ``OrdersService`` object. + +Then use the object inside your route handlers to generate the order bundle with the ``OrdersService/generateOrderContent(for:on:)`` method and distribute it with the "`application/vnd.apple.order`" MIME type. + +```swift +fileprivate func orderHandler(_ req: Request) async throws -> Response { + ... + guard let orderData = try await OrderData.query(on: req.db) + .filter(...) + .with(\.$order) + .first() + else { + throw Abort(.notFound) + } + + let bundle = try await ordersService.generateOrderContent(for: orderData.order, on: req.db) + let body = Response.Body(data: bundle) + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "application/vnd.apple.order") + headers.add(name: .contentDisposition, value: "attachment; filename=name.order") + headers.lastModified = HTTPHeaders.LastModified(order.updatedAt ?? Date.distantPast) + headers.add(name: .contentTransferEncoding, value: "binary") + return Response(status: .ok, headers: headers, body: body) +} +``` diff --git a/Sources/Orders/Orders.docc/OrderData.md b/Sources/Orders/Orders.docc/OrderData.md deleted file mode 100644 index 803ce7f..0000000 --- a/Sources/Orders/Orders.docc/OrderData.md +++ /dev/null @@ -1,176 +0,0 @@ -# Create the Order Data Model - -Implement the order data model, its model middleware and define the order file contents. - -## Overview - -The Orders framework provides models to save all the basic information for orders, user devices and their registration to each order. -For all the other custom data needed to generate the order (such as the barcodes, merchant info, etc.), you have to create your own model and its model middleware to handle the creation and update of order. -The order data model will be used to generate the `order.json` file contents, along side image files for the icon and other visual elements, such as a logo. - -### Implement the Order Data Model - -Your data model should contain all the fields that you store for your order, as well as a foreign key to ``Order``, the order model offered by the Orders framework. - -```swift -import Fluent -import struct Foundation.UUID -import Orders - -final class OrderData: OrderDataModel, @unchecked Sendable { - static let schema = "order_data" - - @ID - var id: UUID? - - @Parent(key: "order_id") - var order: Order - - // Example of other extra fields: - @Field(key: "merchant_name") - var merchantName: String - - // Add any other field relative to your app, such as an identifier, the order status, etc. - - init() { } -} - -struct CreateOrderData: AsyncMigration { - public func prepare(on database: Database) async throws { - try await database.schema(Self.schema) - .id() - .field("order_id", .uuid, .required, .references(Order.schema, .id, onDelete: .cascade)) - .field("merchant_name", .string, .required) - .create() - } - - public func revert(on database: Database) async throws { - try await database.schema(Self.schema).delete() - } -} -``` - -### Order Data Model Middleware - -You'll want to create a model middleware to handle the creation and update of the order data model. -This middleware could be responsible for creating and linking an ``Order`` to the order data model, depending on your requirements. -When your order data changes, it should also update the ``Order/updatedAt`` field of the ``Order`` and send a push notification to all devices registered to that order. - -See for more information on how to send push notifications. - -```swift -import Vapor -import Fluent -import Orders - -struct OrderDataMiddleware: AsyncModelMiddleware { - private unowned let app: Application - - init(app: Application) { - self.app = app - } - - // Create the `Order` and add it to the `OrderData` automatically at creation - func create(model: OrderData, on db: Database, next: AnyAsyncModelResponder) async throws { - let order = Order( - orderTypeIdentifier: "order.com.yoursite.orderType", - authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString()) - try await order.save(on: db) - model.$order.id = try order.requireID() - try await next.create(model, on: db) - } - - func update(model: OrderData, on db: Database, next: AnyAsyncModelResponder) async throws { - let order = try await model.$order.get(on: db) - order.updatedAt = Date() - try await order.save(on: db) - try await next.update(model, on: db) - try await OrdersService.sendPushNotifications(for: order, on: db, app: self.app) - } -} -``` - -Remember to register it in the `configure.swift` file. - -```swift -app.databases.middleware.use(OrderDataMiddleware(app: app), on: .psql) -``` - -> Important: Whenever your order data changes, you must update the ``Order/updatedAt`` time of the linked order so that Apple knows to send you a new order. - -### Handle Cleanup - -Depending on your implementation details, you may want to automatically clean out the orders and devices table when a registration is deleted. -You'll need to implement based on your type of SQL database as there's not yet a Fluent way to implement something like SQL's `NOT EXISTS` call with a `DELETE` statement. - -> Warning: Be careful with SQL triggers, as they can have unintended consequences if not properly implemented. - -### Model the order.json contents - -Create a `struct` that implements ``OrderJSON/Properties`` which will contain all the fields for the generated `order.json` file. -Create an initializer that takes your custom order data, the ``Order`` and everything else you may need. - -> Tip: For information on the various keys available see the [documentation](https://developer.apple.com/documentation/walletorders/order). - -```swift -import Orders - -struct OrderJSONData: OrderJSON.Properties { - let schemaVersion = OrderJSON.SchemaVersion.v1 - let orderTypeIdentifier = Environment.get("PASSKIT_ORDER_TYPE_IDENTIFIER")! - let orderIdentifier: String - let orderType = OrderJSON.OrderType.ecommerce - let orderNumber = "HM090772020864" - let createdAt: String - let updatedAt: String - let status = OrderJSON.OrderStatus.open - let merchant: MerchantData - let orderManagementURL = "https://www.example.com/" - let authenticationToken: String - - private let webServiceURL = "https://example.com/api/orders/" - - struct MerchantData: OrderJSON.Merchant { - let merchantIdentifier = "com.example.pet-store" - let displayName: String - let url = "https://www.example.com/" - let logo = "pet_store_logo.png" - } - - init(data: OrderData, order: Order) { - self.orderIdentifier = order.id!.uuidString - self.authenticationToken = order.authenticationToken - self.merchant = MerchantData(displayName: data.title) - - let dateFormatter = ISO8601DateFormatter() - dateFormatter.formatOptions = .withInternetDateTime - self.createdAt = dateFormatter.string(from: order.createdAt!) - self.updatedAt = dateFormatter.string(from: order.updatedAt!) - } -} -``` - -> Important: You **must** add `api/orders/` to your `webServiceURL`, as shown in the example above. - -### Register Migrations - -If you're using the default schemas provided by this framework, you can register the default models in your `configure(_:)` method: - -```swift -OrdersService.register(migrations: app.migrations) -``` - -> Important: Register the default models before the migration of your order data model. - -### Custom Implementation - -If you don't like the schema names provided by the framework that are used by default, you can instead create your own models conforming to ``OrderModel``, `DeviceModel`, ``OrdersRegistrationModel`` and `ErrorLogModel` and instantiate the generic ``OrdersServiceCustom``, providing it your model types. - -```swift -import PassKit -import Orders - -let ordersService = OrdersServiceCustom(app: app, delegate: delegate) -``` - -> Important: `DeviceModel` and `ErrorLogModel` are defined in the PassKit framework. diff --git a/Sources/Orders/Orders.docc/Orders.md b/Sources/Orders/Orders.docc/Orders.md index 0a57fcb..9311362 100644 --- a/Sources/Orders/Orders.docc/Orders.md +++ b/Sources/Orders/Orders.docc/Orders.md @@ -4,7 +4,7 @@ Create, distribute, and update orders in Apple Wallet with Vapor. ## Overview -The Orders framework provides a set of tools to help you create, build, and distribute orders that users can track and manage in Apple Wallet using a Vapor server. +The Orders framework provides a set of tools to help you create, build, and distribute orders trackable in the Apple Wallet app using a Vapor server. It also provides a way to update orders after they have been distributed, using APNs, and models to store order and device data. For information on Apple Wallet orders, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletorders). @@ -13,8 +13,7 @@ For information on Apple Wallet orders, see the [Apple Developer Documentation]( ### Essentials -- -- +- - ``OrderJSON`` ### Building and Distribution diff --git a/Sources/Orders/OrdersDelegate.swift b/Sources/Orders/OrdersDelegate.swift index 66c8df1..9837a91 100644 --- a/Sources/Orders/OrdersDelegate.swift +++ b/Sources/Orders/OrdersDelegate.swift @@ -18,13 +18,13 @@ public protocol OrdersDelegate: AnyObject, Sendable { /// - `signature` /// /// - Parameters: - /// - for: The order data from the SQL server. + /// - 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(for: O, db: any Database) async throws -> URL + func template(for order: O, db: any Database) async throws -> URL /// Generates the SSL `signature` file. /// @@ -64,11 +64,6 @@ public protocol OrdersDelegate: AnyObject, Sendable { /// /// > 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. /// @@ -109,10 +104,6 @@ public extension OrdersDelegate { 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 diff --git a/Sources/Orders/OrdersError.swift b/Sources/Orders/OrdersError.swift index 62975bf..2e32c0f 100644 --- a/Sources/Orders/OrdersError.swift +++ b/Sources/Orders/OrdersError.swift @@ -13,7 +13,6 @@ public struct OrdersError: Error, Sendable { case templateNotDirectory case pemCertificateMissing case pemPrivateKeyMissing - case zipBinaryMissing case opensslBinaryMissing } @@ -29,8 +28,6 @@ public struct OrdersError: Error, Sendable { public static let pemCertificateMissing = Self(.pemCertificateMissing) /// The `pemPrivateKey` file is missing. public static let pemPrivateKeyMissing = Self(.pemPrivateKeyMissing) - /// The path to the `zip` binary is incorrect. - public static let zipBinaryMissing = Self(.zipBinaryMissing) /// The path to the `openssl` binary is incorrect. public static let opensslBinaryMissing = Self(.opensslBinaryMissing) @@ -66,9 +63,6 @@ public struct OrdersError: Error, Sendable { /// The `pemPrivateKey` file is missing. public static let pemPrivateKeyMissing = Self(errorType: .pemPrivateKeyMissing) - /// The path to the `zip` binary is incorrect. - public static let zipBinaryMissing = Self(errorType: .zipBinaryMissing) - /// The path to the `openssl` binary is incorrect. public static let opensslBinaryMissing = Self(errorType: .opensslBinaryMissing) } diff --git a/Sources/Orders/OrdersService.swift b/Sources/Orders/OrdersService.swift index bf53385..c91e5e8 100644 --- a/Sources/Orders/OrdersService.swift +++ b/Sources/Orders/OrdersService.swift @@ -12,26 +12,15 @@ import FluentKit public final class OrdersService: Sendable { private let service: OrdersServiceCustom - /// Initializes the service. + /// 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. + /// - 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, logger: Logger? = nil) { - service = .init(app: app, delegate: delegate, logger: logger) - } - - /// Registers all the routes required for Wallet orders to work. - public func registerRoutes() { - service.registerRoutes() - } - - /// Registers routes to send push notifications to updated orders. - /// - /// - Parameter middleware: The `Middleware` which will control authentication for the routes. - public func registerPushRoutes(middleware: any Middleware) throws { - try service.registerPushRoutes(middleware: middleware) + public init(app: Application, delegate: any OrdersDelegate, pushRoutesMiddleware: (any Middleware)? = nil, logger: Logger? = nil) throws { + service = try .init(app: app, delegate: delegate, pushRoutesMiddleware: pushRoutesMiddleware, logger: logger) } /// Generates the order content bundle for a given order. @@ -60,9 +49,8 @@ public final class OrdersService: Sendable { /// - id: The `UUID` of the order to send the notifications for. /// - orderTypeIdentifier: The type identifier of the order. /// - db: The `Database` to use. - /// - app: The `Application` to use. - public static func sendPushNotificationsForOrder(id: UUID, of orderTypeIdentifier: String, on db: any Database, app: Application) async throws { - try await OrdersServiceCustom.sendPushNotificationsForOrder(id: id, of: orderTypeIdentifier, on: db, app: app) + public func sendPushNotificationsForOrder(id: UUID, of orderTypeIdentifier: String, on db: any Database) async throws { + try await service.sendPushNotificationsForOrder(id: id, of: orderTypeIdentifier, on: db) } /// Sends push notifications for a given order. @@ -70,18 +58,7 @@ public final class OrdersService: Sendable { /// - Parameters: /// - order: The order to send the notifications for. /// - db: The `Database` to use. - /// - app: The `Application` to use. - public static func sendPushNotifications(for order: Order, on db: any Database, app: Application) async throws { - try await OrdersServiceCustom.sendPushNotifications(for: order, on: db, app: app) - } - - /// Sends push notifications for a given order. - /// - /// - Parameters: - /// - order: The order (as the `ParentProperty`) to send the notifications for. - /// - db: The `Database` to use. - /// - app: The `Application` to use. - public static func sendPushNotifications(for order: ParentProperty, on db: any Database, app: Application) async throws { - try await OrdersServiceCustom.sendPushNotifications(for: order, on: db, app: app) + public func sendPushNotifications(for order: Order, on db: any Database) async throws { + try await service.sendPushNotifications(for: order, on: db) } } diff --git a/Sources/Orders/OrdersServiceCustom.swift b/Sources/Orders/OrdersServiceCustom.swift index fb767bf..b8c7a0c 100644 --- a/Sources/Orders/OrdersServiceCustom.swift +++ b/Sources/Orders/OrdersServiceCustom.swift @@ -5,13 +5,15 @@ // Created by Francesco Paolo Severino on 01/07/24. // -@preconcurrency import Vapor +import Vapor import APNS import VaporAPNS -@preconcurrency import APNSCore +import APNSCore import Fluent import NIOSSL import PassKit +import Zip +@_spi(CMS) import X509 /// Class to handle ``OrdersService``. /// @@ -21,73 +23,42 @@ import PassKit /// - Registration Type /// - Error Log Type public final class OrdersServiceCustom: Sendable where O == R.OrderType, D == R.DeviceType { - /// The ``OrdersDelegate`` to use for order generation. - public unowned let delegate: any OrdersDelegate private unowned let app: Application - - private let v1: any RoutesBuilder + private unowned let delegate: any OrdersDelegate private let logger: Logger? - /// Initializes the service. + /// 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. + /// - 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, logger: Logger? = nil) { + public init( + app: Application, + delegate: any OrdersDelegate, + pushRoutesMiddleware: (any Middleware)? = nil, + logger: Logger? = nil + ) throws { + self.app = app self.delegate = delegate self.logger = logger - self.app = app - - v1 = app.grouped("api", "orders", "v1") - } - - /// Registers all the routes required for Apple Wallet to work. - public func registerRoutes() { - v1.get("devices", ":deviceIdentifier", "registrations", ":orderTypeIdentifier", use: { try await self.ordersForDevice(req: $0) }) - v1.post("log", use: { try await self.logError(req: $0) }) - - let v1auth = v1.grouped(AppleOrderMiddleware()) - - v1auth.post("devices", ":deviceIdentifier", "registrations", ":orderTypeIdentifier", ":orderIdentifier", use: { try await self.registerDevice(req: $0) }) - v1auth.get("orders", ":orderTypeIdentifier", ":orderIdentifier", use: { try await self.latestVersionOfOrder(req: $0) }) - v1auth.delete("devices", ":deviceIdentifier", "registrations", ":orderTypeIdentifier", ":orderIdentifier", use: { try await self.unregisterDevice(req: $0) }) - } - - /// Registers routes to send push notifications for updated orders. - /// - /// ### Example ### - /// ```swift - /// try ordersService.registerPushRoutes(middleware: SecretMiddleware(secret: "foo")) - /// ``` - /// - /// - Parameter middleware: The `Middleware` which will control authentication for the routes. - /// - Throws: An error of type ``OrdersError``. - public func registerPushRoutes(middleware: any Middleware) throws { - let privateKeyPath = URL( - fileURLWithPath: delegate.pemPrivateKey, - relativeTo: delegate.sslSigningFilesDirectory).unixPath() + let privateKeyPath = URL(fileURLWithPath: delegate.pemPrivateKey, relativeTo: delegate.sslSigningFilesDirectory).unixPath() guard FileManager.default.fileExists(atPath: privateKeyPath) else { throw OrdersError.pemPrivateKeyMissing } - - let pemPath = URL( - fileURLWithPath: delegate.pemCertificate, - relativeTo: delegate.sslSigningFilesDirectory).unixPath() - + let pemPath = URL(fileURLWithPath: delegate.pemCertificate, relativeTo: delegate.sslSigningFilesDirectory).unixPath() guard FileManager.default.fileExists(atPath: pemPath) else { throw OrdersError.pemCertificateMissing } - - // Apple Wallet *only* works with the production APNs. You can't pass in `.sandbox` here. let apnsConfig: APNSClientConfiguration - if let pwd = delegate.pemPrivateKeyPassword { + if let password = delegate.pemPrivateKeyPassword { apnsConfig = APNSClientConfiguration( authenticationMethod: try .tls( privateKey: .privateKey( NIOSSLPrivateKey(file: privateKeyPath, format: .pem) { closure in - closure(pwd.utf8) + closure(password.utf8) }), certificateChain: NIOSSLCertificate.fromPEMFile(pemPath).map { .certificate($0) } ), @@ -96,7 +67,7 @@ public final class OrdersServiceCustom()) + v1auth.post("devices", ":deviceIdentifier", "registrations", ":orderTypeIdentifier", ":orderIdentifier", use: { try await self.registerDevice(req: $0) }) + v1auth.get("orders", ":orderTypeIdentifier", ":orderIdentifier", use: { try await self.latestVersionOfOrder(req: $0) }) + v1auth.delete("devices", ":deviceIdentifier", "registrations", ":orderTypeIdentifier", ":orderIdentifier", use: { try await self.unregisterDevice(req: $0) }) - pushAuth.post("push", ":orderTypeIdentifier", ":orderIdentifier", use: { try await self.pushUpdatesForOrder(req: $0) }) - pushAuth.get("push", ":orderTypeIdentifier", ":orderIdentifier", use: { try await self.tokensForOrderUpdate(req: $0) }) + if let pushRoutesMiddleware { + let pushAuth = v1.grouped(pushRoutesMiddleware) + pushAuth.post("push", ":orderTypeIdentifier", ":orderIdentifier", use: { try await self.pushUpdatesForOrder(req: $0) }) + pushAuth.get("push", ":orderTypeIdentifier", ":orderIdentifier", use: { try await self.tokensForOrderUpdate(req: $0) }) + } } } @@ -122,17 +103,9 @@ public final class OrdersServiceCustom Response { logger?.debug("Called latestVersionOfOrder") - - guard FileManager.default.fileExists(atPath: delegate.zipBinary.unixPath()) else { - throw Abort(.internalServerError, suggestedFixes: ["Provide full path to zip command"]) - } - - let dateFormatter = ISO8601DateFormatter() - dateFormatter.formatOptions = .withInternetDateTime - - var ifModifiedSince = Date.distantPast - if let header = req.headers[.ifModifiedSince].first, let ims = dateFormatter.date(from: header) { + var ifModifiedSince: TimeInterval = 0 + if let header = req.headers[.ifModifiedSince].first, let ims = TimeInterval(header) { ifModifiedSince = ims } @@ -140,7 +113,6 @@ extension OrdersServiceCustom { let id = req.parameters.get("orderIdentifier", as: UUID.self) else { throw Abort(.badRequest) } - guard let order = try await O.query(on: req.db) .filter(\._$id == id) .filter(\._$orderTypeIdentifier == orderTypeIdentifier) @@ -149,39 +121,36 @@ extension OrdersServiceCustom { throw Abort(.notFound) } - guard ifModifiedSince < order.updatedAt ?? Date.distantPast else { + guard ifModifiedSince < order.updatedAt?.timeIntervalSince1970 ?? 0 else { throw Abort(.notModified) } - let data = try await self.generateOrderContent(for: order, on: req.db) - let body = Response.Body(data: data) - var headers = HTTPHeaders() headers.add(name: .contentType, value: "application/vnd.apple.order") - headers.add(name: .lastModified, value: dateFormatter.string(from: order.updatedAt ?? Date.distantPast)) + headers.lastModified = HTTPHeaders.LastModified(order.updatedAt ?? Date.distantPast) headers.add(name: .contentTransferEncoding, value: "binary") - - return Response(status: .ok, headers: headers, body: body) + return try await Response( + status: .ok, + headers: headers, + body: Response.Body(data: self.generateOrderContent(for: order, on: req.db)) + ) } func registerDevice(req: Request) async throws -> HTTPStatus { logger?.debug("Called register device") - guard let orderIdentifier = req.parameters.get("orderIdentifier", as: UUID.self) else { - throw Abort(.badRequest) - } - let pushToken: String do { - let content = try req.content.decode(RegistrationDTO.self) - pushToken = content.pushToken + pushToken = try req.content.decode(RegistrationDTO.self).pushToken } catch { throw Abort(.badRequest) } + guard let orderIdentifier = req.parameters.get("orderIdentifier", as: UUID.self) else { + throw Abort(.badRequest) + } let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier")! let deviceIdentifier = req.parameters.get("deviceIdentifier")! - guard let order = try await O.query(on: req.db) .filter(\._$id == orderIdentifier) .filter(\._$orderTypeIdentifier == orderTypeIdentifier) @@ -194,7 +163,6 @@ extension OrdersServiceCustom { .filter(\._$deviceLibraryIdentifier == deviceIdentifier) .filter(\._$pushToken == pushToken) .first() - if let device = device { return try await Self.createRegistration(device: device, order: order, db: req.db) } else { @@ -205,21 +173,15 @@ extension OrdersServiceCustom { } private static func createRegistration(device: D, order: O, db: any Database) async throws -> HTTPStatus { - let r = try await R.for( - deviceLibraryIdentifier: device.deviceLibraryIdentifier, - orderTypeIdentifier: order.orderTypeIdentifier, - on: db - ).filter(O.self, \._$id == order.id!).first() - - if r != nil { - // If the registration already exists, docs say to return a 200 - return .ok - } + let r = try await R.for(deviceLibraryIdentifier: device.deviceLibraryIdentifier, orderTypeIdentifier: order.orderTypeIdentifier, on: db) + .filter(O.self, \._$id == order.requireID()) + .first() + // If the registration already exists, docs say to return 200 OK + if r != nil { return .ok } let registration = R() - registration._$order.id = order.id! - registration._$device.id = device.id! - + registration._$order.id = try order.requireID() + registration._$device.id = try device.requireID() try await registration.create(on: db) return .created } @@ -230,15 +192,9 @@ extension OrdersServiceCustom { let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier")! let deviceIdentifier = req.parameters.get("deviceIdentifier")! - var query = R.for( - deviceLibraryIdentifier: deviceIdentifier, - orderTypeIdentifier: orderTypeIdentifier, - on: req.db) - - if let since: String = req.query["ordersModifiedSince"] { - let dateFormatter = ISO8601DateFormatter() - dateFormatter.formatOptions = .withInternetDateTime - let when = dateFormatter.date(from: since) ?? Date.distantPast + var query = R.for(deviceLibraryIdentifier: deviceIdentifier, orderTypeIdentifier: orderTypeIdentifier, on: req.db) + if let since: TimeInterval = req.query["ordersModifiedSince"] { + let when = Date(timeIntervalSince1970: since) query = query.filter(O.self, \._$updatedAt > when) } @@ -249,11 +205,9 @@ extension OrdersServiceCustom { var orderIdentifiers: [String] = [] var maxDate = Date.distantPast - - registrations.forEach { r in + try registrations.forEach { r in let order = r.order - - orderIdentifiers.append(order.id!.uuidString) + try orderIdentifiers.append(order.requireID().uuidString) if let updatedAt = order.updatedAt, updatedAt > maxDate { maxDate = updatedAt } @@ -266,42 +220,35 @@ extension OrdersServiceCustom { logger?.debug("Called logError") let body: ErrorLogDTO - do { body = try req.content.decode(ErrorLogDTO.self) } catch { throw Abort(.badRequest) } - - guard body.logs.isEmpty == false else { + + guard !body.logs.isEmpty else { throw Abort(.badRequest) } - + try await body.logs.map(E.init(message:)).create(on: req.db) - return .ok } func unregisterDevice(req: Request) async throws -> HTTPStatus { logger?.debug("Called unregisterDevice") - let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier")! - guard let orderIdentifier = req.parameters.get("orderIdentifier", as: UUID.self) else { throw Abort(.badRequest) } - + let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier")! let deviceIdentifier = req.parameters.get("deviceIdentifier")! - guard let r = try await R.for( - deviceLibraryIdentifier: deviceIdentifier, - orderTypeIdentifier: orderTypeIdentifier, - on: req.db - ).filter(O.self, \._$id == orderIdentifier).first() + guard let r = try await R.for(deviceLibraryIdentifier: deviceIdentifier, orderTypeIdentifier: orderTypeIdentifier, on: req.db) + .filter(O.self, \._$id == orderIdentifier) + .first() else { throw Abort(.notFound) } - try await r.delete(on: req.db) return .ok } @@ -313,24 +260,22 @@ extension OrdersServiceCustom { guard let id = req.parameters.get("orderIdentifier", as: UUID.self) else { throw Abort(.badRequest) } - let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier")! - try await Self.sendPushNotificationsForOrder(id: id, of: orderTypeIdentifier, on: req.db, app: req.application) + try await sendPushNotificationsForOrder(id: id, of: orderTypeIdentifier, on: req.db) return .noContent } func tokensForOrderUpdate(req: Request) async throws -> [String] { logger?.debug("Called tokensForOrderUpdate") - + guard let id = req.parameters.get("orderIdentifier", as: UUID.self) else { throw Abort(.badRequest) } - let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier")! - let registrations = try await Self.registrationsForOrder(id: id, of: orderTypeIdentifier, on: req.db) - return registrations.map { $0.device.pushToken } + return try await Self.registrationsForOrder(id: id, of: orderTypeIdentifier, on: req.db) + .map { $0.device.pushToken } } } @@ -342,8 +287,7 @@ extension OrdersServiceCustom { /// - id: The `UUID` of the order to send the notifications for. /// - orderTypeIdentifier: The type identifier of the order. /// - db: The `Database` to use. - /// - app: The `Application` to use. - public static func sendPushNotificationsForOrder(id: UUID, of orderTypeIdentifier: String, on db: any Database, app: Application) async throws { + public func sendPushNotificationsForOrder(id: UUID, of orderTypeIdentifier: String, on db: any Database) async throws { let registrations = try await Self.registrationsForOrder(id: id, of: orderTypeIdentifier, on: db) for reg in registrations { let backgroundNotification = APNSBackgroundNotification( @@ -351,13 +295,11 @@ extension OrdersServiceCustom { topic: reg.order.orderTypeIdentifier, payload: EmptyPayload() ) - do { - try await app.apns.client(.init(string: "orders")) - .sendBackgroundNotification( - backgroundNotification, - deviceToken: reg.device.pushToken - ) + try await app.apns.client(.init(string: "orders")).sendBackgroundNotification( + backgroundNotification, + deviceToken: reg.device.pushToken + ) } catch let error as APNSCore.APNSError where error.reason == .badDeviceToken { try await reg.device.delete(on: db) try await reg.delete(on: db) @@ -370,37 +312,13 @@ extension OrdersServiceCustom { /// - Parameters: /// - order: The order to send the notifications for. /// - db: The `Database` to use. - /// - app: The `Application` to use. - public static func sendPushNotifications(for order: O, on db: any Database, app: Application) async throws { - guard let id = order.id else { - throw FluentError.idRequired - } - - try await Self.sendPushNotificationsForOrder(id: id, of: order.orderTypeIdentifier, on: db, app: app) - } - - /// Sends push notifications for a given order. - /// - /// - Parameters: - /// - order: The order (as the `ParentProperty`) to send the notifications for. - /// - db: The `Database` to use. - /// - app: The `Application` to use. - public static func sendPushNotifications(for order: ParentProperty, on db: any Database, app: Application) async throws { - let value: O - - if let eagerLoaded = order.value { - value = eagerLoaded - } else { - value = try await order.get(on: db) - } - - try await sendPushNotifications(for: value, on: db, app: app) + public func sendPushNotifications(for order: O, on db: any Database) async throws { + try await sendPushNotificationsForOrder(id: order.requireID(), of: order.orderTypeIdentifier, on: db) } - private 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. + 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) .join(parent: \._$order) .join(parent: \._$device) @@ -414,74 +332,76 @@ extension OrdersServiceCustom { // MARK: - order file generation extension OrdersServiceCustom { - private static func generateManifestFile(using encoder: JSONEncoder, in root: URL) throws { + private static func generateManifestFile(using encoder: JSONEncoder, in root: URL) throws -> Data { 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 - } - + 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")) + let data = try encoder.encode(manifest) + try data.write(to: root.appendingPathComponent("manifest.json")) + return data } - 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 + private func generateSignatureFile(for manifest: Data, in root: URL) throws { + // If the caller's delegate generated a file we don't have to do it. + if delegate.generateSignatureFile(in: root) { return } - 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() - } + // 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 + guard FileManager.default.fileExists(atPath: sslBinary.unixPath()) else { + throw OrdersError.opensslBinaryMissing + } - 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 = 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", + "-passin", "pass:\(password)" + ] + try proc.run() + proc.waitUntilExit() + return } - let proc = Process() - proc.currentDirectoryURL = directory - proc.executableURL = zipBinary - - proc.arguments = [ to.unixPath(), "-r", "-q", "." ] - - try proc.run() - proc.waitUntilExit() + let signature = try CMS.sign( + manifest, + signatureAlgorithm: .sha256WithRSAEncryption, + additionalIntermediateCertificates: [ + Certificate( + pemEncoded: String( + contentsOf: delegate.sslSigningFilesDirectory + .appendingPathComponent(delegate.wwdrCertificate) + ) + ) + ], + certificate: Certificate( + pemEncoded: String( + contentsOf: delegate.sslSigningFilesDirectory + .appendingPathComponent(delegate.pemCertificate) + ) + ), + privateKey: .init( + pemEncoded: String( + contentsOf: delegate.sslSigningFilesDirectory + .appendingPathComponent(delegate.pemPrivateKey) + ) + ), + signingTime: Date() + ) + try Data(signature).write(to: root.appendingPathComponent("signature")) } /// Generates the order content bundle for a given order. @@ -491,39 +411,29 @@ 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) + guard (try? templateDirectory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false else { + throw OrdersError.templateNotDirectory + } + let tmp = FileManager.default.temporaryDirectory let root = tmp.appendingPathComponent(UUID().uuidString, isDirectory: true) - let zipFile = tmp.appendingPathComponent("\(UUID().uuidString).zip") + try FileManager.default.copyItem(at: templateDirectory, to: root) + defer { _ = try? FileManager.default.removeItem(at: root) } + let encoder = JSONEncoder() + try await self.delegate.encode(order: order, db: db, encoder: encoder) + .write(to: root.appendingPathComponent("order.json")) - 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 - } + try self.generateSignatureFile( + for: Self.generateManifestFile(using: encoder, in: root), + in: root + ) + + 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)) + return try Data(contentsOf: Zip.quickZipFiles(files, fileName: UUID().uuidString)) } } diff --git a/Sources/PassKit/DTOs/ErrorLogDTO.swift b/Sources/PassKit/DTOs/ErrorLogDTO.swift index 3096031..8c7e6c7 100644 --- a/Sources/PassKit/DTOs/ErrorLogDTO.swift +++ b/Sources/PassKit/DTOs/ErrorLogDTO.swift @@ -30,4 +30,8 @@ import Vapor package struct ErrorLogDTO: Content { package let logs: [String] + + package init(logs: [String]) { + self.logs = logs + } } diff --git a/Sources/PassKit/DTOs/RegistrationDTO.swift b/Sources/PassKit/DTOs/RegistrationDTO.swift index 179ec41..a095521 100644 --- a/Sources/PassKit/DTOs/RegistrationDTO.swift +++ b/Sources/PassKit/DTOs/RegistrationDTO.swift @@ -30,4 +30,8 @@ import Vapor package struct RegistrationDTO: Content { package let pushToken: String + + package init(pushToken: String) { + self.pushToken = pushToken + } } diff --git a/Sources/PassKit/Models/DeviceModel.swift b/Sources/PassKit/Models/DeviceModel.swift index 50f86e1..206a980 100644 --- a/Sources/PassKit/Models/DeviceModel.swift +++ b/Sources/PassKit/Models/DeviceModel.swift @@ -44,15 +44,6 @@ public protocol DeviceModel: Model where IDValue == Int { } package extension DeviceModel { - var _$id: ID { - guard let mirror = Mirror(reflecting: self).descendant("_id"), - let id = mirror as? ID else { - fatalError("id property must be declared using @ID") - } - - return id - } - var _$pushToken: Field { guard let mirror = Mirror(reflecting: self).descendant("_pushToken"), let pushToken = mirror as? Field else { diff --git a/Sources/PassKit/PassKit.docc/PassKit.md b/Sources/PassKit/PassKit.docc/PassKit.md index 17b02b9..6c4f717 100644 --- a/Sources/PassKit/PassKit.docc/PassKit.md +++ b/Sources/PassKit/PassKit.docc/PassKit.md @@ -19,7 +19,7 @@ The PassKit framework provides a set of tools shared by the Passes and Orders fr The Passes framework provides a set of tools to help you create, build, and distribute digital passes for the Apple Wallet app using a Vapor server. It also provides a way to update passes after they have been distributed, using APNs, and models to store pass and device data. -See the framework's [documentation](https://swiftpackageindex.com/vapor-community/PassKit/0.5.0/documentation/passes) for information on how to use it. +See the framework's [documentation](https://swiftpackageindex.com/vapor-community/PassKit/documentation/passes) for information and guides on how to use it. For information on Apple Wallet passes, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletpasses). @@ -28,6 +28,6 @@ For information on Apple Wallet passes, see the [Apple Developer Documentation]( The Orders framework provides a set of tools to help you create, build, and distribute orders that users can track and manage in Apple Wallet using a Vapor server. It also provides a way to update orders after they have been distributed, using APNs, and models to store order and device data. -See the framework's [documentation](https://swiftpackageindex.com/vapor-community/PassKit/0.5.0/documentation/orders) for information on how to use it. +See the framework's [documentation](https://swiftpackageindex.com/vapor-community/PassKit/documentation/orders) for information and guides on how to use it. For information on Apple Wallet orders, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletorders). diff --git a/Sources/Passes/DTOs/PassJSON.swift b/Sources/Passes/DTOs/PassJSON.swift index d585176..df11686 100644 --- a/Sources/Passes/DTOs/PassJSON.swift +++ b/Sources/Passes/DTOs/PassJSON.swift @@ -86,6 +86,37 @@ extension PassJSON { } } +extension PassJSON { + /// A protocol that represents a location that the system uses to show a relevant pass. + /// + /// > Tip: See the [`Pass.Locations`](https://developer.apple.com/documentation/walletpasses/pass/locations) object to understand the keys. + public protocol Locations: Encodable { + /// The latitude, in degrees, of the location. + var latitude: Double { get } + + /// (Required) + var longitude: Double { get } + } +} + +extension PassJSON { + /// An object that represents the near-field communication (NFC) payload the device passes to an Apple Pay terminal. + /// + /// > Tip: See the [`Pass.NFC`](https://developer.apple.com/documentation/walletpasses/pass/nfc) object to understand the keys. + public protocol NFC: Encodable { + /// The payload the device transmits to the Apple Pay terminal. + /// + /// The size must be no more than 64 bytes. + /// The system truncates messages longer than 64 bytes. + var message: String { get } + + /// The public encryption key the Value Added Services protocol uses. + /// + /// Use a Base64-encoded X.509 SubjectPublicKeyInfo structure that contains an ECDH public key for group P256. + var encryptionPublicKey: String { get } + } +} + extension PassJSON { /// The version of the file format. public enum FormatVersion: Int, Encodable { diff --git a/Sources/Passes/Models/Concrete Models/PKPass.swift b/Sources/Passes/Models/Concrete Models/Pass.swift similarity index 61% rename from Sources/Passes/Models/Concrete Models/PKPass.swift rename to Sources/Passes/Models/Concrete Models/Pass.swift index 12f035b..8382d65 100644 --- a/Sources/Passes/Models/Concrete Models/PKPass.swift +++ b/Sources/Passes/Models/Concrete Models/Pass.swift @@ -1,5 +1,5 @@ // -// PKPass.swift +// Pass.swift // PassKit // // Created by Francesco Paolo Severino on 29/06/24. @@ -11,9 +11,11 @@ import FluentKit /// The `Model` that stores PassKit passes. /// /// Uses a UUID so people can't easily guess pass serial numbers. -open class PKPass: PassModel, @unchecked Sendable { +final public class Pass: PassModel, @unchecked Sendable { + public typealias UserPersonalizationType = UserPersonalization + /// The schema name of the pass model. - public static let schema = PKPass.FieldKeys.schemaName + public static let schema = Pass.FieldKeys.schemaName /// The pass alphanumeric serial number. /// @@ -23,16 +25,20 @@ open class PKPass: PassModel, @unchecked Sendable { public var id: UUID? /// The last time the pass was modified. - @Timestamp(key: PKPass.FieldKeys.updatedAt, on: .update) + @Timestamp(key: Pass.FieldKeys.updatedAt, on: .update) public var updatedAt: Date? /// The pass type identifier that’s registered with Apple. - @Field(key: PKPass.FieldKeys.passTypeIdentifier) + @Field(key: Pass.FieldKeys.passTypeIdentifier) public var passTypeIdentifier: String /// The authentication token to use with the web service in the `webServiceURL` key. - @Field(key: PKPass.FieldKeys.authenticationToken) + @Field(key: Pass.FieldKeys.authenticationToken) public var authenticationToken: String + + /// The user personalization info. + @OptionalParent(key: Pass.FieldKeys.userPersonalizationID) + public var userPersonalization: UserPersonalizationType? public required init() { } @@ -42,13 +48,15 @@ open class PKPass: PassModel, @unchecked Sendable { } } -extension PKPass: AsyncMigration { +extension Pass: AsyncMigration { public func prepare(on database: any Database) async throws { try await database.schema(Self.schema) .id() - .field(PKPass.FieldKeys.updatedAt, .datetime, .required) - .field(PKPass.FieldKeys.passTypeIdentifier, .string, .required) - .field(PKPass.FieldKeys.authenticationToken, .string, .required) + .field(Pass.FieldKeys.updatedAt, .datetime, .required) + .field(Pass.FieldKeys.passTypeIdentifier, .string, .required) + .field(Pass.FieldKeys.authenticationToken, .string, .required) + .field(Pass.FieldKeys.userPersonalizationID, .int, .references(UserPersonalizationType.schema, .id)) + .unique(on: Pass.FieldKeys.userPersonalizationID) .create() } @@ -57,11 +65,12 @@ extension PKPass: AsyncMigration { } } -extension PKPass { +extension Pass { enum FieldKeys { static let schemaName = "passes" static let updatedAt = FieldKey(stringLiteral: "updated_at") static let passTypeIdentifier = FieldKey(stringLiteral: "pass_type_identifier") static let authenticationToken = FieldKey(stringLiteral: "authentication_token") + static let userPersonalizationID = FieldKey(stringLiteral: "user_personalization_id") } } diff --git a/Sources/Passes/Models/Concrete Models/PassesRegistration.swift b/Sources/Passes/Models/Concrete Models/PassesRegistration.swift index 9c71284..d071184 100644 --- a/Sources/Passes/Models/Concrete Models/PassesRegistration.swift +++ b/Sources/Passes/Models/Concrete Models/PassesRegistration.swift @@ -9,7 +9,7 @@ import FluentKit /// The `Model` that stores passes registrations. final public class PassesRegistration: PassesRegistrationModel, @unchecked Sendable { - public typealias PassType = PKPass + public typealias PassType = Pass public typealias DeviceType = PassesDevice /// The schema name of the passes registration model. diff --git a/Sources/Passes/Models/Concrete Models/UserPersonalization.swift b/Sources/Passes/Models/Concrete Models/UserPersonalization.swift new file mode 100644 index 0000000..153ea4f --- /dev/null +++ b/Sources/Passes/Models/Concrete Models/UserPersonalization.swift @@ -0,0 +1,86 @@ +// +// UserPersonalization.swift +// PassKit +// +// Created by Francesco Paolo Severino on 12/07/24. +// + +import FluentKit + +/// The `Model` that stores user personalization info. +final public class UserPersonalization: UserPersonalizationModel, @unchecked Sendable { + /// The schema name of the user personalization model. + public static let schema = UserPersonalization.FieldKeys.schemaName + + @ID(custom: .id) + public var id: Int? + + /// The user’s full name, as entered by the user. + @OptionalField(key: UserPersonalization.FieldKeys.fullName) + public var fullName: String? + + /// The user’s given name, parsed from the full name. + /// + /// This is the name bestowed upon an individual to differentiate them from other members of a group that share a family name (for example, “John”). + /// In some locales, this is also known as a first name or forename. + @OptionalField(key: UserPersonalization.FieldKeys.givenName) + public var givenName: String? + + /// The user’s family name, parsed from the full name. + /// + /// This is the name bestowed upon an individual to denote membership in a group or family (for example, “Appleseed”). + @OptionalField(key: UserPersonalization.FieldKeys.familyName) + public var familyName: String? + + /// The email address, as entered by the user. + @OptionalField(key: UserPersonalization.FieldKeys.emailAddress) + public var emailAddress: String? + + /// The postal code, as entered by the user. + @OptionalField(key: UserPersonalization.FieldKeys.postalCode) + public var postalCode: String? + + /// The user’s ISO country code. + /// + /// This key is only included when the system can deduce the country code. + @OptionalField(key: UserPersonalization.FieldKeys.ISOCountryCode) + public var ISOCountryCode: String? + + /// The phone number, as entered by the user. + @OptionalField(key: UserPersonalization.FieldKeys.phoneNumber) + public var phoneNumber: String? + + public init() {} +} + +extension UserPersonalization: AsyncMigration { + public func prepare(on database: any Database) async throws { + try await database.schema(Self.schema) + .field(.id, .int, .identifier(auto: true)) + .field(UserPersonalization.FieldKeys.fullName, .string) + .field(UserPersonalization.FieldKeys.givenName, .string) + .field(UserPersonalization.FieldKeys.familyName, .string) + .field(UserPersonalization.FieldKeys.emailAddress, .string) + .field(UserPersonalization.FieldKeys.postalCode, .string) + .field(UserPersonalization.FieldKeys.ISOCountryCode, .string) + .field(UserPersonalization.FieldKeys.phoneNumber, .string) + .create() + } + + public func revert(on database: any Database) async throws { + try await database.schema(Self.schema).delete() + } +} + +extension UserPersonalization { + enum FieldKeys { + static let schemaName = "user_personalization_info" + static let fullName = FieldKey(stringLiteral: "full_name") + static let givenName = FieldKey(stringLiteral: "given_name") + static let familyName = FieldKey(stringLiteral: "family_name") + static let emailAddress = FieldKey(stringLiteral: "email_address") + static let postalCode = FieldKey(stringLiteral: "postal_code") + static let ISOCountryCode = FieldKey(stringLiteral: "iso_country_code") + static let phoneNumber = FieldKey(stringLiteral: "phone_number") + } +} \ No newline at end of file diff --git a/Sources/Passes/Models/PassModel.swift b/Sources/Passes/Models/PassModel.swift index 1dfbdc1..4af4f78 100644 --- a/Sources/Passes/Models/PassModel.swift +++ b/Sources/Passes/Models/PassModel.swift @@ -33,6 +33,8 @@ import FluentKit /// /// Uses a UUID so people can't easily guess pass serial numbers. public protocol PassModel: Model where IDValue == UUID { + associatedtype UserPersonalizationType: UserPersonalizationModel + /// The pass type identifier that’s registered with Apple. var passTypeIdentifier: String { get set } @@ -41,6 +43,15 @@ public protocol PassModel: Model where IDValue == UUID { /// The authentication token to use with the web service in the `webServiceURL` key. var authenticationToken: String { get set } + + /// The user personalization info. + var userPersonalization: UserPersonalizationType? { get set } + + /// The designated initializer. + /// - Parameters: + /// - passTypeIdentifier: The pass type identifier that’s registered with Apple. + /// - authenticationToken: The authentication token to use with the web service in the `webServiceURL` key. + init(passTypeIdentifier: String, authenticationToken: String) } internal extension PassModel { @@ -79,4 +90,13 @@ internal extension PassModel { return authenticationToken } + + var _$userPersonalization: OptionalParent { + guard let mirror = Mirror(reflecting: self).descendant("_userPersonalization"), + let userPersonalization = mirror as? OptionalParent else { + fatalError("userPersonalization property must be declared using @OptionalParent") + } + + return userPersonalization + } } diff --git a/Sources/Passes/Models/UserPersonalizationModel.swift b/Sources/Passes/Models/UserPersonalizationModel.swift new file mode 100644 index 0000000..83dc79d --- /dev/null +++ b/Sources/Passes/Models/UserPersonalizationModel.swift @@ -0,0 +1,113 @@ +// +// UserPersonalizationModel.swift +// PassKit +// +// Created by Francesco Paolo Severino on 12/07/24. +// + +import FluentKit + +/// Represents the `Model` that stores user personalization info. +public protocol UserPersonalizationModel: Model where IDValue == Int { + /// The user’s full name, as entered by the user. + var fullName: String? { get set } + + /// The user’s given name, parsed from the full name. + /// + /// This is the name bestowed upon an individual to differentiate them from other members of a group that share a family name (for example, “John”). + /// In some locales, this is also known as a first name or forename. + var givenName: String? { get set } + + /// The user’s family name, parsed from the full name. + /// + /// This is the name bestowed upon an individual to denote membership in a group or family (for example, “Appleseed”). + var familyName: String? { get set } + + /// The email address, as entered by the user. + var emailAddress: String? { get set } + + /// The postal code, as entered by the user. + var postalCode: String? { get set } + + /// The user’s ISO country code. + /// + /// This key is only included when the system can deduce the country code. + var ISOCountryCode: String? { get set } + + /// The phone number, as entered by the user. + var phoneNumber: String? { get set } +} + +internal extension UserPersonalizationModel { + var _$id: ID { + guard let mirror = Mirror(reflecting: self).descendant("_id"), + let id = mirror as? ID else { + fatalError("id property must be declared using @ID") + } + + return id + } + + var _$fullName: OptionalField { + guard let mirror = Mirror(reflecting: self).descendant("_fullName"), + let fullName = mirror as? OptionalField else { + fatalError("fullName property must be declared using @OptionalField") + } + + return fullName + } + + var _$givenName: OptionalField { + guard let mirror = Mirror(reflecting: self).descendant("_givenName"), + let givenName = mirror as? OptionalField else { + fatalError("givenName property must be declared using @OptionalField") + } + + return givenName + } + + var _$familyName: OptionalField { + guard let mirror = Mirror(reflecting: self).descendant("_familyName"), + let familyName = mirror as? OptionalField else { + fatalError("familyName property must be declared using @OptionalField") + } + + return familyName + } + + var _$emailAddress: OptionalField { + guard let mirror = Mirror(reflecting: self).descendant("_emailAddress"), + let emailAddress = mirror as? OptionalField else { + fatalError("emailAddress property must be declared using @OptionalField") + } + + return emailAddress + } + + var _$postalCode: OptionalField { + guard let mirror = Mirror(reflecting: self).descendant("_postalCode"), + let postalCode = mirror as? OptionalField else { + fatalError("postalCode property must be declared using @OptionalField") + } + + return postalCode + } + + var _$ISOCountryCode: OptionalField { + guard let mirror = Mirror(reflecting: self).descendant("_ISOCountryCode"), + let ISOCountryCode = mirror as? OptionalField else { + fatalError("ISOCountryCode property must be declared using @OptionalField") + } + + return ISOCountryCode + } + + var _$phoneNumber: OptionalField { + guard let mirror = Mirror(reflecting: self).descendant("_phoneNumber"), + let phoneNumber = mirror as? OptionalField else { + fatalError("phoneNumber property must be declared using @OptionalField") + } + + return phoneNumber + } +} \ No newline at end of file diff --git a/Sources/Passes/Passes.docc/DistributeUpdate.md b/Sources/Passes/Passes.docc/DistributeUpdate.md deleted file mode 100644 index 39d5e2a..0000000 --- a/Sources/Passes/Passes.docc/DistributeUpdate.md +++ /dev/null @@ -1,208 +0,0 @@ -# Building, Distributing and Updating a Pass - -Build a distributable pass and distribute it to your users or update an existing pass. - -## Overview - -The pass you distribute to a user is a signed bundle that contains the JSON description of the pass, images, and optional localizations. -The Passes framework provides the ``PassesService`` class that handles the creation of the pass JSON file and the signing of the pass bundle, using a ``PassesDelegate`` that you must implement. -The ``PassesService`` class also provides methods to send push notifications to all devices registered to a pass when it's updated and all the routes that Apple Wallet expects to get and update passes. - -### Implement the Delegate - -Create a delegate file that implements ``PassesDelegate``. -In the ``PassesDelegate/sslSigningFilesDirectory`` you specify there must be the `WWDR.pem`, `passcertificate.pem` and `passkey.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). - -There are other fields available which have reasonable default values. See ``PassesDelegate``'s documentation. - -Because the files for your pass' template and the method of encoding might vary by pass type, you'll be provided the ``PKPass`` for those methods. -In the ``PassesDelegate/encode(pass:db:encoder:)`` method, you'll want to encode a `struct` that conforms to ``PassJSON``. - -```swift -import Vapor -import Fluent -import Passes - -final class PassDelegate: PassesDelegate { - let sslSigningFilesDirectory = URL(fileURLWithPath: "Certificates/Passes/", isDirectory: true) - - let pemPrivateKeyPassword: String? = Environment.get("PEM_PRIVATE_KEY_PASSWORD")! - - func encode(pass: P, db: Database, encoder: JSONEncoder) async throws -> Data { - // The specific PassData class you use here may vary based on the `pass.passTypeIdentifier` - // if you have multiple different types of passes, and thus multiple types of pass data. - guard let passData = try await PassData.query(on: db) - .filter(\.$pass.$id == pass.id!) - .first() - else { - throw Abort(.internalServerError) - } - guard let data = try? encoder.encode(PassJSONData(data: passData, pass: pass)) else { - throw Abort(.internalServerError) - } - return data - } - - func template(for: P, db: Database) async throws -> URL { - // The location might vary depending on the type of pass. - return URL(fileURLWithPath: "Templates/Passes/", isDirectory: true) - } -} -``` - -> Important: You **must** explicitly declare ``PassesDelegate/pemPrivateKeyPassword`` as a `String?` or Swift will ignore it as it'll think it's a `String` instead. - -### Register the Routes - -Next, register the routes in `routes.swift`. -This will implement all of the routes that Apple Wallet expects to exist on your server for you. - -```swift -import Vapor -import Passes - -let passDelegate = PassDelegate() - -func routes(_ app: Application) throws { - let passesService = PassesService(app: app, delegate: passDelegate) - passesService.registerRoutes() -} -``` - -> Note: Notice how the ``PassesDelegate`` is created as a global variable. You need to ensure that the delegate doesn't go out of scope as soon as the `routes(_:)` method exits. - -### Push Notifications - -If you wish to include routes specifically for sending push notifications to updated passes you can also include this line in your `routes(_:)` method. You'll need to pass in whatever `Middleware` you want Vapor to use to authenticate the two routes. - -> Important: If you don't include this line, you have to configure an APNS container yourself. - -```swift -try passesService.registerPushRoutes(middleware: SecretMiddleware(secret: "foo")) -``` - -That will add two routes, the first one sends notifications and the second one retrieves a list of push tokens which would be sent a notification. - -```http -POST https://example.com/api/passes/v1/push/{passTypeIdentifier}/{passSerial} HTTP/2 -``` - -```http -GET https://example.com/api/passes/v1/push/{passTypeIdentifier}/{passSerial} HTTP/2 -``` - -### Pass Data Model Middleware - -Whether you include the routes or not, you'll want to add a model middleware that sends push notifications and updates the ``PKPass/updatedAt`` field when your pass data updates. The model middleware could also create and link the ``PKPass`` during the creation of the pass data, depending on your requirements. - -See for more information. - -### Apple Push Notification service - -If you did not include the push notification routes, remember to configure APNs yourself. - -> Important: PassKit *only* works with the APNs production environment. You can't pass in the `.sandbox` environment. - -```swift -let apnsConfig: APNSClientConfiguration -if let pemPrivateKeyPassword { - apnsConfig = APNSClientConfiguration( - authenticationMethod: try .tls( - privateKey: .privateKey( - NIOSSLPrivateKey(file: privateKeyPath, format: .pem) { closure in - closure(pemPrivateKeyPassword.utf8) - }), - certificateChain: NIOSSLCertificate.fromPEMFile(pemPath).map { .certificate($0) } - ), - environment: .production - ) -} else { - apnsConfig = APNSClientConfiguration( - authenticationMethod: try .tls( - privateKey: .file(privateKeyPath), - certificateChain: NIOSSLCertificate.fromPEMFile(pemPath).map { .certificate($0) } - ), - environment: .production - ) -} -app.apns.containers.use( - apnsConfig, - eventLoopGroupProvider: .shared(app.eventLoopGroup), - responseDecoder: JSONDecoder(), - requestEncoder: JSONEncoder(), - as: .init(string: "passes"), - isDefault: false -) -``` - -### Generate the Pass Content - -To generate and distribute the `.pkpass` bundle, pass the ``PassesService`` object to your `RouteCollection`. - -```swift -import Fluent -import Vapor -import Passes - -struct PassesController: RouteCollection { - let passesService: PassesService - - func boot(routes: RoutesBuilder) throws { - ... - } -} -``` - -Then use the object inside your route handlers to generate the pass bundle with the ``PassesService/generatePassContent(for:on:)`` method and distribute it with the "`application/vnd.apple.pkpass`" MIME type. - -```swift -fileprivate func passHandler(_ req: Request) async throws -> Response { - ... - guard let passData = try await PassData.query(on: req.db) - .filter(...) - .with(\.$pass) - .first() - else { - throw Abort(.notFound) - } - - let bundle = try await passesService.generatePassContent(for: passData.pass, on: req.db) - let body = Response.Body(data: bundle) - var headers = HTTPHeaders() - headers.add(name: .contentType, value: "application/vnd.apple.pkpass") - headers.add(name: .contentDisposition, value: "attachment; filename=name.pkpass") // Add this header only if you are serving the pass in a web page - headers.add(name: .lastModified, value: String(passData.pass.updatedAt?.timeIntervalSince1970 ?? 0)) - headers.add(name: .contentTransferEncoding, value: "binary") - return Response(status: .ok, headers: headers, body: body) -} -``` - -### Create a Bundle of Passes - -You can also create a bundle of passes to enable your user to download multiple passes at once. -Use the ``PassesService/generatePassesContent(for:on:)`` method to generate the bundle and serve it to the user. -The MIME type for a bundle of passes is "`application/vnd.apple.pkpasses`". - -> Note: You can have up to 10 passes or 150 MB for a bundle of passes. - -> Important: Bundles of passes are supported only in Safari. You can't send the bundle via AirDrop or other methods. - -```swift -fileprivate func passesHandler(_ req: Request) async throws -> Response { - ... - let passesData = try await PassData.query(on: req.db).with(\.$pass).all() - let passes = passesData.map { $0.pass } - - let bundle = try await passesService.generatePassesContent(for: passes, on: req.db) - let body = Response.Body(data: bundle) - var headers = HTTPHeaders() - headers.add(name: .contentType, value: "application/vnd.apple.pkpasses") - headers.add(name: .contentDisposition, value: "attachment; filename=name.pkpasses") - headers.add(name: .lastModified, value: String(Date().timeIntervalSince1970)) - headers.add(name: .contentTransferEncoding, value: "binary") - return Response(status: .ok, headers: headers, body: body) -} -``` diff --git a/Sources/Passes/Passes.docc/Extensions/PassJSON.md b/Sources/Passes/Passes.docc/Extensions/PassJSON.md index 0a46f38..3788249 100644 --- a/Sources/Passes/Passes.docc/Extensions/PassJSON.md +++ b/Sources/Passes/Passes.docc/Extensions/PassJSON.md @@ -8,12 +8,17 @@ - ``FormatVersion`` - ``PassFieldContent`` -### Barcodes +### Barcodes and NFC - ``Barcodes`` - ``BarcodeFormat`` +- ``NFC`` ### Boarding Passes - ``BoardingPass`` -- ``TransitType`` \ No newline at end of file +- ``TransitType`` + +### Miscellaneous + +- ``Locations`` \ No newline at end of file diff --git a/Sources/Passes/Passes.docc/Extensions/PassesService.md b/Sources/Passes/Passes.docc/Extensions/PassesService.md index 86d11d5..8fe801a 100644 --- a/Sources/Passes/Passes.docc/Extensions/PassesService.md +++ b/Sources/Passes/Passes.docc/Extensions/PassesService.md @@ -7,11 +7,8 @@ - ``generatePassContent(for:on:)`` - ``generatePassesContent(for:on:)`` - ``register(migrations:)`` -- ``registerRoutes()`` ### Push Notifications -- ``registerPushRoutes(middleware:)`` -- ``sendPushNotifications(for:on:app:)-2em82`` -- ``sendPushNotifications(for:on:app:)-487hq`` -- ``sendPushNotificationsForPass(id:of:on:app:)`` +- ``sendPushNotifications(for:on:)`` +- ``sendPushNotificationsForPass(id:of:on:)`` diff --git a/Sources/Passes/Passes.docc/GettingStarted.md b/Sources/Passes/Passes.docc/GettingStarted.md new file mode 100644 index 0000000..fba044d --- /dev/null +++ b/Sources/Passes/Passes.docc/GettingStarted.md @@ -0,0 +1,355 @@ +# Getting Started with Passes + +Create the pass data model, build a pass for Apple Wallet and distribute it with a Vapor server. + +## Overview + +The Passes framework provides models to save all the basic information for passes, user devices and their registration to each pass. +For all the other custom data needed to generate the pass, such as the barcodes, locations, etc., you have to create your own model and its model middleware to handle the creation and update of passes. +The pass data model will be used to generate the `pass.json` file contents. + +The pass you distribute to a user is a signed bundle that contains the `pass.json` file, images and optional localizations. +The Passes framework provides the ``PassesService`` class that handles the creation of the pass JSON file and the signing of the pass bundle, using a ``PassesDelegate`` that you must implement. +The ``PassesService`` class also provides methods to send push notifications to all devices registered when you update a pass, and all the routes that Apple Wallet uses to retrieve passes. + +### Implement the Pass Data Model + +Your data model should contain all the fields that you store for your pass, as well as a foreign key to ``Pass``, the pass model offered by the Passes framework. + +```swift +import Fluent +import struct Foundation.UUID +import Passes + +final class PassData: PassDataModel, @unchecked Sendable { + static let schema = "pass_data" + + @ID + var id: UUID? + + @Parent(key: "pass_id") + var pass: Pass + + // Examples of other extra fields: + @Field(key: "punches") + var punches: Int + + @Field(key: "title") + var title: String + + // Add any other field relative to your app, such as a location, a date, etc. + + init() { } +} + +struct CreatePassData: AsyncMigration { + public func prepare(on database: Database) async throws { + try await database.schema(PassData.schema) + .id() + .field("pass_id", .uuid, .required, .references(Pass.schema, .id, onDelete: .cascade)) + .field("punches", .int, .required) + .field("title", .string, .required) + .create() + } + + public func revert(on database: Database) async throws { + try await database.schema(PassData.schema).delete() + } +} +``` + +### Handle Cleanup + +Depending on your implementation details, you may want to automatically clean out the passes and devices table when a registration is deleted. +The implementation will be based on your type of SQL database, as there's not yet a Fluent way to implement something like SQL's `NOT EXISTS` call with a `DELETE` statement. + +> Warning: Be careful with SQL triggers, as they can have unintended consequences if not properly implemented. + +### Model the pass.json contents + +Create a `struct` that implements ``PassJSON/Properties`` which will contain all the fields for the generated `pass.json` file. +Create an initializer that takes your custom pass data, the ``Pass`` and everything else you may need. + +> Tip: For information on the various keys available see the [documentation](https://developer.apple.com/documentation/walletpasses/pass). See also [this guide](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/index.html#//apple_ref/doc/uid/TP40012195-CH1-SW1) for some help. + +```swift +import Passes + +struct PassJSONData: PassJSON.Properties { + let description: String + let formatVersion = PassJSON.FormatVersion.v1 + let organizationName = "vapor-community" + let passTypeIdentifier = Environment.get("PASS_TYPE_IDENTIFIER")! + let serialNumber: String + let teamIdentifier = Environment.get("APPLE_TEAM_IDENTIFIER")! + + private let webServiceURL = "https://example.com/api/passes/" + private let authenticationToken: String + private let logoText = "Vapor" + private let sharingProhibited = true + let backgroundColor = "rgb(207, 77, 243)" + let foregroundColor = "rgb(255, 255, 255)" + + let barcodes = Barcode(message: "test") + struct Barcode: PassJSON.Barcodes { + let format = PassJSON.BarcodeFormat.qr + let message: String + let messageEncoding = "iso-8859-1" + } + + let boardingPass = Boarding(transitType: .air) + struct Boarding: PassJSON.BoardingPass { + let transitType: PassJSON.TransitType + let headerFields: [PassField] + let primaryFields: [PassField] + let secondaryFields: [PassField] + let auxiliaryFields: [PassField] + let backFields: [PassField] + + struct PassField: PassJSON.PassFieldContent { + let key: String + let label: String + let value: String + } + + init(transitType: PassJSON.TransitType) { + self.headerFields = [.init(key: "header", label: "Header", value: "Header")] + self.primaryFields = [.init(key: "primary", label: "Primary", value: "Primary")] + self.secondaryFields = [.init(key: "secondary", label: "Secondary", value: "Secondary")] + self.auxiliaryFields = [.init(key: "auxiliary", label: "Auxiliary", value: "Auxiliary")] + self.backFields = [.init(key: "back", label: "Back", value: "Back")] + self.transitType = transitType + } + } + + init(data: PassData, pass: Pass) { + self.description = data.title + self.serialNumber = pass.id!.uuidString + self.authenticationToken = pass.authenticationToken + } +} +``` + +> Important: You **must** add `api/passes/` to your `webServiceURL`, as shown in the example above. + +### Implement the Delegate + +Create a delegate class that implements ``PassesDelegate``. +In the ``PassesDelegate/sslSigningFilesDirectory`` you specify there must be the `WWDR.pem`, `passcertificate.pem` and `passkey.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). + +There are other fields available which have reasonable default values. See ``PassesDelegate``'s documentation. + +Because the files for your pass' template and the method of encoding might vary by pass type, you'll be provided the ``Pass`` for those methods. +In the ``PassesDelegate/encode(pass:db:encoder:)`` method, you'll want to encode a `struct` that conforms to ``PassJSON``. + +```swift +import Vapor +import Fluent +import Passes + +final class PassDelegate: PassesDelegate { + let sslSigningFilesDirectory = URL(fileURLWithPath: "Certificates/Passes/", isDirectory: true) + + let pemPrivateKeyPassword: String? = Environment.get("PASSES_PEM_PRIVATE_KEY_PASSWORD")! + + func encode(pass: P, db: Database, encoder: JSONEncoder) async throws -> Data { + // The specific PassData class you use here may vary based on the `pass.passTypeIdentifier` + // if you have multiple different types of passes, and thus multiple types of pass data. + guard let passData = try await PassData.query(on: db) + .filter(\.$pass.$id == pass.requireID()) + .first() + else { + throw Abort(.internalServerError) + } + guard let data = try? encoder.encode(PassJSONData(data: passData, pass: pass)) else { + throw Abort(.internalServerError) + } + return data + } + + func template(for pass: P, db: Database) async throws -> URL { + // The location might vary depending on the type of pass. + URL(fileURLWithPath: "Templates/Passes/", isDirectory: true) + } +} +``` + +> Important: If you have an encrypted PEM private key, you **must** explicitly declare ``PassesDelegate/pemPrivateKeyPassword`` as a `String?` or Swift will ignore it as it'll think it's a `String` instead. + +### Initialize the Service + +Next, initialize the ``PassesService`` inside the `configure.swift` file. +This will implement all of the routes that Apple Wallet expects to exist on your server. + +```swift +import Fluent +import Vapor +import Passes + +let passDelegate = PassDelegate() + +public func configure(_ app: Application) async throws { + ... + let passesService = try PassesService(app: app, delegate: passDelegate) +} +``` + +> Note: Notice how the ``PassesDelegate`` is created as a global variable. You need to ensure that the delegate doesn't go out of scope as soon as the `configure(_:)` method exits. + +If you wish to include routes specifically for sending push notifications to updated passes, you can also pass to the ``PassesService`` initializer whatever `Middleware` you want Vapor to use to authenticate the two routes. Doing so will add two routes, the first one sends notifications and the second one retrieves a list of push tokens which would be sent a notification. + +```http +POST https://example.com/api/passes/v1/push/{passTypeIdentifier}/{passSerial} HTTP/2 +``` + +```http +GET https://example.com/api/passes/v1/push/{passTypeIdentifier}/{passSerial} HTTP/2 +``` + +### Custom Implementation of PassesService + +If you don't like the schema names provided by default, you can create your own models conforming to ``PassModel``, ``UserPersonalizationModel``, `DeviceModel`, ``PassesRegistrationModel`` and `ErrorLogModel` and instantiate the generic ``PassesServiceCustom``, providing it your model types. + +```swift +import Fluent +import Vapor +import PassKit +import Passes + +let passDelegate = PassDelegate() + +public func configure(_ app: Application) async throws { + ... + let passesService = try PassesServiceCustom(app: app, delegate: passDelegate) +} +``` + +### Register Migrations + +If you're using the default schemas provided by this framework, you can register the default models in your `configure(_:)` method: + +```swift +PassesService.register(migrations: app.migrations) +``` + +> Important: Register the default models before the migration of your pass data model. + +### Pass Data Model Middleware + +You'll want to create a model middleware to handle the creation and update of the pass data model. +This middleware could be responsible for creating and linking a ``Pass`` to the pass data model, depending on your requirements. +When your pass data changes, it should also update the ``Pass/updatedAt`` field of the ``Pass`` and send a push notification to all devices registered to that pass. + +```swift +import Vapor +import Fluent +import Passes + +struct PassDataMiddleware: AsyncModelMiddleware { + private unowned let service: PassesService + + init(service: PassesService) { + self.service = service + } + + // Create the `Pass` and add it to the `PassData` automatically at creation + func create(model: PassData, on db: Database, next: AnyAsyncModelResponder) async throws { + let pass = Pass( + passTypeIdentifier: Environment.get("PASS_TYPE_IDENTIFIER")!, + authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString()) + try await pass.save(on: db) + model.$pass.id = try pass.requireID() + try await next.create(model, on: db) + } + + func update(model: PassData, on db: Database, next: AnyAsyncModelResponder) async throws { + let pass = try await model.$pass.get(on: db) + pass.updatedAt = Date() + try await pass.save(on: db) + try await next.update(model, on: db) + try await service.sendPushNotifications(for: pass, on: db) + } +} +``` + +You could register it in the `configure.swift` file. + +```swift +app.databases.middleware.use(PassDataMiddleware(service: passesService), on: .psql) +``` + +> Important: Whenever your pass data changes, you must update the ``Pass/updatedAt`` time of the linked ``Pass`` so that Wallet knows to retrieve a new pass. + +### Generate the Pass Content + +To generate and distribute the `.pkpass` bundle, pass the ``PassesService`` object to your `RouteCollection`. + +```swift +import Fluent +import Vapor +import Passes + +struct PassesController: RouteCollection { + let passesService: PassesService + + func boot(routes: RoutesBuilder) throws { + ... + } +} +``` + +> Note: You'll have to register the `PassesController` in the `configure.swift` file, in order to pass it the ``PassesService`` object. + +Then use the object inside your route handlers to generate the pass bundle with the ``PassesService/generatePassContent(for:on:)`` method and distribute it with the "`application/vnd.apple.pkpass`" MIME type. + +```swift +fileprivate func passHandler(_ req: Request) async throws -> Response { + ... + guard let passData = try await PassData.query(on: req.db) + .filter(...) + .with(\.$pass) + .first() + else { + throw Abort(.notFound) + } + + let bundle = try await passesService.generatePassContent(for: passData.pass, on: req.db) + let body = Response.Body(data: bundle) + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "application/vnd.apple.pkpass") + headers.add(name: .contentDisposition, value: "attachment; filename=name.pkpass") + headers.lastModified = HTTPHeaders.LastModified(pass.updatedAt ?? Date.distantPast) + headers.add(name: .contentTransferEncoding, value: "binary") + return Response(status: .ok, headers: headers, body: body) +} +``` + +### Create a Bundle of Passes + +You can also create a bundle of passes to enable your user to download multiple passes at once. +Use the ``PassesService/generatePassesContent(for:on:)`` method to generate the bundle and serve it to the user. +The MIME type for a bundle of passes is "`application/vnd.apple.pkpasses`". + +> Note: You can have up to 10 passes or 150 MB for a bundle of passes. + +```swift +fileprivate func passesHandler(_ req: Request) async throws -> Response { + ... + let passesData = try await PassData.query(on: req.db).with(\.$pass).all() + let passes = passesData.map { $0.pass } + + let bundle = try await passesService.generatePassesContent(for: passes, on: req.db) + let body = Response.Body(data: bundle) + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "application/vnd.apple.pkpasses") + headers.add(name: .contentDisposition, value: "attachment; filename=name.pkpasses") + headers.lastModified = HTTPHeaders.LastModified(Date()) + headers.add(name: .contentTransferEncoding, value: "binary") + return Response(status: .ok, headers: headers, body: body) +} +``` + +> Important: Bundles of passes are supported only in Safari. You can't send the bundle via AirDrop or other methods. diff --git a/Sources/Passes/Passes.docc/PassData.md b/Sources/Passes/Passes.docc/PassData.md deleted file mode 100644 index 0ebc56d..0000000 --- a/Sources/Passes/Passes.docc/PassData.md +++ /dev/null @@ -1,200 +0,0 @@ -# Create the Pass Data Model - -Implement the pass data model, its model middleware and define the pass file contents. - -## Overview - -The Passes framework provides models to save all the basic information for passes, user devices and their registration to each pass. -For all the other custom data needed to generate the pass (such as the barcodes, locations, etc.), you have to create your own model and its model middleware to handle the creation and update of passes. -The pass data model will be used to generate the `pass.json` file contents, along side image files for the icon and other visual elements, such as a logo. - -### Implement the Pass Data Model - -Your data model should contain all the fields that you store for your pass, as well as a foreign key to ``PKPass``, the pass model offered by the Passes framework. - -```swift -import Fluent -import struct Foundation.UUID -import Passes - -final class PassData: PassDataModel, @unchecked Sendable { - static let schema = "pass_data" - - @ID - var id: UUID? - - @Parent(key: "pass_id") - var pass: PKPass - - // Examples of other extra fields: - @Field(key: "punches") - var punches: Int - - @Field(key: "title") - var title: String - - // Add any other field relative to your app, such as a location, a date, etc. - - init() { } -} - -struct CreatePassData: AsyncMigration { - public func prepare(on database: Database) async throws { - try await database.schema(Self.schema) - .id() - .field("pass_id", .uuid, .required, .references(PKPass.schema, .id, onDelete: .cascade)) - .field("punches", .int, .required) - .field("title", .string, .required) - .create() - } - - public func revert(on database: Database) async throws { - try await database.schema(Self.schema).delete() - } -} -``` - -### Pass Data Model Middleware - -You'll want to create a model middleware to handle the creation and update of the pass data model. -This middleware could be responsible for creating and linking a ``PKPass`` to the pass data model, depending on your requirements. -When your pass data changes, it should also update the ``PKPass/updatedAt`` field of the ``PKPass`` and send a push notification to all devices registered to that pass. - -See for more information on how to send push notifications. - -```swift -import Vapor -import Fluent -import Passes - -struct PassDataMiddleware: AsyncModelMiddleware { - private unowned let app: Application - - init(app: Application) { - self.app = app - } - - // Create the `PKPass` and add it to the `PassData` automatically at creation - func create(model: PassData, on db: Database, next: AnyAsyncModelResponder) async throws { - let pkPass = PKPass( - passTypeIdentifier: "pass.com.yoursite.passType", - authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString()) - try await pkPass.save(on: db) - model.$pass.id = try pkPass.requireID() - try await next.create(model, on: db) - } - - func update(model: PassData, on db: Database, next: AnyAsyncModelResponder) async throws { - let pkPass = try await model.$pass.get(on: db) - pkPass.updatedAt = Date() - try await pkPass.save(on: db) - try await next.update(model, on: db) - try await PassesService.sendPushNotifications(for: pkPass, on: db, app: self.app) - } -} -``` - -Remember to register it in the `configure.swift` file. - -```swift -app.databases.middleware.use(PassDataMiddleware(app: app), on: .psql) -``` - -> Important: Whenever your pass data changes, you must update the ``PKPass/updatedAt`` time of the linked pass so that Apple knows to send you a new pass. - -### Handle Cleanup - -Depending on your implementation details, you may want to automatically clean out the passes and devices table when a registration is deleted. -You'll need to implement based on your type of SQL database as there's not yet a Fluent way to implement something like SQL's `NOT EXISTS` call with a `DELETE` statement. - -> Warning: Be careful with SQL triggers, as they can have unintended consequences if not properly implemented. - -### Model the pass.json contents - -Create a `struct` that implements ``PassJSON/Properties`` which will contain all the fields for the generated `pass.json` file. -Create an initializer that takes your custom pass data, the ``PKPass`` and everything else you may need. - -> Tip: For information on the various keys available see the [documentation](https://developer.apple.com/documentation/walletpasses/pass). See also [this guide](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/index.html#//apple_ref/doc/uid/TP40012195-CH1-SW1) for some help. - -```swift -import Passes - -struct PassJSONData: PassJSON.Properties { - let description: String - let formatVersion = PassJSON.FormatVersion.v1 - let organizationName = "vapor-community" - let passTypeIdentifier = Environment.get("PASSKIT_PASS_TYPE_IDENTIFIER")! - let serialNumber: String - let teamIdentifier = Environment.get("APPLE_TEAM_IDENTIFIER")! - - private let webServiceURL = "https://example.com/api/passes/" - private let authenticationToken: String - private let logoText = "Vapor" - private let sharingProhibited = true - let backgroundColor = "rgb(207, 77, 243)" - let foregroundColor = "rgb(255, 255, 255)" - - let barcodes = Barcode(message: "test") - struct Barcode: PassJSON.Barcodes { - let format = PassJSON.BarcodeFormat.qr - let message: String - let messageEncoding = "iso-8859-1" - } - - let boardingPass = Boarding(transitType: .air) - struct Boarding: PassJSON.BoardingPass { - let transitType: PassJSON.TransitType - let headerFields: [PassField] - let primaryFields: [PassField] - let secondaryFields: [PassField] - let auxiliaryFields: [PassField] - let backFields: [PassField] - - struct PassField: PassJSON.PassFieldContent { - let key: String - let label: String - let value: String - } - - init(transitType: PassJSON.TransitType) { - self.headerFields = [.init(key: "header", label: "Header", value: "Header")] - self.primaryFields = [.init(key: "primary", label: "Primary", value: "Primary")] - self.secondaryFields = [.init(key: "secondary", label: "Secondary", value: "Secondary")] - self.auxiliaryFields = [.init(key: "auxiliary", label: "Auxiliary", value: "Auxiliary")] - self.backFields = [.init(key: "back", label: "Back", value: "Back")] - self.transitType = transitType - } - } - - init(data: PassData, pass: PKPass) { - self.description = data.title - self.serialNumber = pass.id!.uuidString - self.authenticationToken = pass.authenticationToken - } -} -``` - -> Important: You **must** add `api/passes/` to your `webServiceURL`, as shown in the example above. - -### Register Migrations - -If you're using the default schemas provided by this framework, you can register the default models in your `configure(_:)` method: - -```swift -PassesService.register(migrations: app.migrations) -``` - -> Important: Register the default models before the migration of your pass data model. - -### Custom Implementation - -If you don't like the schema names provided by the framework that are used by default, you can instead create your own models conforming to ``PassModel``, `DeviceModel`, ``PassesRegistrationModel`` and `ErrorLogModel` and instantiate the generic ``PassesServiceCustom``, providing it your model types. - -```swift -import PassKit -import Passes - -let passesService = PassesServiceCustom(app: app, delegate: delegate) -``` - -> Important: `DeviceModel` and `ErrorLogModel` are defined in the PassKit framework. diff --git a/Sources/Passes/Passes.docc/Passes.md b/Sources/Passes/Passes.docc/Passes.md index 3625ba2..19d5cab 100644 --- a/Sources/Passes/Passes.docc/Passes.md +++ b/Sources/Passes/Passes.docc/Passes.md @@ -12,7 +12,8 @@ Create, distribute, and update passes for the Apple Wallet app with Vapor. @Column { } } -The Passes framework provides a set of tools to help you create, build, and distribute digital passes for the Apple Wallet app using a Vapor server. It also provides a way to update passes after they have been distributed, using APNs, and models to store pass and device data. +The Passes framework provides a set of tools to help you create, build, and distribute digital passes for the Apple Wallet app using a Vapor server. +It also provides a way to update passes after they have been distributed, using APNs, and models to store pass and device data. For information on Apple Wallet passes, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletpasses). @@ -20,8 +21,7 @@ For information on Apple Wallet passes, see the [Apple Developer Documentation]( ### Essentials -- -- +- - ``PassJSON`` ### Building and Distribution @@ -32,7 +32,7 @@ For information on Apple Wallet passes, see the [Apple Developer Documentation]( ### Concrete Models -- ``PKPass`` +- ``Pass`` - ``PassesRegistration`` - ``PassesDevice`` - ``PassesErrorLog`` @@ -51,3 +51,5 @@ For information on Apple Wallet passes, see the [Apple Developer Documentation]( - - ``PersonalizationJSON`` +- ``UserPersonalization`` +- ``UserPersonalizationModel`` diff --git a/Sources/Passes/Passes.docc/Personalization.md b/Sources/Passes/Passes.docc/Personalization.md index a89c8be..32054c6 100644 --- a/Sources/Passes/Passes.docc/Personalization.md +++ b/Sources/Passes/Passes.docc/Personalization.md @@ -1,9 +1,11 @@ -# Setting Up Pass Personalization +# Setting Up Pass Personalization (⚠️ WIP) -Create and sign a personalized pass, and send it to a device. +Create and sign a personalized pass for Apple Wallet, and send it to a device with a Vapor server. ## Overview +> Warning: This section is a work in progress. Testing is hard without access to the certificates required to develop this feature. If you have access to the entitlements, please help us implement this feature. + Pass Personalization lets you create passes, referred to as personalizable passes, that prompt the user to provide personal information during signup that is used to update the pass. > Important: Making a pass personalizable, just like adding NFC to a pass, requires a special entitlement issued by Apple. Although accessing such entitlements is hard if you're not a big company, you can learn more in [Getting Started with Apple Wallet](https://developer.apple.com/wallet/get-started/). @@ -18,7 +20,7 @@ A personalizable pass is just a standard pass package with the following additio - A `personalizationLogo@XX.png` file. Create a `struct` that implements ``PersonalizationJSON/Properties`` which will contain all the fields for the generated `personalization.json` file. -Create an initializer that takes your custom pass data, the ``PKPass`` and everything else you may need. +Create an initializer that takes your custom pass data, the ``Pass`` and everything else you may need. ```swift import Passes @@ -32,7 +34,7 @@ struct PersonalizationJSONData: PersonalizationJSON.Properties { ] var description: String - init(data: PassData, pass: PKPass) { + init(data: PassData, pass: Pass) { self.description = data.title } } @@ -40,7 +42,14 @@ struct PersonalizationJSONData: PersonalizationJSON.Properties { ### Implement the Delegate -Then implement the ``PassesDelegate/encodePersonalization(for:db:encoder:)`` method of the ``PassesDelegate`` to add the personalization JSON file to passes that require it. +You'll have to make a few changes to ``PassesDelegate`` to support personalizable passes. + +Implement the ``PassesDelegate/encodePersonalization(for:db:encoder:)`` method, which gives you the ``Pass`` to encode. +If the pass requires personalization, and if it was not already personalized, encode the ``PersonalizationJSON`` and return it, otherwise return `nil`. + +In the ``PassesDelegate/template(for:db:)`` method, you have to return two different directory URLs, depending on whether the pass has to be personalized or not. If it does, the directory must contain the `personalizationLogo@XX.png` file. + +Finally, you have to implement the ``PassesDelegate/encode(pass:db:encoder:)`` method as usual, but remember to use in the ``PassJSON`` initializer the user info that will be saved inside ``Pass/userPersonalization`` after the pass has been personalized. ```swift import Vapor @@ -50,12 +59,12 @@ import Passes final class PassDelegate: PassesDelegate { let sslSigningFilesDirectory = URL(fileURLWithPath: "Certificates/Passes/", isDirectory: true) - let pemPrivateKeyPassword: String? = Environment.get("PEM_PRIVATE_KEY_PASSWORD")! + let pemPrivateKeyPassword: String? = Environment.get("PASSES_PEM_PRIVATE_KEY_PASSWORD")! func encode(pass: P, db: Database, encoder: JSONEncoder) async throws -> Data { // Here encode the pass JSON data as usual. guard let passData = try await PassData.query(on: db) - .filter(\.$pass.$id == pass.id!) + .filter(\.$pass.$id == pass.requireID()) .first() else { throw Abort(.internalServerError) @@ -68,13 +77,14 @@ final class PassDelegate: PassesDelegate { func encodePersonalization(for pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data? { guard let passData = try await PassData.query(on: db) - .filter(\.$pass.$id == pass.id!) + .filter(\.$pass.$id == pass.requireID()) + .with(\.$pass) .first() else { throw Abort(.internalServerError) } - if passData.requiresPersonalization { + if try await passData.pass.$userPersonalization.get(on: db) == nil { // If the pass requires personalization, encode the personalization JSON data. guard let data = try? encoder.encode(PersonalizationJSONData(data: passData, pass: pass)) else { throw Abort(.internalServerError) @@ -86,9 +96,9 @@ final class PassDelegate: PassesDelegate { } } - func template(for: P, db: Database) async throws -> URL { + func template(for pass: P, db: Database) async throws -> URL { guard let passData = try await PassData.query(on: db) - .filter(\.$pass.$id == pass.id!) + .filter(\.$pass.$id == pass.requireID()) .first() else { throw Abort(.internalServerError) @@ -96,7 +106,7 @@ final class PassDelegate: PassesDelegate { if passData.requiresPersonalization { // If the pass requires personalization, return the URL to the personalization template, - // which must contain the `personalizationLogo@XX.png` files. + // which must contain the `personalizationLogo@XX.png` file. return URL(fileURLWithPath: "Templates/Passes/Personalization/", isDirectory: true) } else { // Otherwise, return the URL to the standard pass template. @@ -106,20 +116,21 @@ final class PassDelegate: PassesDelegate { } ``` -> Note: If you don't need to personalize passes for your app, you don't need to implement the ``PassesDelegate/encodePersonalization(for:db:encoder:)`` method. - -### Implement the User Data Model (⚠️ WIP) +### Implement the Web Service -> Warning: This section is a work in progress. Right now, the data model required to save users' personal information for each pass **is not implemented**. Development is hard without access to the certificates required to test pass personalization. If you have access to the entitlements, please help us implement this feature. +After implementing the JSON `struct` and the delegate, there is nothing else you have to do. -### Implement the Web Service (⚠️ WIP) +Initializing the ``PassesService`` will automatically set up the endpoints that Apple Wallet expects to exist on your server to handle pass personalization. -> Warning: This section is a work in progress. Right now, the endpoint required to handle pass personalization **is not implemented**. Development is hard without access to the certificates required to test this feature. If you have access to the entitlements, please help us implement this feature. +Adding the ``PassesService/register(migrations:)`` method to your `configure.swift` file will automatically set up the database table that stores the user personalization data. -After implementing the JSON `struct` and the delegate, there is nothing else you have to do. -Adding the ``PassesService/registerRoutes()`` method to your `routes.swift` file will automatically set up the endpoints that Apple Wallet expects to exist on your server to handle pass personalization. +Generate the pass bundle with ``PassesService/generatePassContent(for:on:)`` as usual and distribute it. +The user will be prompted to provide the required personal information when they add the pass. +Wallet will then send the user personal information to your server, which will be saved in the ``UserPersonalization`` table. +Immediately after that, Wallet will request the updated pass. +This updated pass will contain the user personalization data that was previously saved inside the ``Pass/userPersonalization`` field. -Generate the pass bundle with ``PassesService/generatePassContent(for:on:)`` as usual and distribute it to the user. The Passes framework and Apple Wallet will take care of the rest. +> Important: This updated and personalized pass **must not** contain the `personalization.json` file, so make sure that the ``PassesDelegate/encodePersonalization(for:db:encoder:)`` method returns `nil` when the pass has already been personalized. ## Topics diff --git a/Sources/Passes/PassesDelegate.swift b/Sources/Passes/PassesDelegate.swift index 19a1b35..f923642 100644 --- a/Sources/Passes/PassesDelegate.swift +++ b/Sources/Passes/PassesDelegate.swift @@ -40,13 +40,13 @@ public protocol PassesDelegate: AnyObject, Sendable { /// - `signature` /// /// - Parameters: - /// - for: The pass data from the SQL server. + /// - pass: 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. /// /// > Important: Be sure to use the `URL(fileURLWithPath:isDirectory:)` constructor. - func template(for: P, db: any Database) async throws -> URL + func template(for pass: P, db: any Database) async throws -> URL /// Generates the SSL `signature` file. /// @@ -104,11 +104,6 @@ public protocol PassesDelegate: AnyObject, Sendable { /// /// > 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. /// @@ -149,10 +144,6 @@ public extension PassesDelegate { 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 diff --git a/Sources/Passes/PassesError.swift b/Sources/Passes/PassesError.swift index 7bb5fde..9de4c3c 100644 --- a/Sources/Passes/PassesError.swift +++ b/Sources/Passes/PassesError.swift @@ -13,7 +13,6 @@ public struct PassesError: Error, Sendable { case templateNotDirectory case pemCertificateMissing case pemPrivateKeyMissing - case zipBinaryMissing case opensslBinaryMissing case invalidNumberOfPasses } @@ -30,8 +29,6 @@ public struct PassesError: Error, Sendable { public static let pemCertificateMissing = Self(.pemCertificateMissing) /// The `pemPrivateKey` file is missing. public static let pemPrivateKeyMissing = Self(.pemPrivateKeyMissing) - /// The path to the `zip` binary is incorrect. - public static let zipBinaryMissing = Self(.zipBinaryMissing) /// The path to the `openssl` binary is incorrect. public static let opensslBinaryMissing = Self(.opensslBinaryMissing) /// The number of passes to bundle is invalid. @@ -69,9 +66,6 @@ public struct PassesError: Error, Sendable { /// The `pemPrivateKey` file is missing. public static let pemPrivateKeyMissing = Self(errorType: .pemPrivateKeyMissing) - /// The path to the `zip` binary is incorrect. - public static let zipBinaryMissing = Self(errorType: .zipBinaryMissing) - /// The path to the `openssl` binary is incorrect. public static let opensslBinaryMissing = Self(errorType: .opensslBinaryMissing) diff --git a/Sources/Passes/PassesService.swift b/Sources/Passes/PassesService.swift index 6ff6bf5..3578c97 100644 --- a/Sources/Passes/PassesService.swift +++ b/Sources/Passes/PassesService.swift @@ -31,28 +31,17 @@ import FluentKit /// The main class that handles PassKit passes. public final class PassesService: Sendable { - private let service: PassesServiceCustom + private let service: PassesServiceCustom - /// Initializes the service. + /// Initializes the service and registers all the routes required for PassKit to work. /// /// - Parameters: /// - app: The `Vapor.Application` to use in route handlers and APNs. /// - delegate: The ``PassesDelegate`` to use for pass generation. + /// - 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 PassesDelegate, logger: Logger? = nil) { - service = .init(app: app, delegate: delegate, logger: logger) - } - - /// Registers all the routes required for PassKit to work. - public func registerRoutes() { - service.registerRoutes() - } - - /// Registers routes to send push notifications to updated passes. - /// - /// - Parameter middleware: The `Middleware` which will control authentication for the routes. - public func registerPushRoutes(middleware: any Middleware) throws { - try service.registerPushRoutes(middleware: middleware) + public init(app: Application, delegate: any PassesDelegate, pushRoutesMiddleware: (any Middleware)? = nil, logger: Logger? = nil) throws { + service = try .init(app: app, delegate: delegate, pushRoutesMiddleware: pushRoutesMiddleware, logger: logger) } /// Generates the pass content bundle for a given pass. @@ -61,7 +50,7 @@ public final class PassesService: Sendable { /// - pass: The pass to generate the content for. /// - db: The `Database` to use. /// - Returns: The generated pass content as `Data`. - public func generatePassContent(for pass: PKPass, on db: any Database) async throws -> Data { + public func generatePassContent(for pass: Pass, on db: any Database) async throws -> Data { try await service.generatePassContent(for: pass, on: db) } @@ -75,7 +64,7 @@ public final class PassesService: Sendable { /// - passes: The passes to include in the bundle. /// - db: The `Database` to use. /// - Returns: The bundle of passes as `Data`. - public func generatePassesContent(for passes: [PKPass], on db: any Database) async throws -> Data { + public func generatePassesContent(for passes: [Pass], on db: any Database) async throws -> Data { try await service.generatePassesContent(for: passes, on: db) } @@ -83,7 +72,8 @@ public final class PassesService: Sendable { /// /// - Parameter migrations: The `Migrations` object to add the migrations to. public static func register(migrations: Migrations) { - migrations.add(PKPass()) + migrations.add(UserPersonalization()) + migrations.add(Pass()) migrations.add(PassesDevice()) migrations.add(PassesRegistration()) migrations.add(PassesErrorLog()) @@ -95,9 +85,8 @@ public final class PassesService: Sendable { /// - id: The `UUID` of the pass to send the notifications for. /// - passTypeIdentifier: The type identifier of the pass. /// - db: The `Database` to use. - /// - app: The `Application` to use. - public static func sendPushNotificationsForPass(id: UUID, of passTypeIdentifier: String, on db: any Database, app: Application) async throws { - try await PassesServiceCustom.sendPushNotificationsForPass(id: id, of: passTypeIdentifier, on: db, app: app) + public func sendPushNotificationsForPass(id: UUID, of passTypeIdentifier: String, on db: any Database) async throws { + try await service.sendPushNotificationsForPass(id: id, of: passTypeIdentifier, on: db) } /// Sends push notifications for a given pass. @@ -105,18 +94,7 @@ public final class PassesService: Sendable { /// - Parameters: /// - pass: The pass to send the notifications for. /// - db: The `Database` to use. - /// - app: The `Application` to use. - public static func sendPushNotifications(for pass: PKPass, on db: any Database, app: Application) async throws { - try await PassesServiceCustom.sendPushNotifications(for: pass, on: db, app: app) - } - - /// Sends push notifications for a given pass. - /// - /// - Parameters: - /// - pass: The pass (as the `ParentProperty`) to send the notifications for. - /// - db: The `Database` to use. - /// - app: The `Application` to use. - public static func sendPushNotifications(for pass: ParentProperty, on db: any Database, app: Application) async throws { - try await PassesServiceCustom.sendPushNotifications(for: pass, on: db, app: app) + public func sendPushNotifications(for pass: Pass, on db: any Database) async throws { + try await service.sendPushNotifications(for: pass, on: db) } } diff --git a/Sources/Passes/PassesServiceCustom.swift b/Sources/Passes/PassesServiceCustom.swift index 5b465d7..a3a140c 100644 --- a/Sources/Passes/PassesServiceCustom.swift +++ b/Sources/Passes/PassesServiceCustom.swift @@ -5,87 +5,61 @@ // Created by Francesco Paolo Severino on 29/06/24. // -@preconcurrency import Vapor +import Vapor import APNS import VaporAPNS -@preconcurrency import APNSCore +import APNSCore import Fluent import NIOSSL import PassKit +import Zip +@_spi(CMS) import X509 /// Class to handle ``PassesService``. /// /// The generics should be passed in this order: /// - Pass Type +/// - User Personalization Type /// - Device Type /// - Registration Type /// - Error Log Type -public final class PassesServiceCustom: Sendable where P == R.PassType, D == R.DeviceType { - /// The ``PassesDelegate`` to use for pass generation. - public unowned let delegate: any PassesDelegate +public final class PassesServiceCustom: Sendable where P == R.PassType, D == R.DeviceType, U == P.UserPersonalizationType { private unowned let app: Application - - private let v1: any RoutesBuilder + private unowned let delegate: any PassesDelegate private let logger: Logger? - /// Initializes the service. + /// Initializes the service and registers all the routes required for PassKit to work. /// /// - Parameters: /// - app: The `Vapor.Application` to use in route handlers and APNs. /// - delegate: The ``PassesDelegate`` to use for pass generation. + /// - 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 PassesDelegate, logger: Logger? = nil) { + public init( + app: Application, + delegate: any PassesDelegate, + pushRoutesMiddleware: (any Middleware)? = nil, + logger: Logger? = nil + ) throws { + self.app = app self.delegate = delegate self.logger = logger - self.app = app - - v1 = app.grouped("api", "passes", "v1") - } - - /// Registers all the routes required for PassKit to work. - public func registerRoutes() { - v1.get("devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", use: { try await self.passesForDevice(req: $0) }) - v1.post("log", use: { try await self.logError(req: $0) }) - - let v1auth = v1.grouped(ApplePassMiddleware

()) - - v1auth.post("devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", ":passSerial", use: { try await self.registerDevice(req: $0) }) - v1auth.get("passes", ":passTypeIdentifier", ":passSerial", use: { try await self.latestVersionOfPass(req: $0) }) - v1auth.delete("devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", ":passSerial", use: { try await self.unregisterDevice(req: $0) }) - v1auth.post("passes", ":passTypeIdentifier", ":passSerial", "personalize", use: { try await self.personalizedPass(req: $0) }) - } - - /// Registers routes to send push notifications for updated passes - /// - /// ### Example ### - /// ```swift - /// try passesService.registerPushRoutes(middleware: SecretMiddleware(secret: "foo")) - /// ``` - /// - /// - Parameter middleware: The `Middleware` which will control authentication for the routes. - /// - Throws: An error of type ``PassesError``. - public func registerPushRoutes(middleware: any Middleware) throws { - let privateKeyPath = URL(fileURLWithPath: delegate.pemPrivateKey, relativeTo: - delegate.sslSigningFilesDirectory).unixPath() + let privateKeyPath = URL(fileURLWithPath: delegate.pemPrivateKey, relativeTo: delegate.sslSigningFilesDirectory).unixPath() guard FileManager.default.fileExists(atPath: privateKeyPath) else { throw PassesError.pemPrivateKeyMissing } - let pemPath = URL(fileURLWithPath: delegate.pemCertificate, relativeTo: delegate.sslSigningFilesDirectory).unixPath() - guard FileManager.default.fileExists(atPath: pemPath) else { throw PassesError.pemCertificateMissing } - - // PassKit *only* works with the production APNs. You can't pass in `.sandbox` here. let apnsConfig: APNSClientConfiguration - if let pwd = delegate.pemPrivateKeyPassword { + if let password = delegate.pemPrivateKeyPassword { apnsConfig = APNSClientConfiguration( authenticationMethod: try .tls( privateKey: .privateKey( NIOSSLPrivateKey(file: privateKeyPath, format: .pem) { closure in - closure(pwd.utf8) + closure(password.utf8) }), certificateChain: NIOSSLCertificate.fromPEMFile(pemPath).map { .certificate($0) } ), @@ -94,7 +68,7 @@ public final class PassesServiceCustom()) + v1auth.post("devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", ":passSerial", use: { try await self.registerDevice(req: $0) }) + v1auth.get("passes", ":passTypeIdentifier", ":passSerial", use: { try await self.latestVersionOfPass(req: $0) }) + v1auth.delete("devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", ":passSerial", use: { try await self.unregisterDevice(req: $0) }) + + if let pushRoutesMiddleware { + let pushAuth = v1.grouped(pushRoutesMiddleware) + pushAuth.post("push", ":passTypeIdentifier", ":passSerial", use: {try await self.pushUpdatesForPass(req: $0) }) + pushAuth.get("push", ":passTypeIdentifier", ":passSerial", use: { try await self.tokensForPassUpdate(req: $0) }) + } } } - + // MARK: - API Routes extension PassesServiceCustom { func registerDevice(req: Request) async throws -> HTTPStatus { logger?.debug("Called register device") - guard let serial = req.parameters.get("passSerial", as: UUID.self) else { - throw Abort(.badRequest) - } - let pushToken: String do { - let content = try req.content.decode(RegistrationDTO.self) - pushToken = content.pushToken + pushToken = try req.content.decode(RegistrationDTO.self).pushToken } catch { throw Abort(.badRequest) } + guard let serial = req.parameters.get("passSerial", as: UUID.self) else { + throw Abort(.badRequest) + } let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! - guard let pass = try await P.query(on: req.db) .filter(\._$passTypeIdentifier == passTypeIdentifier) .filter(\._$id == serial) @@ -148,7 +130,6 @@ extension PassesServiceCustom { .filter(\._$deviceLibraryIdentifier == deviceLibraryIdentifier) .filter(\._$pushToken == pushToken) .first() - if let device = device { return try await Self.createRegistration(device: device, pass: pass, db: req.db) } else { @@ -160,29 +141,25 @@ extension PassesServiceCustom { private static func createRegistration(device: D, pass: P, db: any Database) async throws -> HTTPStatus { let r = try await R.for(deviceLibraryIdentifier: device.deviceLibraryIdentifier, passTypeIdentifier: pass.passTypeIdentifier, on: db) - .filter(P.self, \._$id == pass.id!) + .filter(P.self, \._$id == pass.requireID()) .first() - if r != nil { - // If the registration already exists, docs say to return a 200 - return .ok - } - + // If the registration already exists, docs say to return 200 OK + if r != nil { return .ok } + let registration = R() - registration._$pass.id = pass.id! - registration._$device.id = device.id! - + registration._$pass.id = try pass.requireID() + registration._$device.id = try device.requireID() try await registration.create(on: db) return .created } func passesForDevice(req: Request) async throws -> PassesForDeviceDTO { logger?.debug("Called passesForDevice") - + let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! var query = R.for(deviceLibraryIdentifier: deviceLibraryIdentifier, passTypeIdentifier: passTypeIdentifier, on: req.db) - if let since: TimeInterval = req.query["passesUpdatedSince"] { let when = Date(timeIntervalSince1970: since) query = query.filter(P.self, \._$updatedAt > when) @@ -195,11 +172,9 @@ extension PassesServiceCustom { var serialNumbers: [String] = [] var maxDate = Date.distantPast - - registrations.forEach { r in + try registrations.forEach { r in let pass = r.pass - - serialNumbers.append(pass.id!.uuidString) + try serialNumbers.append(pass.requireID().uuidString) if let updatedAt = pass.updatedAt, updatedAt > maxDate { maxDate = updatedAt } @@ -211,12 +186,7 @@ extension PassesServiceCustom { func latestVersionOfPass(req: Request) async throws -> Response { logger?.debug("Called latestVersionOfPass") - guard FileManager.default.fileExists(atPath: delegate.zipBinary.unixPath()) else { - throw Abort(.internalServerError, suggestedFixes: ["Provide full path to zip command"]) - } - var ifModifiedSince: TimeInterval = 0 - if let header = req.headers[.ifModifiedSince].first, let ims = TimeInterval(header) { ifModifiedSince = ims } @@ -225,7 +195,6 @@ extension PassesServiceCustom { let id = req.parameters.get("passSerial", as: UUID.self) else { throw Abort(.badRequest) } - guard let pass = try await P.query(on: req.db) .filter(\._$id == id) .filter(\._$passTypeIdentifier == passTypeIdentifier) @@ -238,28 +207,26 @@ extension PassesServiceCustom { throw Abort(.notModified) } - let data = try await self.generatePassContent(for: pass, on: req.db) - let body = Response.Body(data: data) - var headers = HTTPHeaders() headers.add(name: .contentType, value: "application/vnd.apple.pkpass") - headers.add(name: .lastModified, value: String(pass.updatedAt?.timeIntervalSince1970 ?? 0)) + headers.lastModified = HTTPHeaders.LastModified(pass.updatedAt ?? Date.distantPast) headers.add(name: .contentTransferEncoding, value: "binary") - - return Response(status: .ok, headers: headers, body: body) + return try await Response( + status: .ok, + headers: headers, + body: Response.Body(data: self.generatePassContent(for: pass, on: req.db)) + ) } func unregisterDevice(req: Request) async throws -> HTTPStatus { logger?.debug("Called unregisterDevice") - - let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! - + guard let passId = req.parameters.get("passSerial", as: UUID.self) else { throw Abort(.badRequest) } - + let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! - + guard let r = try await R.for(deviceLibraryIdentifier: deviceLibraryIdentifier, passTypeIdentifier: passTypeIdentifier, on: req.db) .filter(P.self, \._$id == passId) .first() @@ -272,33 +239,29 @@ extension PassesServiceCustom { func logError(req: Request) async throws -> HTTPStatus { logger?.debug("Called logError") - + let body: ErrorLogDTO - do { body = try req.content.decode(ErrorLogDTO.self) } catch { throw Abort(.badRequest) } - - guard body.logs.isEmpty == false else { + + guard !body.logs.isEmpty else { throw Abort(.badRequest) } - + try await body.logs.map(E.init(message:)).create(on: req.db) - return .ok } func personalizedPass(req: Request) async throws -> Response { logger?.debug("Called personalizedPass") - /* guard let passTypeIdentifier = req.parameters.get("passTypeIdentifier"), let id = req.parameters.get("passSerial", as: UUID.self) else { throw Abort(.badRequest) } - guard let pass = try await P.query(on: req.db) .filter(\._$id == id) .filter(\._$passTypeIdentifier == passTypeIdentifier) @@ -307,37 +270,113 @@ extension PassesServiceCustom { throw Abort(.notFound) } - let personalization = try req.content.decode(PersonalizationDictionaryDTO.self) - */ + let userInfo = try req.content.decode(PersonalizationDictionaryDTO.self) + + let userPersonalization = U() + userPersonalization.fullName = userInfo.requiredPersonalizationInfo.fullName + userPersonalization.givenName = userInfo.requiredPersonalizationInfo.givenName + userPersonalization.familyName = userInfo.requiredPersonalizationInfo.familyName + userPersonalization.emailAddress = userInfo.requiredPersonalizationInfo.emailAddress + userPersonalization.postalCode = userInfo.requiredPersonalizationInfo.postalCode + userPersonalization.ISOCountryCode = userInfo.requiredPersonalizationInfo.ISOCountryCode + userPersonalization.phoneNumber = userInfo.requiredPersonalizationInfo.phoneNumber + try await userPersonalization.create(on: req.db) + + pass._$userPersonalization.id = try userPersonalization.requireID() + try await pass.update(on: req.db) + + let tmp = FileManager.default.temporaryDirectory + let root = tmp.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { _ = try? FileManager.default.removeItem(at: root) } + + guard let token = userInfo.personalizationToken.data(using: .utf8) else { + throw Abort(.internalServerError) + } + let signature: Data + if let password = delegate.pemPrivateKeyPassword { + let sslBinary = delegate.sslBinary + guard FileManager.default.fileExists(atPath: sslBinary.unixPath()) else { + throw PassesError.opensslBinaryMissing + } + + let tokenURL = root.appendingPathComponent("personalizationToken") + try token.write(to: tokenURL) + + 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", tokenURL.unixPath(), + "-out", root.appendingPathComponent("signature").unixPath(), + "-outform", "DER", + "-passin", "pass:\(password)" + ] + try proc.run() + proc.waitUntilExit() + signature = try Data(contentsOf: root.appendingPathComponent("signature")) + } else { + let signatureBytes = try CMS.sign( + token, + signatureAlgorithm: .sha256WithRSAEncryption, + additionalIntermediateCertificates: [ + Certificate( + pemEncoded: String( + contentsOf: delegate.sslSigningFilesDirectory + .appendingPathComponent(delegate.wwdrCertificate) + ) + ) + ], + certificate: Certificate( + pemEncoded: String( + contentsOf: delegate.sslSigningFilesDirectory + .appendingPathComponent(delegate.pemCertificate) + ) + ), + privateKey: .init( + pemEncoded: String( + contentsOf: delegate.sslSigningFilesDirectory + .appendingPathComponent(delegate.pemPrivateKey) + ) + ), + signingTime: Date() + ) + signature = Data(signatureBytes) + } - throw Abort(.notImplemented) + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "application/octet-stream") + headers.add(name: .contentTransferEncoding, value: "binary") + return Response(status: .ok, headers: headers, body: Response.Body(data: signature)) } // MARK: - Push Routes func pushUpdatesForPass(req: Request) async throws -> HTTPStatus { logger?.debug("Called pushUpdatesForPass") - + guard let id = req.parameters.get("passSerial", as: UUID.self) else { throw Abort(.badRequest) } - let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! - - try await Self.sendPushNotificationsForPass(id: id, of: passTypeIdentifier, on: req.db, app: req.application) + + try await sendPushNotificationsForPass(id: id, of: passTypeIdentifier, on: req.db) return .noContent } func tokensForPassUpdate(req: Request) async throws -> [String] { logger?.debug("Called tokensForPassUpdate") - + guard let id = req.parameters.get("passSerial", as: UUID.self) else { throw Abort(.badRequest) } - let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! - - let registrations = try await Self.registrationsForPass(id: id, of: passTypeIdentifier, on: req.db) - return registrations.map { $0.device.pushToken } + + return try await Self.registrationsForPass(id: id, of: passTypeIdentifier, on: req.db) + .map { $0.device.pushToken } } } @@ -349,11 +388,14 @@ extension PassesServiceCustom { /// - id: The `UUID` of the pass to send the notifications for. /// - passTypeIdentifier: The type identifier of the pass. /// - db: The `Database` to use. - /// - app: The `Application` to use. - public static func sendPushNotificationsForPass(id: UUID, of passTypeIdentifier: String, on db: any Database, app: Application) async throws { + public func sendPushNotificationsForPass(id: UUID, of passTypeIdentifier: String, on db: any Database) async throws { let registrations = try await Self.registrationsForPass(id: id, of: passTypeIdentifier, on: db) for reg in registrations { - let backgroundNotification = APNSBackgroundNotification(expiration: .immediately, topic: reg.pass.passTypeIdentifier, payload: EmptyPayload()) + let backgroundNotification = APNSBackgroundNotification( + expiration: .immediately, + topic: reg.pass.passTypeIdentifier, + payload: EmptyPayload() + ) do { try await app.apns.client(.init(string: "passes")).sendBackgroundNotification( backgroundNotification, @@ -371,37 +413,13 @@ extension PassesServiceCustom { /// - Parameters: /// - pass: The pass to send the notifications for. /// - db: The `Database` to use. - /// - app: The `Application` to use. - public static func sendPushNotifications(for pass: P, on db: any Database, app: Application) async throws { - guard let id = pass.id else { - throw FluentError.idRequired - } - - try await Self.sendPushNotificationsForPass(id: id, of: pass.passTypeIdentifier, on: db, app: app) - } - - /// Sends push notifications for a given pass. - /// - /// - Parameters: - /// - pass: The pass (as the `ParentProperty`) to send the notifications for. - /// - db: The `Database` to use. - /// - app: The `Application` to use. - public static func sendPushNotifications(for pass: ParentProperty, on db: any Database, app: Application) async throws { - let value: P - - if let eagerLoaded = pass.value { - value = eagerLoaded - } else { - value = try await pass.get(on: db) - } - - try await sendPushNotifications(for: value, on: db, app: app) + public func sendPushNotifications(for pass: P, on db: any Database) async throws { + try await sendPushNotificationsForPass(id: pass.requireID(), of: pass.passTypeIdentifier, on: db) } - private static func registrationsForPass(id: UUID, of passTypeIdentifier: 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. + static func registrationsForPass(id: UUID, of passTypeIdentifier: 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) .join(parent: \._$pass) .join(parent: \._$device) @@ -415,74 +433,76 @@ extension PassesServiceCustom { // MARK: - pkpass file generation extension PassesServiceCustom { - private static func generateManifestFile(using encoder: JSONEncoder, in root: URL) throws { + private static func generateManifestFile(using encoder: JSONEncoder, in root: URL) throws -> Data { 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 - } - + guard !file.hasDirectoryPath else { return } let data = try Data(contentsOf: file) let hash = Insecure.SHA1.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")) + let data = try encoder.encode(manifest) + try data.write(to: root.appendingPathComponent("manifest.json")) + return data } - 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 - } + private func generateSignatureFile(for manifest: Data, in root: URL) throws { + // If the caller's delegate generated a file we don't have to do it. + if delegate.generateSignatureFile(in: root) { return } - let sslBinary = delegate.sslBinary + // 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 + guard FileManager.default.fileExists(atPath: sslBinary.unixPath()) else { + throw PassesError.opensslBinaryMissing + } - guard FileManager.default.fileExists(atPath: sslBinary.unixPath()) else { - throw PassesError.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", + "-passin", "pass:\(password)" + ] + try proc.run() + proc.waitUntilExit() + return } - 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 PassesError.zipBinaryMissing - } - - let proc = Process() - proc.currentDirectoryURL = directory - proc.executableURL = zipBinary - - proc.arguments = [ to.unixPath(), "-r", "-q", "." ] - - try proc.run() - proc.waitUntilExit() + let signature = try CMS.sign( + manifest, + signatureAlgorithm: .sha256WithRSAEncryption, + additionalIntermediateCertificates: [ + Certificate( + pemEncoded: String( + contentsOf: delegate.sslSigningFilesDirectory + .appendingPathComponent(delegate.wwdrCertificate) + ) + ) + ], + certificate: Certificate( + pemEncoded: String( + contentsOf: delegate.sslSigningFilesDirectory + .appendingPathComponent(delegate.pemCertificate) + ) + ), + privateKey: .init( + pemEncoded: String( + contentsOf: delegate.sslSigningFilesDirectory + .appendingPathComponent(delegate.pemPrivateKey) + ) + ), + signingTime: Date() + ) + try Data(signature).write(to: root.appendingPathComponent("signature")) } /// Generates the pass content bundle for a given pass. @@ -492,45 +512,36 @@ extension PassesServiceCustom { /// - db: The `Database` to use. /// - Returns: The generated pass content as `Data`. public func generatePassContent(for pass: P, on db: any Database) async throws -> Data { + let templateDirectory = try await delegate.template(for: pass, db: db) + guard (try? templateDirectory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false else { + throw PassesError.templateNotDirectory + } + var files = try FileManager.default.contentsOfDirectory(at: templateDirectory, includingPropertiesForKeys: nil) + let tmp = FileManager.default.temporaryDirectory let root = tmp.appendingPathComponent(UUID().uuidString, isDirectory: true) - let zipFile = tmp.appendingPathComponent("\(UUID().uuidString).zip") - let encoder = JSONEncoder() + try FileManager.default.copyItem(at: templateDirectory, to: root) + defer { _ = try? FileManager.default.removeItem(at: root) } - let src = try await delegate.template(for: pass, db: db) - guard (try? src.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false else { - throw PassesError.templateNotDirectory + let encoder = JSONEncoder() + try await self.delegate.encode(pass: pass, db: db, encoder: encoder) + .write(to: root.appendingPathComponent("pass.json")) + + // Pass Personalization + if let encodedPersonalization = try await self.delegate.encodePersonalization(for: pass, db: db, encoder: encoder) { + try encodedPersonalization.write(to: root.appendingPathComponent("personalization.json")) + files.append(URL(fileURLWithPath: "personalization.json", relativeTo: root)) } - let encoded = try await self.delegate.encode(pass: pass, 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("pass.json")) + try self.generateSignatureFile( + for: Self.generateManifestFile(using: encoder, in: root), + in: root + ) - // Pass Personalization - if let encodedPersonalization = try await self.delegate.encodePersonalization(for: pass, db: db, encoder: encoder) { - try encodedPersonalization.write(to: root.appendingPathComponent("personalization.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 - } + files.append(URL(fileURLWithPath: "pass.json", relativeTo: root)) + files.append(URL(fileURLWithPath: "manifest.json", relativeTo: root)) + files.append(URL(fileURLWithPath: "signature", relativeTo: root)) + return try Data(contentsOf: Zip.quickZipFiles(files, fileName: UUID().uuidString)) } /// Generates a bundle of passes to enable your user to download multiple passes at once. @@ -550,25 +561,16 @@ extension PassesServiceCustom { let tmp = FileManager.default.temporaryDirectory let root = tmp.appendingPathComponent(UUID().uuidString, isDirectory: true) - let zipFile = tmp.appendingPathComponent("\(UUID().uuidString).zip") - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { _ = try? FileManager.default.removeItem(at: root) } + var files: [URL] = [] for (i, pass) in passes.enumerated() { + let name = "pass\(i).pkpass" try await self.generatePassContent(for: pass, on: db) - .write(to: root.appendingPathComponent("pass\(i).pkpass")) - } - - defer { - _ = try? FileManager.default.removeItem(at: root) + .write(to: root.appendingPathComponent(name)) + files.append(URL(fileURLWithPath: name, relativeTo: root)) } - - try self.zip(directory: root, to: zipFile) - - defer { - _ = try? FileManager.default.removeItem(at: zipFile) - } - - return try Data(contentsOf: zipFile) + return try Data(contentsOf: Zip.quickZipFiles(files, fileName: UUID().uuidString)) } } diff --git a/Tests/Certificates/WWDR.pem b/Tests/Certificates/WWDR.pem new file mode 100644 index 0000000..7202de0 --- /dev/null +++ b/Tests/Certificates/WWDR.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEVTCCAz2gAwIBAgIUE9x3lVJx5T3GMujM/+Uh88zFztIwDQYJKoZIhvcNAQEL +BQAwYjELMAkGA1UEBhMCVVMxEzARBgNVBAoTCkFwcGxlIEluYy4xJjAkBgNVBAsT +HUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRYwFAYDVQQDEw1BcHBsZSBS +b290IENBMB4XDTIwMTIxNjE5MzYwNFoXDTMwMTIxMDAwMDAwMFowdTFEMEIGA1UE +Aww7QXBwbGUgV29ybGR3aWRlIERldmVsb3BlciBSZWxhdGlvbnMgQ2VydGlmaWNh +dGlvbiBBdXRob3JpdHkxCzAJBgNVBAsMAkc0MRMwEQYDVQQKDApBcHBsZSBJbmMu +MQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANAf +eKp6JzKwRl/nF3bYoJ0OKY6tPTKlxGs3yeRBkWq3eXFdDDQEYHX3rkOPR8SGHgjo +v9Y5Ui8eZ/xx8YJtPH4GUnadLLzVQ+mxtLxAOnhRXVGhJeG+bJGdayFZGEHVD41t +QSo5SiHgkJ9OE0/QjJoyuNdqkh4laqQyziIZhQVg3AJK8lrrd3kCfcCXVGySjnYB +5kaP5eYq+6KwrRitbTOFOCOL6oqW7Z+uZk+jDEAnbZXQYojZQykn/e2kv1MukBVl +PNkuYmQzHWxq3Y4hqqRfFcYw7V/mjDaSlLfcOQIA+2SM1AyB8j/VNJeHdSbCb64D +YyEMe9QbsWLFApy9/a8CAwEAAaOB7zCB7DASBgNVHRMBAf8ECDAGAQH/AgEAMB8G +A1UdIwQYMBaAFCvQaUeUdgn+9GuNLkCm90dNfwheMEQGCCsGAQUFBwEBBDgwNjA0 +BggrBgEFBQcwAYYoaHR0cDovL29jc3AuYXBwbGUuY29tL29jc3AwMy1hcHBsZXJv +b3RjYTAuBgNVHR8EJzAlMCOgIaAfhh1odHRwOi8vY3JsLmFwcGxlLmNvbS9yb290 +LmNybDAdBgNVHQ4EFgQUW9n6HeeaGgujmXYiUIY+kchbd6gwDgYDVR0PAQH/BAQD +AgEGMBAGCiqGSIb3Y2QGAgEEAgUAMA0GCSqGSIb3DQEBCwUAA4IBAQA/Vj2e5bbD +eeZFIGi9v3OLLBKeAuOugCKMBB7DUshwgKj7zqew1UJEggOCTwb8O0kU+9h0UoWv +p50h5wESA5/NQFjQAde/MoMrU1goPO6cn1R2PWQnxn6NHThNLa6B5rmluJyJlPef +x4elUWY0GzlxOSTjh2fvpbFoe4zuPfeutnvi0v/fYcZqdUmVIkSoBPyUuAsuORFJ +EtHlgepZAE9bPFo22noicwkJac3AfOriJP6YRLj477JxPxpd1F1+M02cHSS+APCQ +A1iZQT0xWmJArzmoUUOSqwSonMJNsUvSq3xKX+udO7xPiEAGE/+QF4oIRynoYpgp +pU8RBWk6z/Kf +-----END CERTIFICATE----- diff --git a/Tests/Certificates/certificate.pem b/Tests/Certificates/certificate.pem new file mode 100644 index 0000000..c300118 --- /dev/null +++ b/Tests/Certificates/certificate.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC8TCCAdmgAwIBAgICbE8wDQYJKoZIhvcNAQENBQAwGDEWMBQGA1UEAwwNUHVz +aHlUZXN0Um9vdDAgFw0xNzA0MTcwMDUzMzBaGA8yMTE3MDMyNDAwNTMzMFowHzEd +MBsGA1UEAwwUY29tLnJlbGF5cmlkZXMucHVzaHkwggEiMA0GCSqGSIb3DQEBAQUA +A4IBDwAwggEKAoIBAQDHZkZBnDKM4Gt+WZwTc5h2GuT1Di7TfUE8SxDhw5wn3c36 +41/6lnrTj1Sh5tAsed8N2FDrD+Hp9zTkKljDGe8tuDncT1qSrp/UuikgdIAAiCXA +/vClWPYqZcHAUc9/OcfRiyK5AmJdzz+UbY803ArSPHjz3+Mk6C9tnzBXzG8oJq9o +EKJhwUYX+7l8+m0omtZXhMCOrbmZ2s69m6hTwHJKdC0mEngdyeiYIsbHaoSwxR7U +j8wRstdr2xWhPg1fdIVHzudYubJ7M/h95JQFKtwqEevtLUa4BJgi8SKvRX5NnkGE +QMui1ercRuklVURTeoGDQYENiFnzTyI0J2tw3T+dAgMBAAGjPDA6MAkGA1UdEwQC +MAAwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD +ATANBgkqhkiG9w0BAQ0FAAOCAQEAnHHYMvBWglQLOUmNOalCMopmk9yKHM7+Sc9h +KsTWJW+YohF5zkRhnwUFxW85Pc63rRVA0qyI5zHzRtwYlcZHU57KttJyDGe1rm/0 +ZUqXharurJzyI09jcwRpDY8EGktrGirE1iHDqQTHNDHyS8iMVU6aPCo0xur63G5y +XzoIVhQXsBuwoU4VKb3n5CrxKEVcmE/nYF/Tk0rTtCrZF7TR3y/oxrp359goJ1b2 +/OjXN4dlqND41SbVTTL0FyXU3ebaS4DALA3pyVa1Rijw7vgEbFabsuMaAbdvlprn +RwUjsrRVu3Tx7sp/NqmeBLVru5nH/yHStDjSdvQtI2ipNGK/9w== +-----END CERTIFICATE----- diff --git a/Tests/Certificates/encryptedcert.pem b/Tests/Certificates/encryptedcert.pem new file mode 100644 index 0000000..7a9d7cc --- /dev/null +++ b/Tests/Certificates/encryptedcert.pem @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE----- +MIICdDCCAVwCCQCtBOr7dtQS6zANBgkqhkiG9w0BAQsFADB7MQswCQYDVQQGEwJB +VTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0 +cyBQdHkgTHRkMRAwDgYDVQQDDAdQYXNza2l0MSIwIAYJKoZIhvcNAQkBFhNub3Jl +cGx5QGV4YW1wbGUuY29tMB4XDTE5MDEzMTE2NTYzNloXDTI0MDEzMDE2NTYzNlow +RTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGElu +dGVybmV0IFdpZGdpdHMgUHR5IEx0ZDBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC1 ++NQj0QzX5Vu9JMZVntP8i+JYAfOxzeP+MWUL/VaOxGaRp7DSiWAOd8bXDjJZjET0 +4SPZzKvy0a6Suk9aIxCfAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAHd5jKTM4smJ +b4CoVY2PwYogb+bI4AtpUBV1QlsDrb1xMBHQ6zLf+JhRMya2MqJR+5hDKywrN4bC +j3LY87ir5aJFFaBMs9h0sCEoQKs0cnksf6Gq2pVJF9G+Aki4UF9r7jxoQwXjbtS3 +m6ptezzKYvMcw5rKKhtZRgDT1uuy5hgOCapZrV1s0byRv3W6IcdzOD3cWZEuxz2D +AVZCwIvqThqMaAs3Fvs3L3aQsDiOJpZ65gNnBU6j21liMZ3q7txD3eCzuXWMLPI5 +O7C4Sxy+LF4XAfd1/0nmHC2HBgA6CSMgncEzU6PLRR6bXH1daKWlcMAvF+STbLUJ +79kQMXh2OCs= +-----END CERTIFICATE----- diff --git a/Tests/Certificates/encryptedkey.pem b/Tests/Certificates/encryptedkey.pem new file mode 100644 index 0000000..615dcc0 --- /dev/null +++ b/Tests/Certificates/encryptedkey.pem @@ -0,0 +1,11 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIBpjBABgkqhkiG9w0BBQ0wMzAbBgkqhkiG9w0BBQwwDgQIMBNCLGhiuR4CAggA +MBQGCCqGSIb3DQMHBAjXF3m+2fdMRgSCAWAaGMyNREsNYTuTE0Zf/GIORBQH1Vjc +pNTvxV0B/YUHfzthOkotQjL8mfbbCWVixEdDE41Rn66WVrVmgFDVIKoGhjsMLGYd +angmocOnZ77ZYXi0f0/8fZYuQF2dF/zOfsdxyNl2gi4MGbKqt8m9vDcFAWEZsN/r +5l1QJYNpF4OXKwNg4dnf7Ugo3PMWrVxKzKn+KtUvQd+mdYJ3xBjr1yLjLacbCXh0 +4Kh1kWeV6yyaZswYPPItyAeg4smLdDTEqFI+GHIT7NFQ0GIojIqz2Ug8KWZaMwZs +iRCYXHDECkC7zqgcxJKRtjDmCJIxfIFcnwJ8DmMf7bpawtowcfM/z7TGSzAUeptA +bH9rS4Zf5/5Sx/yFRr2esClwli5BJG1uISQBpA0DePhePTiW6LesvAt3YZ3p2BCI +OE0HwdbAr24Nw7LRCuobRsTKFnBmM+uqtGyJhKE6hC1q4CPjZ09F8njX +-----END ENCRYPTED PRIVATE KEY----- diff --git a/Tests/Certificates/key.pem b/Tests/Certificates/key.pem new file mode 100644 index 0000000..db02c80 --- /dev/null +++ b/Tests/Certificates/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDHZkZBnDKM4Gt+ +WZwTc5h2GuT1Di7TfUE8SxDhw5wn3c3641/6lnrTj1Sh5tAsed8N2FDrD+Hp9zTk +KljDGe8tuDncT1qSrp/UuikgdIAAiCXA/vClWPYqZcHAUc9/OcfRiyK5AmJdzz+U +bY803ArSPHjz3+Mk6C9tnzBXzG8oJq9oEKJhwUYX+7l8+m0omtZXhMCOrbmZ2s69 +m6hTwHJKdC0mEngdyeiYIsbHaoSwxR7Uj8wRstdr2xWhPg1fdIVHzudYubJ7M/h9 +5JQFKtwqEevtLUa4BJgi8SKvRX5NnkGEQMui1ercRuklVURTeoGDQYENiFnzTyI0 +J2tw3T+dAgMBAAECggEBAMOsIZWQ6ipEsDe1R+vuq9Z6XeP8nwb7C2FXaKGji0Gz +78YcCruln7KsHKkkD3UVw0Wa2Q1S8Kbf6A9fXutWL9f1yRHg7Ui0BDSE2ob2zAW5 +lRLnGs+nlSnV4WQQ5EY9NVDz8IcNR+o2znWhbb65kATvQuJO+l/lWWWBqbb+7rW+ +RHy43p7U8cK63nXJy9eHZ7eOgGGUMUX+Yg0g47RGYxlIeSDrtPCXlNuwwAJY7Ecp +LVltCAyCJEaLVwQpz61PTdmkb9HCvkwiuL6cnjtpoAdXCWX7tV61UNweNkvALIWR +kMukFFE/H6JlAkcbw4na1KwQ3glWIIB2H/vZyMNdnyECgYEA78VEXo+iAQ6or4bY +xUQFd/hIibIYMzq8PxDMOmD86G78u5Ho0ytetht5Xk1xmhv402FZCL1LsAEWpCBs +a9LUwo30A23KaTA7Oy5oo5Md1YJejSNOCR+vs5wAo0SZov5tQaxVMoj3vZZqnJzJ +3A+XUgYZddIFkn8KJjgU/QVapTMCgYEA1OV1okYF2u5VW90RkVdvQONNcUvlKEL4 +UMSF3WJnORmtUL3Dt8AFt9b7pfz6WtVr0apT5SSIFA1+305PTpjjaw25m1GftL3U +5QwkmgTKxnPD/YPC6tImp+OUXHmk+iTgmQ9HaBpEplcyjD0EP2LQsIc6qiku/P2n +OT8ArOkk5+8CgYEA7B98wRL6G8hv3swRVdMy/36HEPNOWcUR9Zl5RlSVO+FxCtca +Tjt7viM4VuI1aer6FFDd+XlRvDaWMXOs0lKCLEbXczkACK7y5clCSzRqQQVuT9fg +1aNayKptBlxcYOPmfLJWBLpWH2KuAyV0tT61apWPJTR7QFXTjOfV44cOSXkCgYAH +CvAxRg+7hlbcixuhqzrK8roFHXWfN1fvlBC5mh/AC9Fn8l8fHQMTadE5VH0TtCu0 +6+WKlwLJZwjjajvFZdlgGTwinzihSgZY7WXoknAC0KGTKWCxU/Jja2vlA0Ep5T5o +0dCS6QuMVSYe7YXOcv5kWJTgPCyJwfpeMm9bSPsnkQKBgQChy4vU3J6CxGzwuvd/ +011kszao+cHn1DdMTyUhvA/O/paB+BAVktHm+o/i+kOk4OcPjhRqewzZZdf7ie5U +hUC8kIraXM4aZt69ThQkAIER89wlhxsFXUmGf7ZMXm8f7pvM6/MDaMW3mEsfbL0U +Y3jy0E30W5s1XCW3gmZ1Vg2xAg== +-----END PRIVATE KEY----- diff --git a/Tests/OrdersTests/EncryptedOrdersDelegate.swift b/Tests/OrdersTests/EncryptedOrdersDelegate.swift new file mode 100644 index 0000000..dd0712a --- /dev/null +++ b/Tests/OrdersTests/EncryptedOrdersDelegate.swift @@ -0,0 +1,36 @@ +import Vapor +import FluentKit +import Orders + +final class EncryptedOrdersDelegate: OrdersDelegate { + let sslSigningFilesDirectory = URL( + fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/Tests/Certificates/", + isDirectory: true + ) + + let pemCertificate = "encryptedcert.pem" + let pemPrivateKey = "encryptedkey.pem" + + let pemPrivateKeyPassword: String? = "password" + + func encode(order: O, db: any Database, encoder: JSONEncoder) async throws -> Data { + guard let orderData = try await OrderData.query(on: db) + .filter(\.$order.$id == order.requireID()) + .with(\.$order) + .first() + else { + throw Abort(.internalServerError) + } + guard let data = try? encoder.encode(OrderJSONData(data: orderData, order: orderData.order)) else { + throw Abort(.internalServerError) + } + return data + } + + func template(for: O, db: any Database) async throws -> URL { + URL( + fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/Tests/OrdersTests/Templates/", + isDirectory: true + ) + } +} \ No newline at end of file diff --git a/Tests/OrdersTests/EncryptedOrdersTests.swift b/Tests/OrdersTests/EncryptedOrdersTests.swift new file mode 100644 index 0000000..c004f0c --- /dev/null +++ b/Tests/OrdersTests/EncryptedOrdersTests.swift @@ -0,0 +1,110 @@ +import XCTVapor +import Fluent +import FluentSQLiteDriver +@testable import Orders +import PassKit +import Zip + +final class EncryptedOrdersTests: XCTestCase { + let delegate = EncryptedOrdersDelegate() + let ordersURI = "/api/orders/v1/" + var ordersService: OrdersService! + var app: Application! + + override func setUp() async throws { + self.app = try await Application.make(.testing) + app.databases.use(.sqlite(.memory), as: .sqlite) + + OrdersService.register(migrations: app.migrations) + app.migrations.add(CreateOrderData()) + ordersService = try OrdersService( + app: app, + delegate: delegate, + pushRoutesMiddleware: SecretMiddleware(secret: "foo"), + logger: app.logger + ) + app.databases.middleware.use(OrderDataMiddleware(service: ordersService), on: .sqlite) + + try await app.autoMigrate() + + Zip.addCustomFileExtension("order") + } + + override func tearDown() async throws { + try await app.autoRevert() + try await self.app.asyncShutdown() + self.app = nil + } + + func testOrderGeneration() async throws { + let orderData = OrderData(title: "Test Order") + try await orderData.create(on: app.db) + let order = try await orderData.$order.get(on: app.db) + let data = try await ordersService.generateOrderContent(for: order, on: app.db) + let orderURL = FileManager.default.temporaryDirectory.appendingPathComponent("test.order") + try data.write(to: orderURL) + let orderFolder = try Zip.quickUnzipFile(orderURL) + + XCTAssert(FileManager.default.fileExists(atPath: orderFolder.path.appending("/signature"))) + + let passJSONData = try String(contentsOfFile: orderFolder.path.appending("/order.json")).data(using: .utf8) + let passJSON = try JSONSerialization.jsonObject(with: passJSONData!) as! [String: Any] + XCTAssertEqual(passJSON["authenticationToken"] as? String, order.authenticationToken) + try XCTAssertEqual(passJSON["orderIdentifier"] as? String, order.requireID().uuidString) + + let manifestJSONData = try String(contentsOfFile: orderFolder.path.appending("/manifest.json")).data(using: .utf8) + let manifestJSON = try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any] + let iconData = try Data(contentsOf: orderFolder.appendingPathComponent("/icon.png")) + let iconHash = Array(SHA256.hash(data: iconData)).hex + XCTAssertEqual(manifestJSON["icon.png"] as? String, iconHash) + } + + func testAPNSClient() async throws { + XCTAssertNotNil(app.apns.client(.init(string: "orders"))) + + let orderData = OrderData(title: "Test Order") + try await orderData.create(on: app.db) + let order = try await orderData._$order.get(on: app.db) + + try await ordersService.sendPushNotificationsForOrder(id: order.requireID(), of: order.orderTypeIdentifier, on: app.db) + + let deviceLibraryIdentifier = "abcdefg" + let pushToken = "1234567890" + + try await app.test( + .POST, + "\(ordersURI)push/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .noContent) + } + ) + + try await app.test( + .POST, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .created) + } + ) + + try await app.test( + .POST, + "\(ordersURI)push/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .internalServerError) + } + ) + + // Test `OrderDataMiddleware` update method + orderData.title = "Test Order 2" + do { + try await orderData.update(on: app.db) + } catch {} + } +} \ No newline at end of file diff --git a/Tests/OrdersTests/OrderData.swift b/Tests/OrdersTests/OrderData.swift new file mode 100644 index 0000000..61ebc6e --- /dev/null +++ b/Tests/OrdersTests/OrderData.swift @@ -0,0 +1,126 @@ +import Fluent +import struct Foundation.UUID +import Orders +import Vapor + +final class OrderData: OrderDataModel, @unchecked Sendable { + static let schema = OrderData.FieldKeys.schemaName + + @ID(key: .id) + var id: UUID? + + @Field(key: OrderData.FieldKeys.title) + var title: String + + @Parent(key: OrderData.FieldKeys.orderID) + var order: Order + + init() { } + + init(id: UUID? = nil, title: String) { + self.id = id + self.title = title + } + + func toDTO() -> OrderDataDTO { + .init( + id: self.id, + title: self.$title.value + ) + } +} + +struct OrderDataDTO: Content { + var id: UUID? + var title: String? + + func toModel() -> OrderData { + let model = OrderData() + + model.id = self.id + if let title = self.title { + model.title = title + } + return model + } +} + +struct CreateOrderData: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(OrderData.FieldKeys.schemaName) + .id() + .field(OrderData.FieldKeys.title, .string, .required) + .field(OrderData.FieldKeys.orderID, .uuid, .required, .references(Order.schema, .id, onDelete: .cascade)) + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema(OrderData.FieldKeys.schemaName).delete() + } +} + +extension OrderData { + enum FieldKeys { + static let schemaName = "order_data" + static let title = FieldKey(stringLiteral: "title") + static let orderID = FieldKey(stringLiteral: "order_id") + } +} + +struct OrderJSONData: OrderJSON.Properties { + let schemaVersion = OrderJSON.SchemaVersion.v1 + let orderTypeIdentifier = "order.com.example.pet-store" + let orderIdentifier: String + let orderType = OrderJSON.OrderType.ecommerce + let orderNumber = "HM090772020864" + let createdAt: String + let updatedAt: String + let status = OrderJSON.OrderStatus.open + let merchant: MerchantData + let orderManagementURL = "https://www.example.com/" + let authenticationToken: String + + private let webServiceURL = "https://www.example.com/api/orders/" + + struct MerchantData: OrderJSON.Merchant { + let merchantIdentifier = "com.example.pet-store" + let displayName: String + let url = "https://www.example.com/" + let logo = "pet_store_logo.png" + } + + init(data: OrderData, order: Order) { + self.orderIdentifier = order.id!.uuidString + self.authenticationToken = order.authenticationToken + self.merchant = MerchantData(displayName: data.title) + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = .withInternetDateTime + self.createdAt = dateFormatter.string(from: order.createdAt!) + self.updatedAt = dateFormatter.string(from: order.updatedAt!) + } +} + +struct OrderDataMiddleware: AsyncModelMiddleware { + private unowned let service: OrdersService + + init(service: OrdersService) { + self.service = service + } + + func create(model: OrderData, on db: any Database, next: any AnyAsyncModelResponder) async throws { + let order = Order( + orderTypeIdentifier: "order.com.example.pet-store", + authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString()) + try await order.save(on: db) + model.$order.id = try order.requireID() + try await next.create(model, on: db) + } + + func update(model: OrderData, on db: any Database, next: any AnyAsyncModelResponder) async throws { + let order = try await model.$order.get(on: db) + order.updatedAt = Date() + try await order.save(on: db) + try await next.update(model, on: db) + try await service.sendPushNotifications(for: order, on: db) + } +} diff --git a/Tests/OrdersTests/OrdersTests.swift b/Tests/OrdersTests/OrdersTests.swift index ba5d087..5eb5aae 100644 --- a/Tests/OrdersTests/OrdersTests.swift +++ b/Tests/OrdersTests/OrdersTests.swift @@ -1,15 +1,328 @@ import XCTVapor +import Fluent +import FluentSQLiteDriver @testable import Orders +import PassKit +import Zip final class OrdersTests: XCTestCase { - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - //XCTAssertEqual(OrdersService().text, "Hello, World!") + let delegate = TestOrdersDelegate() + let ordersURI = "/api/orders/v1/" + var ordersService: OrdersService! + var app: Application! + + override func setUp() async throws { + self.app = try await Application.make(.testing) + app.databases.use(.sqlite(.memory), as: .sqlite) + + OrdersService.register(migrations: app.migrations) + app.migrations.add(CreateOrderData()) + ordersService = try OrdersService( + app: app, + delegate: delegate, + pushRoutesMiddleware: SecretMiddleware(secret: "foo"), + logger: app.logger + ) + app.databases.middleware.use(OrderDataMiddleware(service: ordersService), on: .sqlite) + + try await app.autoMigrate() + + Zip.addCustomFileExtension("order") + } + + override func tearDown() async throws { + try await app.autoRevert() + try await self.app.asyncShutdown() + self.app = nil + } + + func testOrderGeneration() async throws { + let orderData = OrderData(title: "Test Order") + try await orderData.create(on: app.db) + let order = try await orderData.$order.get(on: app.db) + let data = try await ordersService.generateOrderContent(for: order, on: app.db) + let orderURL = FileManager.default.temporaryDirectory.appendingPathComponent("test.order") + try data.write(to: orderURL) + let orderFolder = try Zip.quickUnzipFile(orderURL) + + XCTAssert(FileManager.default.fileExists(atPath: orderFolder.path.appending("/signature"))) + + let passJSONData = try String(contentsOfFile: orderFolder.path.appending("/order.json")).data(using: .utf8) + let passJSON = try JSONSerialization.jsonObject(with: passJSONData!) as! [String: Any] + XCTAssertEqual(passJSON["authenticationToken"] as? String, order.authenticationToken) + try XCTAssertEqual(passJSON["orderIdentifier"] as? String, order.requireID().uuidString) + + let manifestJSONData = try String(contentsOfFile: orderFolder.path.appending("/manifest.json")).data(using: .utf8) + let manifestJSON = try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any] + let iconData = try Data(contentsOf: orderFolder.appendingPathComponent("/icon.png")) + let iconHash = Array(SHA256.hash(data: iconData)).hex + XCTAssertEqual(manifestJSON["icon.png"] as? String, iconHash) } - static var allTests = [ - ("testExample", testExample), - ] + // Tests the API Apple Wallet calls to get orders + func testGetOrderFromAPI() async throws { + let orderData = OrderData(title: "Test Order") + try await orderData.create(on: app.db) + let order = try await orderData.$order.get(on: app.db) + + try await app.test( + .GET, + "\(ordersURI)orders/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: [ + "Authorization": "AppleOrder \(order.authenticationToken)", + "If-Modified-Since": "0" + ], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .ok) + XCTAssertNotNil(res.body) + XCTAssertEqual(res.headers.contentType?.description, "application/vnd.apple.order") + XCTAssertNotNil(res.headers.lastModified) + } + ) + + // Test call with invalid authentication token + try await app.test( + .GET, + "\(ordersURI)orders/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: [ + "Authorization": "AppleOrder invalidToken", + "If-Modified-Since": "0" + ], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .unauthorized) + } + ) + + // Test distant future `If-Modified-Since` date + try await app.test( + .GET, + "\(ordersURI)orders/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: [ + "Authorization": "AppleOrder \(order.authenticationToken)", + "If-Modified-Since": "2147483647" + ], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .notModified) + } + ) + + // Test call with invalid order ID + try await app.test( + .GET, + "\(ordersURI)orders/\(order.orderTypeIdentifier)/invalidID", + headers: [ + "Authorization": "AppleOrder \(order.authenticationToken)", + "If-Modified-Since": "0" + ], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .badRequest) + } + ) + + // Test call with invalid order type identifier + try await app.test( + .GET, + "\(ordersURI)orders/order.com.example.InvalidType/\(order.requireID())", + headers: [ + "Authorization": "AppleOrder \(order.authenticationToken)", + "If-Modified-Since": "0" + ], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .notFound) + } + ) + } + + func testAPIDeviceRegistration() async throws { + let orderData = OrderData(title: "Test Order") + try await orderData.create(on: app.db) + let order = try await orderData.$order.get(on: app.db) + let deviceLibraryIdentifier = "abcdefg" + let pushToken = "1234567890" + + // Test registration without authentication token + try await app.test( + .POST, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .unauthorized) + } + ) + + try await app.test( + .POST, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .created) + } + ) + + // Test registration of an already registered device + try await app.test( + .POST, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .ok) + } + ) + + try await app.test( + .GET, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)?ordersModifiedSince=0", + afterResponse: { res async throws in + let orders = try res.content.decode(OrdersForDeviceDTO.self) + XCTAssertEqual(orders.orderIdentifiers.count, 1) + let orderID = try order.requireID() + XCTAssertEqual(orders.orderIdentifiers[0], orderID.uuidString) + XCTAssertEqual(orders.lastModified, String(order.updatedAt!.timeIntervalSince1970)) + } + ) + + try await app.test( + .GET, + "\(ordersURI)push/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + let pushTokens = try res.content.decode([String].self) + XCTAssertEqual(pushTokens.count, 1) + XCTAssertEqual(pushTokens[0], pushToken) + } + ) + + try await app.test( + .DELETE, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .ok) + } + ) + } + + func testErrorLog() async throws { + let log1 = "Error 1" + let log2 = "Error 2" + + try await app.test( + .POST, + "\(ordersURI)log", + beforeRequest: { req async throws in + try req.content.encode(ErrorLogDTO(logs: [log1, log2])) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .ok) + } + ) + + let logs = try await OrdersErrorLog.query(on: app.db).all() + XCTAssertEqual(logs.count, 2) + XCTAssertEqual(logs[0].message, log1) + XCTAssertEqual(logs[1]._$message.value, log2) + + // Test call with no DTO + try await app.test( + .POST, + "\(ordersURI)log", + afterResponse: { res async throws in + XCTAssertEqual(res.status, .badRequest) + } + ) + + // Test call with empty logs + try await app.test( + .POST, + "\(ordersURI)log", + beforeRequest: { req async throws in + try req.content.encode(ErrorLogDTO(logs: [])) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .badRequest) + } + ) + } + + func testAPNSClient() async throws { + XCTAssertNotNil(app.apns.client(.init(string: "orders"))) + + let orderData = OrderData(title: "Test Order") + try await orderData.create(on: app.db) + let order = try await orderData._$order.get(on: app.db) + + try await ordersService.sendPushNotificationsForOrder(id: order.requireID(), of: order.orderTypeIdentifier, on: app.db) + + let deviceLibraryIdentifier = "abcdefg" + let pushToken = "1234567890" + + try await app.test( + .POST, + "\(ordersURI)push/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .noContent) + } + ) + + try await app.test( + .POST, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .created) + } + ) + + try await app.test( + .POST, + "\(ordersURI)push/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .internalServerError) + } + ) + + // Test `OrderDataMiddleware` update method + orderData.title = "Test Order 2" + do { + try await orderData.update(on: app.db) + } catch let error as HTTPClientError { + XCTAssertEqual(error.self, .remoteConnectionClosed) + } + } + + func testOrdersError() { + XCTAssertEqual(OrdersError.templateNotDirectory.description, "OrdersError(errorType: templateNotDirectory)") + XCTAssertEqual(OrdersError.pemCertificateMissing.description, "OrdersError(errorType: pemCertificateMissing)") + XCTAssertEqual(OrdersError.pemPrivateKeyMissing.description, "OrdersError(errorType: pemPrivateKeyMissing)") + XCTAssertEqual(OrdersError.opensslBinaryMissing.description, "OrdersError(errorType: opensslBinaryMissing)") + } + + func testDefaultDelegate() { + let delegate = DefaultOrdersDelegate() + XCTAssertEqual(delegate.wwdrCertificate, "WWDR.pem") + XCTAssertEqual(delegate.pemCertificate, "ordercertificate.pem") + XCTAssertEqual(delegate.pemPrivateKey, "orderkey.pem") + XCTAssertNil(delegate.pemPrivateKeyPassword) + XCTAssertEqual(delegate.sslBinary, URL(fileURLWithPath: "/usr/bin/openssl")) + XCTAssertFalse(delegate.generateSignatureFile(in: URL(fileURLWithPath: ""))) + } +} + +final class DefaultOrdersDelegate: OrdersDelegate { + let sslSigningFilesDirectory = URL(fileURLWithPath: "", isDirectory: true) + func template(for order: O, db: any Database) async throws -> URL { URL(fileURLWithPath: "") } + func encode(order: O, db: any Database, encoder: JSONEncoder) async throws -> Data { Data() } } diff --git a/Tests/OrdersTests/SecretMiddleware.swift b/Tests/OrdersTests/SecretMiddleware.swift new file mode 100644 index 0000000..7dadd9e --- /dev/null +++ b/Tests/OrdersTests/SecretMiddleware.swift @@ -0,0 +1,16 @@ +import Vapor + +struct SecretMiddleware: AsyncMiddleware { + let secret: String + + init(secret: String) { + self.secret = secret + } + + func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { + guard request.headers.first(name: "X-Secret") == secret else { + throw Abort(.unauthorized, reason: "Incorrect X-Secret header.") + } + return try await next.respond(to: request) + } +} diff --git a/Tests/OrdersTests/Templates/icon.png b/Tests/OrdersTests/Templates/icon.png new file mode 100644 index 0000000..e08e7df Binary files /dev/null and b/Tests/OrdersTests/Templates/icon.png differ diff --git a/Tests/OrdersTests/Templates/pet_store_logo.png b/Tests/OrdersTests/Templates/pet_store_logo.png new file mode 100644 index 0000000..b0a0dc1 Binary files /dev/null and b/Tests/OrdersTests/Templates/pet_store_logo.png differ diff --git a/Tests/OrdersTests/TestOrdersDelegate.swift b/Tests/OrdersTests/TestOrdersDelegate.swift new file mode 100644 index 0000000..a636cc9 --- /dev/null +++ b/Tests/OrdersTests/TestOrdersDelegate.swift @@ -0,0 +1,34 @@ +import Vapor +import FluentKit +import Orders + +final class TestOrdersDelegate: OrdersDelegate { + let sslSigningFilesDirectory = URL( + fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/Tests/Certificates/", + isDirectory: true + ) + + let pemCertificate = "certificate.pem" + let pemPrivateKey = "key.pem" + + func encode(order: O, db: any Database, encoder: JSONEncoder) async throws -> Data { + guard let orderData = try await OrderData.query(on: db) + .filter(\.$order.$id == order.requireID()) + .with(\.$order) + .first() + else { + throw Abort(.internalServerError) + } + guard let data = try? encoder.encode(OrderJSONData(data: orderData, order: orderData.order)) else { + throw Abort(.internalServerError) + } + return data + } + + func template(for: O, db: any Database) async throws -> URL { + URL( + fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/Tests/OrdersTests/Templates/", + isDirectory: true + ) + } +} diff --git a/Tests/PassesTests/EncryptedPassesDelegate.swift b/Tests/PassesTests/EncryptedPassesDelegate.swift new file mode 100644 index 0000000..e73cecd --- /dev/null +++ b/Tests/PassesTests/EncryptedPassesDelegate.swift @@ -0,0 +1,55 @@ +import Vapor +import FluentKit +import Passes + +final class EncryptedPassesDelegate: PassesDelegate { + let sslSigningFilesDirectory = URL( + fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/Tests/Certificates/", + isDirectory: true + ) + + let pemCertificate = "encryptedcert.pem" + let pemPrivateKey = "encryptedkey.pem" + + let pemPrivateKeyPassword: String? = "password" + + func encode(pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data { + guard let passData = try await PassData.query(on: db) + .filter(\.$pass.$id == pass.requireID()) + .with(\.$pass) + .first() + else { + throw Abort(.internalServerError) + } + guard let data = try? encoder.encode(PassJSONData(data: passData, pass: passData.pass)) else { + throw Abort(.internalServerError) + } + return data + } + + func encodePersonalization(for pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data? { + guard let passData = try await PassData.query(on: db) + .filter(\.$pass.$id == pass.id!) + .with(\.$pass) + .first() + else { + throw Abort(.internalServerError) + } + + if passData.title != "Personalize" { return nil } + + if try await passData.pass.$userPersonalization.get(on: db) == nil { + guard let data = try? encoder.encode(PersonalizationJSONData()) else { + throw Abort(.internalServerError) + } + return data + } else { return nil } + } + + func template(for pass: P, db: any Database) async throws -> URL { + URL( + fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/Tests/PassesTests/Templates/", + isDirectory: true + ) + } +} \ No newline at end of file diff --git a/Tests/PassesTests/EncryptedPassesTests.swift b/Tests/PassesTests/EncryptedPassesTests.swift new file mode 100644 index 0000000..e2e473d --- /dev/null +++ b/Tests/PassesTests/EncryptedPassesTests.swift @@ -0,0 +1,157 @@ +import XCTVapor +import Fluent +import FluentSQLiteDriver +@testable import Passes +import PassKit +import Zip + +final class EncryptedPassesTests: XCTestCase { + let delegate = EncryptedPassesDelegate() + let passesURI = "/api/passes/v1/" + var passesService: PassesService! + var app: Application! + + override func setUp() async throws { + self.app = try await Application.make(.testing) + app.databases.use(.sqlite(.memory), as: .sqlite) + + PassesService.register(migrations: app.migrations) + app.migrations.add(CreatePassData()) + passesService = try PassesService( + app: app, + delegate: delegate, + pushRoutesMiddleware: SecretMiddleware(secret: "foo"), + logger: app.logger + ) + app.databases.middleware.use(PassDataMiddleware(service: passesService), on: .sqlite) + + try await app.autoMigrate() + + Zip.addCustomFileExtension("pkpass") + } + + override func tearDown() async throws { + try await app.autoRevert() + try await self.app.asyncShutdown() + self.app = nil + } + + func testPassGeneration() async throws { + let passData = PassData(title: "Test Pass") + try await passData.create(on: app.db) + let pass = try await passData.$pass.get(on: app.db) + let data = try await passesService.generatePassContent(for: pass, on: app.db) + let passURL = FileManager.default.temporaryDirectory.appendingPathComponent("test.pkpass") + try data.write(to: passURL) + let passFolder = try Zip.quickUnzipFile(passURL) + + XCTAssert(FileManager.default.fileExists(atPath: passFolder.path.appending("/signature"))) + + let passJSONData = try String(contentsOfFile: passFolder.path.appending("/pass.json")).data(using: .utf8) + let passJSON = try JSONSerialization.jsonObject(with: passJSONData!) as! [String: Any] + XCTAssertEqual(passJSON["authenticationToken"] as? String, pass.authenticationToken) + try XCTAssertEqual(passJSON["serialNumber"] as? String, pass.requireID().uuidString) + XCTAssertEqual(passJSON["description"] as? String, passData.title) + + let manifestJSONData = try String(contentsOfFile: passFolder.path.appending("/manifest.json")).data(using: .utf8) + let manifestJSON = try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any] + let iconData = try Data(contentsOf: passFolder.appendingPathComponent("/icon.png")) + let iconHash = Array(Insecure.SHA1.hash(data: iconData)).hex + XCTAssertEqual(manifestJSON["icon.png"] as? String, iconHash) + } + + func testPersonalizationAPI() async throws { + let passData = PassData(title: "Personalize") + try await passData.create(on: app.db) + let pass = try await passData.$pass.get(on: app.db) + let personalizationDict = PersonalizationDictionaryDTO( + personalizationToken: "1234567890", + requiredPersonalizationInfo: .init( + emailAddress: "test@example.com", + familyName: "Doe", + fullName: "John Doe", + givenName: "John", + ISOCountryCode: "US", + phoneNumber: "1234567890", + postalCode: "12345" + ) + ) + + try await app.test( + .POST, + "\(passesURI)passes/\(pass.passTypeIdentifier)/\(pass.requireID())/personalize", + headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(personalizationDict) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .ok) + XCTAssertNotNil(res.body) + XCTAssertEqual(res.headers.contentType?.description, "application/octet-stream") + } + ) + + let personalizationQuery = try await UserPersonalization.query(on: app.db).all() + XCTAssertEqual(personalizationQuery.count, 1) + let passPersonalizationID = try await Pass.query(on: app.db).first()? + ._$userPersonalization.get(on: app.db)? + .requireID() + XCTAssertEqual(personalizationQuery[0]._$id.value, passPersonalizationID) + XCTAssertEqual(personalizationQuery[0]._$emailAddress.value, personalizationDict.requiredPersonalizationInfo.emailAddress) + XCTAssertEqual(personalizationQuery[0]._$familyName.value, personalizationDict.requiredPersonalizationInfo.familyName) + XCTAssertEqual(personalizationQuery[0]._$fullName.value, personalizationDict.requiredPersonalizationInfo.fullName) + XCTAssertEqual(personalizationQuery[0]._$givenName.value, personalizationDict.requiredPersonalizationInfo.givenName) + XCTAssertEqual(personalizationQuery[0]._$ISOCountryCode.value, personalizationDict.requiredPersonalizationInfo.ISOCountryCode) + XCTAssertEqual(personalizationQuery[0]._$phoneNumber.value, personalizationDict.requiredPersonalizationInfo.phoneNumber) + XCTAssertEqual(personalizationQuery[0]._$postalCode.value, personalizationDict.requiredPersonalizationInfo.postalCode) + } + + func testAPNSClient() async throws { + XCTAssertNotNil(app.apns.client(.init(string: "passes"))) + + let passData = PassData(title: "Test Pass") + try await passData.create(on: app.db) + let pass = try await passData._$pass.get(on: app.db) + + try await passesService.sendPushNotificationsForPass(id: pass.requireID(), of: pass.passTypeIdentifier, on: app.db) + + let deviceLibraryIdentifier = "abcdefg" + let pushToken = "1234567890" + + try await app.test( + .POST, + "\(passesURI)push/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .noContent) + } + ) + + try await app.test( + .POST, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .created) + } + ) + + try await app.test( + .POST, + "\(passesURI)push/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .internalServerError) + } + ) + + // Test `PassDataMiddleware` update method + passData.title = "Test Pass 2" + do { + try await passData.update(on: app.db) + } catch {} + } +} \ No newline at end of file diff --git a/Tests/PassesTests/PassData.swift b/Tests/PassesTests/PassData.swift new file mode 100644 index 0000000..b25b05e --- /dev/null +++ b/Tests/PassesTests/PassData.swift @@ -0,0 +1,157 @@ +import Fluent +import struct Foundation.UUID +import Passes +import Vapor + +final class PassData: PassDataModel, @unchecked Sendable { + static let schema = PassData.FieldKeys.schemaName + + @ID(key: .id) + var id: UUID? + + @Field(key: PassData.FieldKeys.title) + var title: String + + @Parent(key: PassData.FieldKeys.passID) + var pass: Pass + + init() { } + + init(id: UUID? = nil, title: String) { + self.id = id + self.title = title + } + + func toDTO() -> PassDataDTO { + .init( + id: self.id, + title: self.$title.value + ) + } +} + +struct PassDataDTO: Content { + var id: UUID? + var title: String? + + func toModel() -> PassData { + let model = PassData() + + model.id = self.id + if let title = self.title { + model.title = title + } + return model + } +} + +struct CreatePassData: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(PassData.FieldKeys.schemaName) + .id() + .field(PassData.FieldKeys.title, .string, .required) + .field(PassData.FieldKeys.passID, .uuid, .required, .references(Pass.schema, .id, onDelete: .cascade)) + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema(PassData.FieldKeys.schemaName).delete() + } +} + +extension PassData { + enum FieldKeys { + static let schemaName = "pass_data" + static let title = FieldKey(stringLiteral: "title") + static let passID = FieldKey(stringLiteral: "pass_id") + } +} + +struct PassJSONData: PassJSON.Properties { + let description: String + let formatVersion = PassJSON.FormatVersion.v1 + let organizationName = "vapor-community" + let passTypeIdentifier = "pass.com.vapor-community.PassKit" + let serialNumber: String + let teamIdentifier = "K6512ZA2S5" + + private let webServiceURL = "https://www.example.com/api/passes/" + private let authenticationToken: String + private let logoText = "Vapor Community" + private let sharingProhibited = true + let backgroundColor = "rgb(207, 77, 243)" + let foregroundColor = "rgb(255, 255, 255)" + + let barcodes = Barcode(message: "test") + struct Barcode: PassJSON.Barcodes { + let format = PassJSON.BarcodeFormat.qr + let message: String + let messageEncoding = "iso-8859-1" + } + + let boardingPass = Boarding(transitType: .air) + struct Boarding: PassJSON.BoardingPass { + let transitType: PassJSON.TransitType + let headerFields: [PassField] + let primaryFields: [PassField] + let secondaryFields: [PassField] + let auxiliaryFields: [PassField] + let backFields: [PassField] + + struct PassField: PassJSON.PassFieldContent { + let key: String + let label: String + let value: String + } + + init(transitType: PassJSON.TransitType) { + self.headerFields = [.init(key: "header", label: "Header", value: "Header")] + self.primaryFields = [.init(key: "primary", label: "Primary", value: "Primary")] + self.secondaryFields = [.init(key: "secondary", label: "Secondary", value: "Secondary")] + self.auxiliaryFields = [.init(key: "auxiliary", label: "Auxiliary", value: "Auxiliary")] + self.backFields = [.init(key: "back", label: "Back", value: "Back")] + self.transitType = transitType + } + } + + init(data: PassData, pass: Pass) { + self.description = data.title + self.serialNumber = pass.id!.uuidString + self.authenticationToken = pass.authenticationToken + } +} + +struct PersonalizationJSONData: PersonalizationJSON.Properties { + var requiredPersonalizationFields = [ + PersonalizationJSON.PersonalizationField.name, + PersonalizationJSON.PersonalizationField.postalCode, + PersonalizationJSON.PersonalizationField.emailAddress, + PersonalizationJSON.PersonalizationField.phoneNumber + ] + var description = "Hello, World!" +} + +struct PassDataMiddleware: AsyncModelMiddleware { + private unowned let service: PassesService + + init(service: PassesService) { + self.service = service + } + + func create(model: PassData, on db: any Database, next: any AnyAsyncModelResponder) async throws { + let pass = Pass( + passTypeIdentifier: "pass.com.vapor-community.PassKit", + authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString()) + try await pass.save(on: db) + model.$pass.id = try pass.requireID() + try await next.create(model, on: db) + } + + func update(model: PassData, on db: any Database, next: any AnyAsyncModelResponder) async throws { + let pass = try await model.$pass.get(on: db) + pass.updatedAt = Date() + try await pass.save(on: db) + try await next.update(model, on: db) + try await service.sendPushNotifications(for: pass, on: db) + } +} diff --git a/Tests/PassesTests/PassesTests.swift b/Tests/PassesTests/PassesTests.swift index 6e30f98..b1d7067 100644 --- a/Tests/PassesTests/PassesTests.swift +++ b/Tests/PassesTests/PassesTests.swift @@ -1,15 +1,444 @@ import XCTVapor +import Fluent +import FluentSQLiteDriver @testable import Passes +import PassKit +import Zip final class PassesTests: XCTestCase { - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - //XCTAssertEqual(PassesService().text, "Hello, World!") + let delegate = TestPassesDelegate() + let passesURI = "/api/passes/v1/" + var passesService: PassesService! + var app: Application! + + override func setUp() async throws { + self.app = try await Application.make(.testing) + app.databases.use(.sqlite(.memory), as: .sqlite) + + PassesService.register(migrations: app.migrations) + app.migrations.add(CreatePassData()) + passesService = try PassesService( + app: app, + delegate: delegate, + pushRoutesMiddleware: SecretMiddleware(secret: "foo"), + logger: app.logger + ) + app.databases.middleware.use(PassDataMiddleware(service: passesService), on: .sqlite) + + try await app.autoMigrate() + + Zip.addCustomFileExtension("pkpass") + } + + override func tearDown() async throws { + try await app.autoRevert() + try await self.app.asyncShutdown() + self.app = nil + } + + func testPassGeneration() async throws { + let passData = PassData(title: "Test Pass") + try await passData.create(on: app.db) + let pass = try await passData.$pass.get(on: app.db) + let data = try await passesService.generatePassContent(for: pass, on: app.db) + let passURL = FileManager.default.temporaryDirectory.appendingPathComponent("test.pkpass") + try data.write(to: passURL) + let passFolder = try Zip.quickUnzipFile(passURL) + + XCTAssert(FileManager.default.fileExists(atPath: passFolder.path.appending("/signature"))) + + let passJSONData = try String(contentsOfFile: passFolder.path.appending("/pass.json")).data(using: .utf8) + let passJSON = try JSONSerialization.jsonObject(with: passJSONData!) as! [String: Any] + XCTAssertEqual(passJSON["authenticationToken"] as? String, pass.authenticationToken) + try XCTAssertEqual(passJSON["serialNumber"] as? String, pass.requireID().uuidString) + XCTAssertEqual(passJSON["description"] as? String, passData.title) + + let manifestJSONData = try String(contentsOfFile: passFolder.path.appending("/manifest.json")).data(using: .utf8) + let manifestJSON = try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any] + let iconData = try Data(contentsOf: passFolder.appendingPathComponent("/icon.png")) + let iconHash = Array(Insecure.SHA1.hash(data: iconData)).hex + XCTAssertEqual(manifestJSON["icon.png"] as? String, iconHash) + } + + func testPassesGeneration() async throws { + let passData1 = PassData(title: "Test Pass 1") + try await passData1.create(on: app.db) + let pass1 = try await passData1.$pass.get(on: app.db) + + let passData2 = PassData(title: "Test Pass 2") + try await passData2.create(on: app.db) + let pass2 = try await passData2._$pass.get(on: app.db) + + let data = try await passesService.generatePassesContent(for: [pass1, pass2], on: app.db) + XCTAssertNotNil(data) + } + + func testPersonalization() async throws { + let passData = PassData(title: "Personalize") + try await passData.create(on: app.db) + let pass = try await passData.$pass.get(on: app.db) + let data = try await passesService.generatePassContent(for: pass, on: app.db) + let passURL = FileManager.default.temporaryDirectory.appendingPathComponent("test.pkpass") + try data.write(to: passURL) + let passFolder = try Zip.quickUnzipFile(passURL) + + let passJSONData = try String(contentsOfFile: passFolder.path.appending("/pass.json")).data(using: .utf8) + let passJSON = try JSONSerialization.jsonObject(with: passJSONData!) as! [String: Any] + XCTAssertEqual(passJSON["authenticationToken"] as? String, pass.authenticationToken) + try XCTAssertEqual(passJSON["serialNumber"] as? String, pass.requireID().uuidString) + XCTAssertEqual(passJSON["description"] as? String, passData.title) + + let personalizationJSONData = try String(contentsOfFile: passFolder.path.appending("/personalization.json")).data(using: .utf8) + let personalizationJSON = try JSONSerialization.jsonObject(with: personalizationJSONData!) as! [String: Any] + XCTAssertEqual(personalizationJSON["description"] as? String, "Hello, World!") + + let manifestJSONData = try String(contentsOfFile: passFolder.path.appending("/manifest.json")).data(using: .utf8) + let manifestJSON = try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any] + let iconData = try Data(contentsOf: passFolder.appendingPathComponent("/personalizationLogo.png")) + let iconHash = Array(Insecure.SHA1.hash(data: iconData)).hex + XCTAssertEqual(manifestJSON["personalizationLogo.png"] as? String, iconHash) + } + + // Tests the API Apple Wallet calls to get passes + func testGetPassFromAPI() async throws { + let passData = PassData(title: "Test Pass") + try await passData.create(on: app.db) + let pass = try await passData.$pass.get(on: app.db) + + try await app.test( + .GET, + "\(passesURI)passes/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: [ + "Authorization": "ApplePass \(pass.authenticationToken)", + "If-Modified-Since": "0" + ], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .ok) + XCTAssertNotNil(res.body) + XCTAssertEqual(res.headers.contentType?.description, "application/vnd.apple.pkpass") + XCTAssertNotNil(res.headers.lastModified) + } + ) + + // Test call with invalid authentication token + try await app.test( + .GET, + "\(passesURI)passes/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: [ + "Authorization": "ApplePass invalid-token", + "If-Modified-Since": "0" + ], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .unauthorized) + } + ) + + // Test distant future `If-Modified-Since` date + try await app.test( + .GET, + "\(passesURI)passes/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: [ + "Authorization": "ApplePass \(pass.authenticationToken)", + "If-Modified-Since": "2147483647" + ], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .notModified) + } + ) + + // Test call with invalid pass ID + try await app.test( + .GET, + "\(passesURI)passes/\(pass.passTypeIdentifier)/invalid-uuid", + headers: [ + "Authorization": "ApplePass \(pass.authenticationToken)", + "If-Modified-Since": "0" + ], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .badRequest) + } + ) + + // Test call with invalid pass type identifier + try await app.test( + .GET, + "\(passesURI)passes/pass.com.example.InvalidType/\(pass.requireID())", + headers: [ + "Authorization": "ApplePass \(pass.authenticationToken)", + "If-Modified-Since": "0" + ], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .notFound) + } + ) + } + + func testPersonalizationAPI() async throws { + let passData = PassData(title: "Personalize") + try await passData.create(on: app.db) + let pass = try await passData.$pass.get(on: app.db) + let personalizationDict = PersonalizationDictionaryDTO( + personalizationToken: "1234567890", + requiredPersonalizationInfo: .init( + emailAddress: "test@example.com", + familyName: "Doe", + fullName: "John Doe", + givenName: "John", + ISOCountryCode: "US", + phoneNumber: "1234567890", + postalCode: "12345" + ) + ) + + try await app.test( + .POST, + "\(passesURI)passes/\(pass.passTypeIdentifier)/\(pass.requireID())/personalize", + beforeRequest: { req async throws in + try req.content.encode(personalizationDict) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .ok) + XCTAssertNotNil(res.body) + XCTAssertEqual(res.headers.contentType?.description, "application/octet-stream") + } + ) + + let personalizationQuery = try await UserPersonalization.query(on: app.db).all() + XCTAssertEqual(personalizationQuery.count, 1) + let passPersonalizationID = try await Pass.query(on: app.db).first()? + ._$userPersonalization.get(on: app.db)? + .requireID() + XCTAssertEqual(personalizationQuery[0]._$id.value, passPersonalizationID) + XCTAssertEqual(personalizationQuery[0]._$emailAddress.value, personalizationDict.requiredPersonalizationInfo.emailAddress) + XCTAssertEqual(personalizationQuery[0]._$familyName.value, personalizationDict.requiredPersonalizationInfo.familyName) + XCTAssertEqual(personalizationQuery[0]._$fullName.value, personalizationDict.requiredPersonalizationInfo.fullName) + XCTAssertEqual(personalizationQuery[0]._$givenName.value, personalizationDict.requiredPersonalizationInfo.givenName) + XCTAssertEqual(personalizationQuery[0]._$ISOCountryCode.value, personalizationDict.requiredPersonalizationInfo.ISOCountryCode) + XCTAssertEqual(personalizationQuery[0]._$phoneNumber.value, personalizationDict.requiredPersonalizationInfo.phoneNumber) + XCTAssertEqual(personalizationQuery[0]._$postalCode.value, personalizationDict.requiredPersonalizationInfo.postalCode) + + // Test call with invalid pass ID + try await app.test( + .POST, + "\(passesURI)passes/\(pass.passTypeIdentifier)/invalid-uuid/personalize", + beforeRequest: { req async throws in + try req.content.encode(personalizationDict) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .badRequest) + } + ) + + // Test call with invalid pass type identifier + try await app.test( + .POST, + "\(passesURI)passes/pass.com.example.InvalidType/\(pass.requireID())/personalize", + beforeRequest: { req async throws in + try req.content.encode(personalizationDict) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .notFound) + } + ) + } + + func testAPIDeviceRegistration() async throws { + let passData = PassData(title: "Test Pass") + try await passData.create(on: app.db) + let pass = try await passData.$pass.get(on: app.db) + let deviceLibraryIdentifier = "abcdefg" + let pushToken = "1234567890" + + // Test registration without authentication token + try await app.test( + .POST, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .unauthorized) + } + ) + + try await app.test( + .POST, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .created) + } + ) + + // Test registration of an already registered device + try await app.test( + .POST, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .ok) + } + ) + + try await app.test( + .GET, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)?passesUpdatedSince=0", + afterResponse: { res async throws in + let passes = try res.content.decode(PassesForDeviceDTO.self) + XCTAssertEqual(passes.serialNumbers.count, 1) + let passID = try pass.requireID() + XCTAssertEqual(passes.serialNumbers[0], passID.uuidString) + XCTAssertEqual(passes.lastUpdated, String(pass.updatedAt!.timeIntervalSince1970)) + } + ) + + try await app.test( + .GET, + "\(passesURI)push/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + let pushTokens = try res.content.decode([String].self) + XCTAssertEqual(pushTokens.count, 1) + XCTAssertEqual(pushTokens[0], pushToken) + } + ) + + try await app.test( + .DELETE, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .ok) + } + ) + } + + func testErrorLog() async throws { + let log1 = "Error 1" + let log2 = "Error 2" + + try await app.test( + .POST, + "\(passesURI)log", + beforeRequest: { req async throws in + try req.content.encode(ErrorLogDTO(logs: [log1, log2])) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .ok) + } + ) + + let logs = try await PassesErrorLog.query(on: app.db).all() + XCTAssertEqual(logs.count, 2) + XCTAssertEqual(logs[0].message, log1) + XCTAssertEqual(logs[1]._$message.value, log2) + + // Test call with no DTO + try await app.test( + .POST, + "\(passesURI)log", + afterResponse: { res async throws in + XCTAssertEqual(res.status, .badRequest) + } + ) + + // Test call with empty logs + try await app.test( + .POST, + "\(passesURI)log", + beforeRequest: { req async throws in + try req.content.encode(ErrorLogDTO(logs: [])) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .badRequest) + } + ) } - static var allTests = [ - ("testExample", testExample), - ] + func testAPNSClient() async throws { + XCTAssertNotNil(app.apns.client(.init(string: "passes"))) + + let passData = PassData(title: "Test Pass") + try await passData.create(on: app.db) + let pass = try await passData._$pass.get(on: app.db) + + try await passesService.sendPushNotificationsForPass(id: pass.requireID(), of: pass.passTypeIdentifier, on: app.db) + + let deviceLibraryIdentifier = "abcdefg" + let pushToken = "1234567890" + + try await app.test( + .POST, + "\(passesURI)push/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .noContent) + } + ) + + try await app.test( + .POST, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .created) + } + ) + + try await app.test( + .POST, + "\(passesURI)push/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .internalServerError) + } + ) + + // Test `PassDataMiddleware` update method + passData.title = "Test Pass 2" + do { + try await passData.update(on: app.db) + } catch let error as HTTPClientError { + XCTAssertEqual(error.self, .remoteConnectionClosed) + } + } + + func testPassesError() { + XCTAssertEqual(PassesError.templateNotDirectory.description, "PassesError(errorType: templateNotDirectory)") + XCTAssertEqual(PassesError.pemCertificateMissing.description, "PassesError(errorType: pemCertificateMissing)") + XCTAssertEqual(PassesError.pemPrivateKeyMissing.description, "PassesError(errorType: pemPrivateKeyMissing)") + XCTAssertEqual(PassesError.opensslBinaryMissing.description, "PassesError(errorType: opensslBinaryMissing)") + XCTAssertEqual(PassesError.invalidNumberOfPasses.description, "PassesError(errorType: invalidNumberOfPasses)") + } + + func testDefaultDelegate() async throws { + let delegate = DefaultPassesDelegate() + XCTAssertEqual(delegate.wwdrCertificate, "WWDR.pem") + XCTAssertEqual(delegate.pemCertificate, "passcertificate.pem") + XCTAssertEqual(delegate.pemPrivateKey, "passkey.pem") + XCTAssertNil(delegate.pemPrivateKeyPassword) + XCTAssertEqual(delegate.sslBinary, URL(fileURLWithPath: "/usr/bin/openssl")) + XCTAssertFalse(delegate.generateSignatureFile(in: URL(fileURLWithPath: ""))) + + let passData = PassData(title: "Test Pass") + try await passData.create(on: app.db) + let pass = try await passData.$pass.get(on: app.db) + let data = try await delegate.encodePersonalization(for: pass, db: app.db, encoder: JSONEncoder()) + XCTAssertNil(data) + } +} + +final class DefaultPassesDelegate: PassesDelegate { + let sslSigningFilesDirectory = URL(fileURLWithPath: "", isDirectory: true) + func template(for pass: P, db: any Database) async throws -> URL { URL(fileURLWithPath: "") } + func encode(pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data { Data() } } diff --git a/Tests/PassesTests/SecretMiddleware.swift b/Tests/PassesTests/SecretMiddleware.swift new file mode 100644 index 0000000..7dadd9e --- /dev/null +++ b/Tests/PassesTests/SecretMiddleware.swift @@ -0,0 +1,16 @@ +import Vapor + +struct SecretMiddleware: AsyncMiddleware { + let secret: String + + init(secret: String) { + self.secret = secret + } + + func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { + guard request.headers.first(name: "X-Secret") == secret else { + throw Abort(.unauthorized, reason: "Incorrect X-Secret header.") + } + return try await next.respond(to: request) + } +} diff --git a/Tests/PassesTests/Templates/icon.png b/Tests/PassesTests/Templates/icon.png new file mode 100644 index 0000000..e08e7df Binary files /dev/null and b/Tests/PassesTests/Templates/icon.png differ diff --git a/Tests/PassesTests/Templates/logo.png b/Tests/PassesTests/Templates/logo.png new file mode 100644 index 0000000..0b96c31 Binary files /dev/null and b/Tests/PassesTests/Templates/logo.png differ diff --git a/Tests/PassesTests/Templates/personalizationLogo.png b/Tests/PassesTests/Templates/personalizationLogo.png new file mode 100644 index 0000000..0b96c31 Binary files /dev/null and b/Tests/PassesTests/Templates/personalizationLogo.png differ diff --git a/Tests/PassesTests/TestPassesDelegate.swift b/Tests/PassesTests/TestPassesDelegate.swift new file mode 100644 index 0000000..51c3429 --- /dev/null +++ b/Tests/PassesTests/TestPassesDelegate.swift @@ -0,0 +1,53 @@ +import Vapor +import FluentKit +import Passes + +final class TestPassesDelegate: PassesDelegate { + let sslSigningFilesDirectory = URL( + fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/Tests/Certificates/", + isDirectory: true + ) + + let pemCertificate = "certificate.pem" + let pemPrivateKey = "key.pem" + + func encode(pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data { + guard let passData = try await PassData.query(on: db) + .filter(\.$pass.$id == pass.requireID()) + .with(\.$pass) + .first() + else { + throw Abort(.internalServerError) + } + guard let data = try? encoder.encode(PassJSONData(data: passData, pass: passData.pass)) else { + throw Abort(.internalServerError) + } + return data + } + + func encodePersonalization(for pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data? { + guard let passData = try await PassData.query(on: db) + .filter(\.$pass.$id == pass.id!) + .with(\.$pass) + .first() + else { + throw Abort(.internalServerError) + } + + if passData.title != "Personalize" { return nil } + + if try await passData.pass.$userPersonalization.get(on: db) == nil { + guard let data = try? encoder.encode(PersonalizationJSONData()) else { + throw Abort(.internalServerError) + } + return data + } else { return nil } + } + + func template(for pass: P, db: any Database) async throws -> URL { + URL( + fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/Tests/PassesTests/Templates/", + isDirectory: true + ) + } +}