diff --git a/.spi.yml b/.spi.yml index bb99af5..ad36f6c 100644 --- a/.spi.yml +++ b/.spi.yml @@ -1,4 +1,4 @@ version: 1 builder: configs: - - documentation_targets: [Passes] \ No newline at end of file + - documentation_targets: [PassKit, Passes, Orders] \ No newline at end of file diff --git a/Package.swift b/Package.swift index 63291a0..ae85b76 100644 --- a/Package.swift +++ b/Package.swift @@ -7,29 +7,45 @@ let package = Package( .macOS(.v13), .iOS(.v16) ], products: [ - .library(name: "Passes", targets: ["Passes"]), + .library(name: "PassKit", targets: ["PassKit"]), + .library(name: "Passes", targets: ["PassKit", "Passes"]), + .library(name: "Orders", targets: ["PassKit", "Orders"]), ], dependencies: [ .package(url: "https://github.com/vapor/vapor.git", from: "4.102.0"), .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/apple/swift-log.git", from: "1.6.0"), ], targets: [ .target( - name: "Passes", + name: "PassKit", dependencies: [ .product(name: "Fluent", package: "fluent"), .product(name: "Vapor", package: "vapor"), .product(name: "VaporAPNS", package: "apns"), - .product(name: "Logging", package: "swift-log"), + ], + swiftSettings: swiftSettings + ), + .target( + name: "Passes", + dependencies: [ + .target(name: "PassKit"), + ], + swiftSettings: swiftSettings + ), + .target( + name: "Orders", + dependencies: [ + .target(name: "PassKit"), ], swiftSettings: swiftSettings ), .testTarget( name: "PassKitTests", dependencies: [ + .target(name: "PassKit"), .target(name: "Passes"), + .target(name: "Orders"), .product(name: "XCTVapor", package: "vapor"), ], swiftSettings: swiftSettings diff --git a/README.md b/README.md index 6a1b55b..8673607 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
avatar -

PassKit

+

PassKit

Documentation @@ -18,7 +18,7 @@

-๐ŸŽŸ๏ธ A Vapor package which handles all the server side elements required to implement passes for Apple Wallet. +๐ŸŽŸ๏ธ ๐Ÿ“ฆ A Vapor package which handles all the server side elements required to implement Apple Wallet passes and orders. ### Major Releases @@ -26,26 +26,26 @@ The table below shows a list of PassKit major releases alongside their compatibl |Version|Swift|SPM| |---|---|---| -|0.3.0|5.10+|`from: "0.3.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.3.0") +.package(url: "https://github.com/vapor-community/PassKit.git", from: "0.4.0") ``` -and add it to your target's dependencies: +> Note: This package is made for Vapor 4. + +## ๐ŸŽŸ๏ธ Wallet Passes + +Add the `Passes` product to your target's dependencies: ```swift .product(name: "Passes", package: "PassKit") ``` -> Note: This package requires Vapor 4. - -## Usage - ### Implement your pass data model Your data model should contain all the fields that you store for your pass, as well as a foreign key for the pass itself. @@ -55,7 +55,7 @@ import Fluent import struct Foundation.UUID import Passes -final class PassData: PassKitPassData, @unchecked Sendable { +final class PassData: PassDataModel, @unchecked Sendable { static let schema = "pass_data" @ID @@ -64,10 +64,15 @@ final class PassData: PassKitPassData, @unchecked Sendable { @Parent(key: "pass_id") var pass: PKPass - // Add any other field relative to your app, such as a location, a date, etc. + // 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() { } } @@ -75,8 +80,9 @@ struct CreatePassData: AsyncMigration { public func prepare(on database: Database) async throws { try await database.schema(Self.schema) .id() - .field("punches", .int, .required) .field("pass_id", .uuid, .required, .references(PKPass.schema, .id, onDelete: .cascade)) + .field("punches", .int, .required) + .field("title", .string, .required) .create() } @@ -99,7 +105,7 @@ CREATE OR REPLACE FUNCTION public."RemoveUnregisteredItems"() RETURNS trigger DELETE FROM devices d WHERE NOT EXISTS ( SELECT 1 - FROM registrations r + FROM passes_registrations r WHERE d."id" = r.device_id LIMIT 1 ); @@ -107,7 +113,7 @@ CREATE OR REPLACE FUNCTION public."RemoveUnregisteredItems"() RETURNS trigger DELETE FROM passes p WHERE NOT EXISTS ( SELECT 1 - FROM registrations r + FROM passes_registrations r WHERE p."id" = r.pass_id LIMIT 1 ); @@ -117,49 +123,95 @@ END $$; CREATE TRIGGER "OnRegistrationDelete" -AFTER DELETE ON "public"."registrations" +AFTER DELETE ON "public"."passes_registrations" FOR EACH ROW EXECUTE PROCEDURE "public"."RemoveUnregisteredItems"(); ``` +> [!CAUTION] +> 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 `Encodable` which will contain all the fields for the generated `pass.json` file. +Create a `struct` that implements `PassJSON` 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. -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. + +> [!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. + +Here's an example of a `struct` that implements `PassJSON`. ```swift -struct PassJSONData: Encodable { - public static let token = "EB80D9C6-AD37-41A0-875E-3802E88CA478" - - private let formatVersion = 1 - private let passTypeIdentifier = "pass.com.yoursite.passType" - private let authenticationToken = token - let serialNumber: String - let relevantDate: String - let barcodes: [PassJSONData.Barcode] - ... +import Passes - struct Barcode: Encodable { - let altText: String - let format = "PKBarcodeFormatQR" +struct PassJSONData: PassJSON { + let description: String + let formatVersion = 1 + 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: Barcodes { + let format = BarcodeFormat.qr let message: String let messageEncoding = "iso-8859-1" } + let boardingPass = Boarding(transitType: .air) + struct Boarding: BoardingPass { + let transitType: TransitType + let headerFields: [PassField] + let primaryFields: [PassField] + let secondaryFields: [PassField] + let auxiliaryFields: [PassField] + let backFields: [PassField] + + struct PassField: PassFieldContent { + let key: String + let label: String + let value: String + } + + init(transitType: 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. + ### Implement the delegate. Create a delegate file that implements `PassesDelegate`. In the `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. -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). + +> [!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 the delegate'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. ```swift @@ -167,12 +219,12 @@ import Vapor import Fluent import Passes -final class PKDelegate: PassesDelegate { - let sslSigningFilesDirectory = URL(fileURLWithPath: "Certificates/", isDirectory: true) +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 { + func encode(pass: P, db: Database, encoder: JSONEncoder) async throws -> Data { // The specific PassData class you use here may vary based on the pass.type if you have multiple // different types of passes, and thus multiple types of pass data. guard let passData = try await PassData.query(on: db) @@ -187,47 +239,57 @@ final class PKDelegate: PassesDelegate { return data } - func template(for: P, db: Database) async throws -> URL { + func template(for: P, db: Database) async throws -> URL { // The location might vary depending on the type of pass. - return URL(fileURLWithPath: "PassKitTemplate/", isDirectory: true) + return URL(fileURLWithPath: "Templates/Passes/", isDirectory: true) } } ``` -You **must** explicitly declare `pemPrivateKeyPassword` as a `String?` or Swift will ignore it as it'll think it's a `String` instead. +> [!IMPORTANT] +> You **must** explicitly declare `pemPrivateKeyPassword` as a `String?` or Swift will ignore it as it'll think it's a `String` instead. ### Register Routes -Next, register the routes in `routes.swift`. Notice how the `delegate` 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! +Next, register the routes in `routes.swift`. This will implement all of the routes that PassKit expects to exist on your server for you. ```swift import Vapor import Passes -let pkDelegate = PKDelegate() +let passDelegate = PassDelegate() func routes(_ app: Application) throws { - let passes = Passes(app: app, delegate: pkDelegate) - passes.registerRoutes(authorizationCode: PassJSONData.token) + let passesService = PassesService(app: app, delegate: passDelegate) + passesService.registerRoutes() } ``` +> [!NOTE] +> Notice how the `delegate` 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. If you don't include this line, you have to configure an APNS container yourself +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 passes.registerPushRoutes(middleware: SecretMiddleware(secret: "foo")) +try passesService.registerPushRoutes(middleware: SecretMiddleware(secret: "foo")) ``` That will add two routes: -- POST .../api/v1/push/*passTypeIdentifier*/*passBarcode* (Sends notifications) -- GET .../api/v1/push/*passTypeIdentifier*/*passBarcode* (Retrieves a list of push tokens which would be sent a notification) +- POST .../api/passes/v1/push/*:passTypeIdentifier*/*:passSerial* (Sends notifications) +- GET .../api/passes/v1/push/*:passTypeIdentifier*/*:passSerial* (Retrieves a list of push tokens which would be sent a notification) + +#### 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 `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. You can implement it like so: +Whether you include the routes or not, you'll want to add a model middleware that sends push notifications and updates the `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. + +You can implement it like so: ```swift import Vapor @@ -243,7 +305,9 @@ struct PassDataMiddleware: AsyncModelMiddleware { // 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") + 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) @@ -254,7 +318,7 @@ struct PassDataMiddleware: AsyncModelMiddleware { pkPass.updatedAt = Date() try await pkPass.save(on: db) try await next.update(model, on: db) - try await Passes.sendPushNotifications(for: pkPass, on: db, app: self.app) + try await PassesService.sendPushNotifications(for: pkPass, on: db, app: self.app) } } ``` @@ -268,7 +332,9 @@ app.databases.middleware.use(PassDataMiddleware(app: app), on: .psql) > [!IMPORTANT] > Whenever your pass data changes, you must update the *updatedAt* time of the linked pass so that Apple knows to send you a new pass. -If you did not include the routes remember to configure APNSwift yourself like this: +#### APNSwift + +If you did not include the routes, remember to configure APNSwift yourself like this: ```swift let apnsConfig: APNSClientConfiguration @@ -302,12 +368,21 @@ app.apns.containers.use( ) ``` -#### Custom Implementation +### Custom Implementation + +If you don't like the schema names 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. -If you don't like the schema names that are used by default, you can instead instantiate the generic `PassesCustom` and provide your model types. +```swift +import PassKit +import Passes + +let passesService = PassesServiceCustom(app: app, delegate: delegate) +``` + +The `DeviceModel` and `ErrorLogModel` protocols are found inside the the `PassKit` product. If you want to customize the devices and error logs models you have to add it to the package manifest: ```swift -let passes = PassesCustom(app: app, delegate: delegate) +.product(name: "PassKit", package: "PassKit") ``` ### Register Migrations @@ -315,14 +390,15 @@ let passes = PassesCustom [!IMPORTANT] +> Register the default models before the migration of your pass data model. ### Generate Pass Content -To generate and distribute the `.pkpass` bundle, pass the `Passes` object to your `RouteCollection`: +To generate and distribute the `.pkpass` bundle, pass the `PassesService` object to your `RouteCollection`: ```swift import Fluent @@ -330,7 +406,7 @@ import Vapor import Passes struct PassesController: RouteCollection { - let passes: Passes + let passesService: PassesService func boot(routes: RoutesBuilder) throws { ... @@ -338,7 +414,7 @@ struct PassesController: RouteCollection { } ``` -and then use it in the route handler: +and then use it in route handlers: ```swift fileprivate func passHandler(_ req: Request) async throws -> Response { @@ -351,7 +427,7 @@ fileprivate func passHandler(_ req: Request) async throws -> Response { throw Abort(.notFound) } - let bundle = try await passes.generatePassContent(for: passData.pass, on: req.db) + 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") @@ -361,3 +437,15 @@ fileprivate func passHandler(_ req: Request) async throws -> Response { return Response(status: .ok, headers: headers, body: body) } ``` + +## ๐Ÿ“ฆ Wallet Orders + +Add the `Orders` product to your target's dependencies: + +```swift +.product(name: "Orders", package: "PassKit") +``` + +> [!WARNING] +> The `Orders` is WIP, right now you can only set up the models and generate `.order` bundles. +APNS support and order updates will be added soon. See the `Orders` target's documentation. \ No newline at end of file diff --git a/Sources/Orders/DTOs/OrdersForDeviceDTO.swift b/Sources/Orders/DTOs/OrdersForDeviceDTO.swift new file mode 100644 index 0000000..921da6b --- /dev/null +++ b/Sources/Orders/DTOs/OrdersForDeviceDTO.swift @@ -0,0 +1,18 @@ +// +// OrdersForDeviceDTO.swift +// PassKit +// +// Created by Francesco Paolo Severino on 30/06/24. +// + +import Vapor + +struct OrdersForDeviceDTO: Content { + let orderIdentifiers: [String] + let lastModified: String + + init(with orderIdentifiers: [String], maxDate: Date) { + self.orderIdentifiers = orderIdentifiers + lastModified = String(maxDate.timeIntervalSince1970) + } +} \ No newline at end of file diff --git a/Sources/Orders/Middleware/AppleOrderMiddleware.swift b/Sources/Orders/Middleware/AppleOrderMiddleware.swift new file mode 100644 index 0000000..517fe9d --- /dev/null +++ b/Sources/Orders/Middleware/AppleOrderMiddleware.swift @@ -0,0 +1,22 @@ +// +// AppleOrderMiddleware.swift +// PassKit +// +// Created by Francesco Paolo Severino on 30/06/24. +// + +import Vapor +import FluentKit + +struct AppleOrderMiddleware: AsyncMiddleware { + func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { + guard let auth = request.headers["Authorization"].first?.replacingOccurrences(of: "AppleOrder ", with: ""), + let _ = try await O.query(on: request.db) + .filter(\._$authenticationToken == auth) + .first() + else { + throw Abort(.unauthorized) + } + return try await next.respond(to: request) + } +} \ No newline at end of file diff --git a/Sources/Orders/Models/Concrete Models/Order.swift b/Sources/Orders/Models/Concrete Models/Order.swift new file mode 100644 index 0000000..d625205 --- /dev/null +++ b/Sources/Orders/Models/Concrete Models/Order.swift @@ -0,0 +1,57 @@ +// +// Order.swift +// PassKit +// +// Created by Francesco Paolo Severino on 30/06/24. +// + +import Foundation +import FluentKit + +/// The `Model` that stores Wallet orders. +open class Order: OrderModel, @unchecked Sendable { + public static let schema = Order.FieldKeys.schemaName + + @ID + public var id: UUID? + + @Timestamp(key: Order.FieldKeys.updatedAt, on: .update) + public var updatedAt: Date? + + @Field(key: Order.FieldKeys.orderTypeIdentifier) + public var orderTypeIdentifier: String + + @Field(key: Order.FieldKeys.authenticationToken) + public var authenticationToken: String + + public required init() { } + + public required init(orderTypeIdentifier: String, authenticationToken: String) { + self.orderTypeIdentifier = orderTypeIdentifier + self.authenticationToken = authenticationToken + } +} + +extension Order: AsyncMigration { + public func prepare(on database: any Database) async throws { + try await database.schema(Self.schema) + .id() + .field(Order.FieldKeys.updatedAt, .datetime, .required) + .field(Order.FieldKeys.orderTypeIdentifier, .string, .required) + .field(Order.FieldKeys.authenticationToken, .string, .required) + .create() + } + + public func revert(on database: any Database) async throws { + try await database.schema(Self.schema).delete() + } +} + +extension Order { + enum FieldKeys { + static let schemaName = "orders" + static let updatedAt = FieldKey(stringLiteral: "updated_at") + static let orderTypeIdentifier = FieldKey(stringLiteral: "order_type_identifier") + static let authenticationToken = FieldKey(stringLiteral: "authentication_token") + } +} \ No newline at end of file diff --git a/Sources/Orders/Models/Concrete Models/OrdersDevice.swift b/Sources/Orders/Models/Concrete Models/OrdersDevice.swift new file mode 100644 index 0000000..b2519ea --- /dev/null +++ b/Sources/Orders/Models/Concrete Models/OrdersDevice.swift @@ -0,0 +1,53 @@ +// +// OrdersDevice.swift +// PassKit +// +// Created by Francesco Paolo Severino on 30/06/24. +// + +import FluentKit +import PassKit + +/// The `Model` that stores Wallet orders devices. +final public class OrdersDevice: DeviceModel, @unchecked Sendable { + public static let schema = OrdersDevice.FieldKeys.schemaName + + @ID(custom: .id) + public var id: Int? + + @Field(key: OrdersDevice.FieldKeys.pushToken) + public var pushToken: String + + @Field(key: OrdersDevice.FieldKeys.deviceLibraryIdentifier) + public var deviceLibraryIdentifier: String + + public init(deviceLibraryIdentifier: String, pushToken: String) { + self.deviceLibraryIdentifier = deviceLibraryIdentifier + self.pushToken = pushToken + } + + public init() {} +} + +extension OrdersDevice: AsyncMigration { + public func prepare(on database: any Database) async throws { + try await database.schema(Self.schema) + .field(.id, .int, .identifier(auto: true)) + .field(OrdersDevice.FieldKeys.pushToken, .string, .required) + .field(OrdersDevice.FieldKeys.deviceLibraryIdentifier, .string, .required) + .unique(on: OrdersDevice.FieldKeys.pushToken, OrdersDevice.FieldKeys.deviceLibraryIdentifier) + .create() + } + + public func revert(on database: any Database) async throws { + try await database.schema(Self.schema).delete() + } +} + +extension OrdersDevice { + enum FieldKeys { + static let schemaName = "orders_devices" + static let pushToken = FieldKey(stringLiteral: "push_token") + static let deviceLibraryIdentifier = FieldKey(stringLiteral: "device_library_identifier") + } +} \ No newline at end of file diff --git a/Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift b/Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift new file mode 100644 index 0000000..173e340 --- /dev/null +++ b/Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift @@ -0,0 +1,52 @@ +// +// OrdersErrorLog.swift +// PassKit +// +// Created by Francesco Paolo Severino on 30/06/24. +// + +import struct Foundation.Date +import FluentKit +import PassKit + +/// The `Model` that stores Wallet orders error logs. +final public class OrdersErrorLog: ErrorLogModel, @unchecked Sendable { + public static let schema = OrdersErrorLog.FieldKeys.schemaName + + @ID(custom: .id) + public var id: Int? + + @Timestamp(key: OrdersErrorLog.FieldKeys.createdAt, on: .create) + public var createdAt: Date? + + @Field(key: OrdersErrorLog.FieldKeys.message) + public var message: String + + public init(message: String) { + self.message = message + } + + public init() {} +} + +extension OrdersErrorLog: AsyncMigration { + public func prepare(on database: any Database) async throws { + try await database.schema(Self.schema) + .field(.id, .int, .identifier(auto: true)) + .field(OrdersErrorLog.FieldKeys.createdAt, .datetime, .required) + .field(OrdersErrorLog.FieldKeys.message, .string, .required) + .create() + } + + public func revert(on database: any Database) async throws { + try await database.schema(Self.schema).delete() + } +} + +extension OrdersErrorLog { + enum FieldKeys { + static let schemaName = "orders_errors" + static let createdAt = FieldKey(stringLiteral: "created_at") + static let message = FieldKey(stringLiteral: "message") + } +} \ No newline at end of file diff --git a/Sources/Orders/Models/Concrete Models/OrdersRegistration.swift b/Sources/Orders/Models/Concrete Models/OrdersRegistration.swift new file mode 100644 index 0000000..6acae38 --- /dev/null +++ b/Sources/Orders/Models/Concrete Models/OrdersRegistration.swift @@ -0,0 +1,49 @@ +// +// OrdersRegistration.swift +// PassKit +// +// Created by Francesco Paolo Severino on 30/06/24. +// + +import FluentKit + +/// The `Model` that stores orders registrations. +final public class OrdersRegistration: OrdersRegistrationModel, @unchecked Sendable { + public typealias OrderType = Order + public typealias DeviceType = OrdersDevice + + public static let schema = OrdersRegistration.FieldKeys.schemaName + + @ID(custom: .id) + public var id: Int? + + @Parent(key: OrdersRegistration.FieldKeys.deviceID) + public var device: DeviceType + + @Parent(key: OrdersRegistration.FieldKeys.orderID) + public var order: OrderType + + public init() {} +} + +extension OrdersRegistration: AsyncMigration { + public func prepare(on database: any Database) async throws { + try await database.schema(Self.schema) + .field(.id, .int, .identifier(auto: true)) + .field(OrdersRegistration.FieldKeys.deviceID, .int, .required, .references(DeviceType.schema, .id, onDelete: .cascade)) + .field(OrdersRegistration.FieldKeys.orderID, .uuid, .required, .references(OrderType.schema, .id, onDelete: .cascade)) + .create() + } + + public func revert(on database: any Database) async throws { + try await database.schema(Self.schema).delete() + } +} + +extension OrdersRegistration { + enum FieldKeys { + static let schemaName = "orders_registrations" + static let deviceID = FieldKey(stringLiteral: "device_id") + static let orderID = FieldKey(stringLiteral: "order_id") + } +} \ No newline at end of file diff --git a/Sources/Orders/Models/OrderDataModel.swift b/Sources/Orders/Models/OrderDataModel.swift new file mode 100644 index 0000000..55f3030 --- /dev/null +++ b/Sources/Orders/Models/OrderDataModel.swift @@ -0,0 +1,27 @@ +// +// OrderDataModel.swift +// PassKit +// +// Created by Francesco Paolo Severino on 30/06/24. +// + +import FluentKit + +/// Represents the `Model` that stores custom app data associated to Wallet orders. +public protocol OrderDataModel: Model { + associatedtype OrderType: OrderModel + + /// The foreign key to the order table + var order: OrderType { get set } +} + +internal extension OrderDataModel { + var _$order: Parent { + guard let mirror = Mirror(reflecting: self).descendant("_order"), + let order = mirror as? Parent else { + fatalError("order property must be declared using @Parent") + } + + return order + } +} \ No newline at end of file diff --git a/Sources/Orders/Models/OrderModel.swift b/Sources/Orders/Models/OrderModel.swift new file mode 100644 index 0000000..09f3221 --- /dev/null +++ b/Sources/Orders/Models/OrderModel.swift @@ -0,0 +1,61 @@ +// +// OrderModel.swift +// PassKit +// +// Created by Francesco Paolo Severino on 30/06/24. +// + +import Foundation +import FluentKit + +/// Represents the `Model` that stores Waller orders. +/// +/// Uses a UUID so people can't easily guess order IDs +public protocol OrderModel: Model where IDValue == UUID { + /// The order type identifier. + var orderTypeIdentifier: String { get set } + + /// The last time the order was modified. + var updatedAt: Date? { get set } + + /// The authentication token for the order. + var authenticationToken: String { get set } +} + +internal extension OrderModel { + 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 _$orderTypeIdentifier: Field { + guard let mirror = Mirror(reflecting: self).descendant("_orderTypeIdentifier"), + let orderTypeIdentifier = mirror as? Field else { + fatalError("orderTypeIdentifier property must be declared using @Field") + } + + return orderTypeIdentifier + } + + var _$updatedAt: Timestamp { + guard let mirror = Mirror(reflecting: self).descendant("_updatedAt"), + let updatedAt = mirror as? Timestamp else { + fatalError("updatedAt property must be declared using @Timestamp(on: .update)") + } + + return updatedAt + } + + var _$authenticationToken: Field { + guard let mirror = Mirror(reflecting: self).descendant("_authenticationToken"), + let authenticationToken = mirror as? Field else { + fatalError("authenticationToken property must be declared using @Field") + } + + return authenticationToken + } +} \ No newline at end of file diff --git a/Sources/Orders/Models/OrdersRegistrationModel.swift b/Sources/Orders/Models/OrdersRegistrationModel.swift new file mode 100644 index 0000000..ffcc912 --- /dev/null +++ b/Sources/Orders/Models/OrdersRegistrationModel.swift @@ -0,0 +1,51 @@ +// +// OrdersRegistrationModel.swift +// PassKit +// +// Created by Francesco Paolo Severino on 30/06/24. +// + +import FluentKit +import PassKit + +/// Represents the `Model` that stores orders registrations. +public protocol OrdersRegistrationModel: Model where IDValue == Int { + associatedtype OrderType: OrderModel + associatedtype DeviceType: DeviceModel + + /// The device for this registration. + var device: DeviceType { get set } + + /// The order for this registration. + var order: OrderType { get set } +} + +internal extension OrdersRegistrationModel { + var _$device: Parent { + guard let mirror = Mirror(reflecting: self).descendant("_device"), + let device = mirror as? Parent else { + fatalError("device property must be declared using @Parent") + } + + return device + } + + var _$order: Parent { + guard let mirror = Mirror(reflecting: self).descendant("_order"), + let order = mirror as? Parent else { + fatalError("order property must be declared using @Parent") + } + + return order + } + + static func `for`(deviceLibraryIdentifier: String, orderTypeIdentifier: String, on db: any Database) -> QueryBuilder { + Self.query(on: db) + .join(parent: \._$order) + .join(parent: \._$device) + .with(\._$order) + .with(\._$device) + .filter(OrderType.self, \._$orderTypeIdentifier == orderTypeIdentifier) + .filter(DeviceType.self, \._$deviceLibraryIdentifier == deviceLibraryIdentifier) + } +} \ No newline at end of file diff --git a/Sources/Orders/OrdersDelegate.swift b/Sources/Orders/OrdersDelegate.swift new file mode 100644 index 0000000..87a784f --- /dev/null +++ b/Sources/Orders/OrdersDelegate.swift @@ -0,0 +1,120 @@ +// +// OrdersDelegate.swift +// PassKit +// +// Created by Francesco Paolo Severino on 01/07/24. +// + +import Foundation +import FluentKit + +/// The delegate which is responsible for generating the order files. +public protocol OrdersDelegate: AnyObject, Sendable { + /// Should return a `URL` which points to the template data for the order. + /// + /// The URL should point to a directory containing all the images and localizations for the generated `.order` archive but should *not* contain any of these items: + /// - `manifest.json` + /// - `order.json` + /// - `signature` + /// + /// - Parameters: + /// - for: The order data from the SQL server. + /// - db: The SQL database to query against. + /// + /// - Returns: A `URL` which points to the template data for the order. + /// + /// > Important: Be sure to use the `URL(fileURLWithPath:isDirectory:)` constructor. + func template(for: O, db: any Database) async throws -> URL + + /// Generates the SSL `signature` file. + /// + /// If you need to implement custom S/Mime signing you can use this + /// method to do so. You must generate a detached DER signature of the `manifest.json` file. + /// + /// - Parameter root: The location of the `manifest.json` and where to write the `signature` to. + /// - Returns: Return `true` if you generated a custom `signature`, otherwise `false`. + func generateSignatureFile(in root: URL) -> Bool + + /// Encode the order into JSON. + /// + /// This method should generate the entire order JSON. You are provided with + /// the order data from the SQL database and you should return a properly + /// formatted order file encoding. + /// + /// - Parameters: + /// - order: The order data from the SQL server + /// - db: The SQL database to query against. + /// - encoder: The `JSONEncoder` which you should use. + /// - Returns: The encoded order JSON data. + /// + /// > Tip: See the [`Order`](https://developer.apple.com/documentation/walletorders/order) object to understand the keys. + func encode(order: O, db: any Database, encoder: JSONEncoder) async throws -> Data + + /// Should return a `URL` which points to the template data for the order. + /// + /// The URL should point to a directory containing the files specified by these keys: + /// - `wwdrCertificate` + /// - `pemCertificate` + /// - `pemPrivateKey` + /// + /// > Important: Be sure to use the `URL(fileURLWithPath:isDirectory:)` initializer! + var sslSigningFilesDirectory: URL { get } + + /// The location of the `openssl` command as a file URL. + /// + /// > Important: Be sure to use the `URL(fileURLWithPath:)` constructor. + var sslBinary: URL { get } + + /// The full path to the `zip` command as a file URL. + /// + /// > Important: Be sure to use the `URL(fileURLWithPath:)` constructor. + var zipBinary: URL { get } + + /// The name of Apple's WWDR.pem certificate as contained in `sslSigningFiles` path. + /// + /// Defaults to `WWDR.pem` + var wwdrCertificate: String { get } + + /// The name of the PEM Certificate for signing the order as contained in `sslSigningFiles` path. + /// + /// Defaults to `ordercertificate.pem` + var pemCertificate: String { get } + + /// The name of the PEM Certificate's private key for signing the order as contained in `sslSigningFiles` path. + /// + /// Defaults to `orderkey.pem` + var pemPrivateKey: String { get } + + /// The password to the private key file. + var pemPrivateKeyPassword: String? { get } +} + +public extension OrdersDelegate { + var wwdrCertificate: String { + get { return "WWDR.pem" } + } + + var pemCertificate: String { + get { return "ordercertificate.pem" } + } + + var pemPrivateKey: String { + get { return "orderkey.pem" } + } + + var pemPrivateKeyPassword: String? { + get { return nil } + } + + var sslBinary: URL { + get { return URL(fileURLWithPath: "/usr/bin/openssl") } + } + + var zipBinary: URL { + get { return URL(fileURLWithPath: "/usr/bin/zip") } + } + + func generateSignatureFile(in root: URL) -> Bool { + return false + } +} \ No newline at end of file diff --git a/Sources/Orders/OrdersError.swift b/Sources/Orders/OrdersError.swift new file mode 100644 index 0000000..223dc75 --- /dev/null +++ b/Sources/Orders/OrdersError.swift @@ -0,0 +1,26 @@ +// +// OrdersError.swift +// PassKit +// +// Created by Francesco Paolo Severino on 30/06/24. +// + +public enum OrdersError: Error { + /// The template path is not a directory + case templateNotDirectory + + /// The `pemCertificate` file is missing. + case pemCertificateMissing + + /// The `pemPrivateKey` file is missing. + case pemPrivateKeyMissing + + /// Swift NIO failed to read the key. + case nioPrivateKeyReadFailed(any Error) + + /// The path to the zip binary is incorrect. + case zipBinaryMissing + + /// The path to the openssl binary is incorrect + case opensslBinaryMissing +} \ No newline at end of file diff --git a/Sources/Orders/OrdersService.swift b/Sources/Orders/OrdersService.swift new file mode 100644 index 0000000..23977ba --- /dev/null +++ b/Sources/Orders/OrdersService.swift @@ -0,0 +1,38 @@ +// +// OrdersService.swift +// PassKit +// +// Created by Francesco Paolo Severino on 01/07/24. +// + +import Vapor +import FluentKit + +/// The main class that handles Wallet orders. +public final class OrdersService: Sendable { + private let service: OrdersServiceCustom + + public init(app: Application, delegate: any OrdersDelegate, logger: Logger? = nil) { + service = .init(app: app, delegate: delegate, logger: logger) + } + + /// Generates the order content bundle for a given order. + /// + /// - Parameters: + /// - order: The order to generate the content for. + /// - db: The `Database` to use. + /// - Returns: The generated order content. + public func generateOrderContent(for order: Order, on db: any Database) async throws -> Data { + try await service.generateOrderContent(for: order, on: db) + } + + /// Adds the migrations for Wallet orders models. + /// + /// - Parameter migrations: The `Migrations` object to add the migrations to. + public static func register(migrations: Migrations) { + migrations.add(Order()) + migrations.add(OrdersDevice()) + migrations.add(OrdersRegistration()) + migrations.add(OrdersErrorLog()) + } +} diff --git a/Sources/Orders/OrdersServiceCustom.swift b/Sources/Orders/OrdersServiceCustom.swift new file mode 100644 index 0000000..c70b974 --- /dev/null +++ b/Sources/Orders/OrdersServiceCustom.swift @@ -0,0 +1,147 @@ +// +// OrdersServiceCustom.swift +// PassKit +// +// Created by Francesco Paolo Severino on 01/07/24. +// + +@preconcurrency import Vapor +import APNS +import VaporAPNS +@preconcurrency import APNSCore +import Fluent +import NIOSSL +import PassKit + +/// Class to handle `OrdersService`. +/// +/// The generics should be passed in this order: +/// - Order Type +/// - Device Type +/// - Registration Type +/// - Error Log Type +public final class OrdersServiceCustom: Sendable where O == R.OrderType, D == R.DeviceType { + public unowned let delegate: any OrdersDelegate + private unowned let app: Application + + private let v1: any RoutesBuilder + private let logger: Logger? + + public init(app: Application, delegate: any OrdersDelegate, logger: Logger? = nil) { + self.delegate = delegate + self.logger = logger + self.app = app + + v1 = app.grouped("api", "orders", "v1") + } +} + +// MARK: - order file generation +extension OrdersServiceCustom { + private static func generateManifestFile(using encoder: JSONEncoder, in root: URL) throws { + var manifest: [String: String] = [:] + + let paths = try FileManager.default.subpathsOfDirectory(atPath: root.unixPath()) + try paths.forEach { relativePath in + let file = URL(fileURLWithPath: relativePath, relativeTo: root) + guard !file.hasDirectoryPath else { + return + } + + let data = try Data(contentsOf: file) + let hash = SHA256.hash(data: data) + manifest[relativePath] = hash.map { "0\(String($0, radix: 16))".suffix(2) }.joined() + } + + let encoded = try encoder.encode(manifest) + try encoded.write(to: root.appendingPathComponent("manifest.json")) + } + + private func generateSignatureFile(in root: URL) throws { + if delegate.generateSignatureFile(in: root) { + // If the caller's delegate generated a file we don't have to do it. + return + } + + let sslBinary = delegate.sslBinary + + guard FileManager.default.fileExists(atPath: sslBinary.unixPath()) else { + throw OrdersError.opensslBinaryMissing + } + + let proc = Process() + proc.currentDirectoryURL = delegate.sslSigningFilesDirectory + proc.executableURL = sslBinary + + proc.arguments = [ + "smime", "-binary", "-sign", + "-certfile", delegate.wwdrCertificate, + "-signer", delegate.pemCertificate, + "-inkey", delegate.pemPrivateKey, + "-in", root.appendingPathComponent("manifest.json").unixPath(), + "-out", root.appendingPathComponent("signature").unixPath(), + "-outform", "DER" + ] + + if let pwd = delegate.pemPrivateKeyPassword { + proc.arguments!.append(contentsOf: ["-passin", "pass:\(pwd)"]) + } + + try proc.run() + + proc.waitUntilExit() + } + + private func zip(directory: URL, to: URL) throws { + let zipBinary = delegate.zipBinary + guard FileManager.default.fileExists(atPath: zipBinary.unixPath()) else { + throw OrdersError.zipBinaryMissing + } + + let proc = Process() + proc.currentDirectoryURL = directory + proc.executableURL = zipBinary + + proc.arguments = [ to.unixPath(), "-r", "-q", "." ] + + try proc.run() + proc.waitUntilExit() + } + + public func generateOrderContent(for order: O, on db: any Database) async throws -> Data { + let tmp = FileManager.default.temporaryDirectory + let root = tmp.appendingPathComponent(UUID().uuidString, isDirectory: true) + let zipFile = tmp.appendingPathComponent("\(UUID().uuidString).zip") + let encoder = JSONEncoder() + + let src = try await delegate.template(for: order, db: db) + guard (try? src.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false else { + throw OrdersError.templateNotDirectory + } + + let encoded = try await self.delegate.encode(order: order, db: db, encoder: encoder) + + do { + try FileManager.default.copyItem(at: src, to: root) + + defer { + _ = try? FileManager.default.removeItem(at: root) + } + + try encoded.write(to: root.appendingPathComponent("order.json")) + + try Self.generateManifestFile(using: encoder, in: root) + try self.generateSignatureFile(in: root) + + try self.zip(directory: root, to: zipFile) + + defer { + _ = try? FileManager.default.removeItem(at: zipFile) + } + + return try Data(contentsOf: zipFile) + } catch { + throw error + } + } +} \ No newline at end of file diff --git a/Sources/Passes/DTOs/ErrorLogDTO.swift b/Sources/PassKit/DTOs/ErrorLogDTO.swift similarity index 96% rename from Sources/Passes/DTOs/ErrorLogDTO.swift rename to Sources/PassKit/DTOs/ErrorLogDTO.swift index a1af6f7..3096031 100644 --- a/Sources/Passes/DTOs/ErrorLogDTO.swift +++ b/Sources/PassKit/DTOs/ErrorLogDTO.swift @@ -28,6 +28,6 @@ import Vapor -struct ErrorLogDTO: Content { - let logs: [String] +package struct ErrorLogDTO: Content { + package let logs: [String] } diff --git a/Sources/Passes/DTOs/RegistrationDTO.swift b/Sources/PassKit/DTOs/RegistrationDTO.swift similarity index 95% rename from Sources/Passes/DTOs/RegistrationDTO.swift rename to Sources/PassKit/DTOs/RegistrationDTO.swift index a0a2068..179ec41 100644 --- a/Sources/Passes/DTOs/RegistrationDTO.swift +++ b/Sources/PassKit/DTOs/RegistrationDTO.swift @@ -28,6 +28,6 @@ import Vapor -struct RegistrationDTO: Content { - let pushToken: String +package struct RegistrationDTO: Content { + package let pushToken: String } diff --git a/Sources/Passes/Models/PassKitDevice.swift b/Sources/PassKit/Models/DeviceModel.swift similarity index 96% rename from Sources/Passes/Models/PassKitDevice.swift rename to Sources/PassKit/Models/DeviceModel.swift index 2df7b68..50f86e1 100644 --- a/Sources/Passes/Models/PassKitDevice.swift +++ b/Sources/PassKit/Models/DeviceModel.swift @@ -26,11 +26,10 @@ /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN /// THE SOFTWARE. -import Vapor -import Fluent +import FluentKit /// Represents the `Model` that stores PassKit devices. -public protocol PassKitDevice: Model where IDValue == Int { +public protocol DeviceModel: Model where IDValue == Int { /// The push token used for sending updates to the device. var pushToken: String { get set } @@ -44,7 +43,7 @@ public protocol PassKitDevice: Model where IDValue == Int { init(deviceLibraryIdentifier: String, pushToken: String) } -internal extension PassKitDevice { +package extension DeviceModel { var _$id: ID { guard let mirror = Mirror(reflecting: self).descendant("_id"), let id = mirror as? ID else { diff --git a/Sources/Passes/Models/PassKitErrorLog.swift b/Sources/PassKit/Models/ErrorLogModel.swift similarity index 95% rename from Sources/Passes/Models/PassKitErrorLog.swift rename to Sources/PassKit/Models/ErrorLogModel.swift index b98ddc6..fc417df 100644 --- a/Sources/Passes/Models/PassKitErrorLog.swift +++ b/Sources/PassKit/Models/ErrorLogModel.swift @@ -26,11 +26,10 @@ /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN /// THE SOFTWARE. -import Vapor -import Fluent +import FluentKit /// Represents the `Model` that stores PassKit error logs. -public protocol PassKitErrorLog: Model { +public protocol ErrorLogModel: Model { /// The error message provided by PassKit var message: String { get set } @@ -39,7 +38,7 @@ public protocol PassKitErrorLog: Model { init(message: String) } -internal extension PassKitErrorLog { +package extension ErrorLogModel { var _$message: Field { guard let mirror = Mirror(reflecting: self).descendant("_message"), let message = mirror as? Field else { diff --git a/Sources/Passes/URL+Extension.swift b/Sources/PassKit/URL+Extension.swift similarity index 97% rename from Sources/Passes/URL+Extension.swift rename to Sources/PassKit/URL+Extension.swift index 5803298..5114164 100644 --- a/Sources/Passes/URL+Extension.swift +++ b/Sources/PassKit/URL+Extension.swift @@ -29,7 +29,7 @@ import Foundation extension URL { - func unixPath() -> String { + package func unixPath() -> String { absoluteString.replacingOccurrences(of: "file://", with: "") } } diff --git a/Sources/Passes/DTOs/PassJSON.swift b/Sources/Passes/DTOs/PassJSON.swift new file mode 100644 index 0000000..0cccc7e --- /dev/null +++ b/Sources/Passes/DTOs/PassJSON.swift @@ -0,0 +1,93 @@ +// +// PassJSON.swift +// PassKit +// +// Created by Francesco Paolo Severino on 28/06/24. +// + +/// A protocol that defines the structure of a `pass.json` file. +/// +/// > Tip: See the [`Pass`](https://developer.apple.com/documentation/walletpasses/pass) object to understand the keys. +public protocol PassJSON: Encodable { + /// A short description that iOS accessibility technologies use for a pass. + var description: String { get } + + /// The version of the file format. The value must be 1. + var formatVersion: Int { get } + + /// The name of the organization. + var organizationName: String { get } + + /// The pass type identifier thatโ€™s registered with Apple. + /// + /// The value must be the same as the distribution certificate used to sign the pass. + var passTypeIdentifier: String { get } + + /// An alphanumeric serial number. + /// + /// The combination of the serial number and pass type identifier must be unique for each pass. + var serialNumber: String { get } + + /// The Team ID for the Apple Developer Program account that registered the pass type identifier. + var teamIdentifier: String { get } +} + +/// A protocol that represents the information to display in a field on a pass. +/// +/// > Tip: See the [`PassFieldContent`](https://developer.apple.com/documentation/walletpasses/passfieldcontent) object to understand the keys. +public protocol PassFieldContent: Encodable { + /// A unique key that identifies a field in the pass; for example, `departure-gate`. + var key: String { get } + + /// The value to use for the field; for example, 42. + /// + /// A date or time value must include a time zone. + var value: String { get } +} + +/// A protocol that represents the groups of fields that display the information for a boarding pass. +/// +/// > Tip: See the [`Pass.BoardingPass`](https://developer.apple.com/documentation/walletpasses/pass/boardingpass) object to understand the keys. +public protocol BoardingPass: Encodable { + /// The type of transit for a boarding pass. + /// + /// This key is invalid for other types of passes. + /// + /// The system may use the value to display more information, + /// such as showing an airplane icon for the pass on watchOS when the value is set to `PKTransitTypeAir`. + var transitType: TransitType { get } +} + +/// The type of transit for a boarding pass. +public enum TransitType: String, Encodable { + case air = "PKTransitTypeAir" + case boat = "PKTransitTypeBoat" + case bus = "PKTransitTypeBus" + case generic = "PKTransitTypeGeneric" + case train = "PKTransitTypeTrain" +} + +/// A protocol that represents a barcode on a pass. +/// +/// > Tip: See the [`Pass.Barcodes`](https://developer.apple.com/documentation/walletpasses/pass/barcodes) object to understand the keys. +public protocol Barcodes: Encodable { + /// The format of the barcode. + /// + /// The barcode format `PKBarcodeFormatCode128` isnโ€™t supported for watchOS. + var format: BarcodeFormat { get } + + /// The message or payload to display as a barcode. + var message: String { get } + + /// The IANA character set name of the text encoding to use to convert message + /// from a string representation to a data representation that the system renders as a barcode, such as `iso-8859-1`. + var messageEncoding: String { get } +} + +/// The format of the barcode. +public enum BarcodeFormat: String, Encodable { + case pdf417 = "PKBarcodeFormatPDF417" + case qr = "PKBarcodeFormatQR" + case aztec = "PKBarcodeFormatAztec" + case code128 = "PKBarcodeFormatCode128" +} diff --git a/Sources/Passes/FakeSendable.swift b/Sources/Passes/FakeSendable.swift deleted file mode 100644 index b048685..0000000 --- a/Sources/Passes/FakeSendable.swift +++ /dev/null @@ -1,32 +0,0 @@ -/// Copyright 2020 Gargoyle Software, LLC -/// -/// Permission is hereby granted, free of charge, to any person obtaining a copy -/// of this software and associated documentation files (the "Software"), to deal -/// in the Software without restriction, including without limitation the rights -/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -/// copies of the Software, and to permit persons to whom the Software is -/// furnished to do so, subject to the following conditions: -/// -/// The above copyright notice and this permission notice shall be included in -/// all copies or substantial portions of the Software. -/// -/// Notwithstanding the foregoing, you may not use, copy, modify, merge, publish, -/// distribute, sublicense, create a derivative work, and/or sell copies of the -/// Software in any work that is designed, intended, or marketed for pedagogical or -/// instructional purposes related to programming, coding, application development, -/// or information technology. Permission for such use, copying, modification, -/// merger, publication, distribution, sublicensing, creation of derivative works, -/// or sale is expressly withheld. -/// -/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -/// THE SOFTWARE. - -// This is a temporary fix until RoutesBuilder and EmptyPayload are not Sendable -struct FakeSendable: @unchecked Sendable { - let value: T -} diff --git a/Sources/Passes/Middleware/ApplePassMiddleware.swift b/Sources/Passes/Middleware/ApplePassMiddleware.swift index cd06789..ee498e5 100644 --- a/Sources/Passes/Middleware/ApplePassMiddleware.swift +++ b/Sources/Passes/Middleware/ApplePassMiddleware.swift @@ -27,13 +27,15 @@ /// THE SOFTWARE. import Vapor +import FluentKit -struct ApplePassMiddleware: AsyncMiddleware { - let authorizationCode: String - +struct ApplePassMiddleware: AsyncMiddleware { func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { - let auth = request.headers["Authorization"] - guard auth.first == "ApplePass \(authorizationCode)" else { + guard let auth = request.headers["Authorization"].first?.replacingOccurrences(of: "ApplePass ", with: ""), + let _ = try await P.query(on: request.db) + .filter(\._$authenticationToken == auth) + .first() + else { throw Abort(.unauthorized) } return try await next.respond(to: request) diff --git a/Sources/Passes/Models/Concrete Models/PKPass.swift b/Sources/Passes/Models/Concrete Models/PKPass.swift new file mode 100644 index 0000000..4103a09 --- /dev/null +++ b/Sources/Passes/Models/Concrete Models/PKPass.swift @@ -0,0 +1,57 @@ +// +// PKPass.swift +// PassKit +// +// Created by Francesco Paolo Severino on 29/06/24. +// + +import Foundation +import FluentKit + +/// The `Model` that stores PassKit passes. +open class PKPass: PassModel, @unchecked Sendable { + public static let schema = PKPass.FieldKeys.schemaName + + @ID + public var id: UUID? + + @Timestamp(key: PKPass.FieldKeys.updatedAt, on: .update) + public var updatedAt: Date? + + @Field(key: PKPass.FieldKeys.passTypeIdentifier) + public var passTypeIdentifier: String + + @Field(key: PKPass.FieldKeys.authenticationToken) + public var authenticationToken: String + + public required init() { } + + public required init(passTypeIdentifier: String, authenticationToken: String) { + self.passTypeIdentifier = passTypeIdentifier + self.authenticationToken = authenticationToken + } +} + +extension PKPass: 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) + .create() + } + + public func revert(on database: any Database) async throws { + try await database.schema(Self.schema).delete() + } +} + +extension PKPass { + 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") + } +} diff --git a/Sources/Passes/Models/Concrete Models/PassesDevice.swift b/Sources/Passes/Models/Concrete Models/PassesDevice.swift new file mode 100644 index 0000000..c06eba0 --- /dev/null +++ b/Sources/Passes/Models/Concrete Models/PassesDevice.swift @@ -0,0 +1,53 @@ +// +// PassesDevice.swift +// PassKit +// +// Created by Francesco Paolo Severino on 29/06/24. +// + +import FluentKit +import PassKit + +/// The `Model` that stores PassKit passes devices. +final public class PassesDevice: DeviceModel, @unchecked Sendable { + public static let schema = PassesDevice.FieldKeys.schemaName + + @ID(custom: .id) + public var id: Int? + + @Field(key: PassesDevice.FieldKeys.pushToken) + public var pushToken: String + + @Field(key: PassesDevice.FieldKeys.deviceLibraryIdentifier) + public var deviceLibraryIdentifier: String + + public init(deviceLibraryIdentifier: String, pushToken: String) { + self.deviceLibraryIdentifier = deviceLibraryIdentifier + self.pushToken = pushToken + } + + public init() {} +} + +extension PassesDevice: AsyncMigration { + public func prepare(on database: any Database) async throws { + try await database.schema(Self.schema) + .field(.id, .int, .identifier(auto: true)) + .field(PassesDevice.FieldKeys.pushToken, .string, .required) + .field(PassesDevice.FieldKeys.deviceLibraryIdentifier, .string, .required) + .unique(on: PassesDevice.FieldKeys.pushToken, PassesDevice.FieldKeys.deviceLibraryIdentifier) + .create() + } + + public func revert(on database: any Database) async throws { + try await database.schema(Self.schema).delete() + } +} + +extension PassesDevice { + enum FieldKeys { + static let schemaName = "passes_devices" + static let pushToken = FieldKey(stringLiteral: "push_token") + static let deviceLibraryIdentifier = FieldKey(stringLiteral: "device_library_identifier") + } +} diff --git a/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift b/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift new file mode 100644 index 0000000..3e0497c --- /dev/null +++ b/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift @@ -0,0 +1,52 @@ +// +// PassesErrorLog.swift +// PassKit +// +// Created by Francesco Paolo Severino on 29/06/24. +// + +import struct Foundation.Date +import FluentKit +import PassKit + +/// The `Model` that stores PassKit passes error logs. +final public class PassesErrorLog: ErrorLogModel, @unchecked Sendable { + public static let schema = PassesErrorLog.FieldKeys.schemaName + + @ID(custom: .id) + public var id: Int? + + @Timestamp(key: PassesErrorLog.FieldKeys.createdAt, on: .create) + public var createdAt: Date? + + @Field(key: PassesErrorLog.FieldKeys.message) + public var message: String + + public init(message: String) { + self.message = message + } + + public init() {} +} + +extension PassesErrorLog: AsyncMigration { + public func prepare(on database: any Database) async throws { + try await database.schema(Self.schema) + .field(.id, .int, .identifier(auto: true)) + .field(PassesErrorLog.FieldKeys.createdAt, .datetime, .required) + .field(PassesErrorLog.FieldKeys.message, .string, .required) + .create() + } + + public func revert(on database: any Database) async throws { + try await database.schema(Self.schema).delete() + } +} + +extension PassesErrorLog { + enum FieldKeys { + static let schemaName = "passes_errors" + static let createdAt = FieldKey(stringLiteral: "created_at") + static let message = FieldKey(stringLiteral: "message") + } +} diff --git a/Sources/Passes/Models/Concrete Models/PassesRegistration.swift b/Sources/Passes/Models/Concrete Models/PassesRegistration.swift new file mode 100644 index 0000000..35cea46 --- /dev/null +++ b/Sources/Passes/Models/Concrete Models/PassesRegistration.swift @@ -0,0 +1,49 @@ +// +// PassesRegistration.swift +// PassKit +// +// Created by Francesco Paolo Severino on 29/06/24. +// + +import FluentKit + +/// The `Model` that stores passes registrations. +final public class PassesRegistration: PassesRegistrationModel, @unchecked Sendable { + public typealias PassType = PKPass + public typealias DeviceType = PassesDevice + + public static let schema = PassesRegistration.FieldKeys.schemaName + + @ID(custom: .id) + public var id: Int? + + @Parent(key: PassesRegistration.FieldKeys.deviceID) + public var device: DeviceType + + @Parent(key: PassesRegistration.FieldKeys.passID) + public var pass: PassType + + public init() {} +} + +extension PassesRegistration: AsyncMigration { + public func prepare(on database: any Database) async throws { + try await database.schema(Self.schema) + .field(.id, .int, .identifier(auto: true)) + .field(PassesRegistration.FieldKeys.deviceID, .int, .required, .references(DeviceType.schema, .id, onDelete: .cascade)) + .field(PassesRegistration.FieldKeys.passID, .uuid, .required, .references(PassType.schema, .id, onDelete: .cascade)) + .create() + } + + public func revert(on database: any Database) async throws { + try await database.schema(Self.schema).delete() + } +} + +extension PassesRegistration { + enum FieldKeys { + static let schemaName = "passes_registrations" + static let deviceID = FieldKey(stringLiteral: "device_id") + static let passID = FieldKey(stringLiteral: "pass_id") + } +} diff --git a/Sources/Passes/Models/ConcreteModels.swift b/Sources/Passes/Models/ConcreteModels.swift deleted file mode 100644 index f6c7bea..0000000 --- a/Sources/Passes/Models/ConcreteModels.swift +++ /dev/null @@ -1,169 +0,0 @@ -/// Copyright 2020 Gargoyle Software, LLC -/// -/// Permission is hereby granted, free of charge, to any person obtaining a copy -/// of this software and associated documentation files (the "Software"), to deal -/// in the Software without restriction, including without limitation the rights -/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -/// copies of the Software, and to permit persons to whom the Software is -/// furnished to do so, subject to the following conditions: -/// -/// The above copyright notice and this permission notice shall be included in -/// all copies or substantial portions of the Software. -/// -/// Notwithstanding the foregoing, you may not use, copy, modify, merge, publish, -/// distribute, sublicense, create a derivative work, and/or sell copies of the -/// Software in any work that is designed, intended, or marketed for pedagogical or -/// instructional purposes related to programming, coding, application development, -/// or information technology. Permission for such use, copying, modification, -/// merger, publication, distribution, sublicensing, creation of derivative works, -/// or sale is expressly withheld. -/// -/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -/// THE SOFTWARE. - -import Vapor -import Fluent - -/// The `Model` that stores PassKit devices. -final public class PKDevice: PassKitDevice, @unchecked Sendable { - public static let schema = "devices" - - @ID(custom: .id) - public var id: Int? - - @Field(key: "push_token") - public var pushToken: String - - @Field(key: "device_library_identifier") - public var deviceLibraryIdentifier: String - - public init(deviceLibraryIdentifier: String, pushToken: String) { - self.deviceLibraryIdentifier = deviceLibraryIdentifier - self.pushToken = pushToken - } - - public init() {} -} - -extension PKDevice: AsyncMigration { - public func prepare(on database: any Database) async throws { - try await database.schema(Self.schema) - .field(.id, .int, .identifier(auto: true)) - .field("push_token", .string, .required) - .field("device_library_identifier", .string, .required) - .unique(on: "push_token", "device_library_identifier") - .create() - } - - public func revert(on database: any Database) async throws { - try await database.schema(Self.schema).delete() - } -} - -/// The `Model` that stores PassKit passes. -open class PKPass: PassKitPass, @unchecked Sendable { - public static let schema = "passes" - - @ID - public var id: UUID? - - @Timestamp(key: "updated_at", on: .update) - public var updatedAt: Date? - - @Field(key: "pass_type_identifier") - public var passTypeIdentifier: String - - public required init() { } - - public required init(passTypeIdentifier: String) { - self.passTypeIdentifier = passTypeIdentifier - } -} - -extension PKPass: AsyncMigration { - public func prepare(on database: any Database) async throws { - try await database.schema(Self.schema) - .id() - .field("updated_at", .datetime, .required) - .field("pass_type_identifier", .string, .required) - .create() - } - - public func revert(on database: any Database) async throws { - try await database.schema(Self.schema).delete() - } -} - -/// The `Model` that stores PassKit error logs. -final public class PKErrorLog: PassKitErrorLog, @unchecked Sendable { - public static let schema = "errors" - - @ID(custom: .id) - public var id: Int? - - @Timestamp(key: "created_at", on: .create) - public var createdAt: Date? - - @Field(key: "message") - public var message: String - - public init(message: String) { - self.message = message - } - - public init() {} -} - -extension PKErrorLog: AsyncMigration { - public func prepare(on database: any Database) async throws { - try await database.schema(Self.schema) - .field(.id, .int, .identifier(auto: true)) - .field("created", .datetime, .required) - .field("message", .string, .required) - .create() - } - - public func revert(on database: any Database) async throws { - try await database.schema(PKErrorLog.schema).delete() - } -} - -/// The `Model` that stores PassKit registrations. -final public class PKRegistration: PassKitRegistration, @unchecked Sendable { - public typealias PassType = PKPass - public typealias DeviceType = PKDevice - - public static let schema = "registrations" - - @ID(custom: .id) - public var id: Int? - - @Parent(key: "device_id") - public var device: DeviceType - - @Parent(key: "pass_id") - public var pass: PassType - - public init() {} -} - -extension PKRegistration: AsyncMigration { - public func prepare(on database: any Database) async throws { - try await database.schema(Self.schema) - .field(.id, .int, .identifier(auto: true)) - .field("device_id", .int, .required) - .field("pass_id", .uuid, .required) - .foreignKey("device_id", references: DeviceType.schema, .id, onDelete: .cascade) - .foreignKey("pass_id", references: PassType.schema, .id, onDelete: .cascade) - .create() - } - - public func revert(on database: any Database) async throws { - try await database.schema(Self.schema).delete() - } -} diff --git a/Sources/Passes/Models/PassKitPassData.swift b/Sources/Passes/Models/PassDataModel.swift similarity index 93% rename from Sources/Passes/Models/PassKitPassData.swift rename to Sources/Passes/Models/PassDataModel.swift index cf419a2..7da4280 100644 --- a/Sources/Passes/Models/PassKitPassData.swift +++ b/Sources/Passes/Models/PassDataModel.swift @@ -26,18 +26,17 @@ /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN /// THE SOFTWARE. -import Vapor -import Fluent +import FluentKit /// Represents the `Model` that stores custom app data associated to PassKit passes. -public protocol PassKitPassData: Model { - associatedtype PassType: PassKitPass +public protocol PassDataModel: Model { + associatedtype PassType: PassModel /// The foreign key to the pass table var pass: PassType { get set } } -internal extension PassKitPassData { +internal extension PassDataModel { var _$pass: Parent { guard let mirror = Mirror(reflecting: self).descendant("_pass"), let pass = mirror as? Parent else { diff --git a/Sources/Passes/Models/PassKitPass.swift b/Sources/Passes/Models/PassModel.swift similarity index 80% rename from Sources/Passes/Models/PassKitPass.swift rename to Sources/Passes/Models/PassModel.swift index 6f4e174..430b035 100644 --- a/Sources/Passes/Models/PassKitPass.swift +++ b/Sources/Passes/Models/PassModel.swift @@ -26,19 +26,24 @@ /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN /// THE SOFTWARE. -import Vapor -import Fluent +import Foundation +import FluentKit -/// Represents the `Model` that stores PassKit passes. Uses a UUID so people can't easily guess pass IDs -public protocol PassKitPass: Model where IDValue == UUID { +/// Represents the `Model` that stores PassKit passes. +/// +/// Uses a UUID so people can't easily guess pass IDs +public protocol PassModel: Model where IDValue == UUID { /// The pass type identifier. var passTypeIdentifier: String { get set } /// The last time the pass was modified. var updatedAt: Date? { get set } + + /// The authentication token for the pass. + var authenticationToken: String { get set } } -internal extension PassKitPass { +internal extension PassModel { var _$id: ID { guard let mirror = Mirror(reflecting: self).descendant("_id"), let id = mirror as? ID else { @@ -65,4 +70,13 @@ internal extension PassKitPass { return updatedAt } + + var _$authenticationToken: Field { + guard let mirror = Mirror(reflecting: self).descendant("_authenticationToken"), + let authenticationToken = mirror as? Field else { + fatalError("authenticationToken property must be declared using @Field") + } + + return authenticationToken + } } diff --git a/Sources/Passes/Models/PassKitRegistration.swift b/Sources/Passes/Models/PassesRegistrationModel.swift similarity index 90% rename from Sources/Passes/Models/PassKitRegistration.swift rename to Sources/Passes/Models/PassesRegistrationModel.swift index 18c53ac..91310b5 100644 --- a/Sources/Passes/Models/PassKitRegistration.swift +++ b/Sources/Passes/Models/PassesRegistrationModel.swift @@ -26,13 +26,13 @@ /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN /// THE SOFTWARE. -import Vapor -import Fluent +import FluentKit +import PassKit -/// Represents the `Model` that stores PassKit registrations. -public protocol PassKitRegistration: Model where IDValue == Int { - associatedtype PassType: PassKitPass - associatedtype DeviceType: PassKitDevice +/// Represents the `Model` that stores passes registrations. +public protocol PassesRegistrationModel: Model where IDValue == Int { + associatedtype PassType: PassModel + associatedtype DeviceType: DeviceModel /// The device for this registration. var device: DeviceType { get set } @@ -41,7 +41,7 @@ public protocol PassKitRegistration: Model where IDValue == Int { var pass: PassType { get set } } -internal extension PassKitRegistration { +internal extension PassesRegistrationModel { var _$device: Parent { guard let mirror = Mirror(reflecting: self).descendant("_device"), let device = mirror as? Parent else { diff --git a/Sources/Passes/PassesDelegate.swift b/Sources/Passes/PassesDelegate.swift index 4154954..ca57ec5 100644 --- a/Sources/Passes/PassesDelegate.swift +++ b/Sources/Passes/PassesDelegate.swift @@ -26,8 +26,8 @@ /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN /// THE SOFTWARE. -import Vapor -import Fluent +import Foundation +import FluentKit /// The delegate which is responsible for generating the pass files. public protocol PassesDelegate: AnyObject, Sendable { @@ -39,13 +39,13 @@ public protocol PassesDelegate: AnyObject, Sendable { /// - `signature` /// /// - Parameters: - /// - pass: The pass data from the SQL server. + /// - for: The pass data from the SQL server. /// - db: The SQL database to query against. /// /// - Returns: A `URL` which points to the template data for the pass. /// /// > Important: Be sure to use the `URL(fileURLWithPath:isDirectory:)` constructor. - func template(for: P, db: any Database) async throws -> URL + func template(for: P, db: any Database) async throws -> URL /// Generates the SSL `signature` file. /// @@ -68,8 +68,8 @@ public protocol PassesDelegate: AnyObject, Sendable { /// - encoder: The `JSONEncoder` which you should use. /// - Returns: The encoded pass JSON data. /// - /// > Tip: See the [Pass](https://developer.apple.com/documentation/walletpasses/pass) object to understand the keys. - func encode(pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data + /// > Tip: See the [`Pass`](https://developer.apple.com/documentation/walletpasses/pass) object to understand the keys. + func encode(pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data /// Should return a `URL` which points to the template data for the pass. /// diff --git a/Sources/Passes/PassKitError.swift b/Sources/Passes/PassesError.swift similarity index 87% rename from Sources/Passes/PassKitError.swift rename to Sources/Passes/PassesError.swift index 1828a39..673211a 100644 --- a/Sources/Passes/PassKitError.swift +++ b/Sources/Passes/PassesError.swift @@ -1,13 +1,11 @@ // -// File.swift -// +// PassesError.swift +// PassKit // // Created by Scott Grosch on 1/22/20. // -import Foundation - -public enum PassKitError: Error { +public enum PassesError: Error { /// The template path is not a directory case templateNotDirectory diff --git a/Sources/Passes/PassesService.swift b/Sources/Passes/PassesService.swift new file mode 100644 index 0000000..e34cc09 --- /dev/null +++ b/Sources/Passes/PassesService.swift @@ -0,0 +1,102 @@ +/// Copyright 2020 Gargoyle Software, LLC +/// +/// Permission is hereby granted, free of charge, to any person obtaining a copy +/// of this software and associated documentation files (the "Software"), to deal +/// in the Software without restriction, including without limitation the rights +/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +/// copies of the Software, and to permit persons to whom the Software is +/// furnished to do so, subject to the following conditions: +/// +/// The above copyright notice and this permission notice shall be included in +/// all copies or substantial portions of the Software. +/// +/// Notwithstanding the foregoing, you may not use, copy, modify, merge, publish, +/// distribute, sublicense, create a derivative work, and/or sell copies of the +/// Software in any work that is designed, intended, or marketed for pedagogical or +/// instructional purposes related to programming, coding, application development, +/// or information technology. Permission for such use, copying, modification, +/// merger, publication, distribution, sublicensing, creation of derivative works, +/// or sale is expressly withheld. +/// +/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +/// THE SOFTWARE. + +import Vapor +import FluentKit + +/// The main class that handles PassKit passes. +public final class PassesService: Sendable { + private let service: PassesServiceCustom + + 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) + } + + /// Generates the pass content bundle for a given pass. + /// + /// - Parameters: + /// - pass: The pass to generate the content for. + /// - db: The `Database` to use. + /// - Returns: The generated pass content. + public func generatePassContent(for pass: PKPass, on db: any Database) async throws -> Data { + try await service.generatePassContent(for: pass, on: db) + } + + /// Adds the migrations for PassKit passes models. + /// + /// - Parameter migrations: The `Migrations` object to add the migrations to. + public static func register(migrations: Migrations) { + migrations.add(PKPass()) + migrations.add(PassesDevice()) + migrations.add(PassesRegistration()) + migrations.add(PassesErrorLog()) + } + + /// Sends push notifications for a given pass. + /// + /// - Parameters: + /// - 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) + } + + /// Sends push notifications for a given pass. + /// + /// - 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) + } +} diff --git a/Sources/Passes/Passes.swift b/Sources/Passes/PassesServiceCustom.swift similarity index 71% rename from Sources/Passes/Passes.swift rename to Sources/Passes/PassesServiceCustom.swift index 13277ce..1a61918 100644 --- a/Sources/Passes/Passes.swift +++ b/Sources/Passes/PassesServiceCustom.swift @@ -1,125 +1,30 @@ -/// Copyright 2020 Gargoyle Software, LLC -/// -/// Permission is hereby granted, free of charge, to any person obtaining a copy -/// of this software and associated documentation files (the "Software"), to deal -/// in the Software without restriction, including without limitation the rights -/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -/// copies of the Software, and to permit persons to whom the Software is -/// furnished to do so, subject to the following conditions: -/// -/// The above copyright notice and this permission notice shall be included in -/// all copies or substantial portions of the Software. -/// -/// Notwithstanding the foregoing, you may not use, copy, modify, merge, publish, -/// distribute, sublicense, create a derivative work, and/or sell copies of the -/// Software in any work that is designed, intended, or marketed for pedagogical or -/// instructional purposes related to programming, coding, application development, -/// or information technology. Permission for such use, copying, modification, -/// merger, publication, distribution, sublicensing, creation of derivative works, -/// or sale is expressly withheld. -/// -/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -/// THE SOFTWARE. +// +// PassesServiceCustom.swift +// PassKit +// +// Created by Francesco Paolo Severino on 29/06/24. +// -import Vapor +@preconcurrency import Vapor import APNS import VaporAPNS @preconcurrency import APNSCore import Fluent import NIOSSL +import PassKit -/// The main class that handles PassKit passes. -public final class Passes: Sendable { - private let kit: PassesCustom - - public init(app: Application, delegate: any PassesDelegate, logger: Logger? = nil) { - kit = .init(app: app, delegate: delegate, logger: logger) - } - - /// Registers all the routes required for PassKit to work. - /// - /// - Parameter authorizationCode: The `authenticationToken` which you are going to use in the `pass.json` file. - public func registerRoutes(authorizationCode: String? = nil) { - kit.registerRoutes(authorizationCode: authorizationCode) - } - - /// 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 kit.registerPushRoutes(middleware: middleware) - } - - /// Generates the pass content bundle for a given pass. - /// - /// - Parameters: - /// - pass: The pass to generate the content for. - /// - db: The `Database` to use. - /// - Returns: The generated pass content. - public func generatePassContent(for pass: PKPass, on db: any Database) async throws -> Data { - try await kit.generatePassContent(for: pass, on: db) - } - - /// Adds the migrations for PassKit passes models. - /// - /// - Parameter migrations: The `Migrations` object to add the migrations to. - public static func register(migrations: Migrations) { - migrations.add(PKPass()) - migrations.add(PKDevice()) - migrations.add(PKRegistration()) - migrations.add(PKErrorLog()) - } - - /// Sends push notifications for a given pass. - /// - /// - Parameters: - /// - 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 PassesCustom.sendPushNotificationsForPass(id: id, of: passTypeIdentifier, on: db, app: app) - } - - /// Sends push notifications for a given pass. - /// - /// - 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 PassesCustom.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 PassesCustom.sendPushNotifications(for: pass, on: db, app: app) - } -} - -/// Class to handle `Passes`. +/// Class to handle `PassesService`. /// /// The generics should be passed in this order: /// - Pass Type /// - Device Type /// - Registration Type /// - Error Log Type -public final class PassesCustom: Sendable where P == R.PassType, D == R.DeviceType { +public final class PassesServiceCustom: Sendable where P == R.PassType, D == R.DeviceType { public unowned let delegate: any PassesDelegate private unowned let app: Application - private let processQueue = DispatchQueue(label: "com.vapor-community.PassKit", qos: .utility, attributes: .concurrent) - private let v1: FakeSendable + private let v1: any RoutesBuilder private let logger: Logger? public init(app: Application, delegate: any PassesDelegate, logger: Logger? = nil) { @@ -127,21 +32,15 @@ public final class PassesCustom()) 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) }) @@ -152,23 +51,23 @@ public final class PassesCustom HTTPStatus { logger?.debug("Called register device") @@ -406,8 +307,10 @@ public final class PassesCustom