Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.70% covered (success)
96.70%
176 / 182
63.64% covered (warning)
63.64%
7 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Settings
96.70% covered (success)
96.70%
176 / 182
63.64% covered (warning)
63.64%
7 / 11
43
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%
74 / 74
100.00% covered (success)
100.00%
1 / 1
2
 get_all
100.00% covered (success)
100.00%
20 / 20
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%
16 / 16
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_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     * Drives `register_settings()` and the sync whitelist.
49     *
50     * @var string[]
51     */
52    const OPTION_NAMES = array(
53        'podcasting_category_id',
54        'podcasting_title',
55        'podcasting_talent_name',
56        'podcasting_summary',
57        'podcasting_copyright',
58        'podcasting_explicit',
59        'podcasting_image',
60        'podcasting_image_id',
61        'podcasting_category_1',
62        'podcasting_category_2',
63        'podcasting_category_3',
64        'podcasting_email',
65        'podcasting_show_urls',
66        'podcasting_show_states',
67    );
68
69    /**
70     * Wire option registrations + Jetpack Sync opt-in. Idempotent: every
71     * callback is named, so WordPress dedupes repeat calls.
72     */
73    public static function register() {
74        add_action( 'admin_init', array( __CLASS__, 'register_settings' ) );
75        add_action( 'rest_api_init', array( __CLASS__, 'register_settings' ) );
76        add_filter( 'jetpack_sync_options_whitelist', array( __CLASS__, 'add_to_sync_whitelist' ) );
77    }
78
79    /**
80     * Add the podcast options to the Jetpack Sync whitelist.
81     *
82     * @param string[] $options Whitelisted option names.
83     * @return string[]
84     */
85    public static function add_to_sync_whitelist( $options ) {
86        return array_merge( $options, self::OPTION_NAMES );
87    }
88
89    /**
90     * `register_setting()` calls. Hooked on `admin_init` and `rest_api_init`.
91     */
92    public static function register_settings() {
93        $media_settings = array(
94            array( 'podcasting_category_id', 'integer', 0, 'absint' ),
95            array( 'podcasting_title', 'string', '', 'sanitize_text_field' ),
96            array( 'podcasting_talent_name', 'string', '', 'sanitize_text_field' ),
97            array( 'podcasting_summary', 'string', '', 'sanitize_textarea_field' ),
98            array( 'podcasting_copyright', 'string', '', 'sanitize_text_field' ),
99            array( 'podcasting_category_1', 'string', '', 'sanitize_text_field' ),
100            array( 'podcasting_category_2', 'string', '', 'sanitize_text_field' ),
101            array( 'podcasting_category_3', 'string', '', 'sanitize_text_field' ),
102        );
103
104        // Registered under WP core's `media` group to match WPCOM's legacy Media
105        // Settings form, so it keeps accepting these.
106        foreach ( $media_settings as list( $name, $type, $default, $sanitize ) ) {
107            register_setting(
108                'media',
109                $name,
110                array(
111                    'type'              => $type,
112                    'default'           => $default,
113                    'sanitize_callback' => $sanitize,
114                )
115            );
116        }
117
118        register_setting(
119            'media',
120            'podcasting_image',
121            array(
122                'type'              => 'string',
123                'default'           => '',
124                'sanitize_callback' => 'esc_url_raw',
125            )
126        );
127
128        register_setting(
129            'media',
130            'podcasting_explicit',
131            array(
132                'type'              => 'boolean',
133                'default'           => false,
134                'sanitize_callback' => array( __CLASS__, 'sanitize_explicit' ),
135            )
136        );
137
138        // Registered under WP core's `options` group: settings WPCOM never wired
139        // into a Settings API form.
140        register_setting(
141            'options',
142            'podcasting_email',
143            array(
144                'type'              => 'string',
145                'default'           => '',
146                'sanitize_callback' => 'sanitize_email',
147            )
148        );
149
150        register_setting(
151            'options',
152            'podcasting_image_id',
153            array(
154                'type'              => 'integer',
155                'default'           => 0,
156                'sanitize_callback' => 'absint',
157            )
158        );
159
160        register_setting(
161            'options',
162            'podcasting_show_urls',
163            array(
164                'type'              => 'object',
165                'default'           => array(),
166                'sanitize_callback' => array( __CLASS__, 'sanitize_show_urls' ),
167            )
168        );
169
170        register_setting(
171            'options',
172            'podcasting_show_states',
173            array(
174                'type'              => 'object',
175                'default'           => array(),
176                'sanitize_callback' => array( __CLASS__, 'sanitize_show_states' ),
177            )
178        );
179    }
180
181    /**
182     * Stable, fully-padded settings payload for the REST endpoint. Every
183     * `OPTION_NAMES` key is present; the two podcatcher maps are padded to all
184     * known directories with empty strings so the SPA always sees a fixed shape.
185     *
186     * @return array<string, mixed>
187     */
188    public static function get_all(): array {
189        $empty_map   = array_fill_keys( array_keys( self::SHOW_URL_HOSTS ), '' );
190        $show_urls   = (array) get_option( 'podcasting_show_urls', array() );
191        $show_states = (array) get_option( 'podcasting_show_states', array() );
192
193        return array(
194            'podcasting_category_id' => (int) get_option( 'podcasting_category_id', 0 ),
195            'podcasting_title'       => (string) get_option( 'podcasting_title', '' ),
196            'podcasting_talent_name' => (string) get_option( 'podcasting_talent_name', '' ),
197            'podcasting_summary'     => (string) get_option( 'podcasting_summary', '' ),
198            'podcasting_copyright'   => (string) get_option( 'podcasting_copyright', '' ),
199            'podcasting_explicit'    => self::sanitize_explicit( get_option( 'podcasting_explicit', false ) ),
200            'podcasting_image'       => self::raw_show_image_url(),
201            'podcasting_image_id'    => (int) get_option( 'podcasting_image_id', 0 ),
202            'podcasting_category_1'  => (string) get_option( 'podcasting_category_1', '' ),
203            'podcasting_category_2'  => (string) get_option( 'podcasting_category_2', '' ),
204            'podcasting_category_3'  => (string) get_option( 'podcasting_category_3', '' ),
205            'podcasting_email'       => (string) get_option( 'podcasting_email', '' ),
206            'podcasting_show_urls'   => array_merge( $empty_map, array_intersect_key( $show_urls, $empty_map ) ),
207            'podcasting_show_states' => array_merge( $empty_map, array_intersect_key( $show_states, $empty_map ) ),
208            'podcasting_feed_url'    => self::feed_url(),
209        );
210    }
211
212    /**
213     * Canonical RSS feed URL for the configured podcast category. Derived
214     * read-only field on the settings payload — not a stored option.
215     *
216     * Built with WordPress's own {@see get_term_feed_link()} so it stays correct
217     * across every permalink structure (pretty, plain `?cat=N`, no trailing
218     * slash) and is identical on WPCOM and self-hosted. This is the URL the
219     * category feed is actually served at — the SPA must not reconstruct it by
220     * string-appending `feed/` to the archive link.
221     *
222     * @return string Feed URL, or '' when no valid category is configured.
223     */
224    public static function feed_url(): string {
225        $category_id = (int) get_option( 'podcasting_category_id', 0 );
226        if ( $category_id <= 0 ) {
227            return '';
228        }
229        $link = get_term_feed_link( $category_id, 'category' );
230        if ( false === $link ) {
231            return '';
232        }
233        // get_term_feed_link() HTML-escapes the query separator (`&amp;`) in the
234        // plain-permalink form because core builds it for HTML attributes. The
235        // dashboard copies this straight into a directory submission field, so
236        // decode it back to a literal URL — otherwise `?feed=rss2&amp;cat=N` loses
237        // the `cat` filter and serves the whole-site feed instead of the category.
238        return html_entity_decode( $link, ENT_QUOTES );
239    }
240
241    /**
242     * Per-key type map for the endpoint's update args. Type coercion only — the
243     * registered `sanitize_callback`s do the real validation on write, so a single
244     * bad field can't 400 the whole partial patch.
245     *
246     * @return array<string, array<string, mixed>>
247     */
248    public static function rest_schema_properties(): array {
249        return array(
250            'podcasting_category_id' => array( 'type' => 'integer' ),
251            'podcasting_title'       => array( 'type' => 'string' ),
252            'podcasting_talent_name' => array( 'type' => 'string' ),
253            'podcasting_summary'     => array( 'type' => 'string' ),
254            'podcasting_copyright'   => array( 'type' => 'string' ),
255            'podcasting_explicit'    => array( 'type' => array( 'boolean', 'string' ) ),
256            'podcasting_image'       => array( 'type' => 'string' ),
257            'podcasting_image_id'    => array( 'type' => 'integer' ),
258            'podcasting_category_1'  => array( 'type' => 'string' ),
259            'podcasting_category_2'  => array( 'type' => 'string' ),
260            'podcasting_category_3'  => array( 'type' => 'string' ),
261            'podcasting_email'       => array( 'type' => 'string' ),
262            'podcasting_show_urls'   => array( 'type' => 'object' ),
263            'podcasting_show_states' => array( 'type' => 'object' ),
264        );
265    }
266
267    /**
268     * Show cover image URL: `podcasting_image_id` resolved to its attachment
269     * URL when it points at an image, otherwise the raw `podcasting_image`
270     * option. Never Photon-routed — feed rendering applies its own resize.
271     *
272     * @return string Image URL, or '' when not configured.
273     */
274    public static function raw_show_image_url(): string {
275        $image_id = (int) get_option( 'podcasting_image_id', 0 );
276        if ( $image_id > 0 && wp_attachment_is_image( $image_id ) ) {
277            $url = wp_get_attachment_url( $image_id );
278            if ( false !== $url ) {
279                return $url;
280            }
281        }
282        return (string) get_option( 'podcasting_image', '' );
283    }
284
285    /**
286     * `'yes'` (any case) or boolean true → true; everything else → false. The
287     * feed only emits true/false; the legacy `'clean'` value collapses to false
288     * because the WPCOM feed builder already treats it that way.
289     *
290     * @param mixed $value Raw input.
291     * @return bool
292     */
293    public static function sanitize_explicit( $value ) {
294        if ( is_string( $value ) ) {
295            return in_array( strtolower( $value ), array( 'yes', 'true', '1' ), true );
296        }
297        return true === $value || 1 === $value;
298    }
299
300    /**
301     * Merge a partial show-URLs patch into the stored value. Empty string for a
302     * known key removes that entry; URLs failing the per-podcatcher hostname
303     * allowlist are silently dropped (the SPA validates the same allowlist).
304     *
305     * @param mixed $input Incoming patch.
306     * @return array<string, string>
307     */
308    public static function sanitize_show_urls( $input ) {
309        $current = array_filter(
310            array_intersect_key( (array) get_option( 'podcasting_show_urls', array() ), self::SHOW_URL_HOSTS ),
311            static function ( $value ) {
312                return is_string( $value ) && '' !== $value;
313            }
314        );
315
316        if ( ! is_array( $input ) ) {
317            return $current;
318        }
319
320        foreach ( array_intersect_key( $input, self::SHOW_URL_HOSTS ) as $key => $value ) {
321            $value = is_string( $value ) ? trim( $value ) : '';
322
323            if ( '' === $value ) {
324                unset( $current[ $key ] );
325                continue;
326            }
327
328            $cleaned = self::sanitize_show_url( $key, $value );
329            if ( null !== $cleaned ) {
330                $current[ $key ] = $cleaned;
331            }
332        }
333
334        return $current;
335    }
336
337    /**
338     * Merge a partial show-states patch into the stored value. Values outside
339     * the allowed `'pending'`/`'active'` set are dropped; empty string clears a
340     * stored entry. `'active'` → `'pending'` is
341     * refused so a stale SPA cache can't downgrade a state that `Feed_Detection`
342     * promoted via real UA evidence (explicit `''` clears still work).
343     *
344     * @param mixed $input Incoming patch.
345     * @return array<string, string>
346     */
347    public static function sanitize_show_states( $input ) {
348        $current = array_filter(
349            array_intersect_key( (array) get_option( 'podcasting_show_states', array() ), self::SHOW_URL_HOSTS ),
350            static function ( $value ) {
351                return is_string( $value ) && '' !== $value;
352            }
353        );
354
355        if ( ! is_array( $input ) ) {
356            return $current;
357        }
358
359        foreach ( array_intersect_key( $input, self::SHOW_URL_HOSTS ) as $key => $value ) {
360            $value = is_string( $value ) ? trim( $value ) : '';
361
362            if ( '' === $value ) {
363                unset( $current[ $key ] );
364                continue;
365            }
366
367            if ( ! in_array( $value, array( 'pending', 'active' ), true ) ) {
368                continue;
369            }
370
371            if ( 'pending' === $value && isset( $current[ $key ] ) && 'active' === $current[ $key ] ) {
372                continue;
373            }
374
375            $current[ $key ] = $value;
376        }
377
378        return $current;
379    }
380
381    /**
382     * Validate a URL against the per-podcatcher hostname allowlist.
383     *
384     * @param string $key Podcatcher key.
385     * @param string $url Candidate URL.
386     * @return string|null Cleaned URL, or null if the host isn't in the allowlist.
387     */
388    private static function sanitize_show_url( $key, $url ) {
389        if ( ! isset( self::SHOW_URL_HOSTS[ $key ] ) ) {
390            return null;
391        }
392
393        if ( ! is_string( $url ) || strlen( $url ) > self::SHOW_URL_MAX_LENGTH ) {
394            return null;
395        }
396
397        $cleaned = esc_url_raw( $url, array( 'https' ) );
398        if ( '' === $cleaned ) {
399            return null;
400        }
401
402        if ( ! wp_http_validate_url( $cleaned ) ) {
403            return null;
404        }
405
406        $host = wp_parse_url( $cleaned, PHP_URL_HOST );
407        if ( ! is_string( $host ) || '' === $host ) {
408            return null;
409        }
410
411        $host = strtolower( $host );
412        if ( 0 === strpos( $host, 'www.' ) ) {
413            $host = substr( $host, 4 );
414        }
415
416        return in_array( $host, self::SHOW_URL_HOSTS[ $key ], true ) ? $cleaned : null;
417    }
418}