Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.47% covered (warning)
66.47%
111 / 167
43.75% covered (danger)
43.75%
7 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Customize_Feed
66.47% covered (warning)
66.47%
111 / 167
43.75% covered (danger)
43.75%
7 / 16
261.08
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 maybe_register_feed_hooks
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 output_namespaces
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 feed_title
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 feed_description
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 output_channel_tags
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
42
 output_item_tags
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
9.13
 rewrite_enclosure
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
10
 filter_posts_with_enclosure
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
9
 explicit_string
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 show_image_url
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 build_stats_url
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 resolve_category_id
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
9
 episode_image_url
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
8.74
 maybe_photon
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 category_tag
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Adds podcast tags + tracked enclosure URLs to the RSS feed for the
4 * configured podcast category.
5 *
6 * @package automattic/jetpack-podcast
7 */
8
9declare( strict_types = 1 );
10
11namespace Automattic\Jetpack\Podcast\Feed;
12
13use Automattic\Jetpack\Connection\Manager as Connection_Manager;
14use Automattic\Jetpack\Podcast\Settings;
15use WP_Post;
16
17/**
18 * Hooks into RSS2 rendering when the current request is the podcast category
19 * feed, adding `<itunes:*>` + `<podcast:*>` tags at channel and item level
20 * and rewriting `<enclosure>` URLs through the WPCOM stats endpoint.
21 */
22class Customize_Feed {
23
24    /**
25     * Whether `init()` has wired its hooks.
26     *
27     * @var bool
28     */
29    private static $registered = false;
30
31    /**
32     * Wire the late-binding `wp` action that decides whether to register the
33     * feed-modification hooks for this request. Idempotent.
34     */
35    public static function init() {
36        if ( self::$registered ) {
37            return;
38        }
39        self::$registered = true;
40
41        add_action( 'wp', array( __CLASS__, 'maybe_register_feed_hooks' ) );
42
43        // `the_posts` fires during query execution — before the `wp` action —
44        // so it has to be registered up-front and self-gated to the podcast
45        // feed query, rather than wired conditionally in `maybe_register_feed_hooks`.
46        add_filter( 'the_posts', array( __CLASS__, 'filter_posts_with_enclosure' ), 10, 2 );
47    }
48
49    /**
50     * Register the RSS2 hooks if this request is the configured podcast feed.
51     * Also fires `Feed_Detection` while we're here — same gating, no need to
52     * walk the post query twice.
53     */
54    public static function maybe_register_feed_hooks() {
55        if ( ! is_feed() ) {
56            return;
57        }
58        $category_id = self::resolve_category_id();
59        if ( 0 === $category_id || ! is_category( $category_id ) ) {
60            return;
61        }
62
63        // Strip channel-level tags that conflict with the iTunes-compliant
64        // header: blavatar / site-icon `<image>` duplicates `<itunes:image>`,
65        // and `<cloud …/>` from rsscloud isn't part of the podcast spec.
66        remove_action( 'rss2_head', 'rss2_blavatar' );
67        remove_action( 'rss2_head', 'rss2_site_icon' );
68        remove_action( 'rss2_head', 'rsscloud_add_rss_cloud_element' );
69
70        add_action( 'rss2_ns', array( __CLASS__, 'output_namespaces' ) );
71        add_filter( 'wp_title_rss', array( __CLASS__, 'feed_title' ) );
72        add_filter( 'bloginfo_rss', array( __CLASS__, 'feed_description' ), 10, 2 );
73        add_action( 'rss2_head', array( __CLASS__, 'output_channel_tags' ) );
74        add_action( 'rss2_item', array( __CLASS__, 'output_item_tags' ) );
75        add_filter( 'rss_enclosure', array( __CLASS__, 'rewrite_enclosure' ) );
76
77        // Prune RSS chrome that podcatchers don't read. Cuts payload size and
78        // keeps incidental post data (body content, gravatar URLs, image EXIF,
79        // comments metadata) out of a feed whose only job is to deliver
80        // podcast episode metadata + the audio enclosure.
81        //
82        // - option_rss_use_excerpt -> suppresses content:encoded (full post body, incl. EXIF in image attrs).
83        // - comments_open + get_comments_number -> together suppress per-item comments / wfw:commentRss / slash:comments.
84        // - the_category_rss -> suppresses per-item category tags (channel itunes:category is the podcatcher signal).
85        // - removing wpcom mrss.php hooks -> suppresses media:content for author gravatar + post images.
86        add_filter( 'option_rss_use_excerpt', '__return_true' );
87        add_filter( 'comments_open', '__return_false' );
88        add_filter( 'get_comments_number', '__return_zero' );
89        add_filter( 'the_category_rss', '__return_empty_string' );
90        remove_action( 'rss2_item', 'mrss_item', 10 );
91        remove_action( 'rss2_item', 'mrss_news_item' );
92
93        Feed_Detection::detect_and_record();
94    }
95
96    /**
97     * Add iTunes and Podcasting 2.0 XML namespaces to the `<rss>` open tag.
98     */
99    public static function output_namespaces() {
100        echo "\n\t" . 'xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"' . "\n";
101        echo "\t" . 'xmlns:podcast="https://podcastindex.org/namespace/1.0"' . "\n";
102    }
103
104    /**
105     * Override the feed title with `podcasting_title`, falling back to
106     * `Blog Name » Category Name`.
107     *
108     * @param string $title Existing title.
109     * @return string
110     */
111    public static function feed_title( $title ) {
112        $override = (string) get_option( 'podcasting_title', '' );
113        if ( '' !== $override ) {
114            return esc_xml( $override );
115        }
116
117        $category = get_category( self::resolve_category_id() );
118        if ( $category && ! is_wp_error( $category ) ) {
119            return esc_xml( get_bloginfo( 'name' ) ) . ' &#187; ' . esc_xml( $category->name );
120        }
121        return esc_xml( $title );
122    }
123
124    /**
125     * Replace the `bloginfo_rss('description')` value with `podcasting_summary`.
126     *
127     * `bloginfo_rss()` echoes the filter return value directly, so we strip and
128     * escape here — matches the channel-level `<itunes:summary>` treatment and
129     * keeps stray markup in the option from leaking into `<description>`.
130     *
131     * @param string $value Existing value.
132     * @param string $field Field being requested.
133     * @return string
134     */
135    public static function feed_description( $value, $field ) {
136        if ( 'description' !== $field ) {
137            return $value;
138        }
139        return esc_xml( wp_strip_all_tags( (string) get_option( 'podcasting_summary', '' ) ) );
140    }
141
142    /**
143     * Channel-level podcast tags (rss2_head).
144     */
145    public static function output_channel_tags() {
146        $summary = (string) get_option( 'podcasting_summary', '' );
147        if ( '' !== $summary ) {
148            echo '<itunes:summary>' . esc_xml( wp_strip_all_tags( $summary ) ) . "</itunes:summary>\n";
149        }
150
151        $author = (string) get_option( 'podcasting_talent_name', '' );
152        if ( '' !== $author ) {
153            echo '<itunes:author>' . esc_xml( wp_strip_all_tags( $author ) ) . "</itunes:author>\n";
154        }
155
156        $email = wp_strip_all_tags( (string) get_option( 'podcasting_email', '' ) );
157        if ( '' !== $email ) {
158            echo '<itunes:owner><itunes:email>' . esc_xml( $email ) . "</itunes:email></itunes:owner>\n";
159        }
160
161        $copyright = (string) get_option( 'podcasting_copyright', '' );
162        if ( '' !== $copyright ) {
163            echo '<copyright>' . esc_xml( wp_strip_all_tags( $copyright ) ) . "</copyright>\n";
164        }
165
166        /**
167         * Explicit content flag
168         */
169        echo '<itunes:explicit>' . esc_html( self::explicit_string() ) . "</itunes:explicit>\n";
170
171        $image = self::show_image_url();
172        if ( '' !== $image ) {
173            echo '<itunes:image href="' . esc_url( $image ) . '" />' . "\n";
174        }
175
176        echo self::category_tag( (string) get_option( 'podcasting_category_1', '' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
177        echo self::category_tag( (string) get_option( 'podcasting_category_2', '' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
178        echo self::category_tag( (string) get_option( 'podcasting_category_3', '' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
179    }
180
181    /**
182     * Item-level podcast tags (rss2_item).
183     */
184    public static function output_item_tags() {
185        global $post;
186
187        if ( ! $post instanceof WP_Post ) {
188            return;
189        }
190
191        $author = get_the_author();
192        if ( '' === $author ) {
193            $author = (string) get_option( 'podcasting_talent_name', '' );
194        }
195        if ( '' !== $author ) {
196            echo '<itunes:author>' . esc_xml( wp_strip_all_tags( $author ) ) . "</itunes:author>\n";
197        }
198
199        // Re-applying `the_excerpt_rss` so `<itunes:summary>` matches whatever
200        // the item's `<description>` ends up emitting — `get_the_excerpt()`
201        // doesn't run the filter chain itself.
202        $excerpt = (string) apply_filters( 'the_excerpt_rss', get_the_excerpt() );
203        if ( '' !== $excerpt ) {
204            echo '<itunes:summary>' . esc_xml( wp_strip_all_tags( $excerpt ) ) . "</itunes:summary>\n";
205        }
206
207        // Per-item cover art: prefer the block's `coverArt`, fall back to the
208        // post's featured image. Either way, photon-resize to 3000×3000 to
209        // honour Apple's square-cover requirement. When neither is present
210        // the channel-level `<itunes:image>` applies as default per spec.
211        $attrs      = Episode_Block_Tags::get_block_attrs( $post );
212        $cover_url  = isset( $attrs['coverArt']['url'] ) ? trim( (string) $attrs['coverArt']['url'] ) : '';
213        $item_image = '' !== $cover_url ? self::maybe_photon( $cover_url ) : self::episode_image_url( $post->ID );
214        if ( '' !== $item_image ) {
215            echo '<itunes:image href="' . esc_url( $item_image ) . '" />' . "\n";
216        }
217
218        // Block-driven iTunes + Podcasting 2.0 tags. Legacy audio posts
219        // without the block contribute nothing — they keep their pre-block
220        // behavior intact aside from the cover art handled above.
221        if ( ! empty( $attrs ) ) {
222            Episode_Block_Tags::render_from_attrs( $attrs );
223        }
224    }
225
226    /**
227     * Rewrite the enclosure URL through the WPCOM stats endpoint and append
228     * `<itunes:duration>` when resolvable. Duration is looked up against the
229     * *original* attachment URL — the stats URL is synthetic.
230     *
231     * @param string $enclosure Generated enclosure markup.
232     * @return string
233     */
234    public static function rewrite_enclosure( $enclosure ) {
235        global $post;
236
237        if ( ! preg_match( '/url="([^"]*)"/i', $enclosure, $match ) ) {
238            return $enclosure;
239        }
240
241        $original_url = $match[1];
242        $post_obj     = $post instanceof WP_Post ? $post : null;
243
244        /**
245         * Whether to rewrite the enclosure through the WPCOM stats endpoint.
246         * Token-gated feeds (notably WPCOM's `private-podcasts.php`) opt out
247         * — the stats URL is a deterministic public endpoint that would
248         * bypass any token gating on the feed itself.
249         *
250         * @param bool         $enable Default true.
251         * @param WP_Post|null $post   The post being rendered.
252         */
253        $enable = (bool) apply_filters( 'wpcom_podcasting_enable_play_tracking', true, $post_obj );
254
255        // Skip rewrite for externally hosted enclosures — the stats endpoint 404s anything that isn't a local attachment.
256        $attachment_id = attachment_url_to_postid( $original_url );
257
258        if ( null !== $post_obj && $enable && $attachment_id > 0 ) {
259            // `null` when the site isn't connected; passed through so the filter can still inject a value.
260            $default_blog_id = Connection_Manager::get_site_id( true );
261
262            /**
263             * Override the blog ID baked into the stats URL.
264             *
265             * @param int|null $blog_id Default Jetpack connection site ID, or null when unavailable.
266             * @param WP_Post  $post    The post being rendered.
267             */
268            $blog_id = (int) apply_filters( 'wpcom_podcasting_tracked_blog_id', $default_blog_id, $post_obj );
269
270            // Bail when we can't resolve a real blog ID — emit the original URL rather than a guaranteed-404 stats URL.
271            if ( $blog_id > 0 ) {
272                $stats_url = self::build_stats_url( $blog_id, (int) $post_obj->ID, $original_url );
273                $enclosure = preg_replace_callback(
274                    '/url="[^"]*"/i',
275                    /**
276                     * Replace the matched `url="…"` attribute with the stats URL.
277                     * `$matches` is required by `preg_replace_callback`'s callable
278                     * signature but ignored — we always emit the same value.
279                     *
280                     * @param array $matches Regex matches.
281                     * @return string
282                     */
283                    static function ( array $matches ) use ( $stats_url ) {
284                        unset( $matches );
285                        return 'url="' . esc_url( $stats_url ) . '"';
286                    },
287                    $enclosure,
288                    1
289                );
290            }
291        }
292
293        if ( 0 === $attachment_id ) {
294            return $enclosure;
295        }
296
297        $metadata = wp_get_attachment_metadata( $attachment_id );
298        $duration = is_array( $metadata ) ? absint( $metadata['length'] ?? 0 ) : 0;
299
300        return 0 === $duration
301            ? $enclosure
302            : $enclosure . '<itunes:duration>' . $duration . "</itunes:duration>\n";
303    }
304
305    /**
306     * A podcast item without an enclosure is invalid per Apple's spec and can
307     * take down the whole submission. The `enclosure` post meta is what
308     * `rss_enclosure()` reads, so it's the authoritative signal here too.
309     *
310     * @param WP_Post[] $posts Posts about to be looped over.
311     * @param \WP_Query $query Query that produced them.
312     * @return WP_Post[]
313     */
314    public static function filter_posts_with_enclosure( $posts, $query ) {
315        if ( ! $query->is_main_query() || ! $query->is_feed() || ! $query->is_category() ) {
316            return $posts;
317        }
318        $category_id = self::resolve_category_id();
319        if ( 0 === $category_id ) {
320            return $posts;
321        }
322        $queried = $query->get_queried_object();
323        if ( ! $queried || ! isset( $queried->term_id ) || (int) $queried->term_id !== $category_id ) {
324            return $posts;
325        }
326        return array_values(
327            array_filter(
328                $posts,
329                static function ( $post ) {
330                    return $post instanceof WP_Post
331                        && ! empty( get_post_meta( $post->ID, 'enclosure', false ) );
332                }
333            )
334        );
335    }
336
337    /**
338     * Stored explicit value, normalized to the `'true'`/`'false'` strings the
339     * iTunes spec requires. Reuses `Settings::sanitize_explicit`
340     * so legacy `'yes'`/`'no'`/`'clean'` and modern boolean storage both work.
341     *
342     * @return string
343     */
344    public static function explicit_string(): string {
345        return Settings::sanitize_explicit( get_option( 'podcasting_explicit', false ) ) ? 'true' : 'false';
346    }
347
348    /**
349     * Show-level cover image URL — `Settings::raw_show_image_url()` routed
350     * through Photon at 3000×3000 when available.
351     *
352     * @return string
353     */
354    private static function show_image_url(): string {
355        $url = Settings::raw_show_image_url();
356        return '' === $url ? '' : self::maybe_photon( $url );
357    }
358
359    /**
360     * Build the WPCOM stats URL for a given episode. The endpoint redirects
361     * to the audio file after recording the play — the package never serves
362     * it, only points at it. Audio extensions outside the recognized set
363     * fall back to `mp3` to keep the URL shape uniform (matches the Podtrac
364     * / Megaphone / Art19 convention).
365     *
366     * @param int    $blog_id      WPCOM blog ID (Atomic should override via the
367     *                             `wpcom_podcasting_tracked_blog_id` filter).
368     * @param int    $post_id      Episode post ID.
369     * @param string $original_url Original enclosure URL — extension is pulled from here.
370     * @return string
371     */
372    private static function build_stats_url( int $blog_id, int $post_id, string $original_url ): string {
373        $path = (string) wp_parse_url( $original_url, PHP_URL_PATH );
374        $ext  = (string) preg_replace( '/[^a-z0-9]/', '', strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) ) );
375        if ( ! in_array( $ext, array( 'mp3', 'm4a', 'm4b', 'aac', 'ogg', 'oga', 'opus', 'wav', 'flac', 'mp4', 'm4v', 'mov' ), true ) ) {
376            $ext = 'mp3';
377        }
378        return sprintf(
379            'https://public-api.wordpress.com/wpcom/v2/sites/%d/podcast-play/%d.%s',
380            $blog_id,
381            $post_id,
382            $ext
383        );
384    }
385
386    /**
387     * Resolve the configured podcast category ID. Prefers the numeric
388     * `podcasting_category_id`, falling back to a slug lookup against the
389     * legacy `podcasting_archive` option — older sites pre-date numeric
390     * storage and only have the slug. Returns 0 when neither resolves.
391     *
392     * A numeric ID whose term was deleted means "not configured" — the slug
393     * is not consulted in that case.
394     *
395     * @return int
396     */
397    public static function resolve_category_id(): int {
398        $category_id = (int) get_option( 'podcasting_category_id', 0 );
399        if ( $category_id > 0 ) {
400            $category = get_category( $category_id );
401            return ( $category && ! is_wp_error( $category ) && isset( $category->term_id ) ) ? (int) $category->term_id : 0;
402        }
403
404        $slug = (string) get_option( 'podcasting_archive', '' );
405        if ( '' === $slug ) {
406            return 0;
407        }
408
409        $term = get_term_by( 'slug', $slug, 'category' );
410        return ( $term && ! is_wp_error( $term ) && isset( $term->term_id ) ) ? (int) $term->term_id : 0;
411    }
412
413    /**
414     * Episode-level image URL — the post's featured image, Photon-resized,
415     * or `''` when no featured image is set. Used as the fallback per-item
416     * cover when the block doesn't supply its own.
417     *
418     * @param int $post_id Episode post ID.
419     * @return string
420     */
421    private static function episode_image_url( int $post_id ): string {
422        if ( ! has_post_thumbnail( $post_id ) ) {
423            return '';
424        }
425        $src = wp_get_attachment_image_src( get_post_thumbnail_id( $post_id ), 'full' );
426        if ( ! is_array( $src ) || empty( $src[0] ) ) {
427            return '';
428        }
429        return self::maybe_photon( $src[0] );
430    }
431
432    /**
433     * Route through Photon at exactly 3000×3000 so the feed always serves a
434     * square cover, regardless of the source aspect ratio. `resize` center-crops
435     * (unlike `fit`, which only constrains within the box); Apple's spec wants
436     * 1400–3000 px square art and rejects non-square covers.
437     *
438     * @param string $url Image URL.
439     * @return string
440     */
441    public static function maybe_photon( string $url ): string {
442        if ( ! function_exists( 'jetpack_photon_url' ) ) {
443            return $url;
444        }
445        // @phan-suppress-next-line PhanUndeclaredFunction -- Provided by Jetpack's Photon module at runtime; guarded by `function_exists` above.
446        return (string) jetpack_photon_url( $url, array( 'resize' => '3000,3000' ), 'https' );
447    }
448
449    /**
450     * Build a single `<itunes:category>` tag from a stored option value. The
451     * stored format is one of:
452     *   - `''` (no category)
453     *   - `'Foo'` → single category
454     *   - `'Foo,Bar'` → category Foo with subcategory Bar
455     *
456     * Includes a back-compat translation pass for a few legacy values that were
457     * stored in non-canonical shapes before validation tightened.
458     *
459     * @param string $stored Raw option value.
460     * @return string Empty string if no category, otherwise an XML fragment.
461     */
462    public static function category_tag( string $stored ): string {
463        static $legacy_aliases = array(
464            'Education,Education'                => 'Education',
465            'Education,Education Technology'     => 'Education,Educational Technology',
466            'Tech News'                          => 'Technology,Tech News',
467            'Sports &amp; Recreation,Technology' => 'Technology',
468            'Sports &amp; Recreation,Gadgets'    => 'Technology,Gadgets',
469            'Sports,Football'                    => 'Sports,American Football',
470            'Sports,Soccer'                      => 'Sports,Football (Soccer)',
471        );
472        $category              = $legacy_aliases[ $stored ] ?? $stored;
473
474        if ( '' === $category ) {
475            return '';
476        }
477
478        // `ent2ncr()` normalises named HTML entities (e.g. `&nbsp;`, `&copy;`) into
479        // numeric character references so an attribute value containing them stays
480        // well-formed XML after esc_attr().
481        $splits = explode( ',', $category );
482        if ( 2 === count( $splits ) ) {
483            return '<itunes:category text="' . ent2ncr( esc_attr( $splits[0] ) ) . '">' . "\n"
484                . "\t" . '<itunes:category text="' . ent2ncr( esc_attr( $splits[1] ) ) . '" />' . "\n"
485                . "</itunes:category>\n";
486        }
487        return '<itunes:category text="' . ent2ncr( esc_attr( $category ) ) . '" />' . "\n";
488    }
489}