Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Topics #49

Merged
merged 3 commits into from
Dec 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions Sources/WebPush/Topic.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//
// Topic.swift
// swift-webpush
//
// Created by Dimitri Bouniol on 2024-12-24.
// Copyright © 2024 Mochi Development, Inc. All rights reserved.
//

@preconcurrency import Crypto
import Foundation

/// Topics are used to de-duplicate and overwrite messages on push services before they are delivered to a subscriber.
///
/// The topic is never delivered to your service worker, though is seen in plain text by the Push Service, so this type encodes it first to prevent leaking any information about the messages you are sending or your subscribers.
///
/// - Important: Since topics are sent in the clear to push services, they must be securely hashed. You must use a stable random value for this, such as the subscriber's ``UserAgentKeyMaterial/authenticationSecret``. This is fine for most applications, though you may wish to use a different key if your application requires it.
///
/// - SeeAlso: [RFC 8030 Generic Event Delivery Using HTTP §5.4. Replacing Push Messages](https://datatracker.ietf.org/doc/html/rfc8030#section-5.4)
public struct Topic: Hashable, Sendable, CustomStringConvertible {
/// The topic value to use.
public let topic: String

/// Create a new topic from encodable data and a salt.
///
/// - Important: Since topics are sent in the clear to push services, they must be securely hashed. You must use a stable random value for this, such as the subscriber's ``UserAgentKeyMaterial/authenticationSecret``. This is fine for most applications, though you may wish to use a different key if your application requires it.
///
/// - Parameters:
/// - encodableTopic: The encodable data that represents a stable topic. This can be a string, identifier, or any other token that can be encoded.
/// - salt: The salt that should be used when encoding the topic.
public init(
encodableTopic: some Encodable,
salt: some DataProtocol
) throws {
/// First, turn the topic into a byte stream.
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let encodedTopic = try encoder.encode(encodableTopic)

/// Next, hash the topic using the provided salt, some info, and cut to length at 24 bytes.
let hashedTopic = HKDF<SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: encodedTopic),
salt: salt,
info: "WebPush Topic".utf8Bytes,
outputByteCount: 24
)

/// Transform these 24 bytes into 32 Base64 URL-safe characters.
self.topic = hashedTopic.base64URLEncodedString()
}

/// Create a new random topic.
///
/// Create a topic with a random identifier to save it in your own data stores, and re-use it as needed.
public init() {
/// Generate a 24-byte topic.
var topicBytes: [UInt8] = Array(repeating: 0, count: 24)
for index in topicBytes.indices { topicBytes[index] = .random(in: .min ... .max) }
self.topic = topicBytes.base64URLEncodedString()
}

/// Initialize a topic with an unchecked string.
///
/// Prefer to use ``init(encodableTopic:salt:)`` when possible.
///
/// - Warning: This may be rejected by a Push Service if it is not 32 Base64 URL-safe characters, and will not be encrypted. Expect to handle a ``PushServiceError`` with a ``PushServiceError/response`` status code of `400 Bad Request` when it does.
public init(unsafeTopic: String) {
topic = unsafeTopic
}

public var description: String {
topic
}
}

extension Topic: Codable {
public init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
topic = try container.decode(String.self)
}

public func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(topic)
}
}
120 changes: 119 additions & 1 deletion Sources/WebPush/WebPushManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -250,12 +250,14 @@ public actor WebPushManager: Sendable {
/// - Parameters:
/// - message: The message to send as raw data.
/// - subscriber: The subscriber to send the push message to.
/// - deduplicationTopic: The topic to use when deduplicating messages stored on a Push Service. When specifying a topic, prefer to use ``send(data:to:encodableDeduplicationTopic:expiration:urgency:logger:)`` instead.
/// - expiration: The expiration of the push message, after wich delivery will no longer be attempted.
/// - urgency: The urgency of the delivery of the push message.
/// - logger: The logger to use for status updates. If not provided, the background activity logger will be used instead. When running in a server environment, your contextual logger should be used instead giving you full control of logging and metadata.
public func send(
data message: some DataProtocol,
to subscriber: some SubscriberProtocol,
deduplicationTopic topic: Topic? = nil,
expiration: Expiration = .recommendedMaximum,
urgency: Urgency = .high,
logger: Logger? = nil
Expand All @@ -269,77 +271,185 @@ public actor WebPushManager: Sendable {
privateKeyProvider: privateKeyProvider,
data: message,
subscriber: subscriber,
deduplicationTopic: topic,
expiration: expiration,
urgency: urgency,
logger: logger
)
case .handler(let handler):
try await handler(.data(Data(message)), Subscriber(subscriber), expiration, urgency)
try await handler(.data(Data(message)), Subscriber(subscriber), topic, expiration, urgency)
}
}

/// Send a push message as raw data.
///
/// The service worker you registered is expected to know how to decode the data you send.
///
/// - Parameters:
/// - message: The message to send as raw data.
/// - subscriber: The subscriber to send the push message to.
/// - encodableDeduplicationTopic: The topic to use when deduplicating messages stored on a Push Service.
/// - expiration: The expiration of the push message, after wich delivery will no longer be attempted.
/// - urgency: The urgency of the delivery of the push message.
/// - logger: The logger to use for status updates. If not provided, the background activity logger will be used instead. When running in a server environment, your contextual logger should be used instead giving you full control of logging and metadata.
@inlinable
public func send(
data message: some DataProtocol,
to subscriber: some SubscriberProtocol,
encodableDeduplicationTopic: some Encodable,
expiration: Expiration = .recommendedMaximum,
urgency: Urgency = .high,
logger: Logger? = nil
) async throws {
try await send(
data: message,
to: subscriber,
deduplicationTopic: Topic(
encodableTopic: encodableDeduplicationTopic,
salt: subscriber.userAgentKeyMaterial.authenticationSecret
),
expiration: expiration,
urgency: urgency,
logger: logger
)
}

/// Send a push message as a string.
///
/// The service worker you registered is expected to know how to decode the string you send.
///
/// - Parameters:
/// - message: The message to send as a string.
/// - subscriber: The subscriber to send the push message to.
/// - deduplicationTopic: The topic to use when deduplicating messages stored on a Push Service. When specifying a topic, prefer to use ``send(string:to:encodableDeduplicationTopic:expiration:urgency:logger:)`` instead.
/// - expiration: The expiration of the push message, after wich delivery will no longer be attempted.
/// - urgency: The urgency of the delivery of the push message.
/// - logger: The logger to use for status updates. If not provided, the background activity logger will be used instead. When running in a server environment, your contextual logger should be used instead giving you full control of logging and metadata.
public func send(
string message: some StringProtocol,
to subscriber: some SubscriberProtocol,
deduplicationTopic topic: Topic? = nil,
expiration: Expiration = .recommendedMaximum,
urgency: Urgency = .high,
logger: Logger? = nil
) async throws {
try await routeMessage(
message: .string(String(message)),
to: subscriber,
deduplicationTopic: topic,
expiration: expiration,
urgency: urgency,
logger: logger ?? backgroundActivityLogger
)
}

/// Send a push message as a string.
///
/// The service worker you registered is expected to know how to decode the string you send.
///
/// - Parameters:
/// - message: The message to send as a string.
/// - subscriber: The subscriber to send the push message to.
/// - encodableDeduplicationTopic: The topic to use when deduplicating messages stored on a Push Service.
/// - expiration: The expiration of the push message, after wich delivery will no longer be attempted.
/// - urgency: The urgency of the delivery of the push message.
/// - logger: The logger to use for status updates. If not provided, the background activity logger will be used instead. When running in a server environment, your contextual logger should be used instead giving you full control of logging and metadata.
@inlinable
public func send(
string message: some StringProtocol,
to subscriber: some SubscriberProtocol,
encodableDeduplicationTopic: some Encodable,
expiration: Expiration = .recommendedMaximum,
urgency: Urgency = .high,
logger: Logger? = nil
) async throws {
try await send(
string: message,
to: subscriber,
deduplicationTopic: Topic(
encodableTopic: encodableDeduplicationTopic,
salt: subscriber.userAgentKeyMaterial.authenticationSecret
),
expiration: expiration,
urgency: urgency,
logger: logger
)
}

/// Send a push message as encoded JSON.
///
/// The service worker you registered is expected to know how to decode the JSON you send. Note that dates are encoded using ``/Foundation/JSONEncoder/DateEncodingStrategy/millisecondsSince1970``, and data is encoded using ``/Foundation/JSONEncoder/DataEncodingStrategy/base64``.
///
/// - Parameters:
/// - message: The message to send as JSON.
/// - subscriber: The subscriber to send the push message to.
/// - deduplicationTopic: The topic to use when deduplicating messages stored on a Push Service. When specifying a topic, prefer to use ``send(json:to:encodableDeduplicationTopic:expiration:urgency:logger:)`` instead.
/// - expiration: The expiration of the push message, after wich delivery will no longer be attempted.
/// - urgency: The urgency of the delivery of the push message.
/// - logger: The logger to use for status updates. If not provided, the background activity logger will be used instead. When running in a server environment, your contextual logger should be used instead giving you full control of logging and metadata.
public func send(
json message: some Encodable&Sendable,
to subscriber: some SubscriberProtocol,
deduplicationTopic topic: Topic? = nil,
expiration: Expiration = .recommendedMaximum,
urgency: Urgency = .high,
logger: Logger? = nil
) async throws {
try await routeMessage(
message: .json(message),
to: subscriber,
deduplicationTopic: topic,
expiration: expiration,
urgency: urgency,
logger: logger ?? backgroundActivityLogger
)
}

/// Send a push message as encoded JSON.
///
/// The service worker you registered is expected to know how to decode the JSON you send. Note that dates are encoded using ``/Foundation/JSONEncoder/DateEncodingStrategy/millisecondsSince1970``, and data is encoded using ``/Foundation/JSONEncoder/DataEncodingStrategy/base64``.
///
/// - Parameters:
/// - message: The message to send as JSON.
/// - subscriber: The subscriber to send the push message to.
/// - encodableDeduplicationTopic: The topic to use when deduplicating messages stored on a Push Service.
/// - expiration: The expiration of the push message, after wich delivery will no longer be attempted.
/// - urgency: The urgency of the delivery of the push message.
/// - logger: The logger to use for status updates. If not provided, the background activity logger will be used instead. When running in a server environment, your contextual logger should be used instead giving you full control of logging and metadata.
@inlinable
public func send(
json message: some Encodable&Sendable,
to subscriber: some SubscriberProtocol,
encodableDeduplicationTopic: some Encodable,
expiration: Expiration = .recommendedMaximum,
urgency: Urgency = .high,
logger: Logger? = nil
) async throws {
try await send(
json: message,
to: subscriber,
deduplicationTopic: Topic(
encodableTopic: encodableDeduplicationTopic,
salt: subscriber.userAgentKeyMaterial.authenticationSecret
),
expiration: expiration,
urgency: urgency,
logger: logger
)
}

/// Route a message to the current executor.
/// - Parameters:
/// - message: The message to send.
/// - subscriber: The subscriber to sign the message against.
/// - deduplicationTopic: The topic to use when deduplicating messages stored on a Push Service.
/// - expiration: The expiration of the message.
/// - urgency: The urgency of the message.
/// - logger: The logger to use for status updates.
func routeMessage(
message: _Message,
to subscriber: some SubscriberProtocol,
deduplicationTopic topic: Topic?,
expiration: Expiration,
urgency: Urgency,
logger: Logger
Expand All @@ -353,6 +463,7 @@ public actor WebPushManager: Sendable {
privateKeyProvider: privateKeyProvider,
data: message.data,
subscriber: subscriber,
deduplicationTopic: topic,
expiration: expiration,
urgency: urgency,
logger: logger
Expand All @@ -361,6 +472,7 @@ public actor WebPushManager: Sendable {
try await handler(
message,
Subscriber(subscriber),
topic,
expiration,
urgency
)
Expand All @@ -373,6 +485,7 @@ public actor WebPushManager: Sendable {
/// - applicationServerECDHPrivateKey: The private key to use for the key exchange. If nil, one will be generated.
/// - message: The message to send as raw data.
/// - subscriber: The subscriber to sign the message against.
/// - deduplicationTopic: The topic to use when deduplicating messages stored on a Push Service.
/// - expiration: The expiration of the message.
/// - urgency: The urgency of the message.
/// - logger: The logger to use for status updates.
Expand All @@ -381,6 +494,7 @@ public actor WebPushManager: Sendable {
privateKeyProvider: Executor.KeyProvider,
data message: some DataProtocol,
subscriber: some SubscriberProtocol,
deduplicationTopic topic: Topic?,
expiration: Expiration,
urgency: Urgency,
logger: Logger
Expand Down Expand Up @@ -483,6 +597,9 @@ public actor WebPushManager: Sendable {
request.headers.add(name: "Content-Type", value: "application/octet-stream")
request.headers.add(name: "TTL", value: "\(max(expiration, .dropIfUndeliverable).seconds)")
request.headers.add(name: "Urgency", value: "\(urgency)")
if let topic {
request.headers.add(name: "Topic", value: "\(topic)")
}
request.body = .bytes(ByteBuffer(bytes: requestContent))

/// Send the request to the push endpoint.
Expand Down Expand Up @@ -778,6 +895,7 @@ extension WebPushManager {
case handler(@Sendable (
_ message: _Message,
_ subscriber: Subscriber,
_ topic: Topic?,
_ expiration: Expiration,
_ urgency: Urgency
) async throws -> Void)
Expand Down
1 change: 1 addition & 0 deletions Sources/WebPushTesting/WebPushManager+Testing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ extension WebPushManager {
messageHandler: @escaping @Sendable (
_ message: Message,
_ subscriber: Subscriber,
_ topic: Topic?,
_ expiration: Expiration,
_ urgency: Urgency
) async throws -> Void
Expand Down
Loading
Loading