<?php
declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\PushNotifications\Entities;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenInvalidDataException;
use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
use Automattic\WooCommerce\Internal\PushNotifications\Validators\PushTokenValidator;
use Automattic\WooCommerce\Utilities\TimeUtil;
use stdClass;
/**
* Object representation of a push token.
*
* @since 10.4.0
*/
class PushToken {
/**
* WordPress post type for storing push tokens.
*/
const POST_TYPE = 'wc_push_token';
/**
* The locale to use for tokens when the locale is not set, e.g. for tokens
* created before `device_locale` was added.
*/
const DEFAULT_DEVICE_LOCALE = 'en_US';
/**
* The MySQL zero date, which WordPress writes when a date is unknown.
*
* @since 11.2.0
*/
const ZERO_DATETIME = '0000-00-00 00:00:00';
/**
* Platform identifier for Apple devices.
*/
const PLATFORM_APPLE = 'apple';
/**
* Platform identifier for Android devices.
*/
const PLATFORM_ANDROID = 'android';
/**
* Platform identifier for web browsers.
*/
const PLATFORM_BROWSER = 'browser';
/**
* Origin identifier for WooCommerce Android app.
*/
const ORIGIN_WOOCOMMERCE_ANDROID = 'com.woocommerce.android';
/**
* Origin identifier for WooCommerce Android app development builds.
*/
const ORIGIN_WOOCOMMERCE_ANDROID_DEV = 'com.woocommerce.android:dev';
/**
* Origin identifier for WooCommerce iOS app.
*/
const ORIGIN_WOOCOMMERCE_IOS = 'com.automattic.woocommerce';
/**
* Origin identifier for WooCommerce iOS app development builds.
*/
const ORIGIN_WOOCOMMERCE_IOS_DEV = 'com.automattic.woocommerce:dev';
/**
* Origin identifier for browsers.
*/
const ORIGIN_BROWSER = 'browser';
/**
* List of valid platforms.
*/
const PLATFORMS = array(
self::PLATFORM_APPLE,
self::PLATFORM_ANDROID,
self::PLATFORM_BROWSER,
);
/**
* List of valid origins.
*/
const ORIGINS = array(
self::ORIGIN_BROWSER,
self::ORIGIN_WOOCOMMERCE_ANDROID,
self::ORIGIN_WOOCOMMERCE_ANDROID_DEV,
self::ORIGIN_WOOCOMMERCE_IOS,
self::ORIGIN_WOOCOMMERCE_IOS_DEV,
);
/**
* The ID of the token post.
*
* @var int|null
*/
private ?int $id = null;
/**
* The ID of the user who owns the token.
*
* @var int|null
*/
private ?int $user_id = null;
/**
* The token representing a device we can send a push notification to.
*
* @var string|null
*/
private ?string $token = null;
/**
* An identifier the app generates for its own install, so a re-registration
* matches the existing record after the OS issues a new token.
*
* Not derived from the device, not stable across reinstalls, and matched
* only within one user. Browser tokens do not have one.
*
* @var string|null
*/
private ?string $device_uuid = null;
/**
* The platform the token was generated by.
*
* @var string|null
*/
private ?string $platform = null;
/**
* The origin the token belongs to.
*
* @var string|null
*/
private ?string $origin = null;
/**
* The locale of the device the token belongs to.
*
* @var string|null
*/
private ?string $device_locale = null;
/**
* An array of metadata for the token.
*
* @var array|null
*/
private ?array $metadata = null;
/**
* The date the token was registered, as a GMT `Y-m-d H:i:s` string.
*
* Stored as WordPress stores it, so a value read back out can be set again.
* {@see self::to_rest_datetime()} converts it for the response.
*
* @var string|null
*/
private ?string $created_at_gmt = null;
/**
* The date the app last confirmed this token, as a GMT `Y-m-d H:i:s` string.
*
* The app re-sends the token periodically, not only when the token value
* changes, and `post_modified_gmt` advances on each of those writes. This
* records how recently the app read the token from the device and sent it
* to us, which is what tells us how likely the token is to still be
* current. It is not a confirmation from Apple or Google that the token is
* deliverable, and it does not mean the token value changed.
*
* @var string|null
*/
private ?string $last_confirmed_at_gmt = null;
/**
* The date the token was last sent to WPCOM, as a GMT `Y-m-d H:i:s` string.
*
* @var string|null
*/
private ?string $last_sent_at_gmt = null;
/**
* Creates a new PushToken instance with the given data.
*
* @param array $data Optional array with keys: id, user_id, token, device_uuid, platform, origin.
* @throws PushTokenInvalidDataException If any of the provided values fail validation.
*
* @since 10.6.0
*/
public function __construct( array $data = array() ) {
if ( array_key_exists( 'id', $data ) ) {
$this->set_id( (int) $data['id'] );
}
if ( array_key_exists( 'user_id', $data ) ) {
$this->set_user_id( (int) $data['user_id'] );
}
if ( array_key_exists( 'token', $data ) ) {
$this->set_token( (string) $data['token'] );
}
if ( array_key_exists( 'device_uuid', $data ) ) {
$this->set_device_uuid( (string) $data['device_uuid'] );
}
if ( array_key_exists( 'platform', $data ) ) {
$this->set_platform( (string) $data['platform'] );
}
if ( array_key_exists( 'origin', $data ) ) {
$this->set_origin( (string) $data['origin'] );
}
if ( array_key_exists( 'device_locale', $data ) ) {
$this->set_device_locale( (string) $data['device_locale'] );
}
if ( array_key_exists( 'metadata', $data ) ) {
$this->set_metadata( (array) $data['metadata'] );
}
if ( array_key_exists( 'created_at_gmt', $data ) ) {
$this->set_created_at_gmt( null === $data['created_at_gmt'] ? null : (string) $data['created_at_gmt'] );
}
if ( array_key_exists( 'last_confirmed_at_gmt', $data ) ) {
$this->set_last_confirmed_at_gmt( null === $data['last_confirmed_at_gmt'] ? null : (string) $data['last_confirmed_at_gmt'] );
}
if ( array_key_exists( 'last_sent_at_gmt', $data ) ) {
$this->set_last_sent_at_gmt( null === $data['last_sent_at_gmt'] ? null : (string) $data['last_sent_at_gmt'] );
}
}
/**
* Validates and sets the ID.
*
* @param int $id The ID of the token post.
* @throws PushTokenInvalidDataException If ID is not valid.
* @return void
*
* @since 10.4.0
*/
public function set_id( int $id ): void {
$result = PushTokenValidator::validate( compact( 'id' ), array( 'id' ) );
if ( is_wp_error( $result ) ) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
throw new PushTokenInvalidDataException( $result->get_error_message() );
}
$this->id = $id;
}
/**
* Validates and sets the user ID.
*
* @param int $user_id The ID of the user who owns the token.
* @throws PushTokenInvalidDataException If user ID is not valid.
* @return void
*
* @since 10.4.0
*/
public function set_user_id( int $user_id ): void {
$result = PushTokenValidator::validate( compact( 'user_id' ), array( 'user_id' ) );
if ( is_wp_error( $result ) ) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
throw new PushTokenInvalidDataException( $result->get_error_message() );
}
$this->user_id = $user_id;
}
/**
* Validates and sets the token.
*
* @param string $token The token representing a device we can send a push notification to.
* @throws PushTokenInvalidDataException If token is not valid.
* @return void
*
* @since 10.4.0
*/
public function set_token( string $token ): void {
$result = PushTokenValidator::validate( compact( 'token' ), array( 'token' ) );
if ( is_wp_error( $result ) ) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
throw new PushTokenInvalidDataException( $result->get_error_message() );
}
$this->token = trim( $token );
}
/**
* Validates and sets the device UUID, normalize empty (non-null) values to null.
*
* @param string|null $device_uuid The identifier the app generated for its own install.
* @throws PushTokenInvalidDataException If device UUID is not valid.
* @return void
*
* @since 10.4.0
*/
public function set_device_uuid( ?string $device_uuid ): void {
$result = PushTokenValidator::validate( compact( 'device_uuid' ), array( 'device_uuid' ) );
if ( is_wp_error( $result ) ) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
throw new PushTokenInvalidDataException( $result->get_error_message() );
}
if ( null !== $device_uuid ) {
$device_uuid = trim( $device_uuid );
}
$this->device_uuid = $device_uuid ? $device_uuid : null;
}
/**
* Validates and sets the device locale.
*
* @param string $device_locale The locale of the device the token belongs to.
* @throws PushTokenInvalidDataException If device locale is not valid.
* @return void
*
* @since 10.6.0
*/
public function set_device_locale( string $device_locale ): void {
$result = PushTokenValidator::validate( compact( 'device_locale' ), array( 'device_locale' ) );
if ( is_wp_error( $result ) ) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
throw new PushTokenInvalidDataException( $result->get_error_message() );
}
$this->device_locale = trim( $device_locale );
}
/**
* Validates and sets the platform.
*
* @param string $platform The platform the token was generated by.
* @throws PushTokenInvalidDataException If platform is not valid.
* @return void
*
* @since 10.4.0
*/
public function set_platform( string $platform ): void {
$result = PushTokenValidator::validate( compact( 'platform' ), array( 'platform' ) );
if ( is_wp_error( $result ) ) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
throw new PushTokenInvalidDataException( $result->get_error_message() );
}
$this->platform = trim( $platform );
}
/**
* Validates and sets the origin.
*
* @param string $origin The origin of the token, e.g. the app it came from.
* @throws PushTokenInvalidDataException If origin is not valid.
* @return void
*
* @since 10.4.0
*/
public function set_origin( string $origin ): void {
$result = PushTokenValidator::validate( compact( 'origin' ), array( 'origin' ) );
if ( is_wp_error( $result ) ) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
throw new PushTokenInvalidDataException( $result->get_error_message() );
}
$this->origin = trim( $origin );
}
/**
* Validates and sets the metadata.
*
* @param array $metadata An array of metadata for the token, e.g. the app version, device OS etc.
* @throws PushTokenInvalidDataException If metadata is not valid.
* @return void
*
* @since 10.6.0
*/
public function set_metadata( array $metadata ): void {
$result = PushTokenValidator::validate( compact( 'metadata' ), array( 'metadata' ) );
if ( is_wp_error( $result ) ) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
throw new PushTokenInvalidDataException( $result->get_error_message() );
}
if ( ! empty( $metadata ) ) {
$keys = array_map( 'sanitize_key', array_keys( $metadata ) );
$values = array_map( 'sanitize_text_field', array_values( $metadata ) );
/**
* Typehint for PHPStan, as it can't infer the $keys and $values are
* the same length therefore array_combine won't return false.
*
* @var array<string, string> $metadata
*/
$metadata = array_combine( $keys, $values );
}
$this->metadata = $metadata;
}
/**
* Sets the date the token was registered.
*
* Unlike the other setters this does not run through
* {@see PushTokenValidator}. Timestamps are derived from the underlying
* post record rather than supplied by an API client, so there is no
* untrusted input to guard against.
*
* @param string|null $created_at_gmt A GMT `Y-m-d H:i:s` datetime, or null if unknown.
* @return void
*
* @since 11.2.0
*/
public function set_created_at_gmt( ?string $created_at_gmt ): void {
$this->created_at_gmt = $this->validate_gmt_datetime( $created_at_gmt );
}
/**
* Sets the date the app last confirmed this token.
*
* See {@see self::set_created_at_gmt()} for why this bypasses validation.
*
* @param string|null $last_confirmed_at_gmt A GMT `Y-m-d H:i:s` datetime, or null if unknown.
* @return void
*
* @since 11.2.0
*/
public function set_last_confirmed_at_gmt( ?string $last_confirmed_at_gmt ): void {
$this->last_confirmed_at_gmt = $this->validate_gmt_datetime( $last_confirmed_at_gmt );
}
/**
* Sets the date this token was last sent to WPCOM.
*
* See {@see self::set_created_at_gmt()} for why this bypasses validation.
*
* @param string|null $last_sent_at_gmt A GMT `Y-m-d H:i:s` datetime, or null if never sent.
* @return void
*
* @since 11.2.0
*/
public function set_last_sent_at_gmt( ?string $last_sent_at_gmt ): void {
$this->last_sent_at_gmt = $this->validate_gmt_datetime( $last_sent_at_gmt );
}
/**
* Returns a GMT datetime as `Y-m-d H:i:s`, or null if it is not one.
*
* @param string|null $datetime The GMT datetime string.
* @return string|null
*/
private function validate_gmt_datetime( ?string $datetime ): ?string {
$datetime = null === $datetime ? '' : trim( $datetime );
if ( '' === $datetime || self::ZERO_DATETIME === $datetime ) {
return null;
}
$normalized = str_replace( 'T', ' ', $datetime );
if ( ! TimeUtil::is_valid_date( $normalized ) ) {
wc_get_logger()->warning(
'Unparseable push token timestamp.',
array(
'source' => PushNotifications::FEATURE_NAME,
'token_id' => $this->id,
'value' => $datetime,
)
);
return null;
}
return $normalized;
}
/**
* Converts a GMT `Y-m-d H:i:s` datetime to `Y-m-d\TH:i:s`, the format
* `wc_rest_prepare_date_response()` gives every other `_gmt` field in the
* Woo REST API.
*
* @param string|null $datetime A datetime already through {@see self::validate_gmt_datetime()}.
* @return string|null
*/
private function to_rest_datetime( ?string $datetime ): ?string {
return null === $datetime ? null : str_replace( ' ', 'T', $datetime );
}
/**
* Gets the ID.
*
* @return int|null
*
* @since 10.4.0
*/
public function get_id(): ?int {
return $this->id;
}
/**
* Gets the user ID.
*
* @return int|null
*
* @since 10.4.0
*/
public function get_user_id(): ?int {
return $this->user_id;
}
/**
* Gets the token.
*
* @return string|null
*
* @since 10.4.0
*/
public function get_token(): ?string {
return $this->token;
}
/**
* Gets the device UUID.
*
* @return string|null
*
* @since 10.4.0
*/
public function get_device_uuid(): ?string {
return $this->device_uuid;
}
/**
* Gets the platform.
*
* @return string|null
*
* @since 10.4.0
*/
public function get_platform(): ?string {
return $this->platform;
}
/**
* Gets the origin.
*
* @return string|null
*
* @since 10.4.0
*/
public function get_origin(): ?string {
return $this->origin;
}
/**
* Gets the device locale.
*
* @return string|null
*
* @since 10.6.0
*/
public function get_device_locale(): ?string {
return $this->device_locale;
}
/**
* Gets the metadata.
*
* @return array|null
*
* @since 10.6.0
*/
public function get_metadata(): ?array {
return $this->metadata;
}
/**
* Gets the date the token was registered, as a GMT `Y-m-d H:i:s` string.
*
* @return string|null
*
* @since 11.2.0
*/
public function get_created_at_gmt(): ?string {
return $this->created_at_gmt;
}
/**
* Gets the date the app last confirmed this token, as a GMT `Y-m-d H:i:s` string.
*
* @return string|null
*
* @since 11.2.0
*/
public function get_last_confirmed_at_gmt(): ?string {
return $this->last_confirmed_at_gmt;
}
/**
* Gets the date this token was last sent to WPCOM, as a GMT `Y-m-d H:i:s`
* string, or null if it has never been sent.
*
* This records that WPCOM accepted a payload containing the token. It is
* not a delivery receipt: what happens between WPCOM and the device is
* downstream of anything this plugin can observe.
*
* @return string|null
*
* @since 11.2.0
*/
public function get_last_sent_at_gmt(): ?string {
return $this->last_sent_at_gmt;
}
/**
* Returns this token formatted for the WPCOM push notifications endpoint.
*
* @return array{user_id: int|null, token: string|null, origin: string|null, device_locale: string|null}
*
* @since 10.7.0
*/
public function to_wpcom_format(): array {
return array(
'user_id' => $this->user_id,
'token' => $this->token,
'origin' => $this->origin,
'device_locale' => $this->device_locale ?? self::DEFAULT_DEVICE_LOCALE,
);
}
/**
* Returns this token formatted for the push tokens REST index response.
*
* Deliberately separate from {@see self::to_wpcom_format()}. That method is
* also the per-token payload the dispatcher POSTs to the WPCOM send
* endpoint, so adding fields to it would change every notification request.
*
* Metadata is cast to an object so that an empty value encodes as `{}` rather
* than `[]`, matching the `object` type the schema declares for it.
*
* @return array{user_id: int|null, token: string|null, origin: string|null, device_locale: string|null, id: int|null, device_uuid: string|null, platform: string|null, metadata: stdClass, created_at_gmt: string|null, last_confirmed_at_gmt: string|null, last_sent_at_gmt: string|null}
*
* @since 11.2.0
*/
public function to_rest_format(): array {
return array_merge(
$this->to_wpcom_format(),
array(
'id' => $this->id,
'device_uuid' => $this->device_uuid,
'platform' => $this->platform,
'metadata' => (object) ( $this->metadata ?? array() ),
'created_at_gmt' => $this->to_rest_datetime( $this->created_at_gmt ),
'last_confirmed_at_gmt' => $this->to_rest_datetime( $this->last_confirmed_at_gmt ),
'last_sent_at_gmt' => $this->to_rest_datetime( $this->last_sent_at_gmt ),
)
);
}
/**
* Determines whether this token can be created.
*
* @return bool
*
* @since 10.4.0
*/
public function can_be_created(): bool {
return ! $this->get_id() && $this->has_required_parameters();
}
/**
* Determines whether this token can be updated.
*
* @return bool
*
* @since 10.4.0
*/
public function can_be_updated(): bool {
return $this->get_id() && $this->has_required_parameters();
}
/**
* Determines whether this token can be read.
*
* @return bool
*
* @since 10.4.0
*/
public function can_be_read(): bool {
return (bool) $this->get_id();
}
/**
* Determines whether this token can be deleted.
*
* @return bool
*
* @since 10.4.0
*/
public function can_be_deleted(): bool {
return (bool) $this->get_id();
}
/**
* Determines whether all the required non-ID parameters are filled.
*
* @return bool
*
* @since 10.4.0
*/
private function has_required_parameters(): bool {
return $this->get_user_id()
&& $this->get_token()
&& $this->get_platform()
&& $this->get_origin()
&& $this->get_device_locale()
&& (
$this->get_device_uuid()
|| $this->get_platform() === self::PLATFORM_BROWSER
);
}
}