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' ); ?> <?php echo esc_html( sprintf( /* translators: %s: author name */ __( 'A comment by %s', 'stubli' ), $name ) ); ?>

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'; } }