apply_filters( 'press_this_redirect_in_parent', false ), ); } /** * Get the source's images and save them locally, for posterity, unless we can't. * * @since 4.2.0 * * @param int $post_id Post ID. * @param string $content Optional. Current expected markup for Press This. Expects slashed. Default empty. * @return string New markup with old image URLs replaced with the local attachment ones if swapped. */ public function side_load_images( $post_id, $content = '' ) { $content = wp_unslash( $content ); if ( preg_match_all( '/]+>/', $content, $matches ) && current_user_can( 'upload_files' ) ) { foreach ( (array) $matches[0] as $image ) { // This is inserted from our JS so HTML attributes should always be in double quotes. if ( ! preg_match( '/src="([^"]+)"/', $image, $url_matches ) ) { continue; } $image_src = $url_matches[1]; // Don't try to sideload a file without a file extension, leads to WP upload error. if ( ! preg_match( '/[^\?]+\.(?:jpe?g|jpe|gif|png)(?:\?|$)/i', $image_src ) ) { continue; } // Sideload image, which gives us a new image src. $new_src = media_sideload_image( $image_src, $post_id, null, 'src' ); if ( ! is_wp_error( $new_src ) ) { // Replace the POSTED content with correct uploaded ones. // Need to do it in two steps so we don't replace links to the original image if any. $new_image = str_replace( $image_src, $new_src, $image ); $content = str_replace( $image, $new_image, $content ); } } } // Expected slashed return wp_slash( $content ); } /** * Ajax handler for saving the post as draft or published. * * @since 4.2.0 */ public function save_post() { if ( empty( $_POST['post_ID'] ) || ! $post_id = (int) $_POST['post_ID'] ) { wp_send_json_error( array( 'errorMessage' => __( 'Missing post ID.' ) ) ); } if ( empty( $_POST['_wpnonce'] ) || ! wp_verify_nonce( $_POST['_wpnonce'], 'update-post_' . $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) { wp_send_json_error( array( 'errorMessage' => __( 'Invalid post.' ) ) ); } $post_data = array( 'ID' => $post_id, 'post_title' => ( ! empty( $_POST['post_title'] ) ) ? sanitize_text_field( trim( $_POST['post_title'] ) ) : '', 'post_content' => ( ! empty( $_POST['post_content'] ) ) ? trim( $_POST['post_content'] ) : '', 'post_type' => 'post', 'post_status' => 'draft', 'post_format' => ( ! empty( $_POST['post_format'] ) ) ? sanitize_text_field( $_POST['post_format'] ) : '', ); // Only accept categories if the user actually can assign $category_tax = get_taxonomy( 'category' ); if ( current_user_can( $category_tax->cap->assign_terms ) ) { $post_data['post_category'] = ( ! empty( $_POST['post_category'] ) ) ? $_POST['post_category'] : array(); } // Only accept taxonomies if the user can actually assign if ( ! empty( $_POST['tax_input'] ) ) { $tax_input = $_POST['tax_input']; foreach ( $tax_input as $tax => $_ti ) { $tax_object = get_taxonomy( $tax ); if ( ! $tax_object || ! current_user_can( $tax_object->cap->assign_terms ) ) { unset( $tax_input[ $tax ] ); } } $post_data['tax_input'] = $tax_input; } // Toggle status to pending if user cannot actually publish if ( ! empty( $_POST['post_status'] ) && 'publish' === $_POST['post_status'] ) { if ( current_user_can( 'publish_posts' ) ) { $post_data['post_status'] = 'publish'; } else { $post_data['post_status'] = 'pending'; } } $post_data['post_content'] = $this->side_load_images( $post_id, $post_data['post_content'] ); /** * Filters the post data of a Press This post before saving/updating. * * The {@see 'side_load_images'} action has already run at this point. * * @since 4.5.0 * * @param array $post_data The post data. */ $post_data = apply_filters( 'press_this_save_post', $post_data ); $updated = wp_update_post( $post_data, true ); if ( is_wp_error( $updated ) ) { wp_send_json_error( array( 'errorMessage' => $updated->get_error_message() ) ); } else { if ( isset( $post_data['post_format'] ) ) { if ( current_theme_supports( 'post-formats', $post_data['post_format'] ) ) { set_post_format( $post_id, $post_data['post_format'] ); } elseif ( $post_data['post_format'] ) { set_post_format( $post_id, false ); } } $forceRedirect = false; if ( 'publish' === get_post_status( $post_id ) ) { $redirect = get_post_permalink( $post_id ); } elseif ( isset( $_POST['pt-force-redirect'] ) && $_POST['pt-force-redirect'] === 'true' ) { $forceRedirect = true; $redirect = get_edit_post_link( $post_id, 'js' ); } else { $redirect = false; } /** * Filters the URL to redirect to when Press This saves. * * @since 4.2.0 * * @param string $url Redirect URL. If `$status` is 'publish', this will be the post permalink. * Otherwise, the default is false resulting in no redirect. * @param int $post_id Post ID. * @param string $status Post status. */ $redirect = apply_filters( 'press_this_save_redirect', $redirect, $post_id, $post_data['post_status'] ); if ( $redirect ) { wp_send_json_success( array( 'redirect' => $redirect, 'force' => $forceRedirect ) ); } else { wp_send_json_success( array( 'postSaved' => true ) ); } } } /** * Ajax handler for adding a new category. * * @since 4.2.0 */ public function add_category() { if ( false === wp_verify_nonce( $_POST['new_cat_nonce'], 'add-category' ) ) { wp_send_json_error(); } $taxonomy = get_taxonomy( 'category' ); if ( ! current_user_can( $taxonomy->cap->edit_terms ) || empty( $_POST['name'] ) ) { wp_send_json_error(); } $parent = isset( $_POST['parent'] ) && (int) $_POST['parent'] > 0 ? (int) $_POST['parent'] : 0; $names = explode( ',', $_POST['name'] ); $added = $data = array(); foreach ( $names as $cat_name ) { $cat_name = trim( $cat_name ); $cat_nicename = sanitize_title( $cat_name ); if ( empty( $cat_nicename ) ) { continue; } // @todo Find a more performant way to check existence, maybe get_term() with a separate parent check. if ( term_exists( $cat_name, $taxonomy->name, $parent ) ) { if ( count( $names ) === 1 ) { wp_send_json_error( array( 'errorMessage' => __( 'This category already exists.' ) ) ); } else { continue; } } $cat_id = wp_insert_term( $cat_name, $taxonomy->name, array( 'parent' => $parent ) ); if ( is_wp_error( $cat_id ) ) { continue; } elseif ( is_array( $cat_id ) ) { $cat_id = $cat_id['term_id']; } $added[] = $cat_id; } if ( empty( $added ) ) { wp_send_json_error( array( 'errorMessage' => __( 'This category cannot be added. Please change the name and try again.' ) ) ); } foreach ( $added as $new_cat_id ) { $new_cat = get_category( $new_cat_id ); if ( is_wp_error( $new_cat ) ) { wp_send_json_error( array( 'errorMessage' => __( 'Error while adding the category. Please try again later.' ) ) ); } $data[] = array( 'term_id' => $new_cat->term_id, 'name' => $new_cat->name, 'parent' => $new_cat->parent, ); } wp_send_json_success( $data ); } /** * Downloads the source's HTML via server-side call for the given URL. * * @since 4.2.0 * * @param string $url URL to scan. * @return string Source's HTML sanitized markup */ public function fetch_source_html( $url ) { if ( empty( $url ) ) { return new WP_Error( 'invalid-url', __( 'A valid URL was not provided.' ) ); } $remote_url = wp_safe_remote_get( $url, array( 'timeout' => 30, // Use an explicit user-agent for Press This 'user-agent' => 'Press This (WordPress/' . get_bloginfo( 'version' ) . '); ' . get_bloginfo( 'url' ) ) ); if ( is_wp_error( $remote_url ) ) { return $remote_url; } $allowed_elements = array( 'img' => array( 'src' => true, 'width' => true, 'height' => true, ), 'iframe' => array( 'src' => true, ), 'link' => array( 'rel' => true, 'itemprop' => true, 'href' => true, ), 'meta' => array( 'property' => true, 'name' => true, 'content' => true, ) ); $source_content = wp_remote_retrieve_body( $remote_url ); $source_content = wp_kses( $source_content, $allowed_elements ); return $source_content; } /** * Utility method to limit an array to 50 values. * * @ignore * @since 4.2.0 * * @param array $value Array to limit. * @return array Original array if fewer than 50 values, limited array, empty array otherwise. */ private function _limit_array( $value ) { if ( is_array( $value ) ) { if ( count( $value ) > 50 ) { return array_slice( $value, 0, 50 ); } return $value; } return array(); } /** * Utility method to limit the length of a given string to 5,000 characters. * * @ignore * @since 4.2.0 * * @param string $value String to limit. * @return bool|int|string If boolean or integer, that value. If a string, the original value * if fewer than 5,000 characters, a truncated version, otherwise an * empty string. */ private function _limit_string( $value ) { $return = ''; if ( is_numeric( $value ) || is_bool( $value ) ) { $return = $value; } else if ( is_string( $value ) ) { if ( mb_strlen( $value ) > 5000 ) { $return = mb_substr( $value, 0, 5000 ); } else { $return = $value; } $return = html_entity_decode( $return, ENT_QUOTES, 'UTF-8' ); $return = sanitize_text_field( trim( $return ) ); } return $return; } /** * Utility method to limit a given URL to 2,048 characters. * * @ignore * @since 4.2.0 * * @param string $url URL to check for length and validity. * @return string Escaped URL if of valid length (< 2048) and makeup. Empty string otherwise. */ private function _limit_url( $url ) { if ( ! is_string( $url ) ) { return ''; } // HTTP 1.1 allows 8000 chars but the "de-facto" standard supported in all current browsers is 2048. if ( strlen( $url ) > 2048 ) { return ''; // Return empty rather than a truncated/invalid URL } // Does not look like a URL. if ( ! preg_match( '/^([!#$&-;=?-\[\]_a-z~]|%[0-9a-fA-F]{2})+$/', $url ) ) { return ''; } // If the URL is root-relative, prepend the protocol and domain name if ( $url && $this->domain && preg_match( '%^/[^/]+%', $url ) ) { $url = $this->domain . $url; } // Not absolute or protocol-relative URL. if ( ! preg_match( '%^(?:https?:)?//[^/]+%', $url ) ) { return ''; } return esc_url_raw( $url, array( 'http', 'https' ) ); } /** * Utility method to limit image source URLs. * * Excluded URLs include share-this type buttons, loaders, spinners, spacers, WordPress interface images, * tiny buttons or thumbs, mathtag.com or quantserve.com images, or the WordPress.com stats gif. * * @ignore * @since 4.2.0 * * @param string $src Image source URL. * @return string If not matched an excluded URL type, the original URL, empty string otherwise. */ private function _limit_img( $src ) { $src = $this->_limit_url( $src ); if ( preg_match( '!/ad[sx]?/!i', $src ) ) { // Ads return ''; } else if ( preg_match( '!(/share-?this[^.]+?\.[a-z0-9]{3,4})(\?.*)?$!i', $src ) ) { // Share-this type button return ''; } else if ( preg_match( '!/(spinner|loading|spacer|blank|rss)\.(gif|jpg|png)!i', $src ) ) { // Loaders, spinners, spacers return ''; } else if ( preg_match( '!/([^./]+[-_])?(spinner|loading|spacer|blank)s?([-_][^./]+)?\.[a-z0-9]{3,4}!i', $src ) ) { // Fancy loaders, spinners, spacers return ''; } else if ( preg_match( '!([^./]+[-_])?thumb[^.]*\.(gif|jpg|png)$!i', $src ) ) { // Thumbnails, too small, usually irrelevant to context return ''; } else if ( false !== stripos( $src, '/wp-includes/' ) ) { // Classic WordPress interface images return ''; } else if ( preg_match( '![^\d]\d{1,2}x\d+\.(gif|jpg|png)$!i', $src ) ) { // Most often tiny buttons/thumbs (< 100px wide) return ''; } else if ( preg_match( '!/pixel\.(mathtag|quantserve)\.com!i', $src ) ) { // See mathtag.com and https://www.quantcast.com/how-we-do-it/iab-standard-measurement/how-we-collect-data/ return ''; } else if ( preg_match( '!/[gb]\.gif(\?.+)?$!i', $src ) ) { // WordPress.com stats gif return ''; } return $src; } /** * Limit embed source URLs to specific providers. * * Not all core oEmbed providers are supported. Supported providers include YouTube, Vimeo, * Vine, Daily Motion, SoundCloud, and Twitter. * * @ignore * @since 4.2.0 * * @param string $src Embed source URL. * @return string If not from a supported provider, an empty string. Otherwise, a reformatted embed URL. */ private function _limit_embed( $src ) { $src = $this->_limit_url( $src ); if ( empty( $src ) ) return ''; if ( preg_match( '!//(m|www)\.youtube\.com/(embed|v)/([^?]+)\?.+$!i', $src, $src_matches ) ) { // Embedded Youtube videos (www or mobile) $src = 'https://www.youtube.com/watch?v=' . $src_matches[3]; } else if ( preg_match( '!//player\.vimeo\.com/video/([\d]+)([?/].*)?$!i', $src, $src_matches ) ) { // Embedded Vimeo iframe videos $src = 'https://vimeo.com/' . (int) $src_matches[1]; } else if ( preg_match( '!//vimeo\.com/moogaloop\.swf\?clip_id=([\d]+)$!i', $src, $src_matches ) ) { // Embedded Vimeo Flash videos $src = 'https://vimeo.com/' . (int) $src_matches[1]; } else if ( preg_match( '!//vine\.co/v/([^/]+)/embed!i', $src, $src_matches ) ) { // Embedded Vine videos $src = 'https://vine.co/v/' . $src_matches[1]; } else if ( preg_match( '!//(www\.)?dailymotion\.com/embed/video/([^/?]+)([/?].+)?!i', $src, $src_matches ) ) { // Embedded Daily Motion videos $src = 'https://www.dailymotion.com/video/' . $src_matches[2]; } else { $oembed = _wp_oembed_get_object(); if ( ! $oembed->get_provider( $src, array( 'discover' => false ) ) ) { $src = ''; } } return $src; } /** * Process a meta data entry from the source. * * @ignore * @since 4.2.0 * * @param string $meta_name Meta key name. * @param mixed $meta_value Meta value. * @param array $data Associative array of source data. * @return array Processed data array. */ private function _process_meta_entry( $meta_name, $meta_value, $data ) { if ( preg_match( '/:?(title|description|keywords|site_name)$/', $meta_name ) ) { $data['_meta'][ $meta_name ] = $meta_value; } else { switch ( $meta_name ) { case 'og:url': case 'og:video': case 'og:video:secure_url': $meta_value = $this->_limit_embed( $meta_value ); if ( ! isset( $data['_embeds'] ) ) { $data['_embeds'] = array(); } if ( ! empty( $meta_value ) && ! in_array( $meta_value, $data['_embeds'] ) ) { $data['_embeds'][] = $meta_value; } break; case 'og:image': case 'og:image:secure_url': case 'twitter:image0:src': case 'twitter:image0': case 'twitter:image:src': case 'twitter:image': $meta_value = $this->_limit_img( $meta_value ); if ( ! isset( $data['_images'] ) ) { $data['_images'] = array(); } if ( ! empty( $meta_value ) && ! in_array( $meta_value, $data['_images'] ) ) { $data['_images'][] = $meta_value; } break; } } return $data; } /** * Fetches and parses _meta, _images, and _links data from the source. * * @since 4.2.0 * * @param string $url URL to scan. * @param array $data Optional. Existing data array if you have one. Default empty array. * @return array New data array. */ public function source_data_fetch_fallback( $url, $data = array() ) { if ( empty( $url ) ) { return array(); } // Download source page to tmp file. $source_content = $this->fetch_source_html( $url ); if ( is_wp_error( $source_content ) ) { return array( 'errors' => $source_content->get_error_messages() ); } // Fetch and gather data first, so discovered media is offered 1st to user. if ( empty( $data['_meta'] ) ) { $data['_meta'] = array(); } if ( preg_match_all( '/]+>/', $source_content, $matches ) ) { $items = $this->_limit_array( $matches[0] ); foreach ( $items as $value ) { if ( preg_match( '/(property|name)="([^"]+)"[^>]+content="([^"]+)"/', $value, $new_matches ) ) { $meta_name = $this->_limit_string( $new_matches[2] ); $meta_value = $this->_limit_string( $new_matches[3] ); // Sanity check. $key is usually things like 'title', 'description', 'keywords', etc. if ( strlen( $meta_name ) > 100 ) { continue; } $data = $this->_process_meta_entry( $meta_name, $meta_value, $data ); } } } // Fetch and gather data. if ( empty( $data['_images'] ) ) { $data['_images'] = array(); } if ( preg_match_all( '/]+>/', $source_content, $matches ) ) { $items = $this->_limit_array( $matches[0] ); foreach ( $items as $value ) { if ( ( preg_match( '/width=(\'|")(\d+)\\1/i', $value, $new_matches ) && $new_matches[2] < 256 ) || ( preg_match( '/height=(\'|")(\d+)\\1/i', $value, $new_matches ) && $new_matches[2] < 128 ) ) { continue; } if ( preg_match( '/src=(\'|")([^\'"]+)\\1/i', $value, $new_matches ) ) { $src = $this->_limit_img( $new_matches[2] ); if ( ! empty( $src ) && ! in_array( $src, $data['_images'] ) ) { $data['_images'][] = $src; } } } } // Fetch and gather