diff --git a/amp.php b/amp.php index 8e8558f888a..07694de2fef 100644 --- a/amp.php +++ b/amp.php @@ -88,6 +88,7 @@ function amp_after_setup_theme() { } add_action( 'init', 'amp_init' ); + add_action( 'rest_api_init', 'amp_rest_init' ); add_action( 'widgets_init', 'AMP_Theme_Support::register_widgets' ); add_action( 'admin_init', 'AMP_Options_Manager::register_settings' ); add_filter( 'amp_post_template_analytics', 'amp_add_custom_analytics' ); @@ -125,6 +126,18 @@ function amp_init() { if ( class_exists( 'Jetpack' ) && ! ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ) { require_once( AMP__DIR__ . '/jetpack-helper.php' ); } + + amp_hook_comments_maybe(); +} + +/** + * Init AMP Rest endpoints. + * + * @since 0.1 + */ +function amp_rest_init() { + require_once AMP__DIR__ . '/includes/amp-rest-functions.php'; + amp_register_endpoints(); } // Make sure the `amp` query var has an explicit value. @@ -361,3 +374,64 @@ function amp_redirect_old_slug_to_new_url( $link ) { return $link; } + +/** + * Hook into a comment submission if an AMP xhr post request. + */ +function amp_hook_comments_maybe() { + + $method = filter_input( INPUT_SERVER, 'REQUEST_METHOD', FILTER_SANITIZE_STRING ); + $is_amp_submit = filter_input( INPUT_GET, '__amp_source_origin', FILTER_VALIDATE_URL ); + + if ( 'post' !== strtolower( $method ) || is_null( $is_amp_submit ) ) { + return; + } + + // Add amp comment hooks. + add_filter( 'comment_post_redirect', 'amp_comment_post_redirect', PHP_INT_MAX, 2 ); + add_filter( 'wp_die_handler', 'amp_set_comment_submit_die_handler' ); + + // Send amp header. + header( 'AMP-Access-Control-Allow-Source-Origin: ' . $is_amp_submit, true ); + +} + +/** + * Set the die handler on comment submit to output the error in json and send AMP headers. + */ +function amp_set_comment_submit_die_handler() { + return 'amp_send_error_json'; +} + +/** + * Send error creating message. + * + * @param string $message The error message to send. + */ +function amp_send_error_json( $message ) { + header( 'HTTP/1.1 400 BAD REQUEST' ); + wp_send_json( array( 'error' => strip_tags( $message, 'strong' ) ) ); +} + +/** + * Redirect after comment submission. + * + * @param string $location The location to redirect to. + * @param WP_Comment $comment The comment that was posted. + * @return string The location URL or void if send json. + */ +function amp_comment_post_redirect( $location, $comment ) { + $is_amp_submit = filter_input( INPUT_GET, '__amp_source_origin', FILTER_VALIDATE_URL ); + if ( is_null( $is_amp_submit ) ) { + return $location; + } + + // AMP-Redirect only works if https. If not, we simply render the success. + if ( is_ssl() ) { + $location = strtok( $location, '#' ); + header( 'AMP-Redirect-To: ' . $location ); + header( 'Access-Control-Expose-Headers: AMP-Redirect-To', false ); + } + $comment->comment_link = get_comment_link( $comment->comment_ID ); + wp_send_json( $comment ); +} diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index 70b9fa42f03..7efcd0adc3e 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -222,6 +222,8 @@ function amp_get_content_sanitizers( $post = null ) { 'AMP_Video_Sanitizer' => array(), 'AMP_Audio_Sanitizer' => array(), 'AMP_Playbuzz_Sanitizer' => array(), + 'AMP_Form_Sanitizer' => array(), + 'AMP_Comments_Sanitizer' => array(), 'AMP_Iframe_Sanitizer' => array( 'add_placeholder' => true, ), diff --git a/includes/amp-rest-functions.php b/includes/amp-rest-functions.php new file mode 100644 index 00000000000..a8dc38819a5 --- /dev/null +++ b/includes/amp-rest-functions.php @@ -0,0 +1,71 @@ +\d+)', array( + 'methods' => 'GET', + 'callback' => 'amp_get_comments', + ) ); + +} + +/** + * Get comments for the Post and Parent + * + * @since 0.7 + * @param WP_REST_Request $request The REST request. + * + * @return array The array of comments. + */ +function amp_get_comments( $request ) { + + $id = $request->get_param( 'id' ); + $comments = get_comments( array( + 'post_ID' => $id, + 'order' => 'ASC', + ) ); + $return = array( + 'items' => array( + 'comment' => amp_get_comments_recursive( 0, $comments ), + ), + ); + + return $return; +} + +/** + * Recursively get comments for the Post and Parent + * + * @since 0.7 + * @param int $parent The comment parent. + * @param array $comments The list of comments. + * + * @return array The array of comments. + */ +function amp_get_comments_recursive( $parent, $comments ) { + + $return = array(); + foreach ( $comments as $comment ) { + if ( (int) $parent !== (int) $comment->comment_parent ) { + continue; + } + $GLOBALS['comment'] = $comment; // WPCS: override ok. + $comment->comment_date = get_comment_date(); + $comment->comment_time = get_comment_time(); + $comment->comment_avatar_url = get_avatar_url( $comment->comment_author_email ); + $comment->comment = amp_get_comments_recursive( $comment->comment_ID, $comments ); + $return[] = $comment; + } + + return $return; +} diff --git a/includes/class-amp-autoloader.php b/includes/class-amp-autoloader.php index 37264085486..adaa5e1015b 100644 --- a/includes/class-amp-autoloader.php +++ b/includes/class-amp-autoloader.php @@ -30,6 +30,7 @@ class AMP_Autoloader { */ private static $_classmap = array( 'AMP_Theme_Support' => 'includes/class-amp-theme-support', + 'AMP_Comment_Walker' => 'includes/class-amp-comment-walker', 'AMP_WP_Styles' => 'includes/class-amp-wp-styles', 'AMP_Template_Customizer' => 'includes/admin/class-amp-customizer', 'AMP_Post_Meta_Box' => 'includes/admin/class-amp-post-meta-box', @@ -65,6 +66,8 @@ class AMP_Autoloader { 'AMP_Blacklist_Sanitizer' => 'includes/sanitizers/class-amp-blacklist-sanitizer', 'AMP_Iframe_Sanitizer' => 'includes/sanitizers/class-amp-iframe-sanitizer', 'AMP_Img_Sanitizer' => 'includes/sanitizers/class-amp-img-sanitizer', + 'AMP_Form_Sanitizer' => 'includes/sanitizers/class-amp-form-sanitizer', + 'AMP_Comments_Sanitizer' => 'includes/sanitizers/class-amp-comments-sanitizer', 'AMP_Playbuzz_Sanitizer' => 'includes/sanitizers/class-amp-playbuzz-sanitizer', 'AMP_Style_Sanitizer' => 'includes/sanitizers/class-amp-style-sanitizer', 'AMP_Tag_And_Attribute_Sanitizer' => 'includes/sanitizers/class-amp-tag-and-attribute-sanitizer', diff --git a/includes/class-amp-comment-walker.php b/includes/class-amp-comment-walker.php new file mode 100644 index 00000000000..9b01bd08023 --- /dev/null +++ b/includes/class-amp-comment-walker.php @@ -0,0 +1,105 @@ +\n"; + } else { + $output .= "\n"; + } + } + $output .= '{{/comment}}'; + } + + /** + * Output amp-list template code and place holder for comments. + * + * @see Walker::paged_walk() + * @param array $elements List of comment Elements. + * @param int $max_depth The maximum hierarchical depth. + * @param int $page_num The specific page number, beginning with 1. + * @param int $per_page Per page counter. + * + * @return string XHTML of the specified page of elements. + */ + public function paged_walk( $elements, $max_depth, $page_num, $per_page ) { + if ( empty( $elements ) || $max_depth < - 1 ) { + return ''; + } + + $args = array_slice( func_get_args(), 4 ); + + $url = get_rest_url( get_current_blog_id(), 'amp/v1/comments/' . get_the_ID() ); + if ( strpos( $url, 'http:' ) === 0 ) { + $url = substr( $url, 5 ); + } + // @todo Identify arguments and make filterable/settable. + $output = ''; + $output .= ''; + $output .= '
' . esc_html__( 'Show more', 'amp' ) . '
'; + $output .= '

' . esc_html__( 'Could not load comments.', 'amp' ) . '

'; + $output .= '
'; + $output .= ''; + $output .= parent::paged_walk( $elements, $max_depth, $page_num, $per_page, $args[0] ); + $output .= ''; + + return $output; + } +} diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index c85bdd9f941..51704aabb29 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -26,6 +26,13 @@ class AMP_Theme_Support { */ const CUSTOM_STYLES_PLACEHOLDER = '/* AMP:CUSTOM_STYLES_PLACEHOLDER */'; + /** + * Replaced with the comments template. + * + * @var string + */ + const COMMENTS_TEMPLATE_PLACEHOLDER = '/* AMP:COMMENTS_TEMPLATE_PLACEHOLDER */'; + /** * AMP Scripts. * @@ -193,6 +200,17 @@ public static function register_hooks() { */ add_action( 'template_redirect', array( __CLASS__, 'start_output_buffering' ), 0 ); + // Add Comments hooks. + add_filter( 'wp_list_comments_args', array( __CLASS__, 'add_amp_comments_template' ), PHP_INT_MAX ); + add_filter( 'comments_array', array( __CLASS__, 'get_comments_template' ) ); + add_action( 'comment_form_top', array( __CLASS__, 'add_amp_comment_form_templates' ), PHP_INT_MAX ); + add_action( 'comment_form', array( __CLASS__, 'add_amp_comment_form_templates' ), PHP_INT_MAX ); + // Filter dynamic data with mustache variable strings. + // @todo add additional filters for dynamic data like "get_comment_author_link", "get_comment_author_IP" etc... + add_filter( 'get_comment_date', array( __CLASS__, 'get_comment_date_template_string' ), PHP_INT_MAX ); + add_filter( 'get_comment_time', array( __CLASS__, 'get_comment_time_template_string' ), PHP_INT_MAX ); + add_filter( 'get_avatar_data', array( __CLASS__, 'get_avatar_data' ), PHP_INT_MAX ); + add_filter( 'get_comment_ID', array( __CLASS__, 'get_comment_id_template_string' ), PHP_INT_MAX ); // @todo Add character conversion. } @@ -343,6 +361,126 @@ public static function add_amp_component_scripts() { echo self::COMPONENT_SCRIPTS_PLACEHOLDER; // phpcs:ignore WordPress.Security.EscapeOutput, WordPress.XSS.EscapeOutput } + /** + * Add the comments template placeholder marker + * + * @param array $args the args for the comments list.. + * @return array Args to return. + */ + public static function add_amp_comments_template( $args ) { + $amp_walker = new AMP_Comment_Walker(); + $args['walker'] = $amp_walker; + + return $args; + } + + /** + * Create a comments_flat array for generating the comment mustache template. + * + * @return array Flat array of comment placeholders. + */ + public static function get_comments_template() { + + $_comment = array( + 'comment_ID' => '{{comment_ID}}', + 'comment_post_ID' => get_the_ID(), + 'comment_author' => '{{comment_author}}', + 'comment_author_email' => '{{comment_author_email}}', + 'comment_author_url' => '{{comment_author_url}}', + 'comment_author_IP' => '{{comment_author_IP}}', + 'comment_date' => '{{comment_date}}', + 'comment_date_gmt' => '{{comment_date_gmt}}', + 'comment_content' => '{{comment_content}}', + 'comment_karma' => '{{comment_karma}}', + 'comment_approved' => '{{comment_approved}}', + 'comment_agent' => '{{comment_agent}}', + 'comment_type' => '{{comment_type}}', + 'comment_parent' => '', + 'user_id' => '{{user_id}}', + ); + + $comments = array(); + $depth = 5; + for ( $i = 0; $i < $depth; $i ++ ) { + $comment = new stdClass(); + foreach ( $_comment as $key => $value ) { + if ( 'comment_ID' === $key ) { + $value = $i + 1; + } + if ( 'comment_parent' === $key && $i > 0 ) { + $value = $i; + } + $comment->{$key} = $value; + } + $comments[] = $comment; + } + + return $comments; + + } + + /** + * Adds the form submit success and fail templates. + * + * @param string $post_id The post ID if action is 'comment_form'. + */ + public static function add_amp_comment_form_templates( $post_id ) { + $output = ''; + if ( empty( $post_id ) ) { + $output .= '
'; + } else { + $output .= '
'; + $output .= '
'; + $output .= '
'; + } + + echo $output; // WPCS: XSS OK. + } + + /** + * Get avatar URL template string. + * + * @param array $args Avatar data args. + * @return array Avatar data with url added. + */ + public static function get_avatar_data( $args ) { + $args['url'] = '{{comment_avatar_url}}'; + + return $args; + } + + /** + * Get comment ID string for template. + * + * @return string Mustache template string. + */ + public static function get_comment_id_template_string() { + return '{{comment_ID}}'; + } + + /** + * Get comment date string for template. + * + * @return string Mustache template string. + */ + public static function get_comment_date_template_string() { + return '{{comment_date}}'; + } + + /** + * Get somment time string for template. + * + * @return string Mustache template string. + */ + public static function get_comment_time_template_string() { + return '{{comment_time}}'; + } + /** * Get canonical URL for current request. * @@ -463,6 +601,23 @@ public static function get_amp_custom_styles() { return $css; } + /** + * Determine the type of component script. + * + * @param string $component The component name. + * @return string the type of component. + */ + public static function get_component_type( $component ) { + $component_types = apply_filters( 'amp_component_types', array( + 'amp-mustache' => 'template', + ) ); + + if ( ! empty( $component_types[ $component ] ) ) { + return $component_types[ $component ]; + } + return 'element'; + } + /** * Determine required AMP scripts. * @@ -493,7 +648,8 @@ public static function get_amp_component_scripts() { $scripts = ''; foreach ( $amp_scripts as $amp_script_component => $amp_script_source ) { $scripts .= sprintf( - '', // phpcs:ignore WordPress.WP.EnqueuedResources, WordPress.XSS.EscapeOutput.OutputNotEscaped + '', // phpcs:ignore WordPress.WP.EnqueuedResources, WordPress.XSS.EscapeOutput.OutputNotEscaped + self::get_component_type( $amp_script_component ), $amp_script_component, $amp_script_source ); diff --git a/includes/sanitizers/class-amp-comments-sanitizer.php b/includes/sanitizers/class-amp-comments-sanitizer.php new file mode 100644 index 00000000000..8cbfd5a9f5f --- /dev/null +++ b/includes/sanitizers/class-amp-comments-sanitizer.php @@ -0,0 +1,100 @@ + tag to identify and process. + * + * @since 0.2 + */ + public static $tag = 'comment-template'; + + /** + * Sanitize the elements from the HTML contained in this instance's DOMDocument. + * + * @since 0.2 + */ + public function sanitize() { + + /** + * Node list. + * + * @var DOMNodeList $node + */ + $nodes = $this->dom->getElementsByTagName( self::$tag ); + $num_nodes = $nodes->length; + + if ( 0 === $num_nodes ) { + return; + } + + for ( $i = 0; $i < $nodes->length; $i++ ) { + $node = $nodes->item( $i ); + if ( ! $node instanceof DOMElement ) { + continue; + } + + // Get the comment template layer parts. (wrapper, amp-list, template, comments-template). + $ol = $node->parentNode; + $new_wrap = $node->parentNode->cloneNode(); + $amp_list = $ol->getElementsByTagName( 'amp-list' )->item( 0 ); + $amp_template = $ol->getElementsByTagName( 'template' )->item( 0 ); + if ( ! $amp_list instanceof DOMElement || ! $amp_template instanceof DOMElement ) { + continue; + } + + // Move Dom parts to an AMP structure. + $new_wrap->appendChild( $node ); + $amp_template->appendChild( $new_wrap ); + $ol->parentNode->replaceChild( $amp_list, $ol ); + + // Convert Links for templating. + $links = $amp_template->getElementsByTagName( 'a' ); + $this->convert_urls( $links, 'href' ); + + // Convert image src for templating. + $imgs = $amp_template->getElementsByTagName( 'amp-img' ); + if ( $imgs->length ) { + $this->convert_urls( $imgs, 'src' ); + $this->convert_urls( $imgs, 'srcset' ); + } + } + + // cleanup comments form. + $comment_form = $this->dom->getElementById( 'commentform' ); + if ( $comment_form instanceof DOMElement ) { + $comment_form->setAttribute( 'on', 'submit-success:amp-comment-form-fields.hide' ); + } + } + + /** + * Convert comment_[field]_url to mustache template strings. + * + * @since 0.2 + * @param DOMNodeList $node_list The list of nodes to convert. + * @param string $type The type of attribute to convert. + */ + private function convert_urls( $node_list, $type = 'href' ) { + + for ( $a = 0; $a < $node_list->length; $a++ ) { + $node = $node_list->item( $a ); + if ( ! $node->hasAttribute( $type ) ) { + continue; + } + $url = preg_replace( '/http[s]?:\/\/(comment_[a-z_]+_url)/', '{{$1}}', $node->getAttribute( $type ) ); + $node->setAttribute( $type, $url ); + } + } +} diff --git a/includes/sanitizers/class-amp-form-sanitizer.php b/includes/sanitizers/class-amp-form-sanitizer.php new file mode 100644 index 00000000000..a8aa147cb95 --- /dev/null +++ b/includes/sanitizers/class-amp-form-sanitizer.php @@ -0,0 +1,81 @@ + tag to identify and process. + * + * @since 0.7 + */ + public static $tag = 'form'; + + /** + * Sanitize the
elements from the HTML contained in this instance's DOMDocument. + * + * @since 0.7 + */ + public function sanitize() { + + /** + * Node list. + * + * @var DOMNodeList $node + */ + $nodes = $this->dom->getElementsByTagName( self::$tag ); + $num_nodes = $nodes->length; + + if ( 0 === $num_nodes ) { + return; + } + + for ( $i = $num_nodes - 1; $i >= 0; $i -- ) { + $node = $nodes->item( $i ); + if ( ! $node instanceof DOMElement ) { + continue; + } + + $method = 'get'; + if ( $node->hasAttribute( 'method' ) ) { + $method = strtolower( $node->getAttribute( 'method' ) ); + } + + // Get the action URL. + if ( ! $node->hasAttribute( 'action' ) ) { + $action_url = esc_url_raw( '//' . $_SERVER['HTTP_HOST'] . wp_unslash( $_SERVER['REQUEST_URI'] ) ); // WPCS: ignore. input var okay, sanitization ok. + } else { + $action_url = $node->getAttribute( 'action' ); + } + + if ( strpos( $action_url, 'http:' ) === 0 ) { + $action_url = substr( $action_url, 5 ); + } + + if ( 'post' === $method ) { + $node->setAttribute( 'action-xhr', $action_url ); + $node->removeAttribute( 'action' ); + } else { + $node->setAttribute( 'action', $action_url ); + } + + // Set a target if needed. + if ( ! $node->hasAttribute( 'target' ) ) { + $node->setAttribute( 'target', '_top' ); + } + } + } +} diff --git a/includes/utils/class-amp-dom-utils.php b/includes/utils/class-amp-dom-utils.php index cea51615da8..6d640d8e459 100644 --- a/includes/utils/class-amp-dom-utils.php +++ b/includes/utils/class-amp-dom-utils.php @@ -59,6 +59,22 @@ public static function get_dom( $document ) { // @todo In the future consider an AMP_DOMDocument subclass that does this automatically. See . $document = self::convert_amp_bind_attributes( $document ); + /* + * Prevent amp-mustache syntax from getting URL-encoded in attributes when saveHTML is done. + * While this is applying to the entire document, it only really matters inside of