'avia-instagram-feed avia_no_block_preview', 'description' => __( 'Displays your latest Instagram photos', 'avia_framework' ), 'show_instance_in_rest' => true, 'customize_selective_refresh' => false ); parent::__construct( $id_base, $name, $widget_options ); $this->defaults = array( 'title' => __( 'Instagram', 'avia_framework' ), 'username' => '', 'cache' => apply_filters( 'avf_instagram_default_cache_location', '' ), // '' | 'server' 'number' => 9, 'columns' => 3, 'size' => 'thumbnail', 'target' => 'lightbox' , 'link' => __( 'Follow Me!', 'avia_framework' ), 'avia_key' => '' ); $this->upload_folders = wp_upload_dir(); if( is_ssl() ) { $this->upload_folders['baseurl'] = str_replace( 'http://', 'https://', $this->upload_folders['baseurl'] ); } $folder = apply_filters( 'avf_instagram_cache_folder_name', 'avia_instagram_cache' ); $this->upload_folders['instagram_dir'] = trailingslashit( trailingslashit( $this->upload_folders['basedir'] ) . $folder ); $this->upload_folders['instagram_url'] = trailingslashit( trailingslashit( $this->upload_folders['baseurl'] ) . $folder ); $this->expire_time = HOUR_IN_SECONDS * 2; $this->expire_time = apply_filters_deprecated( 'null_instagram_cache_time', array( $this->expire_time ), '4.3.1', 'avf_instagram_file_cache_time', __( 'Adding possible file caching on server might need a longer period of time to invalidate cache.', 'avia_framework' ) ); $this->expire_time = apply_filters( 'avf_instagram_file_cache_time', $this->expire_time ); $this->activate_cron = ! ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ); $this->activate_cron = apply_filters( 'avf_instagram_activate_cron', $this->activate_cron ); $this->cache = $this->get_cache(); $this->cached_file_sizes = array( 'thumbnail', 'small', 'large', 'original' ); /** * SECURITY: only these hosts may ever be contacted for a media download. * Adjust via filter if Meta/Instagram changes their CDN domains. * * @since 4.3.1-secure */ $this->allowed_download_hosts = apply_filters( 'avf_instagram_allowed_download_hosts', array( 'cdninstagram.com', 'fbcdn.net', ) ); /** * SECURITY: only these mime types are accepted for cached files. * * @since 4.3.1-secure */ $this->allowed_mime_types = apply_filters( 'avf_instagram_allowed_mime_types', array( 'jpg|jpeg|jpe' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', ) ); /** * SECURITY: reject anything larger than this (default 15 MB) - avoids disk-fill / DoS via * an attacker-controlled or compromised response serving an oversized payload. * * @since 4.3.1-secure */ $this->max_file_size = apply_filters( 'avf_instagram_max_file_size', 15 * MB_IN_BYTES ); /** * WP Cron job events */ if( $this->activate_cron ) { add_action( 'av_instagram_scheduled_filecheck', array( $this, 'handler_scheduled_filecheck' ), 10 ); } /** * Makes sure to keep cron job alive as fallback */ if( is_admin() ) { add_action( 'admin_init', array( $this, 'handler_init_filecheck' ), 99999 ); add_action( 'delete_widget', array( $this, 'handler_delete_widget' ), 10, 3 ); } else { add_action( 'init', array( $this, 'handler_init_filecheck' ), 99999 ); } } /** * @since 4.3.1 */ public function __destruct() { parent::__destruct(); unset( $this->upload_folders ); unset( $this->cache ); unset( $this->cached_file_sizes ); } /** * Returns the cache info array * * @since 4.3.1 * @return array */ public function get_cache() { if( is_null( $this->cache ) ) { $cache = get_option( 'avia_instagram_widgets_cache', '' ); if( is_array( $cache ) ) { $this->cache = $cache; } else if( ! is_string( $cache ) || empty( $cache ) ) { $this->cache = null; } else { $cache = json_decode( $cache, true ); $this->cache = is_array( $cache ) ? $cache : null; } if( empty( $this->cache ) ) { $this->cache = array( 'last_updated' => 0, 'instances' => array() ); } } return $this->cache; } /** * Update the cache array in DB * * @since 4.3.1 * @param array|null $cache */ public function update_cache( ?array $cache = null ) { if( ! is_null( $cache) ) { $this->cache = $cache; } $save = json_encode( $this->cache ); update_option( 'avia_instagram_widgets_cache', $save ); } /** * Ensure a valid instance array filled with defaults * * @since 4.3.1 * @param array $instance_cache * @return array */ protected function parse_args_instance_cache( array $instance_cache ) { $instance_cache = wp_parse_args( (array) $instance_cache, array( 'upload_folder' => '', 'path_error' => '', 'instagram_error' => '', 'upload_errors' => false, 'last_update' => 0, 'cached_list' => array(), 'instagram_list' => array() )); return $instance_cache; } /** * Creates a unique key for the given instance for our cache array * * @since 4.3.1 * @param array $instance * @param string $id_widget * @return string */ protected function create_avia_key( array $instance, $id_widget ) { $k = 0; $key = str_replace( $this->id_base . '-', '', $id_widget ) . '-' . AviaHelper::save_string( $instance['title'], '-' ); $orig_key = $key; while( array_key_exists( $key, $this->cache['instances'] ) ) { $key = $orig_key . "-{$k}"; $k++; } return $key; } /** * Output the widget in frontend * * NOTE: rendering logic (widget/form/update/block preview) is unchanged from the * original - the security issues in the original were all in the data-fetching / * file-caching layer below, not in output escaping, which already looked correct. * * @param array $args * @param array $instance */ public function widget( $args, $instance ) { $instance = $this->parse_args_instance( $instance ); $fields = $this->get_field_names(); foreach( $instance as $key => $value ) { if( in_array( $key, $fields ) ) { $instance[ $key ] = esc_attr( $value ); } } $instance = apply_filters( 'avf_widget_front_instance', $instance, $args, get_called_class() ); if( $this->in_block_editor_preview( $args, $instance ) ) { return; } extract( $args, EXTR_SKIP ); extract( $instance, EXTR_SKIP ); $original_cache = $cache; $cache = apply_filters( 'avf_conditional_setting_external_links', $cache, __CLASS__, $this, $args, $instance ); if( ! in_array( $cache, array( '', 'server' ) ) ) { $cache = $original_cache; } $title = apply_filters( 'widget_title', $title, $args ); if( ( trim( $username ) == '' ) && ! is_user_logged_in() && ! current_user_can( 'edit_posts' ) ) { return; } echo $before_widget; if ( ! empty( $title ) ) { echo $before_title . $title . $after_title; } do_action( 'aviw_before_widget', $instance ); if( $username != '' ) { $errors = array(); $media_array = array(); $instance_cache = isset( $this->cache['instances'][ $instance['avia_key'] ] ) ? $this->cache['instances'][ $instance['avia_key'] ] : null; if( ! is_null( $instance_cache ) ) { if( ! empty( $instance_cache['instagram_error'] ) ) { $errors = array( $instance_cache['instagram_error'] ); } if( ! empty( $instance_cache['upload_errors'] ) && ( 'server' == $instance['cache'] ) ) { foreach( $instance_cache['cached_list'] as $img ) { if( ! empty( $img['errors'] ) ) { $errors = array_merge( $errors, $img['errors'] ); } } } if( 'server' == $instance['cache'] ) { $media_array = $instance_cache['cached_list']; $url = trailingslashit( trailingslashit( $this->upload_folders['instagram_url'] ) . $instance_cache['upload_folder'] ); foreach( $media_array as $key => $media ) { if( ! empty( $media['errors'] ) ) { $errors = array_merge( $errors, $media['errors'] ); } if( ! empty( $media[ $size ] ) ) { $media_array[ $key ][ $size ] = $url . $media[ $size ]; } if( ! empty( $media[ 'original' ] ) ) { $media_array[ $key ]['original'] = $url . $media['original']; } } } else { $media_array = $instance_cache['instagram_list']; } } if( ! empty( $errors ) && is_user_logged_in() && current_user_can( 'edit_posts' ) ) { $errors = array_map( 'esc_html__', $errors ); $out = ''; $out .= '
'; $out .= '

' . esc_html__( 'Only visible for admins:', 'avia_framework' ) . '

'; $out .= '

'; $out .= implode( '
', $errors ); $out .= '

'; $out .= '
'; echo $out; } if( count( $media_array ) > 0 ) { $ulclass = esc_attr( apply_filters( 'aviw_list_class', 'av-instagram-pics av-instagram-size-' . $size ) ); $rowclass = esc_attr( apply_filters( 'aviw_row_class', 'av-instagram-row' ) ); $liclass = esc_attr( apply_filters( 'aviw_item_class', 'av-instagram-item' ) ); $aclass = esc_attr( apply_filters( 'aviw_a_class', '' ) ); $imgclass = esc_attr( apply_filters( 'aviw_img_class', '' ) ); echo '
'; $last_id = end( $media_array ); $last_id = $last_id['id']; $rowcount = 0; $itemcount = 0; foreach ( $media_array as $item ) { if( empty( $item[ $size ] ) ) { continue; } if( $rowcount == 0 ) { echo "
"; } $rowcount ++; $itemcount ++; $targeting = $target; if( $target == "lightbox" ) { $targeting = ""; $item['link'] = ! empty( $item['original'] ) ? $item['original'] : $item[ $size ]; } echo '
'; echo ''; echo ''; echo '
'; if( $rowcount % $columns == 0 || $last_id == $item['id'] || ( $itemcount >= $number ) ) { echo '
'; $rowcount = 0; if( $itemcount >= $number ) { break; } } } echo '
'; } else { echo '

' . esc_html__( 'No images available at the moment', 'avia_framework' ) . '

'; } } else { echo '

' . esc_html__( 'For admins only: Missing Instagram user name !!', 'avia_framework' ) . '

'; } if ( $link != '' ) { echo '' . $link . ''; } do_action( 'aviw_after_widget', $instance ); echo $after_widget; } /** * Callback to output a custom block preview * * @since 4.9 * @param array $args * @param array $instance * @return boolean */ protected function widget_block_preview( array $args, array $instance = array() ) { echo isset( $args['before_widget'] ) ? $args['before_widget'] : ''; echo '
' . $this->name . '
'; echo '
' . __( 'Title:', 'avia_framework' ) . ' ' . esc_html( $instance['title'] ) . '
'; echo '
' . __( 'Username:', 'avia_framework' ) . ' ' . esc_html( $instance['username'] ) . '
'; echo '
' . __( 'Content is only rendered in frontend.', 'avia_framework' ) . '
'; echo isset( $args['after_widget'] ) ? $args['after_widget'] : ''; return true; } /** * Output the form in backend * * @param array $instance */ public function form( $instance ) { $instance = $this->parse_args_instance( $instance ); $fields = $this->get_field_names(); foreach( $instance as $key => $value ) { if( in_array( $key, $fields ) ) { switch( $key ) { case 'number': case 'columns': $instance[ $key ] = absint( $value ); break; default: $instance[ $key ] = esc_attr( $value ); break; } } } extract( $instance ); ?>

activate_cron ) { echo '

'; echo __( 'WP Cron jobs are disabled. To assure a regular update of cached data and an optimal pageload in frontend and backend we recommend to activate this.', 'avia_framework' ); echo '

'; $timestamp = ( $this->cache['last_updated'] != 0 ) ? $this->cache['last_updated'] + $this->expire_time : false; $time = ( false !== $timestamp ) ? date( 'Y/m/d H:i a', $timestamp ) . __( ' UTC', 'avia_framework' ) : __( 'No time available', 'avia_framework' ); echo '

'; echo __( 'The widget preloads and caches Instagram data for better performance.', 'avia_framework' )." "; echo sprintf( __( 'Next update: %s', 'avia_framework' ), $time ); echo '

'; } else { $timestamp = wp_next_scheduled( 'av_instagram_scheduled_filecheck' ); $time = ( false !== $timestamp ) ? date( "Y/m/d H:i", $timestamp ) . __( ' UTC', 'avia_framework' ) : __( 'No time available', 'avia_framework' ); echo '

'; echo __( 'The widget preloads and caches Instagram data for better performance.', 'avia_framework' )." "; echo sprintf( __( 'Next update: %s', 'avia_framework' ), $time ); echo '

'; } if( empty( $instance['avia_key'] ) ) { return; } if( empty( $this->cache['instances'][ $instance['avia_key'] ] ) ) { return; } $instance_cache = $this->cache['instances'][ $instance['avia_key'] ]; $errors = array(); if( ! empty( $instance_cache['instagram_error'] ) ) { $errors = (array) $instance_cache['instagram_error']; } if( 'server' == $instance['cache'] ) { foreach( $instance_cache['cached_list'] as $image ) { if( ! empty( $image['errors'] ) ) { $errors = array_merge( $errors, $image['errors'] ); } } } if( ! empty( $errors ) ) { $errors = array_map( 'esc_html__', $errors ); $out = '
'; $out .= '

' . esc_html__( 'Errors found:', 'avia_framework' ) . '

'; $out .= '

'; $out .= implode( '
', $errors ); $out .= '

'; $out .= '
'; echo $out; } } /** * Update widget options * * @param array $new_instance * @param array $old_instance * @return array */ public function update( $new_instance, $old_instance ) { $instance = $this->parse_args_instance( $old_instance ); $instance['title'] = strip_tags( $new_instance['title'] ); $instance['username'] = trim( strip_tags( $new_instance['username'] ) ); $instance['cache'] = ( $new_instance['cache'] == 'server' || $new_instance['cache'] == '' ) ? $new_instance['cache'] : apply_filters( 'avf_instagram_default_cache_location', 'server' ); $instance['number'] = ! absint( $new_instance['number'] ) ? 9 : $new_instance['number']; $instance['columns'] = ! absint( $new_instance['columns'] ) ? 3 : $new_instance['columns']; $instance['size'] = ( $new_instance['size'] == 'thumbnail' || $new_instance['size'] == 'large' || $new_instance['size'] == 'small' || $new_instance['size'] == 'original' ) ? $new_instance['size'] : 'large'; $instance['target'] = ( $new_instance['target'] == '_self' || $new_instance['target'] == '_blank'|| $new_instance['target'] == 'lightbox' ) ? $new_instance['target'] : '_self'; $instance['link'] = strip_tags( $new_instance['link'] ); if( empty( $instance['avia_key'] ) ) { $key = $this->create_avia_key( $instance, $this->id ); $instance['avia_key'] = $key; $this->cache['instances'][ $key ] = array(); $this->update_cache(); } $this->update_single_instance( $instance, $this->id ); if( $this->activate_cron ) { $this->restart_cron_job(); } return $instance; } /** * Get info from instagram * based on https://gist.github.com/cosmocatalano/4544576 * * SECURITY: sslverify is no longer disabled - the original code disabled TLS certificate * verification, which allowed a man-in-the-middle attacker to forge the "instagram" response * (and, downstream, control which files get downloaded and cached on the server). * * @since 4.3.1-secure hardened * @param string $username * @return array|\WP_Error */ protected function scrape_instagram( $username ) { $username = strtolower( $username ); $username = str_replace( '@', '', $username ); $remote = wp_remote_get( 'https://www.instagram.com/' . trim( $username ), array( 'timeout' => 60, // SECURITY: sslverify intentionally left at the WP default (true). // Do NOT set 'sslverify' => false here. ) ); if ( is_wp_error( $remote ) ) { return new WP_Error( 'site_down', __( 'Unable to communicate with Instagram.', 'avia_framework' ) ); } $code = wp_remote_retrieve_response_code( $remote ); if ( 200 != $code ) { $msg = wp_remote_retrieve_response_message( $remote ); if( empty( $msg ) ) { $msg = __( 'Unknown error code', 'avia_framework' ); } return new WP_Error( 'invalid_response', sprintf( __( 'Instagram returned error %d (= %s).', 'avia_framework' ), $code, $msg ) ); } $shards = explode( 'window._sharedData = ', $remote['body'] ); if( ! isset( $shards[1] ) ) { return new WP_Error( 'bad_json', __( 'Instagram has returned invalid data.', 'avia_framework' ) ); } $insta_json = explode( ';', $shards[1] ); $insta_array = json_decode( $insta_json[0], true ); if ( ! $insta_array ) { return new WP_Error( 'bad_json', __( 'Instagram has returned invalid data.', 'avia_framework' ) ); } if ( isset( $insta_array['entry_data']['ProfilePage'][0]['graphql']['user']['edge_owner_to_timeline_media']['edges'] ) ) { $images = $insta_array['entry_data']['ProfilePage'][0]['graphql']['user']['edge_owner_to_timeline_media']['edges']; } else { return new WP_Error( 'bad_json_2', __( 'Instagram has returned invalid data.', 'avia_framework' ) ); } if ( ! is_array( $images ) ) { return new WP_Error( 'bad_array', __( 'Instagram has returned invalid data.', 'avia_framework' ) ); } $instagram = array(); foreach ( $images as $image ) { if ( ! empty( $image['node']['is_video'] ) ) { $type = 'video'; } else { $type = 'image'; } $caption = __( 'Instagram Image', 'avia_framework' ); if ( ! empty( $image['node']['edge_media_to_caption']['edges'][0]['node']['text'] ) ) { $caption = wp_kses( $image['node']['edge_media_to_caption']['edges'][0]['node']['text'], array() ); } // SECURITY: every URL taken from the remote response is passed through // is_allowed_download_url() before it is ever used - see download_from_instagram(). $instagram[] = array( 'description' => $caption, 'link' => trailingslashit( '//instagram.com/p/' . $image['node']['shortcode'] ), 'time' => $image['node']['taken_at_timestamp'] ?? 0, 'comments' => $image['node']['edge_media_to_comment']['count'] ?? 0, 'likes' => $image['node']['edge_liked_by']['count'] ?? 0, 'thumbnail' => preg_replace( '/^https?\:/i', '', $image['node']['thumbnail_resources'][0]['src'] ?? '' ), 'small' => preg_replace( '/^https?\:/i', '', $image['node']['thumbnail_resources'][2]['src'] ?? '' ), 'large' => preg_replace( '/^https?\:/i', '', $image['node']['thumbnail_resources'][4]['src'] ?? '' ), 'original' => preg_replace( '/^https?\:/i', '', $image['node']['display_url'] ?? '' ), 'type' => $type, 'id' => $image['node']['id'] ); } $aviw_images_only = false; $aviw_images_only = apply_filters_deprecated( 'aviw_images_only', array( $aviw_images_only ), '4.3.1', 'avf_instagram_filter_files', __( 'Filter extended to filter images or videos', 'avia_framework' ) ); $show = $aviw_images_only ? array( 'image' ) : array(); $show = apply_filters( 'avf_instagram_filter_files', $show, $username ); if( ! empty( $show ) ) { foreach( $instagram as $key => $media_item ) { if( ! in_array( $media_item['type'], $show ) ) { unset( $instagram[ $key ] ); } } $instagram = array_merge( $instagram ); } if ( empty( $instagram ) ) { return new WP_Error( 'no_images', __( 'Instagram did not return any images.', 'avia_framework' ) ); } return $instagram; } /** * WP Cron handler for background uploads * * @since 4.3.1 */ public function handler_scheduled_filecheck() { if( defined( 'WP_DEBUG ' ) && WP_DEBUG ) { error_log( '****************** In avia_instagram_widget::handler_scheduled_filecheck started' ); } $this->schedule_cron_job( $this->expire_time * 2 ); $settings = $this->get_settings(); if( ! empty( $settings ) ) { $this->check_all_instances(); } $this->schedule_cron_job( $this->expire_time * 2 ); $this->sync_data(); $this->schedule_cron_job( $this->expire_time ); if( defined( 'WP_DEBUG ' ) && WP_DEBUG ) { error_log( '****************** In avia_instagram_widget::handler_scheduled_filecheck ended' ); } } /** * Synchronises directory and cache data structure. * * @since 4.3.1 */ public function sync_data() { $settings = $this->get_settings(); if( empty( $settings ) && empty( $this->cache['instances'] ) ) { if( is_dir( $this->upload_folders['instagram_dir'] ) ) { avia_backend_delete_folder( $this->upload_folders['instagram_dir'] ); $this->cache['last_updated'] = time(); $this->update_cache(); } return; } $instance_infos = (array) $this->cache['instances']; $keys = array_keys( $instance_infos ); $keys_to_keep = array(); foreach ( $settings as $index => $setting ) { if( in_array( $setting['avia_key'], $keys ) ) { $keys_to_keep[] = $setting['avia_key']; } } $keys_to_remove = array_diff( $keys, $keys_to_keep ); foreach( $keys_to_remove as $key ) { $folder = $this->upload_folders['instagram_dir'] . $instance_infos[ $key ]['upload_folder']; avia_backend_delete_folder( $folder ); unset( $this->cache['instances'][ $key ] ); } $cache_dirs = is_dir( $this->upload_folders['instagram_dir'] ) ? scandir( $this->upload_folders['instagram_dir'] ) : false; if( ! is_array( $cache_dirs ) ) { return; } $cache_dirs = array_diff( $cache_dirs, array( '.', '..' ) ); $ref_dirs = array(); foreach( $this->cache['instances'] as $key => $instance_info ) { if( isset( $instance_info['upload_folder'] ) ) { $ref_dirs[] = $instance_info['upload_folder']; } } $remove_dirs = array_diff( $cache_dirs, $ref_dirs ); foreach( $remove_dirs as $remove_dir ) { avia_backend_delete_folder( $this->upload_folders['instagram_dir'] . $remove_dir ); } if( empty( $this->cache['instances'] ) ) { avia_backend_delete_folder( $this->upload_folders['instagram_dir'] ); } $this->cache['last_updated'] = time(); $this->update_cache(); } /** * WP Cron is disabled - we have to load files during pageload in admin area * * @since 4.3.1 */ public function handler_init_filecheck() { $settings = $this->get_settings(); if( empty( $settings ) ) { if( $this->activate_cron ) { $this->restart_cron_job(); } return; } $instance = array_shift( $settings ); if( ! isset( $instance['avia_key'] ) || empty( $instance['avia_key'] ) ) { $instances = $this->get_settings(); foreach( $instances as $key => &$instance ) { $instance = $this->parse_args_instance( $instance ); $key = $this->create_avia_key( $instance, $this->id_base . "-{$key}" ); $instance['avia_key'] = $key; $this->cache['instances'][ $key ] = array(); } unset( $instance ); $this->save_settings( $instances ); $this->cache['last_updated'] = 0; $this->update_cache(); $this->check_all_instances(); } if( $this->activate_cron ) { $this->restart_cron_job(); return; } if( $this->cache['last_updated'] + $this->expire_time > time() ) { return; } if( is_admin() ) { $this->check_all_instances(); } } /** * Is called, when an instance of a widget is deleted. * * @since 4.3.1 * @param string $widget_id * @param string $sidebar_id * @param string $id_base */ public function handler_delete_widget( $widget_id, $sidebar_id, $id_base ) { $id = str_replace( $id_base . '-', '', $widget_id ); $settings = $this->get_settings(); if( empty( $settings ) || empty( $settings[ $id ] ) ) { return; } $instance = $settings[ $id ]; $instance_info = isset( $this->cache['instances'][ $instance['avia_key'] ] ) ? $this->cache['instances'][ $instance['avia_key'] ] : array(); if( empty( $instance_info ) ) { return; } $instance = $this->parse_args_instance( $instance ); $instance_info = $this->parse_args_instance_cache( $instance_info ); if( count( $settings ) <= 1 ) { avia_backend_delete_folder( $this->upload_folders['instagram_dir'] ); $this->cache['instances'] = array(); } else { $folder = $this->upload_folders['instagram_dir'] . $instance_info['upload_folder']; avia_backend_delete_folder( $folder ); unset( $this->cache['instances'][ $instance['avia_key'] ] ); } $this->update_cache(); } /** * @since 4.3.1 */ protected function restart_cron_job() { $timestamp = wp_next_scheduled( 'av_instagram_scheduled_filecheck' ); if( false === $timestamp ) { $this->schedule_cron_job( $this->expire_time ); return; } if( $timestamp > ( time() + $this->expire_time * 2 ) ) { $this->schedule_cron_job( $this->expire_time * 2 ); } } /** * @since 4.3.1 * @param int $delay_seconds * @return boolean */ protected function schedule_cron_job( $delay_seconds = 0 ) { $timestamp = wp_next_scheduled( 'av_instagram_scheduled_filecheck' ); if( false !== $timestamp ) { wp_unschedule_hook( 'av_instagram_scheduled_filecheck' ); } $timestamp = time() + $delay_seconds; $scheduled = wp_schedule_single_event( $timestamp, 'av_instagram_scheduled_filecheck' ); return false !== $scheduled; } /** * @since 4.3.1 */ protected function check_all_instances() { $settings = $this->get_settings(); foreach ( $settings as $key => $instance ) { $id_widget = $this->id_base . "-{$key}"; if( false === is_active_widget( false, $id_widget, $this->id_base, false ) ) { continue; } $this->update_single_instance( $instance, $id_widget ); } $this->cache['last_updated'] = time(); $this->update_cache(); } /** * @since 4.3.1 * @param array $instance * @param string $id_widget * @return array */ protected function update_single_instance( array $instance, $id_widget ) { set_time_limit( 0 ); $instance = $this->parse_args_instance( $instance ); if( empty( $instance['avia_key'] ) ) { $key = $this->create_avia_key( $instance, $id_widget ); $instance['avia_key'] = $key; $this->cache['instances'][ $key ] = array(); } $instance_cache = isset( $this->cache['instances'][ $instance['avia_key'] ] ) ? $this->cache['instances'][ $instance['avia_key'] ] : array(); $instance_cache = $this->parse_args_instance_cache( $instance_cache ); if( ( 'server' == $instance['cache'] ) && empty( $instance_cache['upload_folder'] ) && ! empty( $instance['username'] ) ) { $id = str_replace( $this->id_base . '-', '', $id_widget ); $f = empty( $instance['title'] ) ? $instance['username'] : $instance['title']; $folder_name = substr( AviaHelper::save_string( $id . '-' . $f, '-' ), 0, 30 ); $folder = $this->upload_folders['instagram_dir'] . $folder_name; $created = avia_backend_create_folder( $folder, false, 'unique' ); if( $created ) { $split = pathinfo( $folder ); $instance_cache['upload_folder'] = $split['filename']; $instance_cache['path_error'] = ''; $instance_cache['cached_list'] = array(); } else { $instance_cache['path_error'] = sprintf( __( 'Unable to create cache folder "%s". Files will be loaded directly from instagram', 'avia_framework' ), $folder ); } } $username = $instance['username']; $number = $instance['number']; if( ! empty( $username) ) { $media_array = $this->scrape_instagram( $username ); if ( ! is_wp_error( $media_array ) ) { $instance_cache['instagram_error'] = ''; $instance_cache['instagram_list'] = array_slice( $media_array, 0, $number ); if( 'server' == $instance['cache'] ) { $instance_cache = $this->cache_files_in_upload_directory( $media_array, $instance, $instance_cache ); } } else { $instance_cache['instagram_error'] = $media_array->get_error_message(); } } else { $instance_cache['instagram_error'] = __( 'You need to specify an Instagram username.', 'avia_framework' ); $instance_cache['instagram_list'] = array(); $instance_cache['cached_list'] = array(); } $instance_cache['last_update'] = time(); $this->cache['instances'][ $instance['avia_key'] ] = $instance_cache; $this->update_cache(); return $instance; } /** * Updates the local stored files in upload directory. * * @since 4.3.1 * @param array $instagram_files * @param array $instance * @param array $instance_cache * @return array */ protected function cache_files_in_upload_directory( array $instagram_files, array $instance, array $instance_cache ) { set_time_limit( 0 ); $cached_files = $instance_cache['cached_list']; $new_cached_files = array(); $no_errors = 0; foreach( $instagram_files as $instagram_file ) { $id = $instagram_file['id']; $found = false; foreach( $cached_files as $key_cache => $cached_file ) { if( $id == $cached_file['id'] ) { if( ! empty( $cached_file['errors'] ) ) { $this->remove_single_cached_files( $cached_file, $instance_cache ); unset( $cached_files[ $key_cache ] ); break; } $path = trailingslashit( $this->upload_folders['instagram_dir'] . $instance_cache['upload_folder'] ); foreach( $this->cached_file_sizes as $size ) { if( empty( $cached_file[ $size ] ) || ! file_exists( $path . $cached_file[ $size ] ) ) { $this->remove_single_cached_files( $cached_file, $instance_cache ); unset( $cached_files[ $key_cache ] ); break; } } if( ! isset( $cached_files[ $key_cache ] ) ) { break; } $ncf = $cached_file; $ncf['description'] = $instagram_file['description']; $ncf['link'] = $instagram_file['link']; $ncf['time'] = $instagram_file['time']; $ncf['comments'] = $instagram_file['comments']; $ncf['likes'] = $instagram_file['likes']; $ncf['type'] = $instagram_file['type']; $new_cached_files[] = $ncf; unset( $cached_files[ $key_cache ] ); $found = true; break; } } if( ! $found ) { $new_cached_files[] = $this->download_from_instagram( $instagram_file, $instance, $instance_cache ); } $last = $new_cached_files[ count( $new_cached_files ) - 1 ]; if( empty( $last['errors'] ) || ! empty( $last[ $instance['size'] ] ) ) { $no_errors++; } if( $no_errors >= $instance['number'] || count( $new_cached_files ) > ( $instance['number'] * 2 ) ) { break; } } if( $no_errors < $instance['number'] ) { foreach( $cached_files as $key_cache => $cached_file ) { $new_cached_files[] = $cached_file; if( empty( $cached_file['errors'] ) ) { $no_errors++; } unset( $cached_files[ $key_cache ] ); if( $no_errors >= $instance['number'] ) { break; } } } foreach( $cached_files as $key_cache => $cached_file ) { $this->remove_single_cached_files( $cached_file, $instance_cache ); unset( $cached_files[ $key_cache ] ); } $err_cnt = 0; $count = 1; foreach( $new_cached_files as $new_file ) { if( ! empty( $new_file['errors'] ) ) { $err_cnt++; } $count++; if( $count > $instance['number'] ) { break; } } $instance_cache['upload_errors'] = ( 0 == $err_cnt ) ? false : $err_cnt; $instance_cache['cached_list'] = $new_cached_files; return $instance_cache; } /** * SECURITY: checks that a URL returned by instagram points to a host we explicitly * trust for media downloads, before we ever pass it to download_url(). Without this, * a compromised/forged response (e.g. via a MITM, or a future scraping bug) could make * this widget download and store an attacker-controlled file on the server. * * @since 4.3.1-secure * @param string $url * @return boolean */ protected function is_allowed_download_url( $url ) { $host = wp_parse_url( $url, PHP_URL_HOST ); if( empty( $host ) ) { return false; } $host = strtolower( $host ); foreach( $this->allowed_download_hosts as $allowed ) { $allowed = strtolower( $allowed ); // exact match or proper subdomain match only (e.g. "scontent.cdninstagram.com" but not "evilcdninstagram.com") if( $host === $allowed || substr( $host, -( strlen( $allowed ) + 1 ) ) === '.' . $allowed ) { return true; } } return false; } /** * SECURITY: validates that a locally saved temp file is actually an image of an * allowed type and within the size limit. Rejects anything else (e.g. a script or * executable disguised behind an image-looking URL/filename). * * @since 4.3.1-secure * @param string $tmp_path * @return boolean */ protected function is_valid_downloaded_image( $tmp_path ) { if( ! file_exists( $tmp_path ) ) { return false; } $filesize = filesize( $tmp_path ); if( false === $filesize || $filesize <= 0 || $filesize > $this->max_file_size ) { return false; } // getimagesize() actually parses the image header - a renamed .php file will fail this check. $info = @getimagesize( $tmp_path ); if( false === $info || empty( $info['mime'] ) ) { return false; } if( ! in_array( $info['mime'], $this->allowed_mime_types, true ) ) { return false; } return true; } /** * Downloads the files from instagram and stores them in local cache. * * SECURITY (vs. original): * - source URL is checked against an allowlist of trusted CDN hosts before download * - downloaded file is verified to actually be an allowed image type/size before being kept * - filename is sanitized instead of trusting the remote-supplied name verbatim * - permissions reduced from 0777 to 0644 * * @since 4.3.1 * @since 4.3.1-secure hardened * @param array $instagram_file * @param array $instance * @param array $instance_cache * @return array */ protected function download_from_instagram( array $instagram_file, array $instance, array $instance_cache ) { $new_cached_file = $instagram_file; $new_cached_file['errors'] = array(); $instagram_schema = 'https:'; $cache_path = trailingslashit( $this->upload_folders['instagram_dir'] . $instance_cache['upload_folder'] ); if( ! function_exists( 'download_url' ) ) { require_once trailingslashit( ABSPATH ) . 'wp-admin/includes/file.php'; } foreach( $this->cached_file_sizes as $size ) { if( empty( $instagram_file[ $size ] ) ) { $new_cached_file[ $size ] = ''; continue; } $remote_url = $instagram_schema . $instagram_file[ $size ]; // SECURITY: reject anything that doesn't point at a trusted instagram/meta CDN host. if( ! $this->is_allowed_download_url( $remote_url ) ) { $new_cached_file[ $size ] = ''; $new_cached_file['errors'][] = __( 'Rejected image URL from an untrusted host.', 'avia_framework' ); continue; } // SECURITY: sanitize the filename instead of trusting the remote name verbatim. $fn = explode( '?', basename( $instagram_file[ $size ] ) ); $safe_name = sanitize_file_name( $fn[0] ); if( empty( $safe_name ) ) { $new_cached_file[ $size ] = ''; $new_cached_file['errors'][] = __( 'Could not determine a safe filename for downloaded image.', 'avia_framework' ); continue; } $tmp_name = download_url( $remote_url, 30 ); if( is_wp_error( $tmp_name ) ) { $new_cached_file[ $size ] = ''; $new_cached_file['errors'] = array_merge( $new_cached_file['errors'], $tmp_name->get_error_messages() ); continue; } // SECURITY: verify the downloaded file is actually an allowed image type/size // before it is moved into the public uploads folder. if( ! $this->is_valid_downloaded_image( $tmp_name ) ) { @unlink( $tmp_name ); $new_cached_file[ $size ] = ''; $new_cached_file['errors'][] = __( 'Downloaded file failed image validation and was discarded.', 'avia_framework' ); continue; } $new_file_name = $size . '_' . $safe_name; $new_name = $cache_path . $new_file_name; $moved = avia_backend_rename_file( $tmp_name, $new_name ); if( is_wp_error( $moved ) ) { @unlink( $tmp_name ); $new_cached_file[ $size ] = ''; $new_cached_file['errors'] = array_merge( $new_cached_file['errors'], $moved->get_error_messages() ); continue; } // SECURITY: 0644 instead of 0777 - files only ever need to be world-readable, never world-writable/executable. if( ! chmod( $new_name, 0644 ) ) { $new_cached_file['errors'][] = sprintf( __( 'Could not change user rights of file %s to 644 - file might not be visible in frontend.', 'avia_framework' ), $new_name ); } $new_cached_file[ $size ] = $new_file_name; } return $new_cached_file; } /** * Removes all cached files from $cached_file_info * * @since 4.3.1 * @param array $cached_file_info * @param array $instance_cache * @return array */ protected function remove_single_cached_files( array $cached_file_info, array $instance_cache ) { $cache_path = trailingslashit( $this->upload_folders['instagram_dir'] . $instance_cache['upload_folder'] ); foreach( $this->cached_file_sizes as $size ) { if( ! empty( $cached_file_info[ $size ] ) ) { $file = $cache_path . $cached_file_info[ $size ]; if( file_exists( $file ) ) { unlink( $file ); } $cached_file_info[ $size ] = ''; } } return $cached_file_info; } } }