post_status ) ? $prepared->post_status : ''; if ( '' === $status && ! empty( $prepared->ID ) ) { $status = get_post_status( $prepared->ID ); } if ( 'publish' !== $status ) { return $prepared; } $meta = $request->get_param( 'meta' ); $streams = null; if ( is_array( $meta ) && array_key_exists( '_streamsera_streams', $meta ) ) { $streams = $this->sanitize_streams( $meta['_streamsera_streams'] ); } elseif ( ! empty( $prepared->ID ) ) { $streams = get_post_meta( $prepared->ID, '_streamsera_streams', true ); } if ( empty( $streams ) || ! is_array( $streams ) ) { return new \WP_Error( 'streamsera_no_streams', __( 'Add at least one stream URL before publishing this station. You can save it as a draft meanwhile.', 'streamsera' ), array( 'status' => 400 ) ); } return $prepared; } /** * Website meta: http(s) URL or empty. * * @param mixed $value Raw. * @return string */ public function sanitize_website( $value ) { $value = is_scalar( $value ) ? trim( (string) $value ) : ''; if ( '' === $value ) { return ''; } if ( ! preg_match( '#^https?://#i', $value ) ) { $value = 'https://' . $value; } $url = esc_url_raw( $value, array( 'http', 'https' ) ); return $url && wp_parse_url( $url, PHP_URL_HOST ) && false !== strpos( (string) wp_parse_url( $url, PHP_URL_HOST ), '.' ) ? $url : ''; } /** * Email meta: valid address or empty. * * @param mixed $value Raw. * @return string */ public function sanitize_email( $value ) { $email = is_scalar( $value ) ? sanitize_email( (string) $value ) : ''; return is_email( $email ) ? $email : ''; } /** * Register the CPT. */ public function register_post_type() { register_post_type( self::POST_TYPE, array( 'labels' => array( 'name' => __( 'Stations', 'streamsera' ), 'singular_name' => __( 'Station', 'streamsera' ), ), 'public' => false, 'show_ui' => false, 'show_in_rest' => true, 'rest_base' => 'streamsera-stations', 'rest_namespace' => 'wp/v2', 'supports' => array( 'title', 'editor', 'thumbnail', 'custom-fields' ), 'capability_type' => 'post', 'map_meta_cap' => true, 'exclude_from_search' => true, ) ); } /** * Register REST-visible meta. */ public function register_meta() { $string = array( 'type' => 'string', 'single' => true, 'show_in_rest' => true, 'auth_callback' => array( $this, 'auth' ), 'sanitize_callback' => 'sanitize_text_field', ); foreach ( array( 'slogan', 'genre', 'language', 'country', 'city', 'frequency', 'phone' ) as $key ) { register_post_meta( self::POST_TYPE, '_streamsera_' . $key, $string ); } // Contact fields get real validation: an http(s) URL and a deliverable address. register_post_meta( self::POST_TYPE, '_streamsera_website', array_merge( $string, array( 'sanitize_callback' => array( $this, 'sanitize_website' ) ) ) ); register_post_meta( self::POST_TYPE, '_streamsera_email', array_merge( $string, array( 'sanitize_callback' => array( $this, 'sanitize_email' ) ) ) ); register_post_meta( self::POST_TYPE, '_streamsera_logo_id', array( 'type' => 'integer', 'single' => true, 'show_in_rest' => true, 'auth_callback' => array( $this, 'auth' ), 'sanitize_callback' => 'absint', ) ); // Live status override: '' (follow the server) | live | offair, with an optional message. register_post_meta( self::POST_TYPE, '_streamsera_live_override', array( 'type' => 'string', 'single' => true, 'show_in_rest' => true, 'auth_callback' => array( $this, 'auth' ), 'sanitize_callback' => array( $this, 'sanitize_override' ), ) ); register_post_meta( self::POST_TYPE, '_streamsera_live_message', $string ); // Streams: [{ url, label, format, bitrate }]. register_post_meta( self::POST_TYPE, '_streamsera_streams', array( 'type' => 'array', 'single' => true, 'default' => array(), 'show_in_rest' => array( 'schema' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'url' => array( 'type' => 'string' ), 'label' => array( 'type' => 'string' ), 'format' => array( 'type' => 'string' ), 'bitrate' => array( 'type' => 'integer' ), ), ), ), ), 'auth_callback' => array( $this, 'auth' ), 'sanitize_callback' => array( $this, 'sanitize_streams' ), ) ); // Metadata source: provider (auto, icecast, shoutcast or none) plus optional url and mount. register_post_meta( self::POST_TYPE, '_streamsera_metadata', array( 'type' => 'object', 'single' => true, 'default' => array( 'provider' => 'auto', 'url' => '', 'mount' => '', ), 'show_in_rest' => array( 'schema' => array( 'type' => 'object', 'properties' => array( 'provider' => array( 'type' => 'string' ), 'url' => array( 'type' => 'string' ), 'mount' => array( 'type' => 'string' ), // Provider-specific settings (API key, station id, field paths...), declared by the provider definition. 'fields' => array( 'type' => 'object', 'properties' => array(), 'additionalProperties' => array( 'type' => 'string' ), ), ), ), // Published stations are readable anonymously over wp/v2: never ship provider secrets. 'prepare_callback' => array( $this, 'prepare_metadata' ), ), 'auth_callback' => array( $this, 'auth' ), 'sanitize_callback' => array( $this, 'sanitize_metadata' ), ) ); // Social links: { facebook, twitter, instagram, youtube, tiktok, whatsapp }. register_post_meta( self::POST_TYPE, '_streamsera_social', array( 'type' => 'object', 'single' => true, 'default' => array(), 'show_in_rest' => array( 'schema' => array( 'type' => 'object', 'properties' => array(), 'additionalProperties' => array( 'type' => 'string' ), ), ), 'auth_callback' => array( $this, 'auth' ), 'sanitize_callback' => array( $this, 'sanitize_social' ), ) ); } /** * Meta auth. * * @return bool */ public function auth() { return current_user_can( 'edit_posts' ); } /** * Sanitize the manual live-status override ('live', 'offair' or '' for automatic). * * @param mixed $value Raw. * @return string */ public function sanitize_override( $value ) { return in_array( $value, array( 'live', 'offair' ), true ) ? $value : ''; } /** * Sanitize streams. * * @param mixed $streams Raw. * @return array */ public function sanitize_streams( $streams ) { $out = array(); if ( ! is_array( $streams ) ) { return $out; } foreach ( $streams as $stream ) { if ( ! is_array( $stream ) ) { continue; } $url = isset( $stream['url'] ) && is_string( $stream['url'] ) ? esc_url_raw( trim( $stream['url'] ) ) : ''; if ( ! $url ) { continue; } $out[] = array( 'url' => $url, 'label' => isset( $stream['label'] ) ? sanitize_text_field( $stream['label'] ) : '', 'format' => isset( $stream['format'] ) ? sanitize_key( $stream['format'] ) : '', // 0 = unknown; otherwise a plausible kbps. 'bitrate' => isset( $stream['bitrate'] ) && absint( $stream['bitrate'] ) ? max( 8, min( 2048, absint( $stream['bitrate'] ) ) ) : 0, ); } return $out; } /** * REST read filter for `_streamsera_metadata`: `fields` holds provider credentials (API keys, auth * headers), so it is blanked unless the requester may edit this station. The provider id, * status URL and mount stay visible. * * @param mixed $value Stored value. * @param \WP_REST_Request $request Request. * @param array $args Meta args. * @return mixed */ public function prepare_metadata( $value, $request, $args ) { if ( ! is_array( $value ) ) { return $value; } $post_id = $request instanceof \WP_REST_Request ? absint( $request['id'] ) : 0; if ( $post_id ) { return current_user_can( 'edit_post', $post_id ) ? $value : $this->blank_fields( $value ); } // Collection requests carry no post id: context=edit is already limited by core to users who // may edit this post type; everything else (view/embed) is public and gets the blanked copy. $context = $request instanceof \WP_REST_Request ? (string) $request['context'] : ''; /** This filter is documented in includes/Admin/Menu.php */ if ( 'edit' === $context && current_user_can( apply_filters( 'streamsera_required_cap', 'edit_posts', 'stations' ) ) ) { return $value; } return $this->blank_fields( $value ); } /** * Copy of a metadata value without provider credentials or the status URL (which can carry a * user name and password, e.g. http://admin:pass@host:8000/admin.cgi). * * @param array $value Metadata. * @return array */ private function blank_fields( array $value ) { $value['fields'] = array(); if ( isset( $value['url'] ) ) { $value['url'] = ''; } return $value; } /** * Sanitize metadata source. * * @param mixed $meta Raw. * @return array */ public function sanitize_metadata( $meta ) { $meta = is_array( $meta ) ? $meta : array(); $provider = isset( $meta['provider'] ) ? sanitize_key( $meta['provider'] ) : 'auto'; $fields = array(); if ( isset( $meta['fields'] ) && is_array( $meta['fields'] ) ) { foreach ( $meta['fields'] as $key => $value ) { $key = sanitize_key( $key ); if ( $key && is_scalar( $value ) ) { // Textarea-safe: some provider settings are one item per line (e.g. extra request headers). $fields[ $key ] = sanitize_textarea_field( (string) $value ); } } } return array( 'provider' => $provider, 'url' => isset( $meta['url'] ) && is_string( $meta['url'] ) ? esc_url_raw( trim( $meta['url'] ) ) : '', 'mount' => isset( $meta['mount'] ) ? sanitize_text_field( $meta['mount'] ) : '', 'fields' => $fields, ); } /** * Sanitize social map. * * @param mixed $social Raw. * @return array */ public function sanitize_social( $social ) { return Base::sanitize_social_links( $social ); } /** * A public station payload without its contact details (e-mail, phone). Player and widget data is * printed on every page that embeds them, so contact details only appear where they are shown * (the station page); add-ons that display them add them back through their payload filters. * * @param array $station Public station payload. * @return array */ public static function without_contact( $station ) { if ( is_array( $station ) ) { unset( $station['email'], $station['phone'] ); } return $station; } /** * Public representation of a station for the player. * * @param int $post_id Station ID. * @return array|null */ public static function to_public( $post_id ) { $post = get_post( $post_id ); if ( ! $post || self::POST_TYPE !== $post->post_type || 'publish' !== $post->post_status ) { return null; } $logo_id = (int) get_post_meta( $post_id, '_streamsera_logo_id', true ); if ( ! $logo_id ) { $logo_id = (int) get_post_thumbnail_id( $post_id ); } $streams = get_post_meta( $post_id, '_streamsera_streams', true ); $data = array( 'id' => $post_id, 'name' => streamsera_plain_title( $post ), 'slogan' => (string) get_post_meta( $post_id, '_streamsera_slogan', true ), 'description' => wp_strip_all_tags( $post->post_content ), 'genre' => (string) get_post_meta( $post_id, '_streamsera_genre', true ), 'frequency' => (string) get_post_meta( $post_id, '_streamsera_frequency', true ), 'website' => (string) get_post_meta( $post_id, '_streamsera_website', true ), 'email' => (string) get_post_meta( $post_id, '_streamsera_email', true ), 'phone' => (string) get_post_meta( $post_id, '_streamsera_phone', true ), 'logo' => $logo_id ? wp_get_attachment_image_url( $logo_id, 'medium' ) : '', 'streams' => is_array( $streams ) ? array_values( $streams ) : array(), 'social' => (array) get_post_meta( $post_id, '_streamsera_social', true ), ); /** * Filter the public station payload. * * @param array $data Payload. * @param int $post_id Station ID. */ return apply_filters( 'streamsera_public_station', $data, $post_id ); } }