diff --git a/blockroll.php b/blockroll.php index a19e3fd..f4e3bae 100644 --- a/blockroll.php +++ b/blockroll.php @@ -74,8 +74,8 @@ function ( $vars ) { $vars[] = 'blockroll-page'; // OPML output. A query var rather than a rewrite endpoint: no rewrite // flush, and when the plugin is disabled the URL falls back to the - // page itself instead of a 404. Only the well-known URL, which has - // no page behind it, gets a rewrite rule of its own. + // page itself instead of a 404. Only the well-known URLs, which have + // no page behind them, get rewrite rules of their own. $vars[] = Opml::QUERY_VAR; // One blogroll of a page, picked by its HTML anchor. $vars[] = Opml::GROUP; diff --git a/includes/class-opml.php b/includes/class-opml.php index b089b62..39cbe4c 100644 --- a/includes/class-opml.php +++ b/includes/class-opml.php @@ -9,7 +9,8 @@ /** * Serve OPML for blogroll pages and a directory of them at the site root - * and at /.well-known/recommendations.opml. + * and at /.well-known/recommendations.opml, and their feeds at + * /.well-known/following.opml. */ class Opml { /** @@ -17,7 +18,14 @@ class Opml { * * See https://opml.org/ */ - const WELL_KNOWN = '.well-known/recommendations.opml'; + const RECOMMENDATIONS_PATH = '.well-known/recommendations.opml'; + + /** + * Path of the well-known following list, without the leading slash. + * + * See https://followspec.org/ + */ + const FOLLOWING_PATH = '.well-known/following.opml'; /** * Query var that asks for the OPML of a page instead of its HTML. @@ -31,6 +39,11 @@ class Opml { */ const DIRECTORY = 'directory'; + /** + * Value of the `opml` query var that always asks for the following list. + */ + const FOLLOWING = 'following'; + /** * Query var that picks one blogroll of a page by its HTML anchor. * @@ -55,10 +68,13 @@ class Opml { */ public static function register() { self::add_rewrite_rules(); - // Before redirect_canonical, which would send the well-known URL to a - // trailing-slash version of itself. + // After `init`, when every plugin has added its rules. + \add_action( 'wp_loaded', array( self::class, 'flush_stale_rewrite_rules' ) ); + // Before redirect_canonical, which would send the well-known URLs to + // a trailing-slash version of themselves. \add_action( 'template_redirect', array( self::class, 'render' ), 9 ); \add_action( 'wp_head', array( self::class, 'discovery_link' ) ); + \add_filter( 'socialspec_following', array( self::class, 'socialspec_following' ) ); foreach ( array( 'rss2', 'atom' ) as $feed ) { \add_action( $feed . '_ns', array( self::class, 'feed_namespace_start' ), 1 ); \add_action( $feed . '_ns', array( self::class, 'feed_namespace' ), PHP_INT_MAX ); @@ -67,25 +83,23 @@ public static function register() { } /** - * Map the well-known URL to the directory OPML and `.opml` to `?opml`. + * Map the well-known URLs to the directory and the following list, + * and `.opml` to `?opml`. * * Also called on activation, before the rules are flushed. Unlike the - * `?opml` query var, the well-known path has no page to fall back to, - * so it needs a rule of its own. + * `?opml` query var, the well-known paths have no page to fall back + * to, so they need rules of their own. */ public static function add_rewrite_rules() { - \add_rewrite_rule( - // WordPress matches rewrite rules with "#" as the delimiter. - \sprintf( '^%s$', \preg_quote( self::WELL_KNOWN, '#' ) ), - \sprintf( 'index.php?%s=%s', self::QUERY_VAR, self::DIRECTORY ), - 'top' - ); + foreach ( \array_filter( self::well_known_rules() ) as $regex => $query ) { + \add_rewrite_rule( $regex, $query, 'top' ); + } // A `.opml` suffix as an alias for `?opml` on pages, so the URL // looks like a file: /blogroll.opml. The query var stays the // canonical form; it also works with plain permalinks. // This rule matches the well-known path too. Rules keep the order - // they were added in, so the well-known rule above has to stay - // above; the well-known test covers that. + // they were added in, so the well-known rules above have to stay + // above; the well-known tests cover that. \add_rewrite_rule( '^(.+?)\\.opml$', \sprintf( 'index.php?pagename=$matches[1]&%s=', self::QUERY_VAR ), @@ -93,6 +107,55 @@ public static function add_rewrite_rules() { ); } + /** + * The rewrite rules of the well-known URLs. + * + * @return array Regex => query, or => null for a URL this plugin + * does not serve on this site. + */ + private static function well_known_rules() { + $well_known = array( + self::RECOMMENDATIONS_PATH => self::DIRECTORY, + self::FOLLOWING_PATH => self::FOLLOWING, + ); + + $rules = array(); + foreach ( $well_known as $path => $value ) { + // WordPress matches rewrite rules with "#" as the delimiter. + $regex = \sprintf( '^%s$', \preg_quote( $path, '#' ) ); + // With the SocialSpec plugin (https://socialspec.org/), its + // following.opml lists the blogroll feeds. + $rules[ $regex ] = ( self::FOLLOWING === $value && \defined( 'SOCIALSPEC_VERSION' ) ) + ? null + : \sprintf( 'index.php?%s=%s', self::QUERY_VAR, $value ); + } + return $rules; + } + + /** + * Flush the rewrite rules when the stored well-known rules are out of + * date. + * + * Activation flushes, an update does not, so a new well-known URL + * would stay a 404 on existing sites. The rules also change when + * SocialSpec is activated or deactivated. Compared with the stored + * rules, so a site flushes once and needs no version of its own. + */ + public static function flush_stale_rewrite_rules() { + $stored = \get_option( 'rewrite_rules' ); + // Plain permalinks store no rules, and missing ones are built anew. + if ( ! \is_array( $stored ) ) { + return; + } + + foreach ( self::well_known_rules() as $regex => $query ) { + if ( ( $stored[ $regex ] ?? null ) !== $query ) { + \flush_rewrite_rules( false ); + return; + } + } + } + /** * Start buffering the namespaces other plugins and themes print. * @@ -404,6 +467,86 @@ public static function directory( $posts = null ) { ); } + /** + * Print the following list: the feeds of every listed blogroll in + * one flat list, each feed once. + * + * See https://followspec.org/ + * + * @param \WP_Post[]|null $posts Blogroll posts, or null to look them up. + */ + public static function following( $posts = null ) { + $posts = null === $posts ? Index::get_posts() : $posts; + + \load_template( + \dirname( BLOCKROLL_PLUGIN_FILE ) . '/templates/opml-following.php', + false, + array( + // Only pages with a feed in the list make it change. + 'posts' => \array_values( + \array_filter( + $posts, + function ( $post ) { + return (bool) self::following_links( array( $post ) ); + } + ) + ), + 'links' => self::following_links( $posts ), + ) + ); + } + + /** + * The links of every listed blogroll that have a feed, each feed once. + * + * @param \WP_Post[]|null $posts Blogroll posts, or null to look them up. + * @return array Normalized links. + */ + public static function following_links( $posts = null ) { + $links = array(); + foreach ( ( null === $posts ? Index::get_posts() : $posts ) as $post ) { + foreach ( self::page_links( $post ) as $link ) { + // Only a feed can be followed, and the same feed only once. + if ( $link['feedUrl'] && ! isset( $links[ $link['feedUrl'] ] ) ) { + $links[ $link['feedUrl'] ] = $link; + } + } + } + return \array_values( $links ); + } + + /** + * Add the blogroll feeds to the following list of SocialSpec. + * + * Feeds SocialSpec already lists, from the Link Manager or another + * plugin, are not added again. + * + * @param array $outlines OPML outline attributes, each with an `xmlUrl`. + * @return array The outlines. + */ + public static function socialspec_following( $outlines ) { + $outlines = (array) $outlines; + $feeds = \array_column( $outlines, 'xmlUrl' ); + + foreach ( self::following_links() as $link ) { + if ( \in_array( $link['feedUrl'], $feeds, true ) ) { + continue; + } + + $outlines[] = \array_filter( + array( + 'type' => 'rss', + 'text' => $link['name'] ? $link['name'] : $link['url'], + 'description' => $link['description'], + 'htmlUrl' => $link['url'], + 'xmlUrl' => $link['feedUrl'], + ) + ); + } + + return $outlines; + } + /** * Print the XML prolog and stylesheet line of an OPML document. */ @@ -437,10 +580,12 @@ public static function render() { return; } - // The well-known URL asks for the directory, whatever page it lands on. + // The well-known URLs ask for the directory or the following list, + // whatever page they land on. $directory = self::DIRECTORY === $opml; + $following = self::FOLLOWING === $opml; $anchor = (string) \get_query_var( self::GROUP, '' ); - $post = $directory ? null : Index::queried_post(); + $post = ( $directory || $following ) ? null : Index::queried_post(); // A page whose lists are all unlisted has no file of its own, and // neither has one that is asked for a list it does not hold. It is // then a page without a blogroll: the root still answers with the @@ -448,12 +593,18 @@ public static function render() { if ( $post && ! self::requested_groups( $post, $anchor ) ) { $post = null; } - $posts = ( ! $post && ( $directory || self::is_blogroll_root() ) ) ? Index::get_posts() : array(); + $posts = ( ! $post && ( $directory || $following || self::is_blogroll_root() ) ) ? Index::get_posts() : array(); - if ( ! $post && ! $posts ) { + // The well-known URLs have no page to fall back to, so they answer + // with an empty list rather than the home page. + if ( ! $post && ! $posts && ! $directory && ! $following ) { return; } + if ( $following ) { + self::serve_following( $posts ); + } + \header( 'Content-Type: text/xml; charset=' . \get_option( 'blog_charset' ) ); if ( $post ) { self::for_post( $post, $anchor ); @@ -463,6 +614,79 @@ public static function render() { exit; } + /** + * Serve the following list the way FollowSpec readers expect it, and + * exit. + * + * The OPML media type, open to other sites in the browser, and with + * an ETag, so a reader that asks again with If-None-Match gets a 304 + * while nothing changed. Such a request from another site asks first + * with OPTIONS. + * + * See https://followspec.org/ + * + * @param \WP_Post[] $posts Blogroll posts. + */ + private static function serve_following( $posts ) { + \header( 'Access-Control-Allow-Origin: *' ); + + $method = \strtoupper( \sanitize_text_field( \wp_unslash( $_SERVER['REQUEST_METHOD'] ?? 'GET' ) ) ); + if ( 'OPTIONS' === $method ) { + \status_header( 204 ); + \header( 'Access-Control-Allow-Methods: GET, HEAD, OPTIONS' ); + \header( 'Access-Control-Allow-Headers: If-None-Match' ); + \header( 'Access-Control-Max-Age: 86400' ); + exit; + } + + \ob_start(); + self::following( $posts ); + $body = (string) \ob_get_clean(); + $etag = '"' . \md5( $body ) . '"'; + + \header( 'Content-Type: text/x-opml; charset=' . \get_option( 'blog_charset' ) ); + \header( 'ETag: ' . $etag ); + \header( 'Access-Control-Expose-Headers: ETag' ); + \header( 'X-Content-Type-Options: nosniff' ); + + if ( self::etag_matches( $etag, \wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ?? '' ) ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only compared. + \status_header( 304 ); + exit; + } + + echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped by the template. + exit; + } + + /** + * Whether an If-None-Match header matches an ETag. + * + * The weak comparison of RFC 9110, section 13.1.2: a `W/` in front + * of a tag is ignored. + * + * @param string $etag ETag of the response. + * @param string $header If-None-Match request header. + * @return bool True if the reader has the current version. + */ + public static function etag_matches( $etag, $header ) { + $header = \trim( (string) $header ); + if ( '*' === $header ) { + return true; + } + + foreach ( \explode( ',', $header ) as $candidate ) { + $candidate = \trim( $candidate ); + if ( 0 === \strpos( $candidate, 'W/' ) ) { + $candidate = \substr( $candidate, 2 ); + } + if ( '' !== $candidate && $candidate === $etag ) { + return true; + } + } + + return false; + } + /** * The OPML URL of a post, or of one of its blogrolls. * diff --git a/readme.md b/readme.md index a711f16..fb78204 100644 --- a/readme.md +++ b/readme.md @@ -92,7 +92,9 @@ example `https://example.com/links/?opml`. That is the address to hand to a feed You do not have to tell anyone about it. Pages with a blogroll announce it in their HTML, and so does your homepage, so a reader that looks for it will find it. Sites that collect blogrolls look at `https://example.com/.well-known/recommendations.opml`, which -lists all blogrolls on your site. +lists all blogrolls on your site. Apps that show who you follow look at +`https://example.com/.well-known/following.opml`, one list of all the feeds in them, as +described in [FollowSpec](https://followspec.org/), part of [SocialSpec](https://socialspec.org/). There is also a download link under the list, for people who prefer a file. @@ -133,6 +135,11 @@ routes are described in the Project and support maintained on github at [pfefferle/wordpress-blockroll](https://github.com/pfefferle/wordpress-blockroll). +### Unreleased + +* Apps that show who you follow can now find the feeds of your blogrolls at `/.well-known/following.opml`, as described in [FollowSpec](https://followspec.org/). If the SocialSpec plugin is active, the feeds are added to its list instead. See #47. +* Without any blogroll, `/.well-known/recommendations.opml` now answers with an empty list instead of the home page. + ### 2.1.1 * The site-wide OPML index now lists each listed blogroll separately, with its own name and subscription address, instead of combining all lists on a page into one entry. Unlisted blogrolls stay out of the index. diff --git a/templates/opml-following.php b/templates/opml-following.php new file mode 100644 index 0000000..3fc6985 --- /dev/null +++ b/templates/opml-following.php @@ -0,0 +1,42 @@ + + + + + <?php + /* translators: %s: site name */ + echo esc_xml( sprintf( __( 'Feeds followed on %s', 'blockroll' ), get_bloginfo( 'name' ) ) ); + ?> + + + + + + + + + $args['links'] ) ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> + + diff --git a/tests/test-opml.php b/tests/test-opml.php index 0119e76..26807d3 100644 --- a/tests/test-opml.php +++ b/tests/test-opml.php @@ -189,14 +189,123 @@ public function test_opml_without_author_has_no_empty_owner() { $this->assertInstanceOf( SimpleXMLElement::class, new SimpleXMLElement( $xml ) ); } - public function test_well_known_url_asks_for_the_directory() { + public function test_recommendations_url_asks_for_the_directory() { $this->set_permalink_structure( '/%postname%/' ); - $this->go_to( home_url( '/' . \Blockroll\Opml::WELL_KNOWN ) ); + $this->go_to( home_url( '/' . \Blockroll\Opml::RECOMMENDATIONS_PATH ) ); $this->assertSame( \Blockroll\Opml::DIRECTORY, get_query_var( 'opml' ) ); $this->assertFalse( is_404() ); $this->set_permalink_structure( '' ); } + public function test_following_url_asks_for_the_following_list() { + $this->set_permalink_structure( '/%postname%/' ); + $this->go_to( home_url( '/' . \Blockroll\Opml::FOLLOWING_PATH ) ); + $this->assertSame( \Blockroll\Opml::FOLLOWING, get_query_var( 'opml' ) ); + $this->assertFalse( is_404() ); + $this->set_permalink_structure( '' ); + } + + public function test_following_lists_the_feeds_of_listed_blogrolls_once() { + self::factory()->post->create( array( 'post_content' => self::TWO_NAMED_BLOCKS ) ); + self::factory()->post->create( + array( + // The same feed again, a link without a feed and an unlisted list. + 'post_content' => self::BLOCK + . '' + . '', + ) + ); + ob_start(); + \Blockroll\Opml::following(); + $xml = ob_get_clean(); + $doc = new SimpleXMLElement( $xml ); + + $feeds = array(); + foreach ( $doc->body->outline as $outline ) { + $this->assertCount( 0, $outline->outline, 'The list is flat.' ); + $feeds[] = (string) $outline['xmlUrl']; + } + sort( $feeds ); + $this->assertSame( array( 'https://a.example/feed/', 'https://b.example/feed/' ), $feeds ); + $this->assertSame( get_bloginfo( 'name' ), (string) $doc->head->ownerName ); + } + + public function test_missing_well_known_rules_are_flushed() { + $following = '^' . preg_quote( \Blockroll\Opml::FOLLOWING_PATH, '#' ) . '$'; + $this->set_permalink_structure( '/%postname%/' ); + // Rules stored by a version without the following list. + $rules = get_option( 'rewrite_rules' ); + unset( $rules[ $following ] ); + update_option( 'rewrite_rules', $rules ); + + \Blockroll\Opml::flush_stale_rewrite_rules(); + + $rules = get_option( 'rewrite_rules' ); + $this->assertSame( 'index.php?opml=following', $rules[ $following ] ); + $this->assertSame( 'index.php?opml=directory', $rules[ '^' . preg_quote( \Blockroll\Opml::RECOMMENDATIONS_PATH, '#' ) . '$' ] ); + $this->set_permalink_structure( '' ); + } + + public function test_following_date_modified_ignores_pages_without_feeds() { + $with_feed = self::factory()->post->create( + array( + 'post_content' => self::BLOCK, + 'post_date' => '2026-01-01 00:00:00', + ) + ); + self::factory()->post->create( + array( + 'post_content' => '', + 'post_date' => '2026-06-01 00:00:00', + ) + ); + ob_start(); + \Blockroll\Opml::following(); + $doc = new SimpleXMLElement( ob_get_clean() ); + $this->assertSame( get_post_modified_time( 'r', true, $with_feed ), (string) $doc->head->dateModified ); + } + + public function test_etag_matches_weak_and_listed_tags() { + $etag = '"abc"'; + $this->assertTrue( \Blockroll\Opml::etag_matches( $etag, '"abc"' ) ); + $this->assertTrue( \Blockroll\Opml::etag_matches( $etag, 'W/"abc"' ) ); + $this->assertTrue( \Blockroll\Opml::etag_matches( $etag, '"x", "abc"' ) ); + $this->assertTrue( \Blockroll\Opml::etag_matches( $etag, '*' ) ); + $this->assertFalse( \Blockroll\Opml::etag_matches( $etag, '"x"' ) ); + $this->assertFalse( \Blockroll\Opml::etag_matches( $etag, '' ) ); + } + + public function test_well_known_lists_without_blogrolls_are_empty_opml() { + foreach ( array( 'directory', 'following' ) as $method ) { + ob_start(); + \Blockroll\Opml::$method( array() ); + $doc = new SimpleXMLElement( ob_get_clean() ); + $this->assertCount( 0, $doc->body->outline, $method ); + } + } + + public function test_socialspec_following_adds_the_blogroll_feeds_once() { + self::factory()->post->create( array( 'post_content' => self::TWO_NAMED_BLOCKS ) ); + $bookmark = array( + 'type' => 'rss', + 'text' => 'Bookmark', + 'xmlUrl' => 'https://a.example/feed/', + ); + $outlines = \Blockroll\Opml::socialspec_following( array( $bookmark ) ); + + $this->assertCount( 2, $outlines ); + $this->assertSame( $bookmark, $outlines[0], 'A feed SocialSpec lists stays as it is.' ); + $this->assertSame( + array( + 'type' => 'rss', + 'text' => 'B', + 'htmlUrl' => 'https://b.example/', + 'xmlUrl' => 'https://b.example/feed/', + ), + $outlines[1] + ); + } + public function test_opml_suffix_is_an_alias_for_the_query_var() { $id = self::factory()->post->create( array(