PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-podcast / src / class-settings.php

class-settings.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.7, at jetpack_vendor/automattic/jetpack-podcast/src/class-settings.php

496 lines 15.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Podcast settings: option schema, sanitizers, and Jetpack Sync opt-in.
4 *
5 * @package automattic/jetpack-podcast
6 */
7
8 namespace 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 */
19 class 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 }
496