PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 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 All 508 releases
← All changes | jetpack_vendor/automattic/jetpack-podcast/src/class-settings.php +495 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,495 @@
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 +}