in /w/.js. */ const TYPE_PATTERN = '/^[a-z0-9][a-z0-9-]{1,39}$/'; /** * Zyff's origin, without a trailing slash. * * @return string */ public static function origin() { return untrailingslashit( ZYFF_WIDGETS_ORIGIN ); } /** * The stored connection, or null when this site is not connected. * * @return array{token: string, email: string, site: string, connected_at: int}|null */ public static function connection() { $conn = get_option( self::OPTION ); if ( ! is_array( $conn ) || empty( $conn['token'] ) ) { return null; } return $conn; } /** * Whether this site is connected to a Zyff account. * * @return bool */ public static function is_connected() { return null !== self::connection(); } /** * Forget the connection locally. Does not contact Zyff; see revoke(). */ public static function forget() { delete_option( self::OPTION ); delete_transient( self::CACHE ); } /** * Exchange a one-time code from the consent page for a token, and store it. * * @param string $code The code Zyff sent back. * @param string $verifier The PKCE verifier generated before the redirect. * @param string $redirect_uri The exact redirect URI sent to Zyff. * @return true|WP_Error */ public static function exchange( $code, $verifier, $redirect_uri ) { $response = wp_remote_post( self::origin() . '/api/connect/v1/token', array( 'timeout' => 15, 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'User-Agent' => self::user_agent(), ), 'body' => wp_json_encode( array( 'code' => $code, 'code_verifier' => $verifier, 'redirect_uri' => $redirect_uri, ) ), ) ); $body = self::decode( $response ); if ( is_wp_error( $body ) ) { return $body; } $token = isset( $body['access_token'] ) ? (string) $body['access_token'] : ''; if ( ! preg_match( '/^zyff_ct_[A-Za-z0-9_-]{43}$/', $token ) ) { return new WP_Error( 'zyff_unavailable', __( 'Zyff sent an unexpected answer. Try connecting again.', 'zyff-widgets' ) ); } update_option( self::OPTION, array( 'token' => $token, 'email' => isset( $body['account']['email'] ) ? sanitize_email( $body['account']['email'] ) : '', 'site' => isset( $body['site']['url'] ) ? esc_url_raw( $body['site']['url'] ) : '', 'connected_at' => time(), ), false ); delete_transient( self::CACHE ); return true; } /** * The connected account's saved widgets. * * Served from a short cache unless $refresh is set. A 401 from Zyff means * the connection was revoked on zyff.app, so the local copy is forgotten * and the caller shows the Connect button again. * * @param bool $refresh Bypass the cache. * @return array{widgets: array[], email: string, links: array}|WP_Error */ public static function widgets( $refresh = false ) { $conn = self::connection(); if ( ! $conn ) { return new WP_Error( 'zyff_not_connected', __( 'This site is not connected to a Zyff account.', 'zyff-widgets' ) ); } if ( ! $refresh ) { $cached = get_transient( self::CACHE ); if ( is_array( $cached ) ) { return $cached; } } $response = wp_remote_get( self::origin() . '/api/connect/v1/widgets', array( 'timeout' => 15, 'headers' => array( 'Authorization' => 'Bearer ' . $conn['token'], 'Accept' => 'application/json', 'User-Agent' => self::user_agent(), ), ) ); $body = self::decode( $response ); if ( is_wp_error( $body ) ) { if ( 'zyff_invalid_token' === $body->get_error_code() ) { self::forget(); } return $body; } $list = array( 'widgets' => self::clean_widgets( isset( $body['widgets'] ) && is_array( $body['widgets'] ) ? $body['widgets'] : array() ), 'email' => isset( $body['account']['email'] ) ? sanitize_email( $body['account']['email'] ) : $conn['email'], 'links' => array( 'builder' => self::zyff_url( isset( $body['links']['builder'] ) ? $body['links']['builder'] : '', '/widget-builder' ), 'account' => self::zyff_url( isset( $body['links']['account'] ) ? $body['links']['account'] : '', '/account' ), ), ); set_transient( self::CACHE, $list, self::CACHE_TTL ); return $list; } /** * Revoke this site's token on Zyff, then forget it locally. Forgets it * even when Zyff cannot be reached: the owner can still disconnect the * site from their account page. */ public static function revoke() { $conn = self::connection(); if ( $conn ) { wp_remote_request( self::origin() . '/api/connect/v1/token', array( 'method' => 'DELETE', 'timeout' => 10, 'headers' => array( 'Authorization' => 'Bearer ' . $conn['token'], 'User-Agent' => self::user_agent(), ), ) ); } self::forget(); } /** * Find one widget in the cached list by public id. * * @param string $public_id The widget's public id. * @return array|null */ public static function find( $public_id ) { $list = get_transient( self::CACHE ); if ( ! is_array( $list ) || empty( $list['widgets'] ) ) { return null; } foreach ( $list['widgets'] as $widget ) { if ( $widget['publicId'] === $public_id ) { return $widget; } } return null; } /** * Keep only well-formed widgets, with every field sanitised. The API is * trusted to be Zyff, but nothing it sends is printed or enqueued without * passing through here — and the runtime URL is never taken from it. * * @param array $raw Widgets as decoded from the API. * @return array[] */ private static function clean_widgets( array $raw ) { $clean = array(); foreach ( $raw as $w ) { if ( ! is_array( $w ) ) { continue; } $id = isset( $w['publicId'] ) ? (string) $w['publicId'] : ''; $type = isset( $w['platform'] ) ? (string) $w['platform'] : ''; if ( ! preg_match( self::ID_PATTERN, $id ) || ! preg_match( self::TYPE_PATTERN, $type ) ) { continue; } $type_name = isset( $w['platformName'] ) ? sanitize_text_field( $w['platformName'] ) : $type; $clean[] = array( 'publicId' => $id, 'platform' => $type, 'platformName' => '' !== $type_name ? $type_name : $type, 'name' => isset( $w['name'] ) && '' !== $w['name'] ? sanitize_text_field( $w['name'] ) : $type_name, 'enabled' => ! empty( $w['enabled'] ), 'shared' => ! empty( $w['shared'] ), 'editUrl' => self::zyff_url( isset( $w['editUrl'] ) ? $w['editUrl'] : '', '' ), ); } return $clean; } /** * A URL from the API, kept only if it points at Zyff's own origin. * * @param mixed $url The URL as received. * @param string $fallback A path on Zyff to use instead, or '' for none. * @return string */ private static function zyff_url( $url, $fallback ) { $origin = self::origin(); if ( is_string( $url ) && 0 === strpos( $url, $origin . '/' ) ) { return esc_url_raw( $url ); } return '' === $fallback ? '' : esc_url_raw( $origin . $fallback ); } /** * Decode a JSON response, turning every failure into a WP_Error whose code * the callers switch on: zyff_invalid_token, zyff_invalid_grant, * zyff_too_many_connections, zyff_rate_limited or zyff_unavailable. * * @param array|WP_Error $response From wp_remote_*. * @return array|WP_Error */ private static function decode( $response ) { if ( is_wp_error( $response ) ) { return new WP_Error( 'zyff_unavailable', __( 'Couldn’t reach Zyff. Check this site can make outbound requests, then try again.', 'zyff-widgets' ) ); } $status = (int) wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $status >= 200 && $status < 300 && is_array( $body ) ) { return $body; } $code = is_array( $body ) && isset( $body['code'] ) ? sanitize_key( $body['code'] ) : ''; $message = is_array( $body ) && isset( $body['error'] ) ? sanitize_text_field( $body['error'] ) : ''; if ( 401 === $status ) { return new WP_Error( 'zyff_invalid_token', __( 'This site is no longer connected to a Zyff account. Connect it again.', 'zyff-widgets' ) ); } if ( in_array( $code, array( 'invalid_grant', 'too_many_connections', 'rate_limited' ), true ) && '' !== $message ) { return new WP_Error( 'zyff_' . $code, $message ); } return new WP_Error( 'zyff_unavailable', __( 'Zyff couldn’t complete that just now. Try again in a moment.', 'zyff-widgets' ) ); } /** * Identifies the plugin to Zyff, for support. Nothing about the site's * visitors or content. * * @return string */ private static function user_agent() { return 'ZyffWidgets/' . ZYFF_WIDGETS_VERSION . ' WordPress/' . get_bloginfo( 'version' ); } }