self::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => 20,
'fields' => 'ids',
'meta_key' => '_stubli_retry_at', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => time(), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'meta_compare' => '<=',
'meta_type' => 'NUMERIC',
)
);
foreach ( $ids as $id ) {
$target = (string) get_post_meta( $id, '_stubli_in_reply_to', true );
$result = $target ? send_webmention( get_permalink( $id ), $target ) : null;
self::schedule_retry( $id, self::delivered_status( $result ) );
}
}
/**
* Remember when to try a comment again, or stop trying.
*
* @param int $id Reply page ID.
* @param string $status sent, paused or not_sent.
*/
private static function schedule_retry( $id, $status ) {
$tries = (int) get_post_meta( $id, '_stubli_retries', true );
$age = time() - (int) get_post_time( 'U', true, $id );
$limit = 'paused' === $status ? 30 * DAY_IN_SECONDS : WEEK_IN_SECONDS;
if ( 'sent' === $status || $age > $limit ) {
delete_post_meta( $id, '_stubli_retry_at' );
delete_post_meta( $id, '_stubli_retries' );
update_post_meta( $id, '_stubli_delivery', 'sent' === $status ? 'sent' : 'gave_up' );
return;
}
$gaps = array( 15 * MINUTE_IN_SECONDS, HOUR_IN_SECONDS, 6 * HOUR_IN_SECONDS );
update_post_meta( $id, '_stubli_retries', $tries + 1 );
update_post_meta( $id, '_stubli_retry_at', time() + ( $gaps[ $tries ] ?? DAY_IN_SECONDS ) );
update_post_meta( $id, '_stubli_delivery', $status );
}
/**
* Reply pages: public so other sites can verify them, hidden from search,
* menus, archives and the site's own feed.
*/
public static function register_post_type() {
register_post_type(
self::POST_TYPE,
array(
'label' => __( 'Comments I wrote', 'stubli' ),
'public' => true,
'publicly_queryable' => true,
'exclude_from_search' => true,
'show_ui' => false,
'show_in_nav_menus' => false,
'show_in_rest' => false,
'has_archive' => false,
'rewrite' => array(
'slug' => 'replies',
'with_front' => false,
),
'supports' => array( 'editor', 'author' ),
)
);
}
/**
* Render a reply page as a small, readable h-entry.
*/
public static function render_reply_page() {
if ( ! is_singular( self::POST_TYPE ) ) {
return;
}
$post = get_queried_object();
$target = (string) get_post_meta( $post->ID, '_stubli_in_reply_to', true );
$name = get_the_author_meta( 'display_name', (int) $post->post_author );
if ( ! $name ) {
$name = get_bloginfo( 'name' );
}
status_header( 200 );
header( 'Content-Type: text/html; charset=utf-8' );
?>
post_content ), array( 'p' => array(), 'br' => array(), 'a' => array( 'href' => true, 'rel' => true ) ) ); ?>
404 ) );
}
return self::send( $item['permalink'], $text, (int) $item['id'] );
}
/**
* Comment on, or sign the guest book of, any Stubli page by address
* (the "Comment from your own Stubli" flow, H · Remote).
*
* @param string $url The page you're replying to.
* @param string $text Comment text.
* @return array|WP_Error
*/
public static function comment_on_url( $url, $text ) {
$url = esc_url_raw( trim( (string) $url ) );
if ( ! $url || ! in_array( wp_parse_url( $url, PHP_URL_SCHEME ), array( 'http', 'https' ), true ) ) {
return new WP_Error( 'stubli_bad_url', __( 'That doesn\'t look like a page address.', 'stubli' ), array( 'status' => 400 ) );
}
if ( Follows::origin( $url ) === Follows::origin( home_url() ) ) {
return new WP_Error( 'stubli_self', __( 'That\'s your own site. Reply there directly.', 'stubli' ), array( 'status' => 400 ) );
}
return self::send( $url, $text, 0 );
}
/**
* Create the reply page and send the Webmention.
*
* @param string $target Page being replied to.
* @param string $text Comment text.
* @param int $item_id Reader item, if any.
* @return array|WP_Error
*/
private static function send( $target, $text, $item_id ) {
// A friend's key never goes on a public reply page (design Q44).
$target = remove_query_arg( Friends::PARAM, html_entity_decode( (string) $target, ENT_QUOTES ) );
$text = trim( sanitize_textarea_field( (string) $text ) );
if ( '' === $text ) {
return new WP_Error( 'stubli_empty', __( 'Write a few words first.', 'stubli' ), array( 'status' => 400 ) );
}
if ( mb_strlen( $text ) > self::MAX_LENGTH ) {
return new WP_Error( 'stubli_comment_too_long', __( 'That comment is over 1,000 characters. Trim a little and it will fit.', 'stubli' ), array( 'status' => 400 ) );
}
$reply_id = wp_insert_post(
array(
'post_type' => self::POST_TYPE,
'post_status' => 'publish',
'post_title' => wp_trim_words( $text, 8, '…' ),
'post_content' => $text,
// Each comment gets its own unguessable address.
'post_name' => 'c-' . strtolower( wp_generate_password( 12, false ) ),
'post_author' => get_current_user_id(),
'meta_input' => array(
'_stubli_in_reply_to' => esc_url_raw( $target ),
'_stubli_item_id' => (int) $item_id,
),
),
true
);
if ( is_wp_error( $reply_id ) ) {
return $reply_id;
}
// The demo's friends are made up: nothing to send to, so it counts as sent.
$result = Demo::person( $target ) ? array( 'response' => array( 'code' => 202 ) ) : ( Helper::active() ? send_webmention( get_permalink( $reply_id ), $target ) : null );
$status = self::delivered_status( $result );
if ( 'sent' !== $status ) {
self::schedule_retry( $reply_id, $status );
}
return array(
'id' => $reply_id,
'url' => get_permalink( $reply_id ),
'delivered' => self::delivered( $result ),
// The other site decides whether it shows now or after their OK.
'status' => $status,
);
}
/**
* Take back a comment you wrote.
*
* @param int $reply_id Reply page ID.
* @return array|WP_Error
*/
public static function delete( $reply_id ) {
$post = get_post( $reply_id );
if ( ! $post || self::POST_TYPE !== $post->post_type ) {
return new WP_Error( 'stubli_not_found', __( 'That comment is already gone.', 'stubli' ), array( 'status' => 404 ) );
}
$source = get_permalink( $post );
$target = (string) get_post_meta( $post->ID, '_stubli_in_reply_to', true );
wp_delete_post( $post->ID, true );
$result = ( $source && $target && Helper::active() ) ? send_webmention( $source, $target ) : null;
return array(
'deleted' => true,
'delivered' => self::delivered( $result, true ),
);
}
/**
* Comments you've written on one reader item.
*
* @param int $item_id Reader item ID.
* @return int
*/
public static function count_for_item( $item_id ) {
$ids = get_posts(
array(
'post_type' => self::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => 50,
'fields' => 'ids',
'meta_key' => '_stubli_item_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => (int) $item_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
)
);
return count( $ids );
}
/**
* Did the other site accept the Webmention?
*
* @param mixed $result send_webmention() result.
* @param bool $removing True when taking a comment back: the receiver
* finds our page gone (404/410) and removes it.
* @return bool
*/
private static function delivered( $result, $removing = false ) {
if ( ! is_array( $result ) || is_wp_error( $result ) ) {
return false;
}
$code = (int) wp_remote_retrieve_response_code( $result );
if ( $code >= 200 && $code < 300 ) {
return true;
}
return $removing && in_array( $code, array( 404, 410 ), true );
}
/**
* Plain status for the app: sent, paused (they're on a break) or not sent.
*
* @param mixed $result send_webmention() result.
* @return string
*/
private static function delivered_status( $result ) {
if ( self::delivered( $result ) ) {
return 'sent';
}
if ( is_array( $result ) && ! is_wp_error( $result ) && 403 === (int) wp_remote_retrieve_response_code( $result ) && false !== strpos( (string) wp_remote_retrieve_body( $result ), 'stubli_on_break' ) ) {
return 'paused';
}
return 'not_sent';
}
}