*/ const SHOW_URL_HOSTS = array( 'pocketcasts' => array( 'pca.st', 'pocketcasts.com' ), 'apple' => array( 'podcasts.apple.com' ), 'spotify' => array( 'open.spotify.com' ), 'youtube' => array( 'youtube.com', 'm.youtube.com', 'youtu.be', 'music.youtube.com' ), 'amazon' => array( 'music.amazon.com', 'music.amazon.co.uk', 'music.amazon.de', 'music.amazon.co.jp', 'music.amazon.com.au', 'music.amazon.fr', 'music.amazon.ca', 'music.amazon.es', ), 'podcastindex' => array( 'podcastindex.org' ), ); const SHOW_URL_MAX_LENGTH = 2048; /** * Fallback when neither `podcasting_feed_limit` nor `posts_per_rss` is set. */ const FEED_LIMIT_DEFAULT = 300; /** * Ceiling for `podcasting_feed_limit`. Sized against measured feed generation. */ const FEED_LIMIT_MAX = 500; /** * Drives `register_settings()` and the sync whitelist. * * @var string[] */ const OPTION_NAMES = array( 'podcasting_category_id', 'podcasting_title', 'podcasting_talent_name', 'podcasting_summary', 'podcasting_copyright', 'podcasting_explicit', 'podcasting_image', 'podcasting_image_id', 'podcasting_category_1', 'podcasting_category_2', 'podcasting_category_3', 'podcasting_email', 'podcasting_show_urls', 'podcasting_show_states', 'podcasting_feed_limit', ); /** * Wire option registrations + Jetpack Sync opt-in. Idempotent: every * callback is named, so WordPress dedupes repeat calls. */ public static function register() { add_action( 'admin_init', array( __CLASS__, 'register_settings' ) ); add_action( 'rest_api_init', array( __CLASS__, 'register_settings' ) ); add_filter( 'jetpack_sync_options_whitelist', array( __CLASS__, 'add_to_sync_whitelist' ) ); } /** * Add the podcast options to the Jetpack Sync whitelist. * * @param string[] $options Whitelisted option names. * @return string[] */ public static function add_to_sync_whitelist( $options ) { return array_merge( $options, self::OPTION_NAMES ); } /** * `register_setting()` calls. Hooked on `admin_init` and `rest_api_init`. */ public static function register_settings() { $media_settings = array( array( 'podcasting_category_id', 'integer', 0, 'absint' ), array( 'podcasting_title', 'string', '', 'sanitize_text_field' ), array( 'podcasting_talent_name', 'string', '', 'sanitize_text_field' ), array( 'podcasting_summary', 'string', '', 'sanitize_textarea_field' ), array( 'podcasting_copyright', 'string', '', 'sanitize_text_field' ), array( 'podcasting_category_1', 'string', '', 'sanitize_text_field' ), array( 'podcasting_category_2', 'string', '', 'sanitize_text_field' ), array( 'podcasting_category_3', 'string', '', 'sanitize_text_field' ), ); // Registered under WP core's `media` group to match WPCOM's legacy Media // Settings form, so it keeps accepting these. foreach ( $media_settings as list( $name, $type, $default, $sanitize ) ) { register_setting( 'media', $name, array( 'type' => $type, 'default' => $default, 'sanitize_callback' => $sanitize, ) ); } register_setting( 'media', 'podcasting_image', array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'esc_url_raw', ) ); register_setting( 'media', 'podcasting_explicit', array( 'type' => 'boolean', 'default' => false, 'sanitize_callback' => array( __CLASS__, 'sanitize_explicit' ), ) ); // Registered under WP core's `options` group: settings WPCOM never wired // into a Settings API form. register_setting( 'options', 'podcasting_email', array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_email', ) ); register_setting( 'options', 'podcasting_image_id', array( 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint', ) ); register_setting( 'options', 'podcasting_show_urls', array( 'type' => 'object', 'default' => array(), 'sanitize_callback' => array( __CLASS__, 'sanitize_show_urls' ), ) ); register_setting( 'options', 'podcasting_show_states', array( 'type' => 'object', 'default' => array(), 'sanitize_callback' => array( __CLASS__, 'sanitize_show_states' ), ) ); // Default is the unset sentinel, not `FEED_LIMIT_DEFAULT`: `update_option()` // compares against the default-backed read and bails before creating the // row, so any other default would make that exact value unsavable. register_setting( 'options', 'podcasting_feed_limit', array( 'type' => 'integer', 'default' => 0, 'sanitize_callback' => array( __CLASS__, 'sanitize_feed_limit' ), ) ); } /** * Stable, fully-padded settings payload for the REST endpoint. Every * `OPTION_NAMES` key is present; the two podcatcher maps are padded to all * known directories with empty strings so the SPA always sees a fixed shape. * * @return array */ public static function get_all(): array { $empty_map = array_fill_keys( array_keys( self::SHOW_URL_HOSTS ), '' ); $show_urls = (array) get_option( 'podcasting_show_urls', array() ); $show_states = (array) get_option( 'podcasting_show_states', array() ); return array( 'podcasting_category_id' => (int) get_option( 'podcasting_category_id', 0 ), 'podcasting_title' => (string) get_option( 'podcasting_title', '' ), 'podcasting_talent_name' => (string) get_option( 'podcasting_talent_name', '' ), 'podcasting_summary' => (string) get_option( 'podcasting_summary', '' ), 'podcasting_copyright' => (string) get_option( 'podcasting_copyright', '' ), 'podcasting_explicit' => self::sanitize_explicit( get_option( 'podcasting_explicit', false ) ), 'podcasting_image' => self::raw_show_image_url(), 'podcasting_image_id' => (int) get_option( 'podcasting_image_id', 0 ), 'podcasting_category_1' => (string) get_option( 'podcasting_category_1', '' ), 'podcasting_category_2' => (string) get_option( 'podcasting_category_2', '' ), 'podcasting_category_3' => (string) get_option( 'podcasting_category_3', '' ), 'podcasting_email' => (string) get_option( 'podcasting_email', '' ), 'podcasting_show_urls' => array_merge( $empty_map, array_intersect_key( $show_urls, $empty_map ) ), 'podcasting_show_states' => array_merge( $empty_map, array_intersect_key( $show_states, $empty_map ) ), 'podcasting_feed_limit' => self::feed_limit(), 'podcasting_feed_url' => self::feed_url(), ); } /** * Episodes the podcast feed should carry. Until the site sets one this seeds * from core's `posts_per_rss`, so existing feeds keep their current length. * * Sanitized on read because Sync writes on shadow blogs never hit the * registered `sanitize_callback`. * * @return int */ public static function feed_limit(): int { return self::sanitize_feed_limit( get_option( 'podcasting_feed_limit', 0 ) ); } /** * Canonical RSS feed URL for the configured podcast category. Derived * read-only field on the settings payload — not a stored option. * * Built with WordPress's own {@see get_term_feed_link()} so it stays correct * across every permalink structure (pretty, plain `?cat=N`, no trailing * slash) and is identical on WPCOM and self-hosted. This is the URL the * category feed is actually served at — the SPA must not reconstruct it by * string-appending `feed/` to the archive link. * * @return string Feed URL, or '' when no valid category is configured. */ public static function feed_url(): string { $category_id = (int) get_option( 'podcasting_category_id', 0 ); if ( $category_id <= 0 ) { return ''; } $link = get_term_feed_link( $category_id, 'category' ); if ( false === $link ) { return ''; } // get_term_feed_link() HTML-escapes the query separator (`&`) in the // plain-permalink form because core builds it for HTML attributes. The // dashboard copies this straight into a directory submission field, so // decode it back to a literal URL — otherwise `?feed=rss2&cat=N` loses // the `cat` filter and serves the whole-site feed instead of the category. return html_entity_decode( $link, ENT_QUOTES ); } /** * Per-key type map for the endpoint's update args. Type coercion only — the * registered `sanitize_callback`s do the real validation on write, so a single * bad field can't 400 the whole partial patch. * * @return array> */ public static function rest_schema_properties(): array { return array( 'podcasting_category_id' => array( 'type' => 'integer' ), 'podcasting_title' => array( 'type' => 'string' ), 'podcasting_talent_name' => array( 'type' => 'string' ), 'podcasting_summary' => array( 'type' => 'string' ), 'podcasting_copyright' => array( 'type' => 'string' ), 'podcasting_explicit' => array( 'type' => array( 'boolean', 'string' ) ), 'podcasting_image' => array( 'type' => 'string' ), 'podcasting_image_id' => array( 'type' => 'integer' ), 'podcasting_category_1' => array( 'type' => 'string' ), 'podcasting_category_2' => array( 'type' => 'string' ), 'podcasting_category_3' => array( 'type' => 'string' ), 'podcasting_email' => array( 'type' => 'string' ), 'podcasting_show_urls' => array( 'type' => 'object' ), 'podcasting_show_states' => array( 'type' => 'object' ), 'podcasting_feed_limit' => array( 'type' => 'integer' ), ); } /** * Show cover image URL: `podcasting_image_id` resolved to its attachment * URL when it points at an image, otherwise the raw `podcasting_image` * option. Never Photon-routed — feed rendering applies its own resize. * * @return string Image URL, or '' when not configured. */ public static function raw_show_image_url(): string { $image_id = (int) get_option( 'podcasting_image_id', 0 ); if ( $image_id > 0 && wp_attachment_is_image( $image_id ) ) { $url = wp_get_attachment_url( $image_id ); if ( false !== $url ) { return $url; } } return (string) get_option( 'podcasting_image', '' ); } /** * `'yes'` (any case) or boolean true → true; everything else → false. The * feed only emits true/false; the legacy `'clean'` value collapses to false * because the WPCOM feed builder already treats it that way. * * @param mixed $value Raw input. * @return bool */ public static function sanitize_explicit( $value ) { if ( is_string( $value ) ) { return in_array( strtolower( $value ), array( 'yes', 'true', '1' ), true ); } return true === $value || 1 === $value; } /** * Clamp the feed episode limit to 1–{@see self::feed_limit_max()}. Cleared and * junk input resolves the way an unset option does — the site's own * `posts_per_rss` — rather than emptying the feed or jumping it to a default * far above whatever the site was already serving. * * @param mixed $value Raw input. * @return int */ public static function sanitize_feed_limit( $value ) { $value = (int) $value; if ( $value < 1 ) { $value = (int) get_option( 'posts_per_rss', self::FEED_LIMIT_DEFAULT ); } return min( $value < 1 ? self::FEED_LIMIT_DEFAULT : $value, self::feed_limit_max() ); } /** * Most episodes the podcast feed will carry. * * @return int */ public static function feed_limit_max(): int { /** * Filters the ceiling for the podcast feed's episode limit. Raising it * renders that many items in one request, so only where the host can * absorb it. * * @since 1.5.0 * * @param int $max Maximum episodes a podcast feed may carry. */ $max = (int) apply_filters( 'jetpack_podcast_feed_limit_max', self::FEED_LIMIT_MAX ); return max( 1, $max ); } /** * Merge a partial show-URLs patch into the stored value. Empty string for a * known key removes that entry; URLs failing the per-podcatcher hostname * allowlist are silently dropped (the SPA validates the same allowlist). * * @param mixed $input Incoming patch. * @return array */ public static function sanitize_show_urls( $input ) { $current = array_filter( array_intersect_key( (array) get_option( 'podcasting_show_urls', array() ), self::SHOW_URL_HOSTS ), static function ( $value ) { return is_string( $value ) && '' !== $value; } ); if ( ! is_array( $input ) ) { return $current; } foreach ( array_intersect_key( $input, self::SHOW_URL_HOSTS ) as $key => $value ) { $value = is_string( $value ) ? trim( $value ) : ''; if ( '' === $value ) { unset( $current[ $key ] ); continue; } $cleaned = self::sanitize_show_url( $key, $value ); if ( null !== $cleaned ) { $current[ $key ] = $cleaned; } } return $current; } /** * Merge a partial show-states patch into the stored value. Values outside * the allowed `'pending'`/`'active'` set are dropped; empty string clears a * stored entry. `'active'` → `'pending'` is * refused so a stale SPA cache can't downgrade a state that `Feed_Detection` * promoted via real UA evidence (explicit `''` clears still work). * * @param mixed $input Incoming patch. * @return array */ public static function sanitize_show_states( $input ) { $current = array_filter( array_intersect_key( (array) get_option( 'podcasting_show_states', array() ), self::SHOW_URL_HOSTS ), static function ( $value ) { return is_string( $value ) && '' !== $value; } ); if ( ! is_array( $input ) ) { return $current; } foreach ( array_intersect_key( $input, self::SHOW_URL_HOSTS ) as $key => $value ) { $value = is_string( $value ) ? trim( $value ) : ''; if ( '' === $value ) { unset( $current[ $key ] ); continue; } if ( ! in_array( $value, array( 'pending', 'active' ), true ) ) { continue; } if ( 'pending' === $value && isset( $current[ $key ] ) && 'active' === $current[ $key ] ) { continue; } $current[ $key ] = $value; } return $current; } /** * Validate a URL against the per-podcatcher hostname allowlist. * * @param string $key Podcatcher key. * @param string $url Candidate URL. * @return string|null Cleaned URL, or null if the host isn't in the allowlist. */ private static function sanitize_show_url( $key, $url ) { if ( ! isset( self::SHOW_URL_HOSTS[ $key ] ) ) { return null; } if ( ! is_string( $url ) || strlen( $url ) > self::SHOW_URL_MAX_LENGTH ) { return null; } $cleaned = esc_url_raw( $url, array( 'https' ) ); if ( '' === $cleaned ) { return null; } if ( ! wp_http_validate_url( $cleaned ) ) { return null; } $host = wp_parse_url( $cleaned, PHP_URL_HOST ); if ( ! is_string( $host ) || '' === $host ) { return null; } $host = strtolower( $host ); if ( 0 === strpos( $host, 'www.' ) ) { $host = substr( $host, 4 ); } return in_array( $host, self::SHOW_URL_HOSTS[ $key ], true ) ? $cleaned : null; } }