<?php
namespace aviaFramework\widgets;
use \WP_Error;
use \AviaHelper;
/**
* AVIA INSTAGRAM WIDGET
*
* Widget that displays your latest Instagram Photos
*
* Modified version - SECURITY HARDENED
* =====================================
*
* Adds a background caching of images on own server to avoid to access instagram to display the images
*
* Security fixes applied (vs. original):
* 1. sslverify no longer disabled when contacting instagram.com (MITM protection)
* 2. Host allowlist enforced before downloading any URL returned by instagram (SSRF protection)
* 3. Downloaded files are validated as real images (wp_check_filetype + getimagesize) before being kept
* 4. File permissions reduced from 0777 to 0644
* 5. Filenames are run through sanitize_file_name() instead of using raw basename() from remote data
* 6. download_url() calls are given an explicit timeout and downloaded files are size-checked
*
* @package AviaFramework
* @since ???
* @since 4.3.1 extended, improved and modified by günter
* @since 4.9 Code was moved from class-framework-widgets.php
* @since 4.3.1-secure hardened by Guenni007 - see notes above
*/
if( ! defined( 'AVIA_FW' ) ) { exit( 'No direct script access allowed' ); }
if( ! class_exists( __NAMESPACE__ . '\avia_instagram_widget', false ) )
{
class avia_instagram_widget extends \aviaFramework\widgets\base\Avia_Widget
{
/**
* @since 4.3.1
* @var array
*/
protected $upload_folders;
/**
* Stores the expire time for cached images in seconds.
*
* @since 4.3.1
* @var int
*/
protected $expire_time;
/**
* @since 4.3.1
* @var boolean
*/
protected $activate_cron;
/**
* Holds all caching info for each widget instance.
*
* @since 4.3.1
* @var array
*/
protected $cache;
/**
* @since 4.3.1
* @var array
*/
protected $cached_file_sizes;
/**
* Allowed remote hosts for image downloads (SSRF protection).
* Instagram/Meta serves media from these CDN domains. Anything else is rejected.
*
* @since 4.3.1-secure
* @var array
*/
protected $allowed_download_hosts;
/**
* Allowed mime types for cached image files.
*
* @since 4.3.1-secure
* @var array
*/
protected $allowed_mime_types;
/**
* Maximum accepted file size (bytes) for a single downloaded image.
*
* @since 4.3.1-secure
* @var int
*/
protected $max_file_size;
public function __construct()
{
$id_base = 'avia-instagram-feed';
$name = THEMENAME . ' ' . __( 'Instagram', 'avia_framework' );
$widget_options = array(
'classname' => '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 .= '<div class="av-instagram-errors">';
$out .= '<p class="av-instagram-errors-msg av-instagram-admin">' . esc_html__( 'Only visible for admins:', 'avia_framework' ) . '</p>';
$out .= '<p class="av-instagram-errors-msg av-instagram-admin">';
$out .= implode( '<br />', $errors );
$out .= '</p>';
$out .= '</div>';
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 '<div class="' . esc_attr( $ulclass ) . '">';
$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 "<div class='{$rowclass}'>";
}
$rowcount ++;
$itemcount ++;
$targeting = $target;
if( $target == "lightbox" )
{
$targeting = "";
$item['link'] = ! empty( $item['original'] ) ? $item['original'] : $item[ $size ];
}
echo '<div class="' . $liclass . '">';
echo '<a href="' . esc_url( $item['link'] ) . '" target="' . esc_attr( $targeting ) . '" class="' . $aclass . ' ' . $imgclass . '" title="' . esc_attr( $item['description'] ) . '" style="background-image:url(' . esc_url( $item[ $size ] ) . ');">';
echo '</a>';
echo '</div>';
if( $rowcount % $columns == 0 || $last_id == $item['id'] || ( $itemcount >= $number ) )
{
echo '</div>';
$rowcount = 0;
if( $itemcount >= $number )
{
break;
}
}
}
echo '</div>';
}
else
{
echo '<p class="av-instagram-errors-msg">' . esc_html__( 'No images available at the moment', 'avia_framework' ) . '</p>';
}
}
else
{
echo '<p class="av-instagram-errors-msg av-instagram-admin">' . esc_html__( 'For admins only: Missing Instagram user name !!', 'avia_framework' ) . '</p>';
}
if ( $link != '' )
{
echo '<a class="av-instagram-follow avia-button" href="https://instagram.com/' . esc_attr( trim( $username ) ) . '" rel="me" target="' . esc_attr( $target ) . '">' . $link . '</a>';
}
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 '<div class="avia-preview-headline">' . $this->name . '</div>';
echo '<div class="avia-preview-info">' . __( 'Title:', 'avia_framework' ) . ' ' . esc_html( $instance['title'] ) . '</div>';
echo '<div class="avia-preview-info">' . __( 'Username:', 'avia_framework' ) . ' ' . esc_html( $instance['username'] ) . '</div>';
echo '<div class="avia-preview-in-front">' . __( 'Content is only rendered in frontend.', 'avia_framework' ) . '</div>';
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 );
?>
<p><label for="<?php echo $this->get_field_id( 'title' ); ?>"><?php _e( 'Title', 'avia_framework' ); ?>: <input class="widefat" id="<?php echo $this->get_field_id( 'title' ); ?>" name="<?php echo $this->get_field_name( 'title' ); ?>" type="text" value="<?php echo esc_attr( $title ); ?>" /></label></p>
<p><label for="<?php echo $this->get_field_id( 'username' ); ?>"><?php _e( 'Username', 'avia_framework' ); ?>: <input class="widefat" id="<?php echo $this->get_field_id( 'username' ); ?>" name="<?php echo $this->get_field_name( 'username' ); ?>" type="text" value="<?php echo esc_attr( $username ); ?>" /></label></p>
<p><label for="<?php echo $this->get_field_id( 'cache' ); ?>"><?php _e( 'Location of your photos or videos', 'avia_framework' ); ?>:</label>
<select id="<?php echo $this->get_field_id( 'cache' ); ?>" name="<?php echo $this->get_field_name( 'cache' ); ?>" class="widefat">
<option value="" <?php selected( '', $cache ) ?>><?php _e( 'Get from your instagram account (instagram server connection needed)', 'avia_framework' ); ?></option>
<option value="server" <?php selected( 'server', $cache ) ?>><?php _e( 'Cache on your server - no instagram connection needed on pageload', 'avia_framework' ); ?></option>
</select>
</p>
<p><label for="<?php echo $this->get_field_id( 'number' ); ?>"><?php _e( 'Number of photos', 'avia_framework' ); ?>:</label>
<select id="<?php echo $this->get_field_id( 'number' ); ?>" name="<?php echo $this->get_field_name( 'number' ); ?>" class="widefat">
<?php for( $i = 1; $i <= 12; $i++ ) : ?>
<option value="<?php echo $i; ?>" <?php selected( $i, $number ) ?>><?php echo $i; ?></option>
<?php endfor; ?>
</select>
</p>
<p><label for="<?php echo $this->get_field_id( 'columns' ); ?>"><?php _e( 'Number of columns', 'avia_framework' ); ?>:</label>
<select id="<?php echo $this->get_field_id( 'columns' ); ?>" name="<?php echo $this->get_field_name( 'columns' ); ?>" class="widefat">
<?php for( $i = 1; $i <= 6; $i++ ) : ?>
<option value="<?php echo $i; ?>" <?php selected( $i, $columns ) ?>><?php echo $i; ?></option>
<?php endfor; ?>
</select>
</p>
<p><label for="<?php echo $this->get_field_id( 'size' ); ?>"><?php _e( 'Thumbnail size', 'avia_framework' ); ?>:</label>
<select id="<?php echo $this->get_field_id( 'size' ); ?>" name="<?php echo $this->get_field_name( 'size' ); ?>" class="widefat">
<option value="thumbnail" <?php selected( 'thumbnail', $size ) ?>><?php _e( 'Thumbnail', 'avia_framework' ); ?></option>
<option value="small" <?php selected( 'small', $size ) ?>><?php _e( 'Small', 'avia_framework' ); ?></option>
<option value="large" <?php selected( 'large', $size ) ?>><?php _e( 'Large', 'avia_framework' ); ?></option>
<option value="original" <?php selected( 'original', $size ) ?>><?php _e( 'Original', 'avia_framework' ); ?></option>
</select>
</p>
<p><label for="<?php echo $this->get_field_id( 'target' ); ?>"><?php _e( 'Open links in', 'avia_framework' ); ?>:</label>
<select id="<?php echo $this->get_field_id( 'target' ); ?>" name="<?php echo $this->get_field_name( 'target' ); ?>" class="widefat">
<option value="lightbox" <?php selected( 'lightbox', $target ) ?>><?php _e( 'Lightbox', 'avia_framework' ); ?></option>
<option value="_self" <?php selected( '_self', $target ) ?>><?php _e( 'Current window (_self)', 'avia_framework' ); ?></option>
<option value="_blank" <?php selected( '_blank', $target ) ?>><?php _e( 'New window (_blank)', 'avia_framework' ); ?></option>
</select>
</p>
<p><label for="<?php echo $this->get_field_id( 'link' ); ?>"><?php _e( 'Link text', 'avia_framework' ); ?>: <input class="widefat" id="<?php echo $this->get_field_id( 'link' ); ?>" name="<?php echo $this->get_field_name( 'link' ); ?>" type="text" value="<?php echo esc_attr( $link ); ?>" /></label></p>
<?php
if( ! $this->activate_cron )
{
echo '<p class="av-instagram-no-cron">';
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 '</p>';
$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 '<p class="av-instagram-next-update">';
echo __( 'The widget preloads and caches Instagram data for better performance.', 'avia_framework' )." ";
echo sprintf( __( 'Next update: %s', 'avia_framework' ), $time );
echo '</p>';
}
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 '<p class="av-instagram-next-update">';
echo __( 'The widget preloads and caches Instagram data for better performance.', 'avia_framework' )." ";
echo sprintf( __( 'Next update: %s', 'avia_framework' ), $time );
echo '</p>';
}
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 = '<div class="av-instagram-errors">';
$out .= '<p class="av-instagram-errors-msg av-instagram-error-headline">' . esc_html__( 'Errors found:', 'avia_framework' ) . '</p>';
$out .= '<p class="av-instagram-errors-msg">';
$out .= implode( '<br />', $errors );
$out .= '</p>';
$out .= '</div>';
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( ';</script>', $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;
}
}
}
Comments
0 B
|👍
/👎
0 B
|👍
/👎