diff --git a/Package.swift b/Package.swift index 3f664f6..9c405bf 100644 --- a/Package.swift +++ b/Package.swift @@ -11,9 +11,9 @@ let package = Package( .library(name: "PassKit", targets: ["PassKit"]), ], dependencies: [ - .package(url: "https://github.com/vapor/vapor.git", from: "4.92.4"), + .package(url: "https://github.com/vapor/vapor.git", from: "4.92.5"), .package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"), - .package(url: "https://github.com/vapor/apns.git", from: "4.0.0"), + .package(url: "https://github.com/vapor/apns.git", from: "4.1.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.5.4") ], targets: [ @@ -46,6 +46,6 @@ var swiftSettings: [SwiftSetting] { [ .enableUpcomingFeature("ConciseMagicFile"), .enableUpcomingFeature("ForwardTrailingClosures"), .enableUpcomingFeature("DisableOutwardActorInference"), -// .enableUpcomingFeature("StrictConcurrency"), -// .enableExperimentalFeature("StrictConcurrency=complete"), + .enableUpcomingFeature("StrictConcurrency"), + .enableExperimentalFeature("StrictConcurrency=complete"), ] } diff --git a/README.md b/README.md index db51105..7e89a0b 100644 --- a/README.md +++ b/README.md @@ -119,37 +119,38 @@ struct PassJsonData: Encodable { Create a delegate file that implements `PassKitDelegate`. 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). 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 import Vapor +import Fluent import PassKit -class PKD: PassKitDelegate { - var sslSigningFilesDirectory = URL(fileURLWithPath: "/www/myapp/sign", isDirectory: true) +final class PKDelegate: PassKitDelegate { + let sslSigningFilesDirectory = URL(fileURLWithPath: "/www/myapp/sign", isDirectory: true) - var pemPrivateKeyPassword: String? = Environment.get("PEM_PRIVATE_KEY_PASSWORD")! + let pemPrivateKeyPassword: String? = Environment.get("PEM_PRIVATE_KEY_PASSWORD")! - func encode(pass: P, db: Database, encoder: JSONEncoder) -> EventLoopFuture { + 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. - return PassData.query(on: db) - .filter(\.$pass == pass.id!) + guard let passData = try await PassData.query(on: db) + .filter(\.$pass.$id == pass.id!) .first() - .unwrap(or: Abort(.internalServerError)) - .flatMap { passData in - guard let data = try? encoder.encode(PassJsonData(data: passData, pass: pass)) else { - return db.eventLoop.makeFailedFuture(Abort(.internalServerError)) - } - return db.eventLoop.makeSucceededFuture(data) + 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) -> EventLoopFuture { + func template(for: P, db: Database) async throws -> URL { // The location might vary depending on the type of pass. - let url = URL(fileURLWithPath: "/www/myapp/pass", isDirectory: true) - return db.eventLoop.makeSucceededFuture(url) + return URL(fileURLWithPath: "/www/myapp/pass", isDirectory: true) } } ``` @@ -163,7 +164,7 @@ a global variable. You need to ensure that the delegate doesn't go out of scope This will implement all of the routes that PassKit expects to exist on your server for you. ```swift -let delegate = PKD() +let delegate = PKDelegate() func routes(_ app: Application) throws { let pk = PassKit(app: app, delegate: delegate) @@ -294,7 +295,7 @@ fileprivate func passHandler(_ req: Request) async throws -> Response { throw Abort(.notFound) } - let bundle = try await passKit.generatePassContent(for: passData.pass, on: req.db).get() + let bundle = try await passKit.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") diff --git a/Sources/PassKit/FakeSendable.swift b/Sources/PassKit/FakeSendable.swift new file mode 100644 index 0000000..b048685 --- /dev/null +++ b/Sources/PassKit/FakeSendable.swift @@ -0,0 +1,32 @@ +/// 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/PassKit/PassKit.swift b/Sources/PassKit/PassKit.swift index b6c7e14..662c707 100644 --- a/Sources/PassKit/PassKit.swift +++ b/Sources/PassKit/PassKit.swift @@ -29,11 +29,11 @@ import Vapor import APNS import VaporAPNS -import APNSCore +@preconcurrency import APNSCore import Fluent import NIOSSL -public class PassKit { +public final class PassKit: Sendable { private let kit: PassKitCustom public init(app: Application, delegate: any PassKitDelegate, logger: Logger? = nil) { @@ -42,8 +42,7 @@ public class PassKit { /// Registers all the routes required for PassKit to work. /// - /// - Parameters: - /// - authorizationCode: The `authenticationToken` which you are going to use in the `pass.json` file. + /// - 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) } @@ -52,8 +51,8 @@ public class PassKit { try kit.registerPushRoutes(middleware: middleware) } - public func generatePassContent(for pass: PKPass, on db: any Database) -> EventLoopFuture { - kit.generatePassContent(for: pass, on: db) + public func generatePassContent(for pass: PKPass, on db: any Database) async throws -> Data { + try await kit.generatePassContent(for: pass, on: db) } public static func register(migrations: Migrations) { @@ -83,12 +82,12 @@ public class PassKit { /// - Device Type /// - Registration Type /// - Error Log Type -public class PassKitCustom where P == R.PassType, D == R.DeviceType { +public final class PassKitCustom: Sendable where P == R.PassType, D == R.DeviceType { public unowned let delegate: any PassKitDelegate private unowned let app: Application private let processQueue = DispatchQueue(label: "com.vapor-community.PassKit", qos: .utility, attributes: .concurrent) - private let v1: any RoutesBuilder + private let v1: FakeSendable private let logger: Logger? public init(app: Application, delegate: any PassKitDelegate, logger: Logger? = nil) { @@ -96,37 +95,35 @@ public class PassKitCustom whe self.logger = logger self.app = app - v1 = app.grouped("api", "v1") + v1 = FakeSendable(value: app.grouped("api", "v1")) } /// Registers all the routes required for PassKit to work. /// - /// - Parameters: - /// - authorizationCode: The `authenticationToken` which you are going to use in the `pass.json` file. + /// - Parameter authorizationCode: The `authenticationToken` which you are going to use in the `pass.json` file. public func registerRoutes(authorizationCode: String? = nil) { - v1.get("devices", ":deviceLibraryIdentifier", "registrations", ":type", use: passesForDevice) - v1.post("log", use: logError) + v1.value.get("devices", ":deviceLibraryIdentifier", "registrations", ":type", use: { try await self.passesForDevice(req: $0) }) + v1.value.post("log", use: { try await self.logError(req: $0) }) guard let code = authorizationCode ?? Environment.get("PASS_KIT_AUTHORIZATION") else { fatalError("Must pass in an authorization code") } - let v1auth = v1.grouped(ApplePassMiddleware(authorizationCode: code)) + let v1auth = v1.value.grouped(ApplePassMiddleware(authorizationCode: code)) - v1auth.post("devices", ":deviceLibraryIdentifier", "registrations", ":type", ":passSerial", use: registerDevice) - v1auth.get("passes", ":type", ":passSerial", use: latestVersionOfPass) - v1auth.delete("devices", ":deviceLibraryIdentifier", "registrations", ":type", ":passSerial", use: unregisterDevice) + v1auth.post("devices", ":deviceLibraryIdentifier", "registrations", ":type", ":passSerial", use: { try await self.registerDevice(req: $0) }) + v1auth.get("passes", ":type", ":passSerial", use: { try await self.latestVersionOfPass(req: $0) }) + v1auth.delete("devices", ":deviceLibraryIdentifier", "registrations", ":type", ":passSerial", use: { try await self.unregisterDevice(req: $0) }) } /// Registers routes to send push notifications for updated passes /// /// ### Example ### - /// ``` + /// ```swift /// try pk.registerPushRoutes(environment: .sandbox, middleware: PushAuthMiddleware()) /// ``` /// - /// - Parameters: - /// - middleware: The `Middleware` which will control authentication for the routes. + /// - Parameter middleware: The `Middleware` which will control authentication for the routes. /// - Throws: An error of type `PassKitError` public func registerPushRoutes(middleware: any Middleware) throws { let privateKeyPath = URL(fileURLWithPath: delegate.pemPrivateKey, relativeTo: @@ -173,14 +170,14 @@ public class PassKitCustom whe isDefault: false ) - let pushAuth = v1.grouped(middleware) + let pushAuth = v1.value.grouped(middleware) - pushAuth.post("push", ":type", ":passSerial", use: pushUpdatesForPass) - pushAuth.get("push", ":type", ":passSerial", use: tokensForPassUpdate) + pushAuth.post("push", ":type", ":passSerial", use: { try await self.pushUpdatesForPass(req: $0) }) + pushAuth.get("push", ":type", ":passSerial", use: { try await self.tokensForPassUpdate(req: $0) }) } // MARK: - API Routes - @Sendable func registerDevice(req: Request) async throws -> HTTPStatus { + func registerDevice(req: Request) async throws -> HTTPStatus { logger?.debug("Called register device") guard let serial = req.parameters.get("passSerial", as: UUID.self) else { @@ -220,7 +217,7 @@ public class PassKitCustom whe } } - @Sendable func passesForDevice(req: Request) async throws -> PassesForDeviceDto { + func passesForDevice(req: Request) async throws -> PassesForDeviceDto { logger?.debug("Called passesForDevice") let type = req.parameters.get("type")! @@ -253,7 +250,7 @@ public class PassKitCustom whe return PassesForDeviceDto(with: serialNumbers, maxDate: maxDate) } - @Sendable func latestVersionOfPass(req: Request) async throws -> Response { + func latestVersionOfPass(req: Request) async throws -> Response { logger?.debug("Called latestVersionOfPass") guard FileManager.default.fileExists(atPath: delegate.zipBinary.unixPath()) else { @@ -283,7 +280,7 @@ public class PassKitCustom whe throw Abort(.notModified) } - let data = try await self.generatePassContent(for: pass, on: req.db).get() + let data = try await self.generatePassContent(for: pass, on: req.db) let body = Response.Body(data: data) var headers = HTTPHeaders() @@ -294,7 +291,7 @@ public class PassKitCustom whe return Response(status: .ok, headers: headers, body: body) } - @Sendable func unregisterDevice(req: Request) async throws -> HTTPStatus { + func unregisterDevice(req: Request) async throws -> HTTPStatus { logger?.debug("Called unregisterDevice") let type = req.parameters.get("type")! @@ -315,7 +312,7 @@ public class PassKitCustom whe return .ok } - @Sendable func logError(req: Request) throws -> EventLoopFuture { + func logError(req: Request) async throws -> HTTPStatus { logger?.debug("Called logError") let body: ErrorLogDto @@ -330,13 +327,12 @@ public class PassKitCustom whe throw Abort(.badRequest) } - return body.logs - .map { E(message: $0).create(on: req.db) } - .flatten(on: req.eventLoop) - .map { .ok } + try await body.logs.map(E.init(message:)).create(on: req.db) + + return .ok } - @Sendable func pushUpdatesForPass(req: Request) async throws -> HTTPStatus { + func pushUpdatesForPass(req: Request) async throws -> HTTPStatus { logger?.debug("Called pushUpdatesForPass") guard let id = req.parameters.get("passSerial", as: UUID.self) else { @@ -349,7 +345,7 @@ public class PassKitCustom whe return .noContent } - @Sendable func tokensForPassUpdate(req: Request) async throws -> [String] { + func tokensForPassUpdate(req: Request) async throws -> [String] { logger?.debug("Called tokensForPassUpdate") guard let id = req.parameters.get("passSerial", as: UUID.self) else { @@ -501,54 +497,40 @@ public class PassKitCustom whe proc.waitUntilExit() } - public func generatePassContent(for pass: P, on db: any Database) -> EventLoopFuture { + public func generatePassContent(for pass: P, 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() - return delegate.template(for: pass, db: db) - .flatMap { src in - var isDir: ObjCBool = false - - guard src.hasDirectoryPath && - FileManager.default.fileExists(atPath: src.unixPath(), isDirectory: &isDir) && - isDir.boolValue else { - return db.eventLoop.makeFailedFuture(PassKitError.templateNotDirectory) - } - - return self.delegate.encode(pass: pass, db: db, encoder: encoder) - .flatMap { encoded in - let result: EventLoopPromise = db.eventLoop.makePromise() - - self.processQueue.async { - 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.generateManifestFile(using: encoder, in: root) - try self.generateSignatureFile(in: root) - - try self.zip(directory: root, to: zipFile) - - defer { - _ = try? FileManager.default.removeItem(at: zipFile) - } - - let data = try Data(contentsOf: zipFile) - result.completeWith(.success(data)) - } catch { - result.completeWith(.failure(error)) - } - } - - return result.futureResult - } + let src = try await delegate.template(for: pass, db: db) + guard (try? src.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false else { + throw PassKitError.templateNotDirectory + } + + 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.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 } } } diff --git a/Sources/PassKit/PassKitDelegate.swift b/Sources/PassKit/PassKitDelegate.swift index 2f4f820..802beb3 100644 --- a/Sources/PassKit/PassKitDelegate.swift +++ b/Sources/PassKit/PassKitDelegate.swift @@ -29,27 +29,28 @@ import Vapor import Fluent -public protocol PassKitDelegate: AnyObject { +public protocol PassKitDelegate: AnyObject, Sendable { /// Should return a `URL` which points to the template data for the pass. /// - /// The URL should point to a directory containing all the images and - /// localizations for the generated pkpass archive but should *not* contain any of these items: - /// - manifest.json - /// - pass.json - /// - signature + /// The URL should point to a directory containing all the images and localizations for the generated `.pkpass` archive but should *not* contain any of these items: + /// - `manifest.json` + /// - `pass.json` + /// - `signature` + /// /// - Parameters: /// - pass: The pass data from the SQL server. /// - db: The SQL database to query against. /// - /// ### Note ### - /// Be sure to use the `URL(fileURLWithPath:isDirectory:)` constructor. - func template(for: P, db: any Database) -> EventLoopFuture + /// - 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 /// 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. + /// 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 @@ -59,30 +60,34 @@ public protocol PassKitDelegate: AnyObject { /// This method should generate the entire pass JSON. You are provided with /// the pass data from the SQL database and you should return a properly /// formatted pass file encoding. + /// /// - Parameters: /// - pass: The pass data from the SQL server /// - db: The SQL database to query against. /// - encoder: The `JSONEncoder` which you should use. - /// - See: [Understanding the Keys](https://developer.apple.com/library/archive/documentation/UserExperience/Reference/PassKit_Bundle/Chapters/Introduction.html) - func encode(pass: P, db: any Database, encoder: JSONEncoder) -> EventLoopFuture + /// - 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 /// Should return a `URL` which points to the template data for the pass. /// /// The URL should point to a directory containing the files specified by these keys: - /// - wwdrCertificate - /// - pemCertificate - /// - pemPrivateKey + /// - `wwdrCertificate` + /// - `pemCertificate` + /// - `pemPrivateKey` /// - /// ### Note ### - /// Be sure to use the `URL(fileURLWithPath:isDirectory:)` initializer! + /// > Important: Be sure to use the `URL(fileURLWithPath:isDirectory:)` initializer! var sslSigningFilesDirectory: URL { get } /// The location of the `openssl` command as a file URL. - /// - Note: Be sure to use the `URL(fileURLWithPath:)` constructor. + /// + /// > Important: Be sure to use the `URL(fileURLWithPath:)` constructor. var sslBinary: URL { get } /// The full path to the `zip` command as a file URL. - /// - Note: Be sure to use the `URL(fileURLWithPath:)` constructor. + /// + /// > 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.