get_method() || '/streamsera/v1/tools/export' !== $request->get_route() || $response->is_error() ) { return $response; } $data = $response->get_data(); if ( is_array( $data ) ) { $response->set_data( self::extend_export_data( $data ) ); } return $response; } /** * Export payload + add-on sections (also used by `wp streamsera export`). * * @param array $data Base export (version, settings, stations, players). * @return array */ public static function extend_export_data( $data ) { $data['format'] = self::EXPORT_FORMAT; foreach ( self::sections() as $section => $type ) { if ( ! isset( $data[ $section ] ) ) { $data[ $section ] = self::export_posts( $type ); } } return $data; } /** * Extra export sections → post type, in import order (a section may refer to posts of an earlier one). * * @return array */ private static function sections() { /** * Filter the post-type sections added to JSON exports and read back on import. * * Each section exports every post of the type with its `_streamsera_*` meta. On import, ids the meta * refers to are remapped through `streamsera_import_meta_value` (stations and earlier sections). * * @param array $sections Section key => post type. */ $sections = (array) apply_filters( 'streamsera_export_sections', array() ); return array_filter( $sections, 'post_type_exists' ); } /** * Posts of a type with their `_streamsera_*` meta. * * @param string $type Post type. * @return array[] */ private static function export_posts( $type ) { $out = array(); foreach ( get_posts( array( 'post_type' => $type, 'numberposts' => -1, 'post_status' => 'any', ) ) as $post ) { $meta = array(); foreach ( array_keys( (array) get_post_meta( $post->ID ) ) as $key ) { if ( 0 === strpos( $key, '_streamsera_' ) ) { $meta[ $key ] = get_post_meta( $post->ID, $key, true ); } } $thumb = get_post_thumbnail_id( $post->ID ); $out[] = array( 'id' => $post->ID, 'title' => streamsera_plain_title( $post ), 'slug' => $post->post_name, 'content' => $post->post_content, 'excerpt' => $post->post_excerpt, 'status' => $post->post_status, 'thumbnail' => $thumb ? (int) $thumb : 0, 'meta' => \StreamSera\Api\Routes::redact_meta( $meta ), ); } return $out; } /** * Existing post an import row should update: same id + same (plain) title, else same plain title. * * Titles are compared decoded on both sides ("Rock & Roll" == "Rock & Roll"), otherwise every * import of such a station would create a duplicate. * * @param string $type Post type. * @param array $row Import row. * @return int 0 = create. */ private static function match_existing( $type, $row ) { $title = streamsera_plain_text( is_scalar( $row['title'] ) ? (string) $row['title'] : '' ); if ( isset( $row['id'] ) ) { $existing = get_post( (int) $row['id'] ); if ( $existing && $type === $existing->post_type && 'trash' !== $existing->post_status && 0 === strcasecmp( streamsera_plain_title( $existing ), trim( $title ) ) ) { return (int) $existing->ID; } } $found = streamsera_find_post_by_title( $type, $title ); return $found ? (int) $found->ID : 0; } /** * Routes. */ public function routes() { register_rest_route( self::NS, '/notices', array( array( 'methods' => 'GET', 'permission_callback' => array( $this, 'can_read_notices' ), 'callback' => array( $this, 'get_notices' ), ), array( 'methods' => 'POST', 'permission_callback' => array( $this, 'can_read_notices' ), 'callback' => array( $this, 'dismiss_notice' ), 'args' => array( 'id' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ), ), ), ) ); register_rest_route( self::NS, '/tools/import', array( 'methods' => 'POST', 'permission_callback' => array( $this, 'can_import' ), 'callback' => array( $this, 'import' ), 'args' => array( // The export file. Every part is sanitized when it is imported (settings through // Routes::sanitize_settings(), posts and registered meta key by key). 'data' => array( 'type' => 'object', 'required' => true, ), 'settings' => array( 'type' => 'boolean', 'default' => true, ), 'stations' => array( 'type' => 'boolean', 'default' => true, ), 'players' => array( 'type' => 'boolean', 'default' => true, ), ), ) ); } /** * REST permission: read and dismiss admin notices (anyone who can use the plugin screens). * * @return bool */ public function can_read_notices() { /** This filter is documented in includes/Admin/Menu.php */ return current_user_can( apply_filters( 'streamsera_required_cap', 'edit_posts', 'rest_read' ) ); } /** * REST permission: import settings, stations and players (administrators). * * @return bool */ public function can_import() { /** This filter is documented in includes/Admin/Menu.php */ return current_user_can( apply_filters( 'streamsera_required_cap', 'manage_options', 'rest_manage' ) ); } /* ------------------------------------------------------------------ notices */ /** * Built-in notices (health of the install). Modules and add-ons append theirs. * * @param array $notices Notices. * @return array */ public function core_notices( $notices ) { $stations = (int) wp_count_posts( Station::POST_TYPE )->publish; $players = (int) wp_count_posts( Player::POST_TYPE )->publish; if ( ! file_exists( STREAMSERA_PATH . 'build/frontend.asset.php' ) ) { $notices[] = array( 'id' => 'assets-missing', 'level' => 'error', 'title' => __( 'Frontend assets are not built', 'streamsera' ), 'message' => __( 'Players will not show. Delete the plugin and install it again.', 'streamsera' ), ); } if ( ! $stations ) { $notices[] = array( 'id' => 'no-stations', 'level' => 'info', 'title' => __( 'Add your first station', 'streamsera' ), 'message' => __( 'A station holds the stream URL, logo and metadata source.', 'streamsera' ), 'action' => array( 'label' => __( 'Add station', 'streamsera' ), 'to' => 'stations/new', ), ); } elseif ( ! $players ) { $notices[] = array( 'id' => 'no-players', 'level' => 'info', 'title' => __( 'Design a player', 'streamsera' ), 'message' => __( 'Pick a layout and skin, then embed it with the block or shortcode.', 'streamsera' ), 'action' => array( 'label' => __( 'Open builder', 'streamsera' ), 'to' => 'players/new', ), ); } if ( 0 === strpos( home_url(), 'https://' ) ) { foreach ( get_posts( array( 'post_type' => Station::POST_TYPE, 'numberposts' => -1, 'post_status' => 'publish', 'fields' => 'ids', ) ) as $id ) { $streams = (array) get_post_meta( $id, '_streamsera_streams', true ); foreach ( $streams as $stream ) { if ( isset( $stream['url'] ) && 0 === strpos( $stream['url'], 'http://' ) ) { $notices[] = array( 'id' => 'mixed-content-' . $id, 'level' => 'warning', /* translators: %s station name */ 'title' => sprintf( __( '"%s" uses an http:// stream', 'streamsera' ), streamsera_plain_title( $id ) ), 'message' => __( 'Browsers block insecure audio on HTTPS pages. Use an https:// stream URL.', 'streamsera' ), 'action' => array( 'label' => __( 'Edit station', 'streamsera' ), 'to' => 'stations/' . $id, ), ); break; } } } } if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON && ! Scheduler::has_as() && streamsera_get_setting( 'background_poll', true ) ) { $notices[] = array( 'id' => 'cron-disabled', 'level' => 'warning', 'title' => __( 'WP-Cron is disabled', 'streamsera' ), 'message' => __( 'Background metadata polling needs a system cron hitting wp-cron.php, or Action Scheduler.', 'streamsera' ), ); } return $notices; } /** * Notices minus the ones this user dismissed. * * @return WP_REST_Response */ public function get_notices() { /** * Filter the notices shown in the admin notices center. * * Each item: { id, level: info|warning|error|success, title, message, action?: { label, to|href }, dismissible? }. * * @param array $notices Notices. */ $notices = (array) apply_filters( 'streamsera_admin_notices', array() ); $dismissed = self::dismissed_ids( get_current_user_id() ); $out = array(); foreach ( $notices as $n ) { if ( empty( $n['id'] ) || in_array( $n['id'], $dismissed, true ) ) { continue; } $out[] = array_merge( array( 'level' => 'info', 'title' => '', 'message' => '', 'dismissible' => true, ), $n ); } return new WP_REST_Response( array( 'notices' => $out, 'dismissed' => count( $dismissed ), ) ); } /** * Dismiss a notice for the current user (id "*" restores all). * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function dismiss_notice( WP_REST_Request $request ) { $id = (string) $request['id']; $user = get_current_user_id(); if ( 'all' === $id || '*' === $id ) { delete_user_meta( $user, self::USER_META ); return $this->get_notices(); } // Only ids of notices that exist, so the list stays small. $known = array(); foreach ( (array) apply_filters( 'streamsera_admin_notices', array() ) as $n ) { if ( ! empty( $n['id'] ) ) { $known[] = (string) $n['id']; } } $id = sanitize_key( $id ); if ( ! in_array( $id, $known, true ) ) { return new WP_Error( 'streamsera_unknown_notice', __( 'Unknown notice.', 'streamsera' ), array( 'status' => 400 ) ); } $dismissed = self::dismissed_ids( $user ); $dismissed[] = $id; $dismissed = array_slice( array_values( array_unique( array_filter( array_map( 'sanitize_key', $dismissed ) ) ) ), -50 ); update_user_meta( $user, self::USER_META, $dismissed ); return $this->get_notices(); } /* ------------------------------------------------------------------- import */ /** * Import a StreamSera JSON export (settings, stations, players and add-on sections). * * Body: { data: , settings: bool, stations: bool, players: bool,
: bool … }. * Existing posts with the same title are updated; everything else is added. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function import( WP_REST_Request $request ) { if ( strlen( (string) $request->get_body() ) > self::IMPORT_LIMIT ) { return new WP_Error( 'streamsera_too_large', __( 'Import file is too large (2 MB limit).', 'streamsera' ), array( 'status' => 413 ) ); } $params = (array) $request->get_json_params(); $data = isset( $params['data'] ) && is_array( $params['data'] ) ? $params['data'] : null; if ( ! $data || ! isset( $data['version'] ) ) { return new WP_Error( 'streamsera_invalid', __( 'Not a StreamSera export file.', 'streamsera' ), array( 'status' => 400 ) ); } $what = array( 'settings' => ! isset( $params['settings'] ) || ! empty( $params['settings'] ), 'stations' => ! isset( $params['stations'] ) || ! empty( $params['stations'] ), 'players' => ! isset( $params['players'] ) || ! empty( $params['players'] ), ); foreach ( array_keys( self::sections() ) as $section ) { $what[ $section ] = ! isset( $params[ $section ] ) || ! empty( $params[ $section ] ); } $result = array( 'settings' => false, 'stations' => 0, 'players' => 0, 'skipped' => 0, ); foreach ( array_keys( self::sections() ) as $section ) { $result[ $section ] = 0; } $id_map = array(); if ( $what['settings'] && isset( $data['settings'] ) && is_array( $data['settings'] ) ) { $current = (array) get_option( 'streamsera_settings', array() ); /** * Filter the raw settings of an import before they are sanitized and saved. * * Add-ons can restore values an export leaves blank (e.g. secrets) from `$current`. * * @param array $incoming Settings from the export file. * @param array $current Settings stored on this site. */ $incoming = (array) apply_filters( 'streamsera_import_settings', $data['settings'], $current ); // Same pipeline as the settings screen: core keys sanitized, add-ons persist theirs. $next = \StreamSera\Api\Routes::sanitize_settings( $incoming, $current ); /** This filter is documented in includes/Api/Routes.php */ $next = apply_filters( 'streamsera_save_settings', $next, $incoming ); update_option( 'streamsera_settings', $next ); $result['settings'] = true; } if ( $what['stations'] && isset( $data['stations'] ) && is_array( $data['stations'] ) ) { foreach ( $data['stations'] as $row ) { if ( ! is_array( $row ) || empty( $row['title'] ) || ! is_scalar( $row['title'] ) ) { ++$result['skipped']; continue; } $args = array( 'post_type' => Station::POST_TYPE, 'post_title' => sanitize_text_field( streamsera_plain_text( $row['title'] ) ), 'post_content' => isset( $row['content'] ) && is_string( $row['content'] ) ? wp_kses_post( $row['content'] ) : '', 'post_status' => isset( $row['status'] ) && in_array( $row['status'], array( 'publish', 'draft' ), true ) ? $row['status'] : 'draft', ); $match = self::match_existing( Station::POST_TYPE, $row ); if ( $match ) { $args['ID'] = $match; } $new_id = wp_insert_post( wp_slash( $args ), true ); if ( is_wp_error( $new_id ) ) { ++$result['skipped']; continue; } if ( isset( $row['id'] ) ) { $id_map[ (int) $row['id'] ] = $new_id; } foreach ( (array) ( isset( $row['meta'] ) ? $row['meta'] : array() ) as $key => $value ) { // Only meta keys StreamSera registers for stations (their sanitize callbacks then run on save). if ( is_string( $key ) && registered_meta_key_exists( 'post', $key, Station::POST_TYPE ) ) { update_post_meta( $new_id, sanitize_key( $key ), wp_slash( self::sanitize_deep( $value ) ) ); } } // Same rule as the editor and the bulk actions: no published station without a stream URL. if ( 'publish' === get_post_status( $new_id ) && ! Station::has_streams( $new_id ) ) { wp_update_post( array( 'ID' => $new_id, 'post_status' => 'draft', ) ); } ++$result['stations']; } } if ( $what['players'] && isset( $data['players'] ) && is_array( $data['players'] ) ) { foreach ( $data['players'] as $row ) { if ( empty( $row['title'] ) || ! is_scalar( $row['title'] ) || empty( $row['config'] ) || ! is_array( $row['config'] ) ) { ++$result['skipped']; continue; } $config = $row['config']; if ( isset( $config['station'], $id_map[ (int) $config['station'] ] ) ) { $config['station'] = $id_map[ (int) $config['station'] ]; } if ( isset( $config['stations'] ) && is_array( $config['stations'] ) ) { $config['stations'] = array_map( static function ( $sid ) use ( $id_map ) { return isset( $id_map[ (int) $sid ] ) ? $id_map[ (int) $sid ] : (int) $sid; }, $config['stations'] ); } $args = array( 'post_type' => Player::POST_TYPE, 'post_title' => sanitize_text_field( streamsera_plain_text( $row['title'] ) ), 'post_status' => isset( $row['status'] ) && in_array( $row['status'], array( 'publish', 'draft' ), true ) ? $row['status'] : 'draft', ); $match = self::match_existing( Player::POST_TYPE, $row ); if ( $match ) { $args['ID'] = $match; } $new_id = wp_insert_post( wp_slash( $args ), true ); if ( is_wp_error( $new_id ) ) { ++$result['skipped']; continue; } update_post_meta( $new_id, '_streamsera_config', wp_slash( streamsera_sanitize_player_config( $config ) ) ); ++$result['players']; } } // Add-on sections (skipped when absent from the file), in registration order so a section // can refer to posts of an earlier one (ids are remapped through streamsera_import_meta_value). $maps = array( Station::POST_TYPE => $id_map ); foreach ( self::sections() as $type ) { $maps[ $type ] = array(); } foreach ( self::sections() as $section => $type ) { if ( empty( $what[ $section ] ) || ! isset( $data[ $section ] ) || ! is_array( $data[ $section ] ) ) { continue; } foreach ( $data[ $section ] as $row ) { if ( ! is_array( $row ) || empty( $row['title'] ) || ! is_scalar( $row['title'] ) ) { ++$result['skipped']; continue; } $new_id = self::import_post( $type, $row, $maps ); if ( ! $new_id ) { ++$result['skipped']; continue; } if ( isset( $row['id'] ) ) { $maps[ $type ][ (int) $row['id'] ] = $new_id; } ++$result[ $section ]; } } /** * Fires after a JSON import ran (add-ons import their own sections here). * * @param array $data Export payload. * @param array $result Import counters (by reference semantics not needed; filter below returns). */ do_action( 'streamsera_after_import', $data, $result ); return new WP_REST_Response( $result ); } /** * Create or update one post of an add-on section from an import row. * * @param string $type Post type. * @param array $row Row { id, title, slug?, content, excerpt?, status, thumbnail?, meta }. * @param array $maps Old id → new id, per post type. * @return int New post id, 0 on failure. */ private static function import_post( $type, $row, $maps ) { $args = array( 'post_type' => $type, 'post_title' => sanitize_text_field( streamsera_plain_text( $row['title'] ) ), 'post_content' => isset( $row['content'] ) && is_string( $row['content'] ) ? wp_kses_post( $row['content'] ) : '', 'post_excerpt' => isset( $row['excerpt'] ) && is_string( $row['excerpt'] ) ? sanitize_textarea_field( $row['excerpt'] ) : '', 'post_status' => isset( $row['status'] ) && in_array( $row['status'], array( 'publish', 'draft', 'private' ), true ) ? $row['status'] : 'draft', ); if ( ! empty( $row['slug'] ) && is_string( $row['slug'] ) ) { $args['post_name'] = sanitize_title( (string) $row['slug'] ); } $match = self::match_existing( $type, $row ); if ( $match ) { $args['ID'] = $match; } $new_id = wp_insert_post( wp_slash( $args ), true ); if ( is_wp_error( $new_id ) || ! $new_id ) { return 0; } $remap = static function ( $map_type, $old ) use ( $maps ) { $old = (int) $old; return isset( $maps[ $map_type ][ $old ] ) ? (int) $maps[ $map_type ][ $old ] : $old; }; $meta = isset( $row['meta'] ) && is_array( $row['meta'] ) ? $row['meta'] : array(); foreach ( $meta as $key => $value ) { if ( 0 !== strpos( (string) $key, '_streamsera_' ) ) { continue; } $value = self::sanitize_deep( $value ); /** * Filter an imported meta value before it is saved. Add-ons remap the post ids their own * meta refers to with $remap( $post_type, $old_id ), which returns the id on this site. * * @param mixed $value Sanitized value. * @param string $key Meta key. * @param string $type Post type being imported. * @param callable $remap Id mapper: ( string $post_type, int $old_id ) => int. */ $value = apply_filters( 'streamsera_import_meta_value', $value, $key, $type, $remap ); update_post_meta( $new_id, sanitize_key( $key ), wp_slash( $value ) ); } // Featured image: keep it only when the same attachment exists here (same-site restore). if ( ! empty( $row['thumbnail'] ) && 'attachment' === get_post_type( (int) $row['thumbnail'] ) ) { set_post_thumbnail( $new_id, (int) $row['thumbnail'] ); } return (int) $new_id; } /** * Recursively sanitize scalar leaves. * * @param mixed $value Value. * @return mixed */ private static function sanitize_deep( $value ) { if ( is_array( $value ) ) { return array_map( array( __CLASS__, 'sanitize_deep' ), $value ); } if ( is_string( $value ) ) { return 0 === strpos( $value, 'http' ) ? esc_url_raw( $value ) : sanitize_textarea_field( $value ); } return is_scalar( $value ) || null === $value ? $value : ''; } /* ------------------------------------------------------------------- status */ /** * Extra checks for the System status section. * * @param array $checks Checks. * @return array */ public function status_checks( $checks ) { $as = Scheduler::has_as(); $cron = defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON; $checks[] = array( 'label' => __( 'Background jobs', 'streamsera' ), 'ok' => $as || ! $cron, 'detail' => $as ? __( 'Action Scheduler', 'streamsera' ) : ( $cron ? __( 'WP-Cron disabled — jobs rely on a system cron', 'streamsera' ) : __( 'WP-Cron', 'streamsera' ) ), ); $next = wp_next_scheduled( \StreamSera\Jobs\Metadata_Poll::HOOK ); $checks[] = array( 'label' => __( 'Metadata poll', 'streamsera' ), 'ok' => ! streamsera_get_setting( 'background_poll', true ) || $as || $next, 'detail' => ! streamsera_get_setting( 'background_poll', true ) ? __( 'Off (players poll from the browser)', 'streamsera' ) : ( $as ? __( 'Scheduled with Action Scheduler', 'streamsera' ) : ( $next ? sprintf( /* translators: %s human time */ __( 'Next run in %s', 'streamsera' ), human_time_diff( time(), $next ) ) : __( 'Not scheduled', 'streamsera' ) ) ), ); $caching = array(); foreach ( array( 'WP_ROCKET_VERSION' => 'WP Rocket', 'LSCWP_V' => 'LiteSpeed Cache', 'W3TC' => 'W3 Total Cache', 'WPCACHEHOME' => 'WP Super Cache', 'AUTOPTIMIZE_PLUGIN_DIR' => 'Autoptimize', 'WPO_VERSION' => 'WP-Optimize', 'BREEZE_VERSION' => 'Breeze', ) as $const => $name ) { if ( defined( $const ) ) { $caching[] = $name; } } if ( class_exists( 'Cloudflare\\Hooks' ) || isset( $_SERVER['HTTP_CF_RAY'] ) ) { $caching[] = 'Cloudflare'; } $checks[] = array( 'label' => __( 'Caching plugin / CDN', 'streamsera' ), 'ok' => true, 'detail' => $caching ? implode( ', ', $caching ) . ' — ' . __( 'exclude /wp-json/streamsera/ from page caching', 'streamsera' ) : __( 'None detected', 'streamsera' ), ); $checks[] = array( 'label' => __( 'PHP extensions', 'streamsera' ), 'ok' => function_exists( 'curl_init' ) && function_exists( 'json_encode' ) && function_exists( 'mb_substr' ), 'detail' => implode( ', ', array_filter( array( function_exists( 'curl_init' ) ? 'curl' : '', 'json', function_exists( 'mb_substr' ) ? 'mbstring' : '' ) ) ), ); $checks[] = array( 'label' => __( 'Memory limit', 'streamsera' ), 'ok' => wp_convert_hr_to_bytes( WP_MEMORY_LIMIT ) >= 64 * MB_IN_BYTES, 'detail' => WP_MEMORY_LIMIT, ); $checks[] = array( 'label' => __( 'Multisite', 'streamsera' ), 'ok' => true, 'detail' => is_multisite() ? __( 'Yes — network settings available under Network Admin → Settings', 'streamsera' ) : __( 'No', 'streamsera' ), ); $checks[] = array( 'label' => __( 'Active theme', 'streamsera' ), 'ok' => true, 'detail' => wp_get_theme()->get( 'Name' ) . ' ' . wp_get_theme()->get( 'Version' ), ); return $checks; } /* ---------------------------------------------------------------- dark mode */ /** * Persist admin_dark. * * @param array $next Settings. * @param array $input Input. * @return array */ public function save_settings( $next, $input ) { if ( array_key_exists( 'admin_dark', $input ) ) { $next['admin_dark'] = in_array( $input['admin_dark'], array( 'auto', 'light', 'dark' ), true ) ? $input['admin_dark'] : 'auto'; } return $next; } /** * Hand the theme preference to the app. * * @param array $data Data. * @return array */ public function admin_data( $data ) { $data['adminDark'] = streamsera_get_setting( 'admin_dark', 'auto' ); $data['isMultisite'] = is_multisite(); // Notices and cards this user dismissed (the Dashboard reads it for its own cards). $data['dismissedNotices'] = self::dismissed_ids( get_current_user_id() ); return $data; } }