paired_routing = $paired_routing; } /** * Register. */ public function register() { add_filter( 'amp_default_options', [ $this, 'filter_default_options' ] ); add_filter( 'amp_options_updating', [ $this, 'sanitize_options' ], 10, 2 ); if ( AMP_Options_Manager::get_option( Option::MOBILE_REDIRECT ) && ! amp_is_canonical() ) { add_action( 'template_redirect', [ $this, 'redirect' ], PHP_INT_MAX ); // Enable AMP-to-AMP linking by default to avoid redirecting to AMP version when navigating. // A low priority is used so that sites can continue overriding this if they have done so. add_filter( 'amp_to_amp_linking_enabled', '__return_true', 0 ); add_filter( 'comment_post_redirect', [ $this, 'filter_comment_post_redirect' ] ); // Amend the comments/respond links to go to non-AMP page when in legacy Reader mode. if ( amp_is_legacy() ) { add_filter( 'get_comments_link', [ $this, 'add_noamp_mobile_query_var' ] ); // For get_comments_link(). add_filter( 'respond_link', [ $this, 'add_noamp_mobile_query_var' ] ); // For comments_popup_link(). } } } /** * Add default option. * * @param array $defaults Default options. * @return array Defaults. */ public function filter_default_options( $defaults ) { $defaults[ Option::MOBILE_REDIRECT ] = true; return $defaults; } /** * Sanitize options. * * @param array $options Existing options with already-sanitized values for updating. * @param array $new_options Unsanitized options being submitted for updating. * * @return array Sanitized options. */ public function sanitize_options( $options, $new_options ) { if ( isset( $new_options[ Option::MOBILE_REDIRECT ] ) ) { $options[ Option::MOBILE_REDIRECT ] = rest_sanitize_boolean( $new_options[ Option::MOBILE_REDIRECT ] ); } return $options; } /** * Get the AMP version of the current URL. * * @return string AMP URL. */ public function get_current_amp_url() { $url = $this->paired_routing->add_endpoint( amp_get_current_url() ); $url = remove_query_arg( QueryVar::NOAMP, $url ); return $url; } /** * Add redirection logic if available for request. */ public function redirect() { // If a site is AMP-first or AMP is not available for the request, then no redirection functionality will apply. // Additionally, prevent adding redirection logic in the Customizer preview since that will currently complicate things. if ( amp_is_canonical() || ! amp_is_available() ) { return; } $js = $this->is_using_client_side_redirection(); if ( ! $js ) { // If using server-side redirection, make sure that caches vary by user agent. if ( ! headers_sent() ) { header( 'Vary: User-Agent', false ); } // Now abort if it's not an AMP page and the user agent is not mobile, since there won't be any redirection // to the AMP version and we don't need to show a footer link to go to the AMP version. if ( ! $this->is_mobile_request() && ! amp_is_request() ) { return; } } // Print the mobile switcher styles. add_action( 'wp_head', [ $this, 'add_mobile_version_switcher_styles' ] ); add_action( 'amp_post_template_head', [ $this, 'add_mobile_version_switcher_styles' ] ); // For legacy Reader mode theme. if ( ! amp_is_request() ) { add_action( 'wp_head', [ $this, 'add_mobile_alternative_link' ] ); if ( $js ) { // Add mobile redirection script. add_action( 'wp_head', [ $this, 'add_mobile_redirect_script' ], ~PHP_INT_MAX ); } elseif ( ! $this->is_redirection_disabled_via_cookie() ) { if ( $this->is_redirection_disabled_via_query_param() ) { // Persist disabling mobile redirection for the session if redirection is disabled for the current request. $this->set_mobile_redirection_disabled_cookie( true ); } else { // Redirect to the AMP version since is_mobile_request and redirection not disabled by cookie or query param. if ( wp_safe_redirect( $this->get_current_amp_url(), 302 ) ) { exit; } } } // Add a link to the footer to allow for navigation to the AMP version. add_action( 'wp_footer', [ $this, 'add_mobile_version_switcher_link' ] ); } else { if ( ! $js && $this->is_redirection_disabled_via_cookie() ) { $this->set_mobile_redirection_disabled_cookie( false ); } add_filter( 'amp_to_amp_linking_element_excluded', [ $this, 'filter_amp_to_amp_linking_element_excluded' ], 100, 2 ); add_filter( 'amp_to_amp_linking_element_query_vars', [ $this, 'filter_amp_to_amp_linking_element_query_vars' ], 10, 2 ); // Add a link to the footer to allow for navigation to the non-AMP version. add_action( 'wp_footer', [ $this, 'add_mobile_version_switcher_link' ] ); add_action( 'amp_post_template_footer', [ $this, 'add_mobile_version_switcher_link' ] ); // For legacy Reader mode theme. } } /** * Ensure that links/forms which point to ?noamp up-front are excluded from AMP-to-AMP linking. * * @param bool $excluded Excluded. * @param string $url URL considered for exclusion. * @return bool Element excluded from AMP-to-AMP linking. */ public function filter_amp_to_amp_linking_element_excluded( $excluded, $url ) { if ( ! $excluded ) { $query_string = wp_parse_url( $url, PHP_URL_QUERY ); if ( ! empty( $query_string ) ) { $query_vars = []; parse_str( $query_string, $query_vars ); $excluded = array_key_exists( QueryVar::NOAMP, $query_vars ); } } return $excluded; } /** * Ensure that links/forms which point to ?noamp up-front are excluded from AMP-to-AMP linking. * * @param string[] $query_vars Query vars. * @param bool $excluded Whether the element was excluded from AMP-to-AMP linking. * @return string[] Query vars to add to the element. */ public function filter_amp_to_amp_linking_element_query_vars( $query_vars, $excluded ) { if ( $excluded ) { $query_vars[ QueryVar::NOAMP ] = QueryVar::NOAMP_MOBILE; } return $query_vars; } /** * Determine if the current request is from a mobile device by looking at the User-Agent request header. * * This only applies if client-side redirection has been disabled. * * @return bool True if current request is from a mobile device, otherwise false. */ public function is_mobile_request() { /** * Filters whether the current request is from a mobile device. This is provided as a means to short-circuit * the normal determination of a mobile request below. * * @since 2.0 * * @param null $is_mobile Whether the current request is from a mobile device. */ $pre_is_mobile = apply_filters( 'amp_pre_is_mobile', null ); if ( null !== $pre_is_mobile ) { return (bool) $pre_is_mobile; } if ( empty( $_SERVER['HTTP_USER_AGENT'] ) ) { return false; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___SERVER__HTTP_USER_AGENT__ -- Value is used only in pattern matching. Logic not used by default since requires amp_mobile_client_side_redirection filter opt-in. $current_user_agent = wp_unslash( $_SERVER['HTTP_USER_AGENT'] ); $regex_regex = sprintf( '#%s#', self::REGEX_REGEX ); foreach ( $this->get_mobile_user_agents() as $user_agent_pattern ) { if ( ( preg_match( $regex_regex, $user_agent_pattern ) // So meta! && preg_match( $user_agent_pattern, $current_user_agent ) ) || false !== strpos( $current_user_agent, $user_agent_pattern ) ) { return true; } } return false; } /** * Determine if mobile redirection should be done via JavaScript. * * If auto-redirection is disabled due to being in the Customizer preview or in AMP Dev Mode (and thus possibly in * Paired Browsing), then client-side redirection is forced. * * @return bool True if mobile redirection should be done, false otherwise. */ public function is_using_client_side_redirection() { if ( is_customize_preview() || Services::has( 'admin.paired_browsing' ) ) { return true; } /** * Filters whether mobile redirection should be done client-side (via JavaScript). * * If false, a server-side solution will be used instead (via PHP). It's important to verify that server-side * redirection does not conflict with a site's page caching logic. To assist with this, you may need to hook * into the `amp_pre_is_mobile` filter. * * Beware that disabling this will result in a cookie being set when the user decides to leave the mobile version. * This may require updating the site's privacy policy or getting user consent for GDPR compliance. Nevertheless, * since the cookie is not used for tracking this may not be necessary. * * Please note that this does not apply when in the Customizer preview or when in AMP Dev Mode (and thus possible * Paired Browsing), since server-side redirects would not be able to be prevented as required. * * @since 2.0 * * @param bool $should_redirect_via_js Whether JS redirection should be used to take mobile visitors to the AMP version. */ return (bool) apply_filters( 'amp_mobile_client_side_redirection', true ); } /** * Get a list of mobile user agents to use for comparison against the user agent from the current request. * * Each entry may either be a simple string needle, or it be a regular expression serialized as a string in the form * of `/pattern/[i]*`. If a user agent string does not match this pattern, then the string will be used as a simple * string needle for the haystack. * * @return string[] An array of mobile user agent search strings (and regex patterns). */ public function get_mobile_user_agents() { // Default list compiled from the user agents listed in `wp_is_mobile()`. $default_user_agents = [ 'Mobile', 'Android', 'Silk/', 'Kindle', 'BlackBerry', 'Opera Mini', 'Opera Mobi', ]; /** * Filters the list of user agents used to determine if the user agent from the current request is a mobile one. * * @since 2.0 * * @param string[] $user_agents List of mobile user agent search strings (and regex patterns). */ return apply_filters( 'amp_mobile_user_agents', $default_user_agents ); } /** * Determine if mobile redirection is disabled via query param. * * @return bool True if disabled, false otherwise. */ public function is_redirection_disabled_via_query_param() { return isset( $_GET[ QueryVar::NOAMP ] ) && QueryVar::NOAMP_MOBILE === wp_unslash( $_GET[ QueryVar::NOAMP ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized } /** * Determine if mobile redirection is disabled via cookie. * * @return bool True if disabled, false otherwise. */ public function is_redirection_disabled_via_cookie() { return isset( $_COOKIE[ self::DISABLED_STORAGE_KEY ] ); } /** * Sets a cookie to disable/enable mobile redirection for the current browser session. * * @param bool $add Whether to add (true) or remove (false) the cookie. * @return void */ public function set_mobile_redirection_disabled_cookie( $add ) { if ( $add ) { $value = '1'; $expires = 0; // Time till expiry. Setting it to `0` means the cookie will only last for the current browser session. // phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___COOKIE -- Cookies not used by default. Requires amp_mobile_client_side_redirection filter opt-in $_COOKIE[ self::DISABLED_STORAGE_KEY ] = $value; } else { $value = null; $expires = time() - YEAR_IN_SECONDS; // phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___COOKIE -- Cookies not used by default. Requires amp_mobile_client_side_redirection filter opt-in unset( $_COOKIE[ self::DISABLED_STORAGE_KEY ] ); } if ( headers_sent() ) { return; } $path = wp_parse_url( home_url( '/' ), PHP_URL_PATH ); // Path. $secure = is_ssl(); // Whether cookie should be transmitted over a secure HTTPS connection. $httponly = true; // Access via JS is unnecessary since cookie only get/set via PHP. $samesite = 'strict'; // Prevents the cookie from being sent by the browser to the target site in all cross-site browsing context. $domain = COOKIE_DOMAIN; // Pre PHP 7.3, the `samesite` cookie attribute had to be set via unconventional means. This was // addressed in PHP 7.3 (see ), // which now allows setting the cookie attribute via an options array. if ( 70300 <= PHP_VERSION_ID ) { // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.cookies_setcookie -- Cookies not used by default. Requires amp_mobile_client_side_redirection filter opt-in setcookie( self::DISABLED_STORAGE_KEY, $value, compact( 'expires', 'path', 'secure', 'httponly', 'samesite', 'domain' ) ); } else { // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.cookies_setcookie -- Cookies not used by default. Requires amp_mobile_client_side_redirection filter opt-in setcookie( self::DISABLED_STORAGE_KEY, $value, $expires, $path . ';samesite=' . $samesite, // Includes the samesite option as a hack to be set in the cookie. See . $domain, $secure, $httponly ); } } /** * Output the mobile redirection Javascript code. */ public function add_mobile_redirect_script() { $source = file_get_contents( __DIR__ . '/../assets/js/mobile-redirection.js' ); // phpcs:ignore WordPress.WP.AlternativeFunctions $exports = [ 'ampUrl' => $this->get_current_amp_url(), 'noampQueryVarName' => QueryVar::NOAMP, 'noampQueryVarValue' => QueryVar::NOAMP_MOBILE, 'disabledStorageKey' => self::DISABLED_STORAGE_KEY, 'mobileUserAgents' => $this->get_mobile_user_agents(), 'regexRegex' => self::REGEX_REGEX, 'isCustomizePreview' => is_customize_preview(), 'isAmpDevMode' => amp_is_dev_mode(), ]; $source = preg_replace( '/\bAMP_MOBILE_REDIRECTION\b/', wp_json_encode( $exports ), $source ); if ( function_exists( 'wp_print_inline_script_tag' ) ) { wp_print_inline_script_tag( $source ); } else { echo $this->get_inline_script_tag( $source ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } } /** * Wraps inline JavaScript in `\n", $this->sanitize_script_attributes( $attributes ), $javascript ); } /** * Sanitizes an attributes array into an attributes string to be placed inside a `