diff --git a/Sources/App/Log.swift b/Sources/App/Log.swift new file mode 100644 index 0000000..b6c99f4 --- /dev/null +++ b/Sources/App/Log.swift @@ -0,0 +1,245 @@ +import Foundation + +// MARK: Public API + +func log(_ message: String, file: String = #file, line: Int = #line) { + Log.log(info: message, file: file, line: line) +} + +func log(error message: String, file: String = #file, line: Int = #line) { + Log.log(error: message, file: file, line: line) +} + +func log(warning message: String, file: String = #file, line: Int = #line) { + Log.log(warning: message, file: file, line: line) +} + +func log(info message: String, file: String = #file, line: Int = #line) { + Log.log(info: message, file: file, line: line) +} + +func log(debug message: String, file: String = #file, line: Int = #line) { + Log.log(debug: message, file: file, line: line) +} + +struct Log { + + enum Level: Int, Comparable { + case debug = 1 + case info = 2 + case warning = 3 + case error = 4 + case none = 5 + + var tag: String { + switch self { + case .debug: return "DEBUG" + case .info: return "INFO" + case .warning: return "WARN" + case .error: return "ERROR" + case .none: return "" + } + } + + var description: String { + switch self { + case .debug: return "Debug" + case .info: return "Infos" + case .warning: return "Warnungen" + case .error: return "Fehler" + case .none: return "Kein Logging" + } + } + + static func < (lhs: Level, rhs: Level) -> Bool { + lhs.rawValue < rhs.rawValue + } + } + + + private static var logLevels = [String : Level]() + + private static var fileHandle: FileHandle? + + private(set) static var path: URL? + + /// The date formatter for the logging timestamps + private static let df: DateFormatter = { + let df = DateFormatter() + df.dateStyle = .short + df.timeStyle = .short + df.locale = Locale(identifier: "de") + return df + }() + + private init() { + // Prevent any initializations + } + + // MARK: Public API + + static func log(error message: String, file: String = #file, line: Int = #line) { + log(message, level: .error, file: file, line: line) + } + + static func log(warning message: String, file: String = #file, line: Int = #line) { + log(message, level: .warning, file: file, line: line) + } + + static func log(info message: String, file: String = #file, line: Int = #line) { + log(message, level: .info, file: file, line: line) + } + + static func log(debug message: String, file: String = #file, line: Int = #line) { + log(message, level: .debug, file: file, line: line) + } + + static func log(raw message: String) { + + } + + /** + The global log level. + + Used when no specific log level is set for a file via `LogLevel.logLevel()` + */ + static var globalLevel: Level = .debug + + /** + Remove the custom log level for a file, reverting it to the `globalLevel` + + Best called without arguments within the file to modify: `Log.clearLogLevel()` + */ + static func defaultLogLevel(file: String = #file) { + logLevels[name(from: file)] = nil + } + + /** + Set a custom log level for a file, ignoring the global default. + + Example: `Log.set(logLevel = .debug)` + */ + static func set(logLevel: Level, file: String = #file) { + logLevels[name(from: file)] = logLevel + } + + /** + Set the file to log to. + */ + static func set(logFile: URL) { + fileHandle?.closeFile() + if !FileManager.default.fileExists(atPath: logFile.path) { + do { + try "New log started.\n".data(using: .utf8)!.write(to: logFile) + } catch { + print("Failed to start logging to \(logFile.path): \(error)") + } + + } + do { + let f = try FileHandle(forWritingTo: logFile) + path = logFile + fileHandle = f + print("Log file set to \(logFile.path)") + } catch { + print("No file handle to write log: \(error)") + fileHandle = nil + path = nil + } + } + + /** + Close the log file. + + Subsequent logging event will no longer be written to the log file. + Set a new log file by calling `Log.set(logFile:)` + */ + static func close() { + fileHandle?.closeFile() + fileHandle = nil + } + + /** + Clear all data from the log file. + + The log file will remain active, to close it permanently call `Log.close()` + */ + static func clear() { + guard let f = fileHandle, let p = path else { + return + } + f.closeFile() + try? FileManager.default.removeItem(at: p) + fileHandle = try? FileHandle(forWritingTo: p) + } + + /** + Get the full text from the log file. + */ + static func fullText() -> String? { + guard let u = path else { + return nil + } + return try? String(contentsOf: u) + } + + /** + Get the full data from the log file. + */ + static func data() -> Data? { + guard let f = fileHandle, let p = path else { + print("No log file set to get data") + return nil + } + f.closeFile() + defer { + do { + fileHandle = try FileHandle(forWritingTo: p) + } catch { + fileHandle = nil + path = nil + print("Failed to create log file handle: \(error)") + } + } + do { + return try Data(contentsOf: p) + } catch { + print("Failed to get log data: \(error)") + return nil + } + } + + // MARK: Private helper + + /// Get the pure file name for a file path from `#file` + fileprivate static func name(from file: String) -> String { + file.components(separatedBy: "/").last!.replacingOccurrences(of: ".swift", with: "") + } + + /// Get the pure file name tag if the specified log level is high enough + fileprivate static func tagIf(logLevel: Level, isSufficientFor file: String) -> String? { + let tag = name(from: file) + let requiredLevel = logLevels[tag] ?? globalLevel + if logLevel >= requiredLevel { + return tag + } + return nil + } + + fileprivate static func log(_ message: String, level: Level, file: String, line: Int) { + guard let tag = tagIf(logLevel: level, isSufficientFor: file) else { + return + } + let date = df.string(from: Date()) + let msg = "[\(date)][\(level.tag)][\(tag):\(line)] \(message)" + log(msg) + } + + private static func log(_ msg: String) { + print(msg) + if let f = fileHandle, let data = (msg + "\n").data(using: .utf8) { + f.write(data) + try? f.synchronize() + } + } +} diff --git a/Sources/App/ServerConfiguration.swift b/Sources/App/ServerConfiguration.swift new file mode 100644 index 0000000..9fd5508 --- /dev/null +++ b/Sources/App/ServerConfiguration.swift @@ -0,0 +1,24 @@ +import Foundation + +struct ServerConfiguration: Codable { + + let logPath: String + + let privateKeyFilePath: String + + let teamIdentifier: String + + let keyIdentifier: String + + let topic: String + + let messageTitle: String + + let messageBody: String + + let buttonPin: Int + + let bounceTime: Double + + let tokens: [String] +} diff --git a/Sources/App/ServerStatus.swift b/Sources/App/ServerStatus.swift new file mode 100644 index 0000000..93bfcb0 --- /dev/null +++ b/Sources/App/ServerStatus.swift @@ -0,0 +1,12 @@ +import Foundation + +enum ServerStatus: Int, Codable { + + case running = 0 + + case somePushTokensFailed = 1 + + case pushFailed = 2 + + case starting = 3 +} diff --git a/Sources/App/TokenUpload.swift b/Sources/App/TokenUpload.swift new file mode 100644 index 0000000..67a2115 --- /dev/null +++ b/Sources/App/TokenUpload.swift @@ -0,0 +1,13 @@ +import Foundation + +struct TokenUpload: Codable { + + let currentToken: Data + + let previousToken: Data? + + enum CodingKeys: Int, CodingKey { + case currentToken = 1 + case previousToken = 2 + } +} diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift new file mode 100644 index 0000000..298457f --- /dev/null +++ b/Sources/App/configure.swift @@ -0,0 +1,135 @@ +import Vapor +import SwiftyGPIO +import APNSwift +import NIO +import Crypto + +private struct Payload: Codable {} + +private var apnsConfiguration: APNSClientConfiguration! +private var apnsNotification: APNSAlertNotification! +private let apnsEventGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) +private let apnsRequestEncoder = JSONEncoder() +private let apnsResponseDecoder = JSONDecoder() + +// configures your application +public func configure(_ app: Application) throws { + + let resourcesFolderUrl = URL(fileURLWithPath: app.directory.resourcesDirectory) + let configUrl = resourcesFolderUrl.appendingPathComponent("config.json") + + let config: ServerConfiguration + do { + let data = try Data(contentsOf: configUrl) + config = try JSONDecoder().decode(from: data) + } catch { + print("Failed to load config: \(error)") + return + } + + guard configureAPNS(config) else { + return + } + + configureGPIO(config) + + // register routes + try routes(app) + + serverStatus = .running +} + +private extension JSONDecoder { + + func decode(from data: Data) throws -> T where T: Decodable { + try self.decode(T.self, from: data) + } +} + +private func load(keyAtPath path: String) -> P256.Signing.PrivateKey? { + do { + guard let key = try P256.Signing.PrivateKey.loadFrom(filePath: path) else { + print("Failed to read key \(path)") + return nil + } + return key + } catch { + print("Failed to load key \(error)") + return nil + } +} + +private func configureAPNS(_ config: ServerConfiguration) -> Bool { + guard let key = load(keyAtPath: config.privateKeyFilePath) else { + return false + } + let authentication = APNSClientConfiguration.AuthenticationMethod.jwt( + privateKey: key, + keyIdentifier: config.keyIdentifier, + teamIdentifier: config.teamIdentifier) + + + apnsConfiguration = APNSClientConfiguration( + authenticationMethod: authentication, + environment: .sandbox) + + let alert = APNSAlertNotificationContent( + title: .raw(config.messageTitle), + body: .raw(config.messageBody)) + + apnsNotification = APNSAlertNotification( + alert: alert, + expiration: .none, + priority: .immediately, + topic: config.topic, + payload: Payload(), + sound: .default) + return true +} + +private func configureGPIO(_ config: ServerConfiguration) { + let gpio = RaspberryGPIO( + name: "GPIO\(config.buttonPin)", + id: config.buttonPin, + baseAddr: 0x7E000000) + + gpio.direction = .IN + gpio.pull = .down + gpio.bounceTime = config.bounceTime + gpio.onChange { _ in + log(info: "Push detected") + sendPush() + } + log(info: "GPIO \(config.buttonPin) configured") +} + +private func sendPush() { + Task(priority: .userInitiated) { + let client = APNSClient( + configuration: apnsConfiguration, + eventLoopGroupProvider: .shared(apnsEventGroup), + responseDecoder: apnsResponseDecoder, + requestEncoder: apnsRequestEncoder) + log(info: "Client created") + do { + for token in knownTokens { + log(info: "Sending push to \(token.prefix(6))...") + try await client.sendAlertNotification( + apnsNotification, + deviceToken: token, + deadline: .now() + .seconds(10)) + log(info: "Sent push to \(token.prefix(6))...") + } + } catch let error as APNSError { + print(error) + } catch { + log(error: error.localizedDescription) + } + do { + log("Closing client") + try client.syncShutdown() + } catch { + log(error: error.localizedDescription) + } + } +} diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift new file mode 100644 index 0000000..9adbdc6 --- /dev/null +++ b/Sources/App/routes.swift @@ -0,0 +1,36 @@ +import Vapor +import BinaryCodable + +private let responseEncoder = BinaryEncoder() +private let requestDecoder = BinaryDecoder() + +var serverStatus: ServerStatus = .starting + +var knownTokens = Set() + +func routes(_ app: Application) throws { + + app.post("token") { req async throws -> HTTPResponseStatus in + guard let data = req.body.data?.allReadableBytes else { + return .badRequest + } + let tokenUpload: TokenUpload = try requestDecoder.decode(from: data) + if let oldToken = tokenUpload.previousToken { + knownTokens.remove(oldToken.hex) + } + knownTokens.insert(tokenUpload.currentToken.hex) + return .ok + } + + app.get("status") { req async throws -> Response in + let data = try responseEncoder.encode(serverStatus) + return Response(status: .ok, body: .init(data: data)) + } +} + +private extension ByteBuffer { + + var allReadableBytes: Data? { + getData(at: 0, length: readableBytes) + } +} diff --git a/Sources/Run/main.swift b/Sources/Run/main.swift new file mode 100644 index 0000000..373be5f --- /dev/null +++ b/Sources/Run/main.swift @@ -0,0 +1,9 @@ +import App +import Vapor + +var env = try Environment.detect() +try LoggingSystem.bootstrap(from: &env) +let app = Application(env) +defer { app.shutdown() } +try configure(app) +try app.run() diff --git a/Tests/AppTests/AppTests.swift b/Tests/AppTests/AppTests.swift new file mode 100644 index 0000000..1ec6fd2 --- /dev/null +++ b/Tests/AppTests/AppTests.swift @@ -0,0 +1,16 @@ +@testable import App +import XCTVapor + +final class AppTests: XCTestCase { + + func testHelloWorld() throws { + let app = Application(.testing) + defer { app.shutdown() } + try configure(app) + + try app.test(.GET, "status") { res in + XCTAssertEqual(res.status, .ok) + XCTAssertEqual(res.body.getData(at: 0, length: 1), Data([3])) + } + } +}