Author URI: http://petermolnar.eu/ License: GPLv3 Required minimum PHP version: 5.3 */ if ( ! class_exists( 'WP_Webmention_Again' ) ): // global send_webmention function if ( ! function_exists( 'send_webmention' ) ) { function send_webmention( $source, $target ) { return WP_Webmention_Again::queue_add ( 'out', $source, $target ); } } // something else might have loaded this already if ( ! class_exists( 'Mf2\Parser' ) ) { require ( __DIR__ . '/vendor/autoload.php' ); } // if something has loaded Mf2 already, the autoload won't kick in, // and we need this if ( ! class_exists( 'EmojiRecognizer' ) ) { require ( __DIR__ . '/vendor/dissolve/single-emoji-recognizer/src/emoji.php' ); } class WP_Webmention_Again { // post meta key for queued incoming mentions const meta_received = '_webmention_received'; // cron handle for processing incoming const cron_received = 'webmention_received'; // post meta key for posts marked as outgoing and in need of processing const meta_send = '_webmention_send'; // cron handle for processing outgoing const cron_send = 'webmention_send'; // WP cache expiration seconds const expire = 10; // const tablename = 'webmentions'; /** * regular cron interval for processing incoming * * use 'wp-webmention-again_interval_received' to filter this integer * * @return int cron interval in seconds * */ protected static function known_reacji () { return apply_filters( 'wp-webmention-again_known_reacji', 'reacji' ); } /** * regular cron interval for processing incoming * * use 'wp-webmention-again_interval_received' to filter this integer * * @return int cron interval in seconds * */ protected static function remote_timeout () { return apply_filters( 'wp-webmention-again_remote_timeout', 100 ); } /** * regular cron interval for processing incoming * * use 'wp-webmention-again_interval_received' to filter this integer * * @return int cron interval in seconds * */ protected static function interval_received () { return apply_filters( 'wp-webmention-again_interval_received', 90 ); } /** * regular cron interval for processing outgoing * * use 'wp-webmention-again_interval_send' to filter this integer * * @return int cron interval in seconds * */ protected static function interval_send () { return apply_filters( 'wp-webmention-again_interval_send', 90 ); } /** * max number of retries ( both for outgoing and incoming ) * * use 'wp-webmention-again_retry' to filter this integer * * @return int cron interval in seconds * */ protected static function retry () { return apply_filters( 'wp-webmention-again_retry', 5 ); } /** * endpoint for receiving webmentions * * use 'wp-webmention-again_endpoint' to filter this string * * @return string name of endpoint * */ protected static function endpoint() { return apply_filters( 'wp-webmention-again_endpoint', 'webmention' ); } /** * maximum amount of posts per batch to be processed * * use 'wp-webmention-again_per_batch' to filter this int * * @return int posts per batch * */ protected static function per_batch () { return apply_filters( 'wp-webmention-again_per_batch', 10 ); } /** * mapping for comment types -> mf2 names * * use 'wp-webmention-again_mftypes' to filter this array * * @return array array of comment_type => mf2_name entries * */ protected static function mftypes () { $map = array ( // http://indiewebcamp.com/reply 'reply' => 'in-reply-to', // http://indiewebcamp.com/repost 'repost' => 'repost-of', // http://indiewebcamp.com/like 'like' => 'like-of', // http://indiewebcamp.com/favorite 'favorite' => 'favorite-of', // http://indiewebcamp.com/bookmark 'bookmark' => 'bookmark-of', // http://indiewebcamp.com/rsvp 'rsvp' => 'rsvp', // http://indiewebcamp.com/tag 'tag' => 'tag-of', ); return apply_filters( 'wp-webmention-again_mftypes', $map ); } /** * runs on plugin load */ public function __construct() { add_action( 'init', array( &$this, 'init' ) ); add_action( 'parse_query', array( &$this, 'receive' ) ); add_action( 'wp_head', array( &$this, 'html_header' ), 99 ); add_action( 'send_headers', array( &$this, 'http_header' ) ); // this is mostly for debugging reasons register_activation_hook( __FILE__ , array( 'WP_Webmention_Again', 'plugin_activate' ) ); // clear schedules if there's any on deactivation register_deactivation_hook( __FILE__ , array( 'WP_Webmention_Again', 'plugin_deactivate' ) ); // extend current cron schedules with our entry add_filter( 'cron_schedules', array(&$this, 'add_cron_schedule' ) ); // register the action for processing received add_action( static::cron_received, array( &$this, 'process_received' ) ); // register the action for processing received add_action( static::cron_send, array( &$this, 'process_send' ) ); // additional comment types add_action( 'admin_comment_types_dropdown', array( &$this, 'comment_types_dropdown' ) ); // register new posts add_action( 'transition_post_status', array( &$this, 'queue_send' ), 12, 5 ); } /** * runs at WordPress init hook * */ public function init() { // add webmention endpoint to query vars add_filter( 'query_vars', array( &$this, 'add_query_var' ) ); // because this needs one more filter add_filter( 'get_avatar_comment_types', array( &$this, 'add_comment_types' ) ); // additional avatar filter add_filter( 'get_avatar' , array( &$this, 'get_avatar' ), 1, 5 ); // get_pung is not restrictive enough add_filter ( 'get_pung', array( &$this, 'get_pung' ) ); if ( ! wp_get_schedule( static::cron_received ) ) wp_schedule_event( time(), static::cron_received, static::cron_received ); if ( ! wp_get_schedule( static::cron_send ) ) wp_schedule_event( time(), static::cron_send, static::cron_send ); } /** * plugin activation hook * * dies if PHP version is too low * */ public static function plugin_activate() { if ( version_compare( phpversion(), 5.3, '<' ) ) { die( 'The minimum PHP version required for this plugin is 5.3' ); } global $wpdb; $dbname = $wpdb->prefix . static::tablename; //Use the character set and collation that's configured for WP tables $charset_collate = ''; if ( !empty($wpdb->charset) ){ $charset = str_replace('-', '', $wpdb->charset); $charset_collate = "DEFAULT CHARACTER SET {$charset}"; } if ( !empty($wpdb->collate) ){ $charset_collate .= " COLLATE {$wpdb->collate}"; } $db_command = "CREATE TABLE IF NOT EXISTS `{$dbname}` ( `id` char(160) CHARACTER SET ascii NOT NULL, `date` datetime NOT NULL, `direction` varchar(12) NOT NULL DEFAULT 'in', `tries` int(4) NOT NULL DEFAULT '0', `source` text NOT NULL, `target` text NOT NULL, `object_type` varchar(255) NOT NULL DEFAULT 'post', `object_id` bigint(20) NOT NULL, PRIMARY KEY (`id`), KEY `time` (`date`), KEY `key` (`direction`) ) {$charset_collate};"; static::debug("Initiating DB {$dbname}"); try { $wpdb->query( $db_command ); } catch (Exception $e) { static::debug('Something went wrong: ' . $e->getMessage()); } } /** * plugin deactivation hook * * makes sure there are no scheduled cron hooks left * */ public static function plugin_deactivate () { wp_unschedule_event( time(), static::cron_received ); wp_clear_scheduled_hook( static::cron_received ); wp_unschedule_event( time(), static::cron_send ); wp_clear_scheduled_hook( static::cron_send ); global $wpdb; $dbname = $wpdb->prefix . static::tablename; $db_command = "DROP TABLE IF EXISTS `{$dbname}`;"; static::debug("Deleting DB {$dbname}"); try { $wpdb->query( $db_command ); } catch (Exception $e) { static::debug('Something went wrong: ' . $e->getMessage()); } } /** * add our own schedule * * @param array $schedules - current schedules list * * @return array $schedules - extended schedules list */ public function add_cron_schedule ( $schedules ) { $schedules[ static::cron_received ] = array ( 'interval' => static::interval_received(), 'display' => sprintf(__( 'every %d seconds' ), static::interval_received() ) ); $schedules[ static::cron_send ] = array ( 'interval' => static::interval_send(), 'display' => sprintf(__( 'every %d seconds' ), static::interval_send() ) ); return $schedules; } /** * extends HTML header with webmention endpoint * * @output string two lines of HTML * */ public function html_header () { $endpoint = site_url( '?'. static::endpoint() .'=endpoint' ); // backwards compatibility with v0.1 echo '' . "\n"; echo '' . "\n"; } /** * extends HTTP response header with webmention endpoints * * @output two lines of Link: in HTTP header * */ public function http_header() { $endpoint = site_url( '?'. static::endpoint() .'=endpoint' ); // backwards compatibility with v0.1 header( 'Link: <' . $endpoint . '>; rel="http://webmention.org/"', false ); header( 'Link: <' . $endpoint . '>; rel="webmention"', false ); } /** * add webmention to accepted query vars * * @param array $vars current query vars * * @return array extended vars */ public function add_query_var( $vars ) { array_push( $vars, static::endpoint() ); return $vars; } /** * get plugin options * * use 'wp-webmention-again_comment_types' to filter registered comment types * before being applied * * @return array plugin options * */ public static function get_options () { $options = get_option( __CLASS__ ); if ( isset( $options['comment_types'] ) && is_array( $options['comment_types'] ) ) $options['comment_types'] = array_merge($options['comment_types'], static::mftypes()); else $options['comment_types'] = static::mftypes(); $options['comment_types'] = apply_filters( 'wp-webmention-again_comment_types', $options['comment_types'] ); return $options; } /** * extends the current registered comment types in options to have * single char emoji as comment type * * @param char $reacji single emoticon character to add as comment type * * public static function register_reacji ( $reacji ) { $known_reacji = get_option( static::known_reacji() ); if (!is_array($known_reacji)) $known_reacji = array(); if ( ! in_array( $reacji, $known_reacji ) ) { array_push( $known_reacji, $reacji ); update_option( static::known_reacji() , $options ); } } /** * Extend the "filter by comment type" of in the comments section * of the admin interface with all of our methods * * @param array $types the different comment types * * @return array the filtered comment types */ public function comment_types_dropdown( $types ) { $options = static::get_options(); foreach ( $options['comment_types'] as $type => $fancy ) if ( ! isset( $types[ $type ] ) ) $types[ $type ] = ucfirst( $type ); return $types; } /** * extend the abailable comment types in WordPress with the ones recognized * by the plugin, including reacji * * @param array $types current types * * @return array extended types */ public function add_comment_types ( $types ) { $options = static::get_options(); foreach ( $options['comment_types'] as $type => $fancy ) if ( ! in_array( $type, $types ) ) array_push( $types, $type ); return $types; } /** * insert a webmention to the queue * * @param string $direction - 'in' or 'out' * @param string $source - source URL * @param string $target - target URL * @param string $object - object type: post, comment, etc. * @param int $object_id - ID of object * * @return false|string - false on failure, inserted ID on success * */ public static function queue_add ( $direction, $source, $target, $object = '', $object_id = 0 ) { global $wpdb; $dbname = $wpdb->prefix . static::tablename; $direction = strtolower($direction); $valid_directions = array ( 'in', 'out' ); if ( ! in_array ( $direction, $valid_directions ) ) return false; $id = sha1($source . $target); if ( static::queue_exists ( $direction, $source, $target ) ) return true; $q = $wpdb->prepare( "INSERT INTO `{$dbname}` (`id`,`date`,`direction`, `tries`,`source`, `target`, `object_type`, `object_id`) VALUES ( '%s', NOW(), '%s', 0, '%s', '%s', '%s', %d );", $id, $direction, $source, $target, $object, $object_id ); try { $req = $wpdb->query( $q ); } catch (Exception $e) { static::debug('Something went wrong: ' . $e->getMessage()); } return $id; } /** * increment tries counter for a queue element * * @param string $id - ID of queue element * * @return bool - query success/failure * */ public static function queue_inc ( $id ) { if ( empty( $id ) ) return false; global $wpdb; $dbname = $wpdb->prefix . static::tablename; $q = $wpdb->prepare( "UPDATE `{$dbname}` SET `tries` = `tries` + 1 WHERE `id` = '%s'; ", $id ); try { $req = $wpdb->query( $q ); } catch (Exception $e) { static::debug('Something went wrong: ' . $e->getMessage()); } return $req; } /** * delete an entry from the webmentions queue * * @param string $id - ID of webmention queue element * * @return bool - query success/failure * */ public static function queue_del ( $id ) { if ( empty( $id ) ) return false; global $wpdb; $dbname = $wpdb->prefix . static::tablename; $q = $wpdb->prepare( "DELETE FROM `{$dbname}` WHERE `id` = '%s' LIMIT 1;", $id ); try { $req = $wpdb->query( $q ); } catch (Exception $e) { static::debug('Something went wrong: ' . $e->getMessage()); } return $req; } /** * get a batch of elements according to direction * * @param string $direction - 'in' or 'out' * @param int $limit - max number of items to get * * @return array of queue objects * */ public static function queue_get ( $direction, $limit = 1 ) { $direction = strtolower($direction); $valid_directions = array ( 'in', 'out' ); if ( ! in_array ( $direction, $valid_directions ) ) return false; global $wpdb; $dbname = $wpdb->prefix . static::tablename; $q = $wpdb->prepare( "SELECT * FROM `{$dbname}` WHERE `direction` = '%s' LIMIT %d;", $direction, $limit ); try { $req = $wpdb->get_results( $q ); } catch (Exception $e) { static::debug('Something went wrong: ' . $e->getMessage()); } if ( ! empty ( $req ) ) return $req; return false; } /** * checks existence of a queue element * * @param string $direction - 'in' or 'out' * @param string $source - source URL * @param string $target - target URL * * @return bool true on existing element, false on not found */ public static function queue_exists ( $direction, $source, $target ) { global $wpdb; $dbname = $wpdb->prefix . static::tablename; $direction = strtolower($direction); $valid_directions = array ( 'in', 'out' ); if ( ! in_array ( $direction, $valid_directions ) ) return false; $id = sha1($source . $target); $q = $wpdb->prepare( "SELECT date FROM `{$dbname}` WHERE `direction` = '%s' and `id` = '%s';", $direction, $id ); try { $req = $wpdb->get_results( $q ); } catch (Exception $e) { static::debug('Something went wrong: ' . $e->getMessage()); } if ( ! empty ( $req ) ) return true; return false; } /** * parse & queue incoming webmention endpoint requests * * @param mixed $wp WordPress Query * */ public function receive ( $wp ) { // check if it is a webmention request or not if ( ! array_key_exists( static::endpoint(), $wp->query_vars ) ) return false; // plain text header header( 'Content-Type: text/plain; charset=' . get_option( 'blog_charset' ) ); // check if source url is transmitted if ( ! isset( $_POST['source'] ) ) { status_header( 400 ); echo '"source" is missing'; exit; } // check if target url is transmitted if ( ! isset( $_POST['target'] ) ) { status_header( 400 ); echo '"target" is missing'; exit; } $target = filter_var( $_POST['target'], FILTER_SANITIZE_URL ); $source = filter_var( $_POST['source'], FILTER_SANITIZE_URL ); if ( false === filter_var( $target, FILTER_VALIDATE_URL ) ) { status_header( 400 ); echo '"target" is an invalid URL'; exit; } if ( false === filter_var( $source, FILTER_VALIDATE_URL ) ) { status_header( 400 ); echo '"source" is an invalid URL'; exit; } $post_id = static::validate_local( $target ); if (! $post_id || 0 == $post_id ) { status_header( 404 ); echo '"target" not found.'; exit; } // check if pings are allowed if ( ! pings_open( $post_id ) ) { status_header( 403 ); echo 'Pings are disabled for this post'; exit; } // queue here, the remote check will be async //$r = static::queue_receive( $source, $target, $post_id ); $r = static::queue_add( 'in', $source, $target, 'post', $post_id ); if ( true == $r ) { status_header( 202 ); echo 'Webmention accepted in the queue.'; } else { status_header( 500 ); echo 'Something went wrong; please try again later!'; } exit; } /** * worker method for doing received webmentions * triggered by cron * */ public function process_received () { $incoming = static::queue_get ( 'in', static::per_batch() ); if ( empty( $incoming ) ) return true; foreach ( (array)$incoming as $received ) { // this really should not happen, but if it does, get rid of this entry immediately if (! isset( $received->target ) || empty( $received->target ) || ! isset( $received->source ) || empty( $received->source ) ) { static::debug( " target or souce empty, aborting" ); static::queue_del ( $received->id ); continue; } static::debug( "processing webmention: target -> {$received->target}, source -> {$received->source}" ); if ( empty( $received->object_id ) || 0 == $received->object_id ) $post_id = url_to_postid ( $received->target ); else $post_id = $received->object_id; $post = get_post ( $post_id ); if ( ! static::is_post( $post ) ) { static::debug( " no post found for this mention, try again later, who knows?" ); //static::queue_del ( $received->id ); continue; } // too many retries, drop this mention and walk away if ( $received->tries >= static::retry() ) { static::debug( " this mention was tried earlier and failed too many times, drop it" ); static::queue_del ( $received->id ); continue; } // increment retries static::queue_inc ( $received->id ); // validate target $remote = static::try_receive_remote( $post_id, $received->source, $received->target ); if ( false === $remote || empty( $remote ) ) { static::debug( " parsing this mention failed, retrying next time" ); continue; } // we have remote data ! $c = static::try_parse_remote ( $post_id, $received->source, $received->target, $remote ); $ins = static::insert_comment ( $post_id, $received->source, $received->target, $remote, $c ); if ( true === $ins ) { static::debug( " duplicate (or something similar): this queue element has to be ignored; deleting queue entry" ); static::queue_del ( $received->id ); } elseif ( is_numeric( $ins ) ) { static::debug( " all went well, we have a comment id: {$ins}, deleting queue entry" ); static::queue_del ( $received->id ); } else { static::debug( "This is unexpected. Try again." ); } } } /** * extended wp_remote_get with debugging * * @param string $source URL to pull * * @return array wp_remote_get array */ protected static function _wp_remote_get ( &$source ) { $content = false; static::debug( " fetching {$source}" ); $url = htmlspecialchars_decode( $source ); $q = wp_remote_get( $source ); if ( is_wp_error( $q ) ) { static::debug( " something went wrong: " . $q->get_error_message() ); return false; } if ( !is_array( $q ) ) { static::debug( " $q is not an array. It should be one." ); return false; } if ( ! isset( $q['headers'] ) || ! is_array( $q['headers'] ) ) { static::debug( " missing response headers." ); return false; } if ( ! isset( $q['body'] ) || empty( $q['body'] ) ) { static::debug( " missing body" ); return false; } static::debug('Headers: ' . json_encode($q['headers'])); return $q; } /** * try to get contents of webmention originator * * @param int $post_id ID of post * @param string $source Originator URL * @param string $target Target URL * * @return bool|array false on error; plain array or Mf2 parsed (and * flattened ) array of remote content on success */ protected static function try_receive_remote ( &$post_id, &$source, &$target ) { $content = false; $q = static::_wp_remote_get( $source ); if ( false === $q ) return false; $targets = array ( $target, wp_get_shortlink( $post_id ), get_permalink( $post_id ) ); $found = false; foreach ( $targets as $k => $t ) { $t = preg_replace( '/https?:\/\/(?:www.)?/', '', $t ); $t = preg_replace( '/#.*/', '', $t ); $t = untrailingslashit( $t ); //$targets[ $k ] = $t; if ( ! stristr( $q['body'], $t ) ) $found = true; } // check if source really links to target // this could be a temporary error, so we'll retry later this one as well if ( false == $found ) { static::debug( " missing link to {$t} in remote body" ); return false; } $ctype = isset( $q['headers']['content-type'] ) ? $q['headers']['content-type'] : 'text/html'; if ( "text/plain" == $ctype ) { static::debug( " interesting, plain text webmention. I'm not prepared for this yet" ); return false; } elseif ( $ctype == "application/json" ) { static::debug( " content is JSON" ); $content = json_decode( $q['body'], true ); } else { static::debug( " content is (probably) html, trying to parse it with MF2" ); try { $content = Mf2\parse( $q['body'], $source ); } catch ( Exception $e ) { static::debug( " parsing MF2 failed: " . $e->getMessage() ); return false; } $content = static::flatten_mf2_array( $content ); } return $content; } /** * try to convert remote content to comment * * @param int $post_id ID of post * @param string $source Originator URL * @param string $target Target URL * @param array $content (Mf2) array of remote content * * @return bool|int false on error; insterted comment ID on success */ protected static function try_parse_remote ( &$post_id, &$source, &$target, &$content ) { $item = false; $p_authors = array(); if ( isset( $content['items']['properties'] ) && isset( $content['items']['type'] ) ) { $item = $content['items']; } elseif ( is_array($content['items'] ) && ! empty( $content['items']['type'] ) ) { foreach ( $content['items'] as $i ) { if ( 'h-entry' == $i['type'] ) { $items[] = $i; } elseif ( 'h-card' == $i['type'] ) { $p_authors[] = $i; } elseif ( 'u-comment' == $i['type'] ) { $comments[] = $i; } } } if ( ! empty ( $items ) ) $item = array_pop( $items ); elseif ( empty( $items ) && ! empty( $comments ) ) $item = array_pop( $comments ); if (! $item || empty( $item )) { static::debug(' no parseable h-entry found, saving as standard mention comment'); $c = array ( 'comment_author' => $source, 'comment_author_url' => $source, 'comment_author_email' => '', 'comment_post_ID' => $post_id, 'comment_type' => 'webmention', 'comment_date' => date("Y-m-d H:i:s"), 'comment_date_gmt' => date("Y-m-d H:i:s"), 'comment_agent' => __CLASS__, 'comment_approved' => 0, 'comment_content' => sprintf( __( 'This entry was webmentioned on %s.' ), $source, $source ), ); return $c; } // process author $author_name = $author_url = $avatar = $a = false; if ( isset( $item['properties']['author'] ) ) { $a = $item['properties']['author']; } elseif ( ! empty( $p_authors ) ) { $a = array_pop( $p_authors ); } if ( $a && isset( $a['properties'] ) ) { $a = $a['properties']; if ( isset($a['name']) && ! empty( $a['name'] ) ) $author_name = $a['name']; $try_photos = array ('photo', 'avatar'); $p = false; foreach ( $try_photos as $photo ) { if (isset( $a[ $photo ]) && ! empty( $a[ $photo ] ) ) { $p = $a[ $photo ]; if ( !empty( $p ) ) { $avatar = $p; break; } } } } // process type $type = 'webmention'; foreach ( static::mftypes() as $k => $mapped ) { if ( is_array( $item['properties'] ) && isset( $item['properties'][ $mapped ]) ) $type = $k; } //process content $c = ''; if ( isset( $item['properties']['content'] ) && isset( $item['properties']['content']['html'] ) ) $c = $item['properties']['content']['html']; if ( isset( $item['properties']['content'] ) && isset( $item['properties']['content']['value'] ) ) $c = wp_filter_kses( $item['properties']['content']['value'] ); // REACJI $emoji = EmojiRecognizer::isSingleEmoji( $c ); if ( $emoji ) { static::debug( "wheeeee, reacji!" ); $type = 'reacji'; //static::register_reacji( $type ); } // process date if ( isset( $item['properties']['modified'] ) ) $date = strtotime( $item['properties']['modified'] ); elseif ( isset( $item['properties']['published'] ) ) $date = strtotime( $item['properties']['published'] ); else $date = time(); $name = empty( $author_name ) ? $source : $author_name; $c = array ( 'comment_author' => $name, 'comment_author_url' => $source, 'comment_author_email' => '', 'comment_post_ID' => $post_id, 'comment_type' => $type, 'comment_date' => date( "Y-m-d H:i:s", $date ), 'comment_date_gmt' => date( "Y-m-d H:i:s", $date ), 'comment_agent' => __CLASS__, 'comment_approved' => 0, 'comment_content' => $c, 'comment_avatar' => $avatar, ); return $c; } /** * Comment inserter * * @param string &$post_id post ID * @param string &$source Originator URL * @param string &$target Target URL * @param mixed &$raw Raw format of the comment, like JSON response from the provider * @param array &$comment array formatted to match a WP Comment requirement * */ protected static function insert_comment ( &$post_id, &$source, &$target, &$raw, &$comment ) { $comment_id = false; $avatar = false; if( isset( $comment['comment_avatar'] ) ) { $avatar = $comment['comment_avatar']; unset( $comment['comment_avatar'] ); } // safety first $comment['comment_author_email'] = filter_var ( $comment['comment_author_email'], FILTER_SANITIZE_EMAIL ); $comment['comment_author_url'] = filter_var ( $comment['comment_author_url'], FILTER_SANITIZE_URL ); $comment['comment_author'] = filter_var ( $comment['comment_author'], FILTER_SANITIZE_STRING); //test if we already have this imported $testargs = array( 'author_url' => $comment['comment_author_url'], 'post_id' => $post_id, ); // so if the type is comment and you add type = 'comment', WP will not return the comments // such logical! if ( 'comment' != $comment['comment_type'] ) $testargs['type'] = $comment['comment_type']; // in case it's a fav or a like, the date field is not always present // but there should be only one of those, so the lack of a date field indicates // that we should not look for a date when checking the existence of the // comment if ( isset( $comment['comment_date'] ) && ! empty( $comment['comment_date'] ) ) { // in case you're aware of a nicer way of doing this, please tell me // or commit a change... $tmp = explode ( " ", $comment['comment_date'] ); $d = explode( "-", $tmp[0] ); $t = explode ( ':', $tmp[1] ); $testargs['date_query'] = array( 'year' => $d[0], 'monthnum' => $d[1], 'day' => $d[2], 'hour' => $t[0], 'minute' => $t[1], 'second' => $t[2], ); //test if we already have this imported static::debug( "checking comment existence (with date) for post #{$post_id}" ); } else { // we do need a date $comment['comment_date'] = date( "Y-m-d H:i:s" ); $comment['comment_date_gmt'] = date( "Y-m-d H:i:s" ); static::debug( "checking comment existence (no date) for post #{$post_id}" ); } $existing = get_comments( $testargs ); // no matching comment yet, insert it if ( ! empty( $existing ) ) { static::debug ( "comment already exists" ); return true; } // disable flood control, just in case remove_filter( 'check_comment_flood', 'check_comment_flood_db', 10, 3 ); $comment = apply_filters( 'preprocess_comment', $comment ); if ( $comment_id = wp_new_comment( $comment ) ) { // add avatar for later use if present if ( ! empty( $avatar ) ) update_comment_meta( $comment_id, 'avatar', $avatar ); // full raw response for the vote, just in case update_comment_meta( $comment_id, 'webmention_source_mf2', $raw ); // original request update_comment_meta( $comment_id, 'webmention_original', array( 'target' => $target, 'source' => $source) ); // info $r = "new comment inserted for {$post_id} as #{$comment_id}"; // notify author // wp_notify_postauthor( $comment_id ); } else { $r = "something went wrong when trying to insert comment for post #{$post_id}"; } // re-add flood control add_filter( 'check_comment_flood', 'check_comment_flood_db', 10, 3 ); static::debug( $r ); return $comment_id; } /** * triggered on post transition, applied when new status is publish, therefore * applied on edit of published posts as well * add a post meta to the post to be processed by the send processor * * @param string $new_status New post status * @param string $old_status Previous post status * @param object $post WP Post object * public function queue_send( $new_status, $old_status, $post ) { if ( ! static::is_post( $post ) ) { static::debug( "Whoops, this is not a post." ); return false; } if ( 'publish' != $new_status ) { static::debug( "Not adding {$post->ID} to mention queue yet; not published" ); return false; } $r = add_post_meta( $post->ID, static::meta_send, 1, true ); if ( ! $r ) { static::debug( "Tried adding post #{$post->ID} to mention queue, but it didn't go well" ); } //else { //// fire up a single cron event if the scheduled is too far in the future //$next = wp_next_scheduled( static::cron_send ) - time (); //if ( $next > static::interval_send_min() ) //wp_schedule_single_event( time() , static::cron_send ); //} return $r; } */ /** * triggered on post transition, applied when new status is publish, therefore * applied on edit of published posts as well * add a post meta to the post to be processed by the send processor * * @param string $new_status New post status * @param string $old_status Previous post status * @param object $post WP Post object */ public function queue_send( $new_status, $old_status, $post ) { if ( ! static::is_post( $post ) ) { static::debug( "Whoops, this is not a post." ); return false; } if ( 'publish' != $new_status ) { static::debug( "Not adding {$post->ID} to mention queue yet; not published" ); return false; } static::debug("Trying to get urls for #{$post->ID}"); // try to avoid redirects, so no shortlink is sent for now as source $source = get_permalink( $post->ID ); // process the content as if it was the_content() $content = static::get_the_content( $post ); // get all urls in content $urls = static::extract_urls( $content ); // for special ocasions when someone wants to add to this list $urls = apply_filters( 'webmention_links', $urls, $post->ID ); // lowercase url is good for your mental health foreach ( $urls as $k => $url ) $urls[ $k ] = strtolower( $url ); // remove all already pinged urls $pung = get_pung( $post->ID ); $urls = array_diff ( $urls, $pung ); foreach ( $urls as $target ) { $r = static::queue_add ( 'out', $source, $target, $post->post_type, $post->ID ); if ( !$r ) static::debug( " tried adding post #{$post->ID}, url: {$target} to mention queue, but it didn't go well" ); } } /** * worker method for doing received webmentions * triggered by cron * */ public function process_send () { $outgoing = static::queue_get ( 'out', static::per_batch() ); if ( empty( $outgoing ) ) return true; foreach ( (array)$outgoing as $send ) { // this really should not happen, but if it does, get rid of this entry immediately if (! isset( $send->target ) || empty( $send->target ) || ! isset( $send->source ) || empty( $send->source ) ) { static::debug( " target or souce empty, aborting" ); static::queue_del ( $send->id ); continue; } static::debug( "processing webmention: target -> {$send->target}, source -> {$send->source}" ); // too many retries, drop this mention and walk away if ( $send->tries >= static::retry() ) { static::debug( " this mention was tried earlier and failed too many times, drop it" ); static::queue_del ( $send->id ); continue; } // increment retries static::queue_inc ( $send->id ); // try sending $s = static::send( $send->source, $send->target ); if ( !$s ) { static::debug( " sending failed; retrying later ({$tries} time)" ); } else { static::debug( " sending succeeded!" ); $post_types = get_post_types( '', 'names' ); if ( in_array( $send->object_type, $post_types ) && 0 != $send->object_id ) add_ping( $send->object_id, $send->target ); static::queue_del ( $send->id ); } } } /** * make pung stricter * * @param array $pung array of pinged urls * * @return array a better array of pinged urls * */ public function get_pung ( $pung ) { foreach ($pung as $k => $e ) $pung[ $k ] = strtolower( $e ); $pung = array_unique($pung); return $pung; } /** * get all posts that have meta entry for sending * * @return array of WP Post objects */ protected static function get_send () { global $wpdb; $r = array(); $dbname = "{$wpdb->prefix}postmeta"; $key = static::meta_send; $limit = static::per_batch(); $db_command = "SELECT DISTINCT `post_id` FROM `{$dbname}` WHERE `meta_key` = '{$key}' LIMIT {$limit}"; try { $q = $wpdb->get_results( $db_command ); } catch ( Exception $e ) { static::debug( "Something went wrong: " . $e->getMessage() ); } if ( ! empty( $q ) && is_array( $q ) ) { foreach ( $q as $post ) { array_push( $r, $post->post_id ); } } return $r; } /** * send a single webmention * based on [webmention](https://github.com/pfefferle/wordpress-webmention) * */ public static function send ( $source, $target, $post_id = false ) { $options = static::get_options(); // stop selfpings on the same URL if ( isset( $options['disable_selfpings_same_url'] ) && $options['disable_selfpings_same_url'] == '1' && $source === $target ) return false; // stop selfpings on the same domain if ( isset( $options['disable_selfpings_same_domain'] ) && $options['disable_selfpings_same_domain'] == '1' && parse_url( $source, PHP_URL_HOST ) == parse_url( $target, PHP_URL_HOST ) ) return false; // discover the webmention endpoint $webmention_server_url = static::discover_endpoint( $target ); // if I can't find an endpoint, perhaps you can! $webmention_server_url = apply_filters( 'webmention_server_url', $webmention_server_url, $target ); if ( $webmention_server_url ) { $args = array( 'body' => 'source=' . urlencode( $source ) . '&target=' . urlencode( $target ), 'timeout' => static::remote_timeout(), ); static::debug( "Sending webmention to: " .$webmention_server_url . " as: " . $args['body'] ); $response = wp_remote_post( $webmention_server_url, $args ); // use the response to do something usefull // do_action( 'webmention_post_send', $response, $source, $target, $post_ID ); return $response; } return false; } /** * Finds a WebMention server URI based on the given URL * * code from [webmention](https://github.com/pfefferle/wordpress-webmention) * * Checks the HTML for the rel="http://webmention.org/" link and http://webmention.org/ headers. It does * a check for the http://webmention.org/ headers first and returns that, if available. The * check for the rel="http://webmention.org/" has more overhead than just the header. * * @param string $url URL to ping * * @return bool|string False on failure, string containing URI on success */ protected static function discover_endpoint( $url ) { // Not an URL. This should never happen. if ( false === filter_var( $url, FILTER_VALIDATE_URL ) ) return false; // do not search for a WebMention server on our own uploads $uploads_dir = wp_upload_dir(); if ( 0 === strpos( $url, $uploads_dir['baseurl'] ) ) return false; $response = wp_remote_head( $url, array( 'timeout' => static::remote_timeout(), 'httpversion' => '1.0' ) ); if ( is_wp_error( $response ) ) { static::debug( "Something went wrong: " . $response->get_error_message() ); return false; } // check link header if ( $links = wp_remote_retrieve_header( $response, 'link' ) ) { if ( is_array( $links ) ) { foreach ( $links as $link ) { if ( preg_match( '/<(.[^>]+)>;\s+rel\s?=\s?[\"\']?(http:\/\/)?webmention(.org)?\/?[\"\']?/i', $link, $result ) ) { return self::make_url_absolute( $url, $result[1] ); } } } else { if ( preg_match( '/<(.[^>]+)>;\s+rel\s?=\s?[\"\']?(http:\/\/)?webmention(.org)?\/?[\"\']?/i', $links, $result ) ) { return self::make_url_absolute( $url, $result[1] ); } } } // not an (x)html, sgml, or xml page, no use going further if ( preg_match( '#(image|audio|video|model)/#is', wp_remote_retrieve_header( $response, 'content-type' ) ) ) { return false; } // now do a GET since we're going to look in the html headers (and we're sure its not a binary file) $response = wp_remote_get( $url, array( 'timeout' => static::remote_timeout(), 'httpversion' => '1.0' ) ); if ( is_wp_error( $response ) ) { return false; } $contents = wp_remote_retrieve_body( $response ); // boost performance and use alreade the header $header = substr( $contents, 0, stripos( $contents, '' ) ); // unicode to HTML entities $contents = mb_convert_encoding( $contents, 'HTML-ENTITIES', mb_detect_encoding( $contents ) ); libxml_use_internal_errors( true ); $doc = new DOMDocument(); $doc->loadHTML( $contents ); $xpath = new DOMXPath( $doc ); // check elements // checks only head-links foreach ( $xpath->query( '//head/link[contains(concat(" ", @rel, " "), " webmention ") or contains(@rel, "webmention.org")]/@href' ) as $result ) { return self::make_url_absolute( $url, $result->value ); } // check elements // checks only body>a-links foreach ( $xpath->query( '//body//a[contains(concat(" ", @rel, " "), " webmention ") or contains(@rel, "webmention.org")]/@href' ) as $result ) { return self::make_url_absolute( $url, $result->value ); } return false; } /** * validated a URL that is supposed to be on our site * * @param string $url URL to check against * * @return bool|int false on not found, int post_id on found */ protected static function validate_local ( $url ) { // normalize url scheme, url_to_postid will take care of it anyway $url = preg_replace( '/^https?:\/\//i', 'http://', $url ); return url_to_postid( $url ); } /** * recursive walker for an mf2 array: if it find an element which has a value * of a single element array, flatten the array to the element * * @param array $mf2 Mf2 array to flatten * * @return array flattened Mf2 array * */ protected static function flatten_mf2_array ( $mf2 ) { if (is_array($mf2) && count($mf2) == 1) { $mf2 = array_pop($mf2); } if (is_array($mf2)) { foreach($mf2 as $key => $value) { $mf2[$key] = static::flatten_mf2_array($value); } } return $mf2; } /** * find all urls in a text * * @param string $text - text to parse * * @return array array of URLS found in the test */ protected static function extract_urls( &$text ) { $matches = array(); preg_match_all("/\b(?:http|https)\:\/\/?[a-zA-Z0-9\.\/\?\:@\-_=#]+\.[a-zA-Z0-9\.\/\?\:@\-_=#]*/i", $text, $matches); $matches = $matches[0]; $matches = array_unique($matches); return $matches; } /** * validates a post object if it really is a post * * @param $post object Wordpress Post Object to check * * @return bool true if it's a post, false if not */ protected static function is_post ( &$post ) { if ( !empty($post) && is_object($post) && isset($post->ID) && !empty($post->ID) ) return true; return false; } /** * Converts relative to absolute urls * * code from [webmention](https://github.com/pfefferle/wordpress-webmention) * which is based on the code of 99webtools.com * * @link http://99webtools.com/relative-path-into-absolute-url.php * * @param string $base the base url * @param string $rel the relative url * * @return string the absolute url */ protected static function make_url_absolute( $base, $rel ) { if ( 0 === strpos( $rel, '//' ) ) { return parse_url( $base, PHP_URL_SCHEME ) . ':' . $rel; } // return if already absolute URL if ( parse_url( $rel, PHP_URL_SCHEME ) != '' ) { return $rel; } // queries and anchors if ( '#' == $rel[0] || '?' == $rel[0] ) { return $base . $rel; } // parse base URL and convert to local variables: // $scheme, $host, $path extract( parse_url( $base ) ); // remove non-directory element from path $path = preg_replace( '#/[^/]*$#', '', $path ); // destroy path if relative url points to root if ( '/' == $rel[0] ) { $path = ''; } // dirty absolute URL $abs = "$host"; // check port if ( isset( $port ) && ! empty( $port ) ) { $abs .= ":$port"; } // add path + rel $abs .= "$path/$rel"; // replace '//' or '/./' or '/foo/../' with '/' $re = array( '#(/\.?/)#', '#/(?!\.\.)[^/]+/\.\./#' ); for ( $n = 1; $n > 0; $abs = preg_replace( $re, '/', $abs, -1, $n ) ) { } // absolute URL is ready! return $scheme . '://' . $abs; } /** * of there is a comment meta 'avatar' field, use that as avatar for the commenter * * @param string $avatar the current avatar image string * @param mixed $id_or_email this could be anything that triggered the avatar all * @param string $size size for the image to display * @param string $default optional fallback * @param string $alt alt text for the avatar image */ public function get_avatar( $avatar, $id_or_email, $size, $default = '', $alt = '' ) { if ( ! is_object( $id_or_email ) || ! isset( $id_or_email->comment_type ) ) return $avatar; // check if comment has an avatar $c_avatar = get_comment_meta( $id_or_email->comment_ID, 'avatar', true ); if ( ! $c_avatar ) return $avatar; if ( false === $alt ) $safe_alt = ''; else $safe_alt = esc_attr( $alt ); return sprintf( '%s', $safe_alt, $c_avatar, $size, $size ); } /** * get content like the_content * * @param object $post - WP Post object * * @return string the_content */ protected static function get_the_content( &$post ){ if ( ! static::is_post( $post ) ) return false; if ( $cached = wp_cache_get ( $post->ID, __CLASS__ . __FUNCTION__ ) ) return $cached; $r = apply_filters( 'the_content', $post->post_content ); wp_cache_set ( $post->ID, $r, __CLASS__ . __FUNCTION__, static::expire ); return $r; } /** * debug messages; will only work if WP_DEBUG is on * or if the level is LOG_ERR, but that will kill the process * * @param string $message * @param int $level */ protected static function debug( $message, $level = LOG_NOTICE ) { if ( @is_array( $message ) || @is_object ( $message ) ) $message = json_encode( $message ); switch ( $level ) { case LOG_ERR : wp_die( '

Error:

' . '

' . $message . '

' ); exit; default: if ( ! defined( 'WP_DEBUG' ) || true != WP_DEBUG ) return; break; } error_log( __CLASS__ . ": " . $message ); } } $WP_Webmention_Again = new WP_Webmention_Again(); endif;