*/ public static function site() { $site = get_option( self::OPTION_SITE, array() ); return is_array( $site ) ? $site : array(); } /** * The site ID, secret and the address they belong to, or null when not connected. Only * {@see Client} uses them. * * @return array{site_id: string, secret: string, home: string}|null */ public static function credentials() { return self::clean_credentials( get_option( self::OPTION_CREDENTIALS, array() ) ); } /** * Whether this site holds a connection, working or not. * * @return bool */ public static function is_connected() { $site = self::site(); return ! empty( $site['site_id'] ); } /** * Whether this install holds a connection made at another address: a copy of the connected * site's database (a staging site, a migration). A copy sends nothing in that site's name. * * @return bool */ public static function is_copy() { $site = self::site(); return ! empty( $site['site_id'] ) && ! empty( $site['home'] ) && self::home_key( $site['home'] ) !== self::home_key( home_url( '/' ) ); } /** * Whether the connection works: connected, not revoked, and not a copy. Forms are sent and the * loader runs everywhere only while it does. * * @return bool */ public static function is_active() { $site = self::site(); return ! empty( $site['site_id'] ) && empty( $site['revoked'] ) && ! self::is_copy(); } /** * The connection's state. * * @return string `not_connected`; `copy` (connected at another address); `revoked` (LaunchList no * longer knows this site or its secret); `clock_skew` (LaunchList refused a request * because this server's clock is off); or `connected`. */ public static function status() { $site = self::site(); if ( empty( $site['site_id'] ) ) { return 'not_connected'; } if ( self::is_copy() ) { return 'copy'; } if ( ! empty( $site['revoked'] ) ) { return 'revoked'; } if ( ! empty( $site['clock_skew'] ) ) { return 'clock_skew'; } return 'connected'; } /** * The recorded clock skew. * * @return array{offset: int|null, at: int}|null `offset` is the server's clock minus this one, in * seconds, when the server sent its time. */ public static function clock_skew() { $site = self::site(); return isset( $site['clock_skew'] ) && is_array( $site['clock_skew'] ) ? $site['clock_skew'] : null; } /** * Mark the connection revoked (PROTOCOL §2: `unknown_site` or `invalid_signature`), but only if * the credentials the refused request was signed with are still the stored ones: a request that * left before a reconnect must not revoke the new connection. * * @param string $reason The server's reason. * @param array $credentials The credentials the request was signed with. * @return void */ public static function mark_revoked( $reason, array $credentials ) { self::update_if_current( $credentials, static function ( array $site ) use ( $reason ) { if ( ! empty( $site['revoked'] ) ) { return null; } $site['revoked'] = (string) $reason; $site['revoked_at'] = time(); return $site; } ); } /** * Record that LaunchList refused a request as `stale`. * * @param int|null $server_time The server's clock from its `Date` header, if it sent one. * @param array $credentials The credentials the request was signed with. * @return void */ public static function record_clock_skew( $server_time, array $credentials ) { self::update_if_current( $credentials, static function ( array $site ) use ( $server_time ) { $site['clock_skew'] = array( 'offset' => null === $server_time ? null : (int) $server_time - time(), 'at' => time(), ); return $site; } ); } /** * Forget a recorded clock skew once a signed request has been accepted. * * @param array $credentials The credentials the request was signed with. * @return void */ public static function clear_clock_skew( array $credentials ) { $site = self::site(); // Cheap check first: this runs after every accepted request. if ( empty( $site['clock_skew'] ) ) { return; } self::update_if_current( $credentials, static function ( array $site ) { if ( empty( $site['clock_skew'] ) ) { return null; } unset( $site['clock_skew'] ); return $site; } ); } /** * Note that LaunchList accepted a signup from this site's own forms (what `receiving` in * `GET /wordpress/site` reports). * * @param array $credentials The credentials the request was signed with. * @return void */ public static function mark_receiving( array $credentials ) { $site = self::site(); if ( isset( $site['receiving'] ) && true === $site['receiving'] ) { return; } self::update_if_current( $credentials, static function ( array $site ) { $site['receiving'] = true; return $site; } ); } /** * Change the stored details, read fresh from the database (another request may have reconnected * since this one loaded its options), only while the given credentials are still the stored ones. * * @param array $credentials The credentials a request was signed with. * @param callable $change Takes the fresh details, returns them changed or null for no change. * @return void */ private static function update_if_current( array $credentials, $change ) { $stored = self::clean_credentials( self::fresh_option( self::OPTION_CREDENTIALS ) ); if ( null === $stored || ! isset( $credentials['site_id'], $credentials['secret'] ) || $stored['site_id'] !== $credentials['site_id'] || ! hash_equals( $stored['secret'], (string) $credentials['secret'] ) ) { return; } $site = self::fresh_option( self::OPTION_SITE ); if ( ! is_array( $site ) || ! isset( $site['site_id'] ) || $site['site_id'] !== $stored['site_id'] ) { return; } $site = call_user_func( $change, $site ); if ( is_array( $site ) ) { update_option( self::OPTION_SITE, $site ); } } /** * One option straight from the database, past every cache. * * @param string $name Option name. * @return mixed Null when it does not exist. */ private static function fresh_option( $name ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Deliberately uncached: the cached copy may predate a reconnect. $value = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", $name ) ); return null === $value ? null : maybe_unserialize( $value ); } /** * Credentials in their shape, or null. * * @param mixed $credentials The stored value. * @return array{site_id: string, secret: string, home: string}|null */ private static function clean_credentials( $credentials ) { if ( ! is_array( $credentials ) || empty( $credentials['site_id'] ) || empty( $credentials['secret'] ) ) { return null; } return array( 'site_id' => (string) $credentials['site_id'], 'secret' => (string) $credentials['secret'], 'home' => isset( $credentials['home'] ) ? (string) $credentials['home'] : '', ); } /** * Start connecting: create the state and verifier, keep them for this user, and return the URL * to send the browser to. * * @param int $user_id The founder's user ID. * @return string The connect URL, or `""` when the origin is not valid. */ public static function start( $user_id ) { $origin = self::origin(); if ( '' === $origin ) { return ''; } $state = self::random_string( 32, self::STATE_ALPHABET ); $verifier = self::random_string( 64, self::VERIFIER_ALPHABET ); set_transient( self::PENDING_PREFIX . (int) $user_id, array( 'state' => $state, 'verifier' => $verifier, ), self::PENDING_TTL ); return self::connect_url( $origin, $state, self::code_challenge( $verifier ) ); } /** * The `GET {origin}/wordpress/connect` URL. * * @param string $origin The embed origin. * @param string $state The state. * @param string $challenge The code challenge. * @return string */ public static function connect_url( $origin, $state, $challenge ) { $name = trim( wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ) ); $query = array( 'site_url' => home_url( '/' ), 'name' => self::truncate( $name, 120 ), 'admin_url' => admin_url( 'admin.php?page=launchlist' ), 'return_url' => admin_url( 'admin-post.php?action=launchlist_callback' ), 'state' => $state, 'code_challenge' => $challenge, 'code_challenge_method' => 'S256', 'plugin_version' => LAUNCHLIST_VERSION, ); return $origin . '/wordpress/connect?' . http_build_query( $query, '', '&', PHP_QUERY_RFC3986 ); } /** * The PKCE S256 challenge: base64url(sha256(verifier)), no padding. * * @param string $verifier The code verifier. * @return string */ public static function code_challenge( $verifier ) { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- RFC 7636 encoding, not obfuscation. return rtrim( strtr( base64_encode( hash( 'sha256', (string) $verifier, true ) ), '+/', '-_' ), '=' ); } /** * A random string from a cryptographically secure source. * * @param int $length Characters. * @param string $alphabet Allowed characters. * @return string */ public static function random_string( $length, $alphabet ) { $max = strlen( $alphabet ) - 1; $string = ''; for ( $i = 0; $i < $length; $i++ ) { $string .= $alphabet[ random_int( 0, $max ) ]; } return $string; } /** * Why LaunchList would refuse this site's connect link, or `""`. The consent page's rules * (PROTOCOL.md §1.3), checked here first so the founder gets a reason instead of an error page: * * - every address is absolute http(s), at most 2,048 characters, with no credentials and a plain * ASCII host ({@see Connection::valid_host()}); * - the admin is exactly `{dir}/admin.php?page=launchlist` and the callback exactly * `{dir}/admin-post.php?action=launchlist_callback` in the same `{dir}`, with no fragment and no * empty, `.`, `..` or `%` segment in `{dir}`; * - the admin is on the site's origin, or its HTTPS upgrade (an `http` site on port 80 with an * `https` admin on 443, same host, as `FORCE_SSL_ADMIN` makes it). Never a downgrade, another * host or another port. * * @return string */ public static function address_problem() { $home = home_url( '/' ); $admin_url = admin_url( 'admin.php?page=launchlist' ); $return_url = admin_url( 'admin-post.php?action=launchlist_callback' ); $site = self::url_parts( $home ); $admin = self::url_parts( $admin_url ); $return = self::url_parts( $return_url ); if ( null === $site || null === $admin || null === $return ) { return __( 'LaunchList can\'t connect this site because its address under Settings, then General, is not a complete http or https address.', 'launchlist' ); } foreach ( array( $site, $admin, $return ) as $parts ) { if ( ! self::valid_host( $parts['host'] ) ) { return sprintf( /* translators: 1: a host name, 2: the prefix of a punycode host name. */ __( 'LaunchList can\'t connect this site because the host %1$s is not one it accepts: only letters, digits, dots and hyphens, with no dot at the end. For an address with accents or other scripts, use its punycode form (starting with %2$s) under Settings, then General.', 'launchlist' ), $parts['host'], 'xn--' ); } } $admin_suffix = '/admin.php?page=launchlist'; $return_suffix = '/admin-post.php?action=launchlist_callback'; $folder = substr( $admin_url, 0, -strlen( $admin_suffix ) ); $folder_path = (string) wp_parse_url( $folder . '/', PHP_URL_PATH ); $segments = array_slice( explode( '/', $folder_path ), 1, -1 ); $bad_segment = false; foreach ( $segments as $segment ) { $bad_segment = $bad_segment || '' === $segment || '.' === $segment || '..' === $segment || false !== strpos( $segment, '%' ); } if ( substr( $admin_url, -strlen( $admin_suffix ) ) !== $admin_suffix || $folder . $return_suffix !== $return_url || $bad_segment || '' !== $admin['fragment'] ) { return sprintf( /* translators: 1: admin.php, 2: admin-post.php */ __( 'LaunchList can\'t connect this site because its admin pages are not at their usual addresses (%1$s and %2$s in the same folder). A plugin that moves or hides the admin can cause this.', 'launchlist' ), 'admin.php', 'admin-post.php' ); } $same_origin = $site['scheme'] === $admin['scheme'] && $site['port'] === $admin['port']; $ssl_admin = 'http' === $site['scheme'] && 80 === $site['port'] && 'https' === $admin['scheme'] && 443 === $admin['port']; if ( $site['host'] !== $admin['host'] || ! ( $same_origin || $ssl_admin ) ) { return sprintf( /* translators: 1: the site's address, 2: the WordPress admin's address. */ __( 'LaunchList can only connect a site whose admin is at the same address as the site. This site is at %1$s and its admin is at %2$s. Make them match under Settings, then General, and connect again.', 'launchlist' ), $site['origin'], $admin['origin'] ); } return ''; } /** * Whether a host is one LaunchList accepts in a connect link: dot-separated labels of lowercase * letters, digits and inner hyphens (punycode `xn--` labels and `localhost` included), at most 253 * characters, or a dotted IPv4 or bracketed IPv6 address. Refused: `%`, a trailing dot, an * underscore, any non-ASCII byte, and a numeric last label that is not a whole IPv4 address. * * @param string $host The host, lowercased. * @return bool */ public static function valid_host( $host ) { $host = (string) $host; if ( '' === $host || strlen( $host ) > 253 ) { return false; } if ( '[' === $host[0] ) { return ']' === substr( $host, -1 ) && false !== filter_var( substr( $host, 1, -1 ), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ); } $labels = explode( '.', $host ); foreach ( $labels as $label ) { if ( ! preg_match( '/^[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?$/D', $label ) ) { return false; } } // A last label that reads as a number (`123`, `0x1`) is only an address when the whole host // is IPv4; browsers would read anything else as one. if ( preg_match( '/^(?:[0-9]+|0x[0-9a-f]*)$/D', (string) end( $labels ) ) ) { return false !== filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ); } return true; } /** * Finish connecting: check the state, then exchange the code. Pure enough to test: it reads the * pending transient, may call the token endpoint, and returns the notice to show. * * @param array $query `state`, `code` and `error` from the callback URL. * @param int $user_id The founder's user ID. * @return array{type: string, message: string} */ public static function callback( array $query, $user_id ) { $key = self::PENDING_PREFIX . (int) $user_id; $pending = get_transient( $key ); $state = isset( $query['state'] ) && is_string( $query['state'] ) ? $query['state'] : ''; if ( ! is_array( $pending ) || empty( $pending['state'] ) || empty( $pending['verifier'] ) ) { return self::notice( 'error', __( 'This connection link has expired, so the site was not connected. Connect again.', 'launchlist' ) ); } if ( '' === $state || ! hash_equals( (string) $pending['state'], $state ) ) { return self::notice( 'error', __( 'This connection did not start from this site\'s LaunchList screen, so the site was not connected. Connect again from there.', 'launchlist' ) ); } delete_transient( $key ); if ( isset( $query['error'] ) && '' !== $query['error'] ) { if ( 'access_denied' === $query['error'] ) { return self::notice( 'info', __( 'You canceled in LaunchList, so the site was not connected.', 'launchlist' ) ); } return self::notice( 'error', __( 'LaunchList did not connect the site. Connect again.', 'launchlist' ) ); } $code = isset( $query['code'] ) && is_string( $query['code'] ) ? $query['code'] : ''; if ( '' === $code || strlen( $code ) > 256 || preg_match( '/[^A-Za-z0-9._~\-]/', $code ) ) { return self::notice( 'error', __( 'LaunchList sent back an incomplete connection, so the site was not connected. Connect again.', 'launchlist' ) ); } $result = Client::post( '/wordpress/token', array( 'code' => $code, 'code_verifier' => (string) $pending['verifier'], ) ); return self::finish( $result ); } /** * Store the credentials from a token exchange, then remove an older connection of this site. * * @param Result $result The token endpoint's answer. * @return array{type: string, message: string} */ private static function finish( Result $result ) { if ( ! $result->ok() ) { if ( 'invalid_grant' === $result->reason ) { return self::notice( 'error', __( 'LaunchList could not confirm this connection: the link expired or was already used, or the approval no longer stands (the workspace is gone, or you can no longer manage its integrations). Connect again.', 'launchlist' ) ); } if ( 429 === $result->status ) { return self::notice( 'error', __( 'Too many connection attempts from this server. Wait a few minutes, then connect again.', 'launchlist' ) ); } if ( 0 === $result->status ) { return self::notice( 'error', __( 'We couldn\'t reach LaunchList, so the site was not connected. Try again.', 'launchlist' ) ); } return self::notice( 'error', sprintf( /* translators: %d: HTTP status code. */ __( 'LaunchList answered with an error (HTTP %d), so the site was not connected. Try again.', 'launchlist' ), $result->status ) ); } $body = $result->body; $site_id = isset( $body['site_id'] ) && is_string( $body['site_id'] ) ? $body['site_id'] : ''; $secret = isset( $body['secret'] ) && is_string( $body['secret'] ) ? $body['secret'] : ''; if ( ! preg_match( '/^wps_[A-Za-z0-9]{1,64}$/D', $site_id ) || ! preg_match( '/^[A-Fa-f0-9]{64}$/D', $secret ) ) { return self::notice( 'error', __( 'LaunchList answered in a way this plugin does not understand, so the site was not connected. Update the plugin, then connect again.', 'launchlist' ) ); } $previous = self::credentials(); // A copy never removes the connection of the site it was copied from. $was_copy = self::is_copy(); self::store( $site_id, strtolower( $secret ), $body ); if ( null !== $previous && $previous['site_id'] !== $site_id && ! $was_copy ) { // Best effort: the older connection may already be gone. Client::signed( 'DELETE', '/wordpress/site', null, Client::RELAY_TIMEOUT, $previous ); } Forms::forget(); Queue::reschedule(); $site = self::site(); if ( empty( $site['workspace'] ) ) { return self::notice( 'success', __( 'Connected. Choose which of your forms send their signups to LaunchList under Your forms.', 'launchlist' ) ); } return self::notice( 'success', /* translators: %s: LaunchList workspace name. */ sprintf( __( 'Connected to %s. Choose which of your forms send their signups to LaunchList under Your forms.', 'launchlist' ), $site['workspace'] ) ); } /** * Ask LaunchList whether the connection works, and refresh the stored details. * * @return array{type: string, message: string} */ public static function check() { if ( null === self::credentials() ) { return self::notice( 'error', __( 'This site is not connected to LaunchList.', 'launchlist' ) ); } if ( self::is_copy() ) { return self::notice( 'error', __( 'This site is a copy of a connected site, so it was not checked. Connect it to connect this copy as its own site.', 'launchlist' ) ); } $result = Client::signed( 'GET', '/wordpress/site' ); if ( $result->ok() ) { $body = $result->body; $site = array_merge( self::site(), self::details( $body ) ); unset( $site['revoked'], $site['revoked_at'], $site['clock_skew'] ); $site['checked_at'] = time(); if ( isset( $body['receiving'] ) ) { $site['receiving'] = (bool) $body['receiving']; } self::save_site( $site ); // A connection that works again sends what was kept while it did not. Queue::reschedule(); $workspace = '' !== $site['workspace'] ? $site['workspace'] : __( 'your LaunchList workspace', 'launchlist' ); return self::notice( 'success', /* translators: %s: LaunchList workspace name. */ sprintf( __( 'Connected to %s. The connection works.', 'launchlist' ), $workspace ) ); } if ( $result->revoked() ) { return self::notice( 'error', __( 'LaunchList no longer recognizes this site, so your forms are not reaching it. Connect again to resume.', 'launchlist' ) ); } if ( 401 === $result->status && 'stale' === $result->reason ) { return self::notice( 'error', __( 'LaunchList refused the request because this server\'s clock is off. Ask your host to sync the server clock, then check again.', 'launchlist' ) ); } if ( 429 === $result->status && 'rate_limited' === $result->reason ) { return self::notice( 'error', __( 'LaunchList asked this server to slow down, so the connection was not checked. Try again in a minute.', 'launchlist' ) ); } if ( 0 === $result->status ) { return self::notice( 'error', __( 'We couldn\'t reach LaunchList. Try again in a moment.', 'launchlist' ) ); } return self::notice( 'error', /* translators: %d: HTTP status code. */ sprintf( __( 'LaunchList answered with an error (HTTP %d). Try again in a moment.', 'launchlist' ), $result->status ) ); } /** * Remove the connection: ask LaunchList to remove the site (never from a copy, whose credentials * belong to the site it was copied from), then forget it here whatever the answer, and delete the * signups still waiting to be sent (they can no longer be). * * @return string `removed` (LaunchList removed it or no longer knew it), `unreachable` * (forgotten here only), or `local` (a copy: forgotten here only, on purpose). */ public static function disconnect() { $credentials = self::credentials(); $outcome = 'local'; if ( null !== $credentials && ! self::is_copy() ) { $result = Client::signed( 'DELETE', '/wordpress/site', null, Client::ADMIN_TIMEOUT, $credentials ); $outcome = $result->ok() || $result->revoked() ? 'removed' : 'unreachable'; } Queue::clear( true ); self::forget(); return $outcome; } /** * Forget the connection on this site only. * * @return void */ public static function forget() { delete_option( self::OPTION_CREDENTIALS ); delete_option( self::OPTION_SITE ); Forms::forget(); } /** * `admin-post.php?action=launchlist_connect`. * * @return void */ public static function handle_connect() { self::guard( 'launchlist_connect' ); $problem = self::address_problem(); if ( '' !== $problem ) { Notices::add( 'error', $problem ); self::back(); } $url = self::start( get_current_user_id() ); if ( '' === $url ) { Notices::add( 'error', sprintf( /* translators: %s: the LAUNCHLIST_ORIGIN constant. */ __( 'The LaunchList address in %s is not a valid http or https address, so this site cannot connect. Fix it in wp-config.php.', 'launchlist' ), 'LAUNCHLIST_ORIGIN' ) ); self::back(); } // The consent page lives on LaunchList: allow its host for this one redirect. $host = (string) wp_parse_url( self::origin(), PHP_URL_HOST ); add_filter( 'allowed_redirect_hosts', static function ( $hosts ) use ( $host ) { $hosts = is_array( $hosts ) ? $hosts : array(); $hosts[] = $host; return $hosts; } ); wp_safe_redirect( $url ); exit; } /** * `admin-post.php?action=launchlist_callback`, where LaunchList sends the founder back. * * @return void */ public static function handle_callback() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to connect this site to LaunchList.', 'launchlist' ), '', array( 'response' => 403 ) ); } $query = array(); foreach ( array( 'state', 'code', 'error' ) as $key ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The state, bound to this user, is the CSRF check. if ( isset( $_GET[ $key ] ) && is_string( $_GET[ $key ] ) ) { $query[ $key ] = sanitize_text_field( wp_unslash( $_GET[ $key ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } } $notice = self::callback( $query, get_current_user_id() ); Notices::add( $notice['type'], $notice['message'] ); self::back(); } /** * The callback reached by someone signed out (their session ended on the consent page): sign in * and come back to the same URL. * * @return void */ public static function handle_callback_signed_out() { auth_redirect(); } /** * `admin-post.php?action=launchlist_disconnect`. * * @return void */ public static function handle_disconnect() { self::guard( 'launchlist_disconnect' ); $site = self::site(); $outcome = self::disconnect(); if ( 'removed' === $outcome ) { Notices::add( 'success', __( 'Disconnected. LaunchList no longer receives this site\'s forms, and the signups it already received stay in LaunchList.', 'launchlist' ) ); } elseif ( 'unreachable' === $outcome ) { Notices::add( 'warning', __( 'Disconnected on this site, but we couldn\'t reach LaunchList to remove the site there. Disconnect it in LaunchList under Embed & API, then Your website.', 'launchlist' ) ); } else { Notices::add( 'success', sprintf( /* translators: %s: the address the connection was made at. */ __( 'The connection was removed from this copy. The site at %s stays connected to LaunchList.', 'launchlist' ), isset( $site['home'] ) ? $site['home'] : '' ) ); } self::back(); } /** * `admin-post.php?action=launchlist_check`. * * @return void */ public static function handle_check() { self::guard( 'launchlist_check' ); $notice = self::check(); Notices::add( $notice['type'], $notice['message'] ); self::back(); } /** * Go back to the LaunchList screen. * * @return void */ public static function back() { wp_safe_redirect( admin_url( 'admin.php?page=launchlist' ) ); exit; } /** * Refuse anyone who may not manage the site, and any request without this action's nonce. * * @param string $action Nonce action. * @return void */ private static function guard( $action ) { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to change the LaunchList connection.', 'launchlist' ), '', array( 'response' => 403 ) ); } check_admin_referer( $action ); } /** * Save the credentials (not autoloaded) and the public details (autoloaded), both with the * address this site has now. * * @param string $site_id The site ID. * @param string $secret The secret. * @param array $body The token endpoint's answer. * @return void */ private static function store( $site_id, $secret, array $body ) { $home = home_url( '/' ); // Delete first so the option is re-created not autoloaded, whatever it was before. delete_option( self::OPTION_CREDENTIALS ); add_option( self::OPTION_CREDENTIALS, array( 'site_id' => $site_id, 'secret' => $secret, 'home' => $home, ), '', false ); $site = self::details( $body ); $site['site_id'] = $site_id; $site['home'] = $home; $site['connected_at'] = time(); $site['checked_at'] = time(); delete_option( self::OPTION_SITE ); add_option( self::OPTION_SITE, $site, '', true ); } /** * The public details in a token or site answer. * * @param array $body The answer. * @return array{site_name: string, site_url: string, workspace: string, dashboard_url: string} */ private static function details( array $body ) { $site = isset( $body['site'] ) && is_array( $body['site'] ) ? $body['site'] : array(); $workspace = isset( $body['workspace'] ) && is_array( $body['workspace'] ) ? $body['workspace'] : array(); return array( 'site_name' => self::text( isset( $site['name'] ) ? $site['name'] : '' ), 'site_url' => self::url( isset( $site['url'] ) ? $site['url'] : '' ), 'workspace' => self::text( isset( $workspace['name'] ) ? $workspace['name'] : '' ), 'dashboard_url' => self::url( isset( $body['dashboard_url'] ) ? $body['dashboard_url'] : '' ), ); } /** * Save the public details. * * @param array $site The details. * @return void */ private static function save_site( array $site ) { update_option( self::OPTION_SITE, $site ); } /** * A notice. * * @param string $type success, warning, error or info. * @param string $message The message. * @return array{type: string, message: string} */ private static function notice( $type, $message ) { return array( 'type' => $type, 'message' => $message, ); } /** * A short, plain string from the server. * * @param mixed $value The value. * @return string */ private static function text( $value ) { return is_string( $value ) ? self::truncate( sanitize_text_field( $value ), 200 ) : ''; } /** * An http(s) URL from the server, or `""`. * * @param mixed $value The value. * @return string */ private static function url( $value ) { if ( ! is_string( $value ) || ! preg_match( '#^https?://#i', $value ) ) { return ''; } return esc_url_raw( $value, array( 'http', 'https' ) ); } /** * The parts of an absolute http(s) URL LaunchList checks, or null when it is not one (also * refused: whitespace, control characters, a backslash, and credentials before the host). * * @param string $url The URL. * @return array{scheme: string, host: string, port: int, fragment: string, origin: string}|null */ private static function url_parts( $url ) { $url = (string) $url; if ( strlen( $url ) > 2048 || preg_match( '/[\x00-\x20\x7F\\\\]/', $url ) ) { return null; } $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) || isset( $parts['user'] ) || isset( $parts['pass'] ) ) { return null; } $scheme = strtolower( $parts['scheme'] ); if ( 'http' !== $scheme && 'https' !== $scheme ) { return null; } $host = strtolower( $parts['host'] ); $default = 'https' === $scheme ? 443 : 80; $port = isset( $parts['port'] ) ? (int) $parts['port'] : $default; return array( 'scheme' => $scheme, 'host' => $host, 'port' => $port, 'fragment' => isset( $parts['fragment'] ) ? (string) $parts['fragment'] : '', 'origin' => $scheme . '://' . $host . ( $port === $default ? '' : ':' . $port ), ); } /** * A home URL as one comparable key: host and port lowercased, the path without its trailing * slash. The scheme is left out, so moving the same site to https is not mistaken for a copy. * * @param string $url The URL. * @return string */ public static function home_key( $url ) { $parts = wp_parse_url( (string) $url ); if ( ! is_array( $parts ) || empty( $parts['host'] ) ) { return (string) $url; } $scheme = isset( $parts['scheme'] ) ? strtolower( $parts['scheme'] ) : 'http'; $port = isset( $parts['port'] ) && (int) $parts['port'] !== ( 'https' === $scheme ? 443 : 80 ) ? ':' . (int) $parts['port'] : ''; // Adding or dropping `www.` moves the live site; it does not make a copy of it. $host = (string) preg_replace( '/^www\./', '', strtolower( $parts['host'] ) ); return $host . $port . rtrim( isset( $parts['path'] ) ? $parts['path'] : '', '/' ); } /** * Cut a string to a number of characters (not bytes). * * @param string $value The string. * @param int $length Characters. * @return string */ public static function truncate( $value, $length ) { $value = (string) $value; if ( function_exists( 'mb_substr' ) ) { return mb_substr( $value, 0, $length, 'UTF-8' ); } return substr( $value, 0, $length ); } }