PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.1-beta.3
Jetpack – WP Security, Backup, Speed, & Growth v16.1-beta.3
16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / jetpack_vendor / automattic / jetpack-podcast / src / class-settings.php
jetpack / jetpack_vendor / automattic / jetpack-podcast / src Last commit date
admin-pages 1 month ago blocks 1 month ago endpoints 1 month ago feed 1 month ago class-admin-page.php 1 month ago class-create-ai-podcast-page.php 1 month ago class-new-episode-prefill.php 1 month ago class-podcast-gate.php 1 month ago class-podcast.php 2 weeks ago class-settings.php 1 month ago class-tracks.php 1 month ago
class-settings.php
419 lines
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 * 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 }
419