$url, 'site' => $site, ]; } /** * Store the server and site. * * @param string $url Server base address, as {@see Parse::serverUrl()} gives it. * @param int $site Site id. */ public static function save( string $url, int $site ): void { update_option( self::OPTION, $url . '|' . $site, true ); } /** * Forget the server, the site and the token. */ public static function forget(): void { delete_option( self::OPTION ); delete_option( self::TOKEN_OPTION ); } /** * The write token, or null when none is set or it cannot be read on this site. */ public static function token(): ?string { $stored = get_option( self::TOKEN_OPTION, '' ); if ( ! is_string( $stored ) || '' === $stored ) { return null; } $raw = base64_decode( $stored, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Binary ciphertext stored in an option, not obfuscation. if ( false === $raw || strlen( $raw ) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ) { return null; } $plain = sodium_crypto_secretbox_open( substr( $raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ), substr( $raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ), self::key() ); return false === $plain || '' === $plain ? null : $plain; } /** * Store or clear the write token. * * @param string $token Token; empty clears it. */ public static function saveToken( string $token ): void { if ( '' === $token ) { delete_option( self::TOKEN_OPTION ); return; } $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); update_option( self::TOKEN_OPTION, base64_encode( $nonce . sodium_crypto_secretbox( $token, $nonce, self::key() ) ), false ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- See token(). } /** * The last server-side send. * * @return array{at: int, order: string, code: int, error: string, sent: int} */ public static function status(): array { $stored = get_option( self::STATUS_OPTION, [] ); $stored = is_array( $stored ) ? $stored : []; return [ 'at' => (int) ( $stored['at'] ?? 0 ), 'order' => (string) ( $stored['order'] ?? '' ), 'code' => (int) ( $stored['code'] ?? 0 ), 'error' => (string) ( $stored['error'] ?? '' ), 'sent' => (int) ( $stored['sent'] ?? 0 ), ]; } /** * Remember how a server-side send went. * * @param string $order Order id. * @param int $code HTTP status, 0 for a network failure. * @param string $error What went wrong, empty when nothing did. */ public static function recordSend( string $order, int $code, string $error ): void { $status = self::status(); update_option( self::STATUS_OPTION, [ 'at' => time(), 'order' => $order, 'code' => $code, 'error' => $error, 'sent' => $status['sent'] + ( '' === $error ? 1 : 0 ), ], false ); } /** * `matomo` of the connector heartbeat (contract 1.3): enough for the hub to * see the plugin sends to the site it reads. Never the token. * * @param bool $enabled Whether the module is switched on. * * @return array{enabled: bool, host: string|null, site_id: int|null, server_side: bool} */ public static function heartbeat( bool $enabled ): array { $site = self::site(); $host = null === $site ? null : wp_parse_url( $site['url'], PHP_URL_HOST ); return [ 'enabled' => $enabled && null !== $site, 'host' => is_string( $host ) ? $host : null, 'site_id' => null === $site ? null : $site['site'], 'server_side' => null !== self::token(), ]; } /** * 32-byte key derived from the site's auth salt, apart from the hub key's. */ private static function key(): string { return hash( 'sha256', wp_salt( 'auth' ) . '|rahkar-matomo', true ); } }