Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.00% covered (success)
97.00%
194 / 200
71.43% covered (warning)
71.43%
10 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Settings
97.00% covered (success)
97.00%
194 / 200
71.43% covered (warning)
71.43%
10 / 14
48
0.00% covered (danger)
0.00%
0 / 1
 register
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 add_to_sync_whitelist
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_settings
100.00% covered (success)
100.00%
83 / 83
100.00% covered (success)
100.00%
1 / 1
2
 get_all
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 feed_limit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 feed_url
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 rest_schema_properties
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 raw_show_image_url
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 sanitize_explicit
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 sanitize_feed_limit
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 feed_limit_max
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 sanitize_show_urls
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
7.01
 sanitize_show_states
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
10.01
 sanitize_show_url
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
10.66
1<?php
2/**
3 * Podcast settings: option schema, sanitizers, and Jetpack Sync opt-in.
4 *
5 * @package automattic/jetpack-podcast
6 */
7
8namespace Automattic\Jetpack\Podcast;
9
10/**
11 * Registers the `podcasting_*` options with their `sanitize_callback`s so writes
12 * through any path stay validated. The dashboard reads and writes them through the
13 * dedicated {@see Podcast_Settings_Endpoint} (`wpcom/v2/podcast/settings`); they
14 * are intentionally not exposed through core `/wp/v2/settings`.
15 *
16 * Array-shaped options merge against stored values on sanitize, not replace —
17 * the SPA can PATCH partial entries without losing the rest.
18 */
19class Settings {
20
21    /**
22     * Per-podcatcher hostname allowlist for `podcasting_show_urls`. `www.` is
23     * stripped before comparison.
24     *
25     * @var array<string, string[]>
26     */
27    const SHOW_URL_HOSTS = array(
28        'pocketcasts'  => array( 'pca.st', 'pocketcasts.com' ),
29        'apple'        => array( 'podcasts.apple.com' ),
30        'spotify'      => array( 'open.spotify.com' ),
31        'youtube'      => array( 'youtube.com', 'm.youtube.com', 'youtu.be', 'music.youtube.com' ),
32        'amazon'       => array(
33            'music.amazon.com',
34            'music.amazon.co.uk',
35            'music.amazon.de',
36            'music.amazon.co.jp',
37            'music.amazon.com.au',
38            'music.amazon.fr',
39            'music.amazon.ca',
40            'music.amazon.es',
41        ),
42        'podcastindex' => array( 'podcastindex.org' ),
43    );
44
45    const SHOW_URL_MAX_LENGTH = 2048;
46
47    /**
48     * Fallback when neither `podcasting_feed_limit` nor `posts_per_rss` is set.
49     */
50    const FEED_LIMIT_DEFAULT = 300;
51
52    /**
53     * Ceiling for `podcasting_feed_limit`. Sized against measured feed generation.
54     */
55    const FEED_LIMIT_MAX = 500;
56
57    /**
58     * Drives `register_settings()` and the sync whitelist.
59     *
60     * @var string[]
61     */
62    const OPTION_NAMES = array(
63        'podcasting_category_id',
64        'podcasting_title',
65        'podcasting_talent_name',
66        'podcasting_summary',
67        'podcasting_copyright',
68        'podcasting_explicit',
69        'podcasting_image',
70        'podcasting_image_id',
71        'podcasting_category_1',
72        'podcasting_category_2',
73        'podcasting_category_3',
74        'podcasting_email',
75        'podcasting_show_urls',
76        'podcasting_show_states',
77        'podcasting_feed_limit',
78    );
79
80    /**
81     * Wire option registrations + Jetpack Sync opt-in. Idempotent: every
82     * callback is named, so WordPress dedupes repeat calls.
83     */
84    public static function register() {
85        add_action( 'admin_init', array( __CLASS__, 'register_settings' ) );
86        add_action( 'rest_api_init', array( __CLASS__, 'register_settings' ) );
87        add_filter( 'jetpack_sync_options_whitelist', array( __CLASS__, 'add_to_sync_whitelist' ) );
88    }
89
90    /**
91     * Add the podcast options to the Jetpack Sync whitelist.
92     *
93     * @param string[] $options Whitelisted option names.
94     * @return string[]
95     */
96    public static function add_to_sync_whitelist( $options ) {
97        return array_merge( $options, self::OPTION_NAMES );
98    }
99
100    /**
101     * `register_setting()` calls. Hooked on `admin_init` and `rest_api_init`.
102     */
103    public static function register_settings() {
104        $media_settings = array(
105            array( 'podcasting_category_id', 'integer', 0, 'absint' ),
106            array( 'podcasting_title', 'string', '', 'sanitize_text_field' ),
107            array( 'podcasting_talent_name', 'string', '', 'sanitize_text_field' ),
108            array( 'podcasting_summary', 'string', '', 'sanitize_textarea_field' ),
109            array( 'podcasting_copyright', 'string', '', 'sanitize_text_field' ),
110            array( 'podcasting_category_1', 'string', '', 'sanitize_text_field' ),
111            array( 'podcasting_category_2', 'string', '', 'sanitize_text_field' ),
112            array( 'podcasting_category_3', 'string', '', 'sanitize_text_field' ),
113        );
114
115        // Registered under WP core's `media` group to match WPCOM's legacy Media
116        // Settings form, so it keeps accepting these.
117        foreach ( $media_settings as list( $name, $type, $default, $sanitize ) ) {
118            register_setting(
119                'media',
120                $name,
121                array(
122                    'type'              => $type,
123                    'default'           => $default,
124                    'sanitize_callback' => $sanitize,
125                )
126            );
127        }
128
129        register_setting(
130            'media',
131            'podcasting_image',
132            array(
133                'type'              => 'string',
134                'default'           => '',
135                'sanitize_callback' => 'esc_url_raw',
136            )
137        );
138
139        register_setting(
140            'media',
141            'podcasting_explicit',
142            array(
143                'type'              => 'boolean',
144                'default'           => false,
145                'sanitize_callback' => array( __CLASS__, 'sanitize_explicit' ),
146            )
147        );
148
149        // Registered under WP core's `options` group: settings WPCOM never wired
150        // into a Settings API form.
151        register_setting(
152            'options',
153            'podcasting_email',
154            array(
155                'type'              => 'string',
156                'default'           => '',
157                'sanitize_callback' => 'sanitize_email',
158            )
159        );
160
161        register_setting(
162            'options',
163            'podcasting_image_id',
164            array(
165                'type'              => 'integer',
166                'default'           => 0,
167                'sanitize_callback' => 'absint',
168            )
169        );
170
171        register_setting(
172            'options',
173            'podcasting_show_urls',
174            array(
175                'type'              => 'object',
176                'default'           => array(),
177                'sanitize_callback' => array( __CLASS__, 'sanitize_show_urls' ),
178            )
179        );
180
181        register_setting(
182            'options',
183            'podcasting_show_states',
184            array(
185                'type'              => 'object',
186                'default'           => array(),
187                'sanitize_callback' => array( __CLASS__, 'sanitize_show_states' ),
188            )
189        );
190
191        // Default is the unset sentinel, not `FEED_LIMIT_DEFAULT`: `update_option()`
192        // compares against the default-backed read and bails before creating the
193        // row, so any other default would make that exact value unsavable.
194        register_setting(
195            'options',
196            'podcasting_feed_limit',
197            array(
198                'type'              => 'integer',
199                'default'           => 0,
200                'sanitize_callback' => array( __CLASS__, 'sanitize_feed_limit' ),
201            )
202        );
203    }
204
205    /**
206     * Stable, fully-padded settings payload for the REST endpoint. Every
207     * `OPTION_NAMES` key is present; the two podcatcher maps are padded to all
208     * known directories with empty strings so the SPA always sees a fixed shape.
209     *
210     * @return array<string, mixed>
211     */
212    public static function get_all(): array {
213        $empty_map   = array_fill_keys( array_keys( self::SHOW_URL_HOSTS ), '' );
214        $show_urls   = (array) get_option( 'podcasting_show_urls', array() );
215        $show_states = (array) get_option( 'podcasting_show_states', array() );
216
217        return array(
218            'podcasting_category_id' => (int) get_option( 'podcasting_category_id', 0 ),
219            'podcasting_title'       => (string) get_option( 'podcasting_title', '' ),
220            'podcasting_talent_name' => (string) get_option( 'podcasting_talent_name', '' ),
221            'podcasting_summary'     => (string) get_option( 'podcasting_summary', '' ),
222            'podcasting_copyright'   => (string) get_option( 'podcasting_copyright', '' ),
223            'podcasting_explicit'    => self::sanitize_explicit( get_option( 'podcasting_explicit', false ) ),
224            'podcasting_image'       => self::raw_show_image_url(),
225            'podcasting_image_id'    => (int) get_option( 'podcasting_image_id', 0 ),
226            'podcasting_category_1'  => (string) get_option( 'podcasting_category_1', '' ),
227            'podcasting_category_2'  => (string) get_option( 'podcasting_category_2', '' ),
228            'podcasting_category_3'  => (string) get_option( 'podcasting_category_3', '' ),
229            'podcasting_email'       => (string) get_option( 'podcasting_email', '' ),
230            'podcasting_show_urls'   => array_merge( $empty_map, array_intersect_key( $show_urls, $empty_map ) ),
231            'podcasting_show_states' => array_merge( $empty_map, array_intersect_key( $show_states, $empty_map ) ),
232            'podcasting_feed_limit'  => self::feed_limit(),
233            'podcasting_feed_url'    => self::feed_url(),
234        );
235    }
236
237    /**
238     * Episodes the podcast feed should carry. Until the site sets one this seeds
239     * from core's `posts_per_rss`, so existing feeds keep their current length.
240     *
241     * Sanitized on read because Sync writes on shadow blogs never hit the
242     * registered `sanitize_callback`.
243     *
244     * @return int
245     */
246    public static function feed_limit(): int {
247        return self::sanitize_feed_limit( get_option( 'podcasting_feed_limit', 0 ) );
248    }
249
250    /**
251     * Canonical RSS feed URL for the configured podcast category. Derived
252     * read-only field on the settings payload — not a stored option.
253     *
254     * Built with WordPress's own {@see get_term_feed_link()} so it stays correct
255     * across every permalink structure (pretty, plain `?cat=N`, no trailing
256     * slash) and is identical on WPCOM and self-hosted. This is the URL the
257     * category feed is actually served at — the SPA must not reconstruct it by
258     * string-appending `feed/` to the archive link.
259     *
260     * @return string Feed URL, or '' when no valid category is configured.
261     */
262    public static function feed_url(): string {
263        $category_id = (int) get_option( 'podcasting_category_id', 0 );
264        if ( $category_id <= 0 ) {
265            return '';
266        }
267        $link = get_term_feed_link( $category_id, 'category' );
268        if ( false === $link ) {
269            return '';
270        }
271        // get_term_feed_link() HTML-escapes the query separator (`&amp;`) in the
272        // plain-permalink form because core builds it for HTML attributes. The
273        // dashboard copies this straight into a directory submission field, so
274        // decode it back to a literal URL — otherwise `?feed=rss2&amp;cat=N` loses
275        // the `cat` filter and serves the whole-site feed instead of the category.
276        return html_entity_decode( $link, ENT_QUOTES );
277    }
278
279    /**
280     * Per-key type map for the endpoint's update args. Type coercion only — the
281     * registered `sanitize_callback`s do the real validation on write, so a single
282     * bad field can't 400 the whole partial patch.
283     *
284     * @return array<string, array<string, mixed>>
285     */
286    public static function rest_schema_properties(): array {
287        return array(
288            'podcasting_category_id' => array( 'type' => 'integer' ),
289            'podcasting_title'       => array( 'type' => 'string' ),
290            'podcasting_talent_name' => array( 'type' => 'string' ),
291            'podcasting_summary'     => array( 'type' => 'string' ),
292            'podcasting_copyright'   => array( 'type' => 'string' ),
293            'podcasting_explicit'    => array( 'type' => array( 'boolean', 'string' ) ),
294            'podcasting_image'       => array( 'type' => 'string' ),
295            'podcasting_image_id'    => array( 'type' => 'integer' ),
296            'podcasting_category_1'  => array( 'type' => 'string' ),
297            'podcasting_category_2'  => array( 'type' => 'string' ),
298            'podcasting_category_3'  => array( 'type' => 'string' ),
299            'podcasting_email'       => array( 'type' => 'string' ),
300            'podcasting_show_urls'   => array( 'type' => 'object' ),
301            'podcasting_show_states' => array( 'type' => 'object' ),
302            'podcasting_feed_limit'  => array( 'type' => 'integer' ),
303        );
304    }
305
306    /**
307     * Show cover image URL: `podcasting_image_id` resolved to its attachment
308     * URL when it points at an image, otherwise the raw `podcasting_image`
309     * option. Never Photon-routed — feed rendering applies its own resize.
310     *
311     * @return string Image URL, or '' when not configured.
312     */
313    public static function raw_show_image_url(): string {
314        $image_id = (int) get_option( 'podcasting_image_id', 0 );
315        if ( $image_id > 0 && wp_attachment_is_image( $image_id ) ) {
316            $url = wp_get_attachment_url( $image_id );
317            if ( false !== $url ) {
318                return $url;
319            }
320        }
321        return (string) get_option( 'podcasting_image', '' );
322    }
323
324    /**
325     * `'yes'` (any case) or boolean true → true; everything else → false. The
326     * feed only emits true/false; the legacy `'clean'` value collapses to false
327     * because the WPCOM feed builder already treats it that way.
328     *
329     * @param mixed $value Raw input.
330     * @return bool
331     */
332    public static function sanitize_explicit( $value ) {
333        if ( is_string( $value ) ) {
334            return in_array( strtolower( $value ), array( 'yes', 'true', '1' ), true );
335        }
336        return true === $value || 1 === $value;
337    }
338
339    /**
340     * Clamp the feed episode limit to 1–{@see self::feed_limit_max()}. Cleared and
341     * junk input resolves the way an unset option does — the site's own
342     * `posts_per_rss` — rather than emptying the feed or jumping it to a default
343     * far above whatever the site was already serving.
344     *
345     * @param mixed $value Raw input.
346     * @return int
347     */
348    public static function sanitize_feed_limit( $value ) {
349        $value = (int) $value;
350        if ( $value < 1 ) {
351            $value = (int) get_option( 'posts_per_rss', self::FEED_LIMIT_DEFAULT );
352        }
353
354        return min( $value < 1 ? self::FEED_LIMIT_DEFAULT : $value, self::feed_limit_max() );
355    }
356
357    /**
358     * Most episodes the podcast feed will carry.
359     *
360     * @return int
361     */
362    public static function feed_limit_max(): int {
363        /**
364         * Filters the ceiling for the podcast feed's episode limit. Raising it
365         * renders that many items in one request, so only where the host can
366         * absorb it.
367         *
368         * @since 1.5.0
369         *
370         * @param int $max Maximum episodes a podcast feed may carry.
371         */
372        $max = (int) apply_filters( 'jetpack_podcast_feed_limit_max', self::FEED_LIMIT_MAX );
373
374        return max( 1, $max );
375    }
376
377    /**
378     * Merge a partial show-URLs patch into the stored value. Empty string for a
379     * known key removes that entry; URLs failing the per-podcatcher hostname
380     * allowlist are silently dropped (the SPA validates the same allowlist).
381     *
382     * @param mixed $input Incoming patch.
383     * @return array<string, string>
384     */
385    public static function sanitize_show_urls( $input ) {
386        $current = array_filter(
387            array_intersect_key( (array) get_option( 'podcasting_show_urls', array() ), self::SHOW_URL_HOSTS ),
388            static function ( $value ) {
389                return is_string( $value ) && '' !== $value;
390            }
391        );
392
393        if ( ! is_array( $input ) ) {
394            return $current;
395        }
396
397        foreach ( array_intersect_key( $input, self::SHOW_URL_HOSTS ) as $key => $value ) {
398            $value = is_string( $value ) ? trim( $value ) : '';
399
400            if ( '' === $value ) {
401                unset( $current[ $key ] );
402                continue;
403            }
404
405            $cleaned = self::sanitize_show_url( $key, $value );
406            if ( null !== $cleaned ) {
407                $current[ $key ] = $cleaned;
408            }
409        }
410
411        return $current;
412    }
413
414    /**
415     * Merge a partial show-states patch into the stored value. Values outside
416     * the allowed `'pending'`/`'active'` set are dropped; empty string clears a
417     * stored entry. `'active'` → `'pending'` is
418     * refused so a stale SPA cache can't downgrade a state that `Feed_Detection`
419     * promoted via real UA evidence (explicit `''` clears still work).
420     *
421     * @param mixed $input Incoming patch.
422     * @return array<string, string>
423     */
424    public static function sanitize_show_states( $input ) {
425        $current = array_filter(
426            array_intersect_key( (array) get_option( 'podcasting_show_states', array() ), self::SHOW_URL_HOSTS ),
427            static function ( $value ) {
428                return is_string( $value ) && '' !== $value;
429            }
430        );
431
432        if ( ! is_array( $input ) ) {
433            return $current;
434        }
435
436        foreach ( array_intersect_key( $input, self::SHOW_URL_HOSTS ) as $key => $value ) {
437            $value = is_string( $value ) ? trim( $value ) : '';
438
439            if ( '' === $value ) {
440                unset( $current[ $key ] );
441                continue;
442            }
443
444            if ( ! in_array( $value, array( 'pending', 'active' ), true ) ) {
445                continue;
446            }
447
448            if ( 'pending' === $value && isset( $current[ $key ] ) && 'active' === $current[ $key ] ) {
449                continue;
450            }
451
452            $current[ $key ] = $value;
453        }
454
455        return $current;
456    }
457
458    /**
459     * Validate a URL against the per-podcatcher hostname allowlist.
460     *
461     * @param string $key Podcatcher key.
462     * @param string $url Candidate URL.
463     * @return string|null Cleaned URL, or null if the host isn't in the allowlist.
464     */
465    private static function sanitize_show_url( $key, $url ) {
466        if ( ! isset( self::SHOW_URL_HOSTS[ $key ] ) ) {
467            return null;
468        }
469
470        if ( ! is_string( $url ) || strlen( $url ) > self::SHOW_URL_MAX_LENGTH ) {
471            return null;
472        }
473
474        $cleaned = esc_url_raw( $url, array( 'https' ) );
475        if ( '' === $cleaned ) {
476            return null;
477        }
478
479        if ( ! wp_http_validate_url( $cleaned ) ) {
480            return null;
481        }
482
483        $host = wp_parse_url( $cleaned, PHP_URL_HOST );
484        if ( ! is_string( $host ) || '' === $host ) {
485            return null;
486        }
487
488        $host = strtolower( $host );
489        if ( 0 === strpos( $host, 'www.' ) ) {
490            $host = substr( $host, 4 );
491        }
492
493        return in_array( $host, self::SHOW_URL_HOSTS[ $key ], true ) ? $cleaned : null;
494    }
495}