Current section

Files

Jump to
wallet_passes lib wallet_passes apple push.ex
Raw

lib/wallet_passes/apple/push.ex

defmodule WalletPasses.Apple.Push do
@moduledoc """
Sends silent APNs push notifications to Apple Wallet devices.
Uses HTTP/2 with client certificate authentication to notify devices
that a pass has been updated.
"""
alias WalletPasses.Config
alias WalletPasses.Schema
@doc """
Notifies all registered devices for an Apple pass by serial number.
Looks up push tokens via Schema and sends notifications.
Returns `{:ok, {successes, errors}}`.
"""
def notify(%Schema.ApplePass{serial_number: serial_number}) do
push_tokens = Schema.list_push_tokens_for_serial(serial_number)
notify_devices(push_tokens)
end
@doc """
Sends silent push notifications to a list of APNs push tokens.
Returns `{:ok, {successes, errors}}` or `{:error, reason}`.
Emits `[:wallet_passes, :apple, :push, :start|:stop]` telemetry events.
Stop measurements include `duration`, `success_count`, and `error_count`.
"""
def notify_devices(push_tokens) when is_list(push_tokens) do
metadata = %{token_count: length(push_tokens)}
start_time = System.monotonic_time()
:telemetry.execute(
[:wallet_passes, :apple, :push, :start],
%{system_time: System.system_time()},
metadata
)
result = do_notify_devices(push_tokens)
duration = System.monotonic_time() - start_time
{success_count, error_count} =
case result do
{:ok, {s, e}} -> {s, e}
_ -> {0, 0}
end
:telemetry.execute(
[:wallet_passes, :apple, :push, :stop],
%{duration: duration, success_count: success_count, error_count: error_count},
metadata
)
result
end
defp do_notify_devices([]), do: {:ok, {0, 0}}
defp do_notify_devices(push_tokens) do
with {:ok, {cert_der, key_der}} <- load_ssl_credentials() do
ssl_opts = [
cert: cert_der,
key: key_der,
versions: [:"tlsv1.2"],
]
pass_type_id = Config.apple_pass_type_id()
results =
Enum.map(push_tokens, fn token ->
send_push(token, pass_type_id, ssl_opts)
end)
successes = Enum.count(results, &match?(:ok, &1))
errors = Enum.count(results, &match?({:error, _}, &1))
{:ok, {successes, errors}}
end
end
defp send_push(token, pass_type_id, ssl_opts) do
url = "#{Config.apple_push_base_url()}/3/device/#{token}"
headers = [
{"apns-topic", pass_type_id},
{"apns-push-type", "background"},
{"apns-priority", "5"},
]
# Only apply SSL options for HTTPS URLs (production)
# For HTTP URLs (testing), skip SSL configuration
opts =
if String.starts_with?(url, "https://") do
[json: %{}, headers: headers, connect_options: [transport_opts: ssl_opts]]
else
[json: %{}, headers: headers]
end
case Req.post(url, opts) do
{:ok, %{status: status}} when status in 200..299 -> :ok
{:ok, %{status: status, body: body}} -> {:error, {status, body}}
{:error, reason} -> {:error, reason}
end
end
defp load_ssl_credentials do
with {:ok, cert_pem} <- load_pem(Config.apple_pass_type_cert()),
{:ok, key_pem} <- load_pem(Config.apple_pass_type_key()) do
with {:ok, cert_der} <- pem_to_der(cert_pem, :cert),
{:ok, key_der} <- pem_to_der(key_pem, :key) do
{:ok, {cert_der, key_der}}
end
else
_ -> {:error, :no_signing_credentials}
end
end
defp load_pem(value) when is_binary(value) do
cond do
File.exists?(value) ->
File.read(value)
String.starts_with?(value, "-----BEGIN") ->
{:ok, value}
true ->
case Base.decode64(value) do
{:ok, decoded} -> {:ok, decoded}
:error -> {:error, :invalid_pem}
end
end
end
defp load_pem(_), do: {:error, :no_signing_credentials}
defp pem_to_der(pem_binary, :cert) do
case :public_key.pem_decode(pem_binary) do
[{:Certificate, der, _} | _] -> {:ok, {:Certificate, der}}
_ -> {:error, :invalid_cert_pem}
end
rescue
_ -> {:error, :invalid_cert_pem}
end
defp pem_to_der(pem_binary, :key) do
case :public_key.pem_decode(pem_binary) do
[{type, der, _} | _] when type in [:RSAPrivateKey, :PrivateKeyInfo, :ECPrivateKey] ->
{:ok, {type, der}}
_ ->
{:error, :invalid_key_pem}
end
rescue
_ -> {:error, :invalid_key_pem}
end
end