vapor and apns

Sending Apple push notifications from a Vapor web app turns out to be pretty easy using the JWT package.

import CTLS  
import Foundation  
import HTTP  
import JWT  
import Vapor

public class APNSService: Service {  
  public enum LaunchError: Error {
    case loadAuthKeyFailed
    case setSecretFailed
    case setTokenFailed
  }

  fileprivate static let topic = "<your.app.bundle.id>"
  fileprivate static let teamID = "<your team id>"
  fileprivate static let keyID = "<your key id>"
#if Xcode
  fileprivate static let apnsServer = "api.development.push.apple.com"
#else
  fileprivate static let apnsServer = "api.push.apple.com"
#endif
  fileprivate static let tokenUpdateInterval = TimeInterval(50 * 60)
  fileprivate static let tokenQueue = DispatchQueue(label: "token", attributes: .concurrent)

  fileprivate let privateKey: Data
  fileprivate var prevToken: String = ""
  fileprivate var prevTokenDate = Date(timeIntervalSince1970: 0)

  public init(config: Config) throws {
    var pKey = EVP_PKEY_new()
    let fp = fopen("</path/to/your/.p8 file>", "r")

    guard fp != nil else {
      throw LaunchError.loadAuthKeyFailed
    }

    PEM_read_PrivateKey(fp, &pKey, nil, nil)

    fclose(fp)

    let ecKey = EVP_PKEY_get1_EC_KEY(pKey)

    EC_KEY_set_conv_form(ecKey, POINT_CONVERSION_UNCOMPRESSED)

    guard let privateKeyBigNum = BN_bn2hex(EC_KEY_get0_private_key(ecKey)),
          let privateKeyHex = String(validatingUTF8: privateKeyBigNum),
          let privateKeyData = Data(hexString:  "00" + privateKeyHex) else {
      throw LaunchError.setSecretFailed
    }

    self.privateKey = privateKeyData

    guard !self.token().isEmpty else {
      throw LaunchError.setTokenFailed
    }
  }

  public func sendNotification(to deviceToken: String, title: String?, body: String?, badge: Int?, sound: String?, payload: [String: Node]) {
    guard let url = URL(string: "https://\(APNSService.apnsServer)/3/device/\(deviceToken)") else {
      Logger.logError("invalid url for device token \(deviceToken)")
      return
    }

    var alertDict: [String: Node] = [:]

    if let title = title {
      alertDict["title"] = Node(title)
    }

    if let body = body {
      alertDict["body"] = Node(body)
    }

    var apsDict: [String: Node] = [:]

    if alertDict.count > 0 {
      apsDict["alert"] = Node(alertDict)
    }

    if let badge = badge {
      apsDict["badge"] = Node(badge)
    }

    if let sound = sound {
      apsDict["sound"] = Node(sound)
    }

    var bodyDict = payload

    bodyDict["aps"] = Node(apsDict)

    guard let json = try? JSON(node: bodyDict),
          let bytes = try? json.makeBytes() else {
      Logger.logError("failed to create json for \(bodyDict)")
      return
    }

    var request = URLRequest(url: url)

    request.httpMethod = "POST"
    request.httpBody = Data(bytes)

    request.setValue("0", forHTTPHeaderField: "apns-expiration")
    request.setValue("10", forHTTPHeaderField: "apns-priority")
    request.setValue(APNSService.topic, forHTTPHeaderField: "apns-topic")

    request.setValue("bearer \(self.token())", forHTTPHeaderField: HeaderKey.authorization.key)

    let task = URLSession.shared.dataTask(with: request) { data, response, error in
      if let error = error {
        Logger.logError("send push notification failed with error \(error)")
      }

      if let response = response as? HTTPURLResponse {
        if response.statusCode < 200 || response.statusCode > 299 {
          let reason = String(data: data ?? Data(), encoding: .utf8) ?? "nil"
          Logger.logError("send push notification failed with status code \(response.statusCode), reason \(reason)")
        }
      }
    }

    task.resume()
  }

  fileprivate func token() -> String {
    let now = Date()
    var prevToken = ""
    var prevTokenDate = Date()

    APNSService.tokenQueue.sync() {
      prevToken = self.prevToken
      prevTokenDate = self.prevTokenDate
    }

    if !prevToken.isEmpty && now.timeIntervalSince(prevTokenDate) < APNSService.tokenUpdateInterval {
      return prevToken
    }

    guard let jwt = try? JWT(additionalHeaders: [KeyIDHeader(identifier: APNSService.keyID)],
                             payload: JSON([IssuerClaim(string: APNSService.teamID), IssuedAtClaim(date: Date())]),
                             signer: ES256(key: Bytes(self.privateKey))),
          let token = try? jwt.createToken() else {
      return prevToken
    }

    APNSService.tokenQueue.sync(flags: .barrier) {
      self.prevToken = token
      self.prevTokenDate = now
    }

    return token
  }
}

(Updated for Vapor 2. This is not HTTP/2, too much traffic will get you banned.)

pro git

From the Hacker News discussion of Purposes, Concepts, Misfits, and a Redesign of Git.

the app store parabola

Lots of fake five-star reviews, lots of unhappy one-star reviews, very little in the middle. I wonder what percentage of app ratings look like this.

mnmlly mnml

Enter Andrew Kim, at the time a minimalist young gun. Kim's site is paradoxically called "Minimally Minimal." In other words, according to Kim, the site is the least minimal it could possibly be. But this is the opposite of the case, as the site is actually quite minimal.

Eli Schiff is my favorite grumpy old man.

Ma’agalim

Fantastic animation, needs to be seen full-screen.