bucket = $bucket; $this->key = $key; $this->secret = $secret; $this->bucket_url = $bucket_url; $this->region = $region; } /** * Setup the hooks, urls filtering etc for S3 Uploads */ public function setup() : void { $this->register_stream_wrapper(); add_filter( 'upload_dir', [ $this, 'filter_upload_dir' ] ); add_filter( 'wp_image_editors', [ $this, 'filter_editors' ], 9 ); add_action( 'delete_attachment', [ $this, 'delete_attachment_files' ] ); add_filter( 'wp_read_image_metadata', [ $this, 'wp_filter_read_image_metadata' ], 10, 2 ); add_filter( 'wp_resource_hints', [ $this, 'wp_filter_resource_hints' ], 10, 2 ); add_filter( 'wp_handle_sideload_prefilter', [ $this, 'filter_sideload_move_temp_file_to_s3' ] ); add_filter( 'wp_generate_attachment_metadata', [ $this, 'set_filesize_in_attachment_meta' ], 10, 2 ); add_filter( 'wp_get_attachment_url', [ $this, 'add_s3_signed_params_to_attachment_url' ], 10, 2 ); add_filter( 'wp_get_attachment_image_src', [ $this, 'add_s3_signed_params_to_attachment_image_src' ], 10, 2 ); add_filter( 'wp_calculate_image_srcset', [ $this, 'add_s3_signed_params_to_attachment_image_srcset' ], 10, 5 ); add_filter( 'wp_generate_attachment_metadata', [ $this, 'set_attachment_private_on_generate_attachment_metadata' ], 10, 2 ); add_filter( 'pre_wp_unique_filename_file_list', [ $this, 'get_files_for_unique_filename_file_list' ], 10, 3 ); } /** * Tear down the hooks, url filtering etc for S3 Uploads */ public function tear_down() : void { stream_wrapper_unregister( 's3' ); remove_filter( 'upload_dir', [ $this, 'filter_upload_dir' ] ); remove_filter( 'wp_image_editors', [ $this, 'filter_editors' ], 9 ); remove_filter( 'wp_handle_sideload_prefilter', [ $this, 'filter_sideload_move_temp_file_to_s3' ] ); remove_filter( 'wp_generate_attachment_metadata', [ $this, 'set_filesize_in_attachment_meta' ] ); remove_filter( 'wp_get_attachment_url', [ $this, 'add_s3_signed_params_to_attachment_url' ] ); remove_filter( 'wp_get_attachment_image_src', [ $this, 'add_s3_signed_params_to_attachment_image_src' ] ); remove_filter( 'wp_calculate_image_srcset', [ $this, 'add_s3_signed_params_to_attachment_image_srcset' ] ); remove_filter( 'wp_generate_attachment_metadata', [ $this, 'set_attachment_private_on_generate_attachment_metadata' ] ); } /** * Register the stream wrapper for s3 */ public function register_stream_wrapper() : void { if ( defined( 'S3_UPLOADS_USE_LOCAL' ) && S3_UPLOADS_USE_LOCAL ) { stream_wrapper_register( 's3', 'S3_Uploads\Local_Stream_Wrapper', STREAM_IS_URL ); } else { Stream_Wrapper::register( $this ); $acl = defined( 'S3_UPLOADS_OBJECT_ACL' ) ? S3_UPLOADS_OBJECT_ACL : 'public-read'; stream_context_set_option( stream_context_get_default(), 's3', 'ACL', $acl ); } stream_context_set_option( stream_context_get_default(), 's3', 'seekable', true ); } /** * Get the s3:// path for the bucket. */ public function get_s3_path() : string { return 's3://' . $this->bucket; } /** * Overwrite the default wp_upload_dir. * * @param array{path: string, basedir: string, baseurl: string, url: string, subdir: string, error: string|false} $dirs * @return array{path: string, basedir: string, baseurl: string, url: string, subdir: string, error: string|false} */ public function filter_upload_dir( array $dirs ) : array { $this->original_upload_dir = $dirs; $s3_path = $this->get_s3_path(); $dirs['path'] = str_replace( WP_CONTENT_DIR, $s3_path, $dirs['path'] ); $dirs['basedir'] = str_replace( WP_CONTENT_DIR, $s3_path, $dirs['basedir'] ); if ( ! defined( 'S3_UPLOADS_DISABLE_REPLACE_UPLOAD_URL' ) || ! S3_UPLOADS_DISABLE_REPLACE_UPLOAD_URL ) { if ( defined( 'S3_UPLOADS_USE_LOCAL' ) && S3_UPLOADS_USE_LOCAL ) { $dirs['url'] = str_replace( $s3_path, $dirs['baseurl'] . '/s3/' . $this->bucket, $dirs['path'] ); $dirs['baseurl'] = str_replace( $s3_path, $dirs['baseurl'] . '/s3/' . $this->bucket, $dirs['basedir'] ); } else { $dirs['url'] = str_replace( $s3_path, $this->get_s3_url(), $dirs['path'] ); $dirs['baseurl'] = str_replace( $s3_path, $this->get_s3_url(), $dirs['basedir'] ); } } return $dirs; } /** * Delete all attachment files from S3 when an attachment is deleted. * * WordPress Core's handling of deleting files for attachments via * wp_delete_attachment_files is not compatible with remote streams, as * it makes many assumptions about local file paths. The hooks also do * not exist to be able to modify their behavior. As such, we just clean * up the s3 files when an attachment is removed, and leave WordPress to try * a failed attempt at mangling the s3:// urls. * * @param int $post_id */ public function delete_attachment_files( int $post_id ) : void { $meta = wp_get_attachment_metadata( $post_id ); $file = get_attached_file( $post_id ); if ( $file === false ) { return; } if ( isset( $meta['sizes'] ) ) { foreach ( $meta['sizes'] as $sizeinfo ) { $intermediate_file = str_replace( basename( $file ), $sizeinfo['file'], $file ); wp_delete_file( $intermediate_file ); } } wp_delete_file( $file ); } /** * Get the S3 URL base for uploads. * * @return string */ public function get_s3_url() : string { if ( $this->bucket_url !== null ) { return $this->bucket_url; } $bucket = strtok( $this->bucket, '/' ); $path = substr( $this->bucket, strlen( $bucket ) ); return apply_filters( 's3_uploads_bucket_url', 'https://' . $bucket . '.s3.amazonaws.com' . $path ); } /** * Get the S3 bucket name * * @return string */ public function get_s3_bucket() : string { return strtok( $this->bucket, '/' ); } /** * Get the region of the S3 bucket. * * @return string */ public function get_s3_bucket_region() : ?string { return $this->region; } /** * Get the original upload directory before it was replaced by S3 uploads. * * @return array{path: string, basedir: string, baseurl: string, url: string, subdir: string, error: string|false} */ public function get_original_upload_dir() : array { if ( empty( $this->original_upload_dir ) ) { wp_upload_dir(); } /** * @var array{path: string, basedir: string, baseurl: string, url: string, subdir: string, error: string|false} */ $upload_dir = $this->original_upload_dir; return $upload_dir; } /** * Reverse a file url in the uploads directory to the params needed for S3. * * @param string $url * @return array{bucket: string, key: string, query: string|null}|null */ public function get_s3_location_for_url( string $url ) : ?array { $s3_url = 'https://' . $this->get_s3_bucket() . '.s3.amazonaws.com/'; if ( strpos( $url, $s3_url ) === 0 ) { $parsed = wp_parse_url( $url ); return [ 'bucket' => $this->get_s3_bucket(), 'key' => isset( $parsed['path'] ) ? ltrim( $parsed['path'], '/' ) : '', 'query' => $parsed['query'] ?? null, ]; } $upload_dir = wp_upload_dir(); if ( strpos( $url, $upload_dir['baseurl'] ) === false ) { return null; } $path = str_replace( $upload_dir['baseurl'], $upload_dir['basedir'], $url ); $parsed = wp_parse_url( $path ); if ( ! isset( $parsed['host'] ) || ! isset( $parsed['path'] ) ) { return null; } return [ 'bucket' => $parsed['host'], 'key' => ltrim( $parsed['path'], '/' ), 'query' => $parsed['query'] ?? null, ]; } /** * Reverse a file path in the uploads directory to the params needed for S3. * * @param string $url * @return array{key: string, bucket: string} */ public function get_s3_location_for_path( string $path ) : ?array { $parsed = wp_parse_url( $path ); if ( ! isset( $parsed['path'] ) || ! isset( $parsed['host'] ) || ! isset( $parsed['scheme'] ) || $parsed['scheme'] !== 's3' ) { return null; } return [ 'bucket' => $parsed['host'], 'key' => ltrim( $parsed['path'], '/' ), ]; } /** * @return Aws\S3\S3Client */ public function s3() : Aws\S3\S3Client { if ( ! empty( $this->s3 ) ) { return $this->s3; } $this->s3 = $this->get_aws_sdk()->createS3(); return $this->s3; } /** * Get the AWS Sdk. * * @return Aws\Sdk */ public function get_aws_sdk() : Aws\Sdk { /** @var null|Aws\Sdk */ $sdk = apply_filters( 's3_uploads_aws_sdk', null, $this ); if ( $sdk ) { return $sdk; } $params = [ 'version' => 'latest' ]; if ( $this->key !== null && $this->secret !== null ) { $params['credentials']['key'] = $this->key; $params['credentials']['secret'] = $this->secret; } if ( $this->region !== null ) { $params['signature'] = 'v4'; $params['region'] = $this->region; } if ( defined( 'WP_PROXY_HOST' ) && defined( 'WP_PROXY_PORT' ) ) { $proxy_auth = ''; $proxy_address = WP_PROXY_HOST . ':' . WP_PROXY_PORT; if ( defined( 'WP_PROXY_USERNAME' ) && defined( 'WP_PROXY_PASSWORD' ) ) { $proxy_auth = WP_PROXY_USERNAME . ':' . WP_PROXY_PASSWORD . '@'; } $params['request.options']['proxy'] = $proxy_auth . $proxy_address; } $params = apply_filters( 's3_uploads_s3_client_params', $params ); $sdk = new Aws\Sdk( $params ); return $sdk; } public function filter_editors( array $editors ) : array { $position = array_search( 'WP_Image_Editor_Imagick', $editors ); if ( $position !== false ) { unset( $editors[ $position ] ); } array_unshift( $editors, __NAMESPACE__ . '\\Image_Editor_Imagick' ); return $editors; } /** * Copy the file from /tmp to an s3 dir so handle_sideload doesn't fail due to * trying to do a rename() on the file cross streams. This is somewhat of a hack * to work around the core issue https://core.trac.wordpress.org/ticket/29257 * * @param array{tmp_name: string, name: string, type: string, size: int, error: int} $file File array * @return array{tmp_name: string, name: string, type: string, size: int, error: int} */ public function filter_sideload_move_temp_file_to_s3( array $file ) { $upload_dir = wp_upload_dir(); $new_path = $upload_dir['basedir'] . '/tmp/' . basename( $file['tmp_name'] ); copy( $file['tmp_name'], $new_path ); unlink( $file['tmp_name'] ); $file['tmp_name'] = $new_path; return $file; } /** * Store the attachment filesize in the attachment meta array. * * Getting the filesize of an image in S3 involves a remote HEAD request, * which is a bit slower than a local filesystem operation would be. As a * result, operations like `wp_prepare_attachments_for_js' take substantially * longer to complete against s3 uploads than if they were performed with a * local filesystem.i * * Saving the filesize in the attachment metadata when the image is * uploaded allows core to skip this stat when retrieving and formatting it. * * @param array $metadata Attachment metadata. * @param int $attachment_id Attachment ID. * @return array Attachment metadata array, with "filesize" value added. */ function set_filesize_in_attachment_meta( array $metadata, int $attachment_id ) : array { $file = get_attached_file( $attachment_id ); if ( $file === false ) { return $metadata; } if ( ! isset( $metadata['filesize'] ) && file_exists( $file ) ) { $metadata['filesize'] = filesize( $file ); } return $metadata; } /** * Filters wp_read_image_metadata. exif_read_data() doesn't work on * file streams so we need to make a temporary local copy to extract * exif data from. * * @param array $meta * @param string $file * @return array|false */ public function wp_filter_read_image_metadata( array $meta, string $file ) { remove_filter( 'wp_read_image_metadata', [ $this, 'wp_filter_read_image_metadata' ], 10 ); $temp_file = $this->copy_image_from_s3( $file ); $meta = wp_read_image_metadata( $temp_file ); add_filter( 'wp_read_image_metadata', [ $this, 'wp_filter_read_image_metadata' ], 10, 2 ); unlink( $temp_file ); return $meta; } /** * Add the DNS address for the S3 Bucket to list for DNS prefetch. * * @param array $hints * @param string $relation_type * @return array */ function wp_filter_resource_hints( array $hints, string $relation_type ) : array { if ( ( defined( 'S3_UPLOADS_DISABLE_REPLACE_UPLOAD_URL' ) && S3_UPLOADS_DISABLE_REPLACE_UPLOAD_URL ) || ( defined( 'S3_UPLOADS_USE_LOCAL' ) && S3_UPLOADS_USE_LOCAL ) ) { return $hints; } if ( 'dns-prefetch' === $relation_type ) { $hints[] = $this->get_s3_url(); } return $hints; } /** * Get a local copy of the file. * * @param string $file * @return string */ public function copy_image_from_s3( string $file ) : string { if ( ! function_exists( 'wp_tempnam' ) ) { require_once( ABSPATH . 'wp-admin/includes/file.php' ); } $temp_filename = wp_tempnam( $file ); copy( $file, $temp_filename ); return $temp_filename; } /** * Check if the attachment is private. * * @param integer $attachment_id * @return boolean */ public function is_private_attachment( int $attachment_id ) : bool { /** * Filters whether an attachment should be private. * * @param bool Whether the attachment is private. * @param int The attachment ID. */ $private = apply_filters( 's3_uploads_is_attachment_private', false, $attachment_id ); return $private; } /** * Update the ACL (Access Control List) for an attachments files. * * @param integer $attachment_id * @param 'public-read'|'private' $acl public-read|private * @return WP_Error|null */ public function set_attachment_files_acl( int $attachment_id, string $acl ) : ?WP_Error { $files = static::get_attachment_files( $attachment_id ); $locations = array_map( [ $this, 'get_s3_location_for_path' ], $files ); // Remove any null items in the array from get_s3_location_for_path(). $locations = array_filter( $locations ); $s3 = $this->s3(); $commands = []; foreach ( $locations as $location ) { $commands[] = $s3->getCommand( 'putObjectAcl', [ 'Bucket' => $location['bucket'], 'Key' => $location['key'], 'ACL' => $acl, ] ); } try { Aws\CommandPool::batch( $s3, $commands ); } catch ( Exception $e ) { return new WP_Error( $e->getCode(), $e->getMessage() ); } /** * Fires after ACL of files of an attachment is set. * * @param int $attachment_id Attachment whose ACL has been changed. * @param string $acl The new ACL that's been set. * @psalm-suppress TooManyArguments -- Currently do_action doesn't detect variable number of arguments. */ do_action( 's3_uploads_set_attachment_files_acl', $attachment_id, $acl ); return null; } /** * Get all the files stored for a given attachment. * * @param integer $attachment_id * @return list Array of all full paths to the attachment's files. */ public static function get_attachment_files( int $attachment_id ) : array { /** @var string */ $main_file = get_attached_file( $attachment_id ); $main_file_directory = dirname( $main_file ); $files = [ $main_file ]; $meta = wp_get_attachment_metadata( $attachment_id ); if ( isset( $meta['sizes'] ) ) { foreach ( $meta['sizes'] as $size => $sizeinfo ) { $files[] = $main_file_directory . '/' . $sizeinfo['file']; } } /** @var string|false */ $original_image = get_post_meta( $attachment_id, 'original_image', true ); if ( $original_image !== false && $original_image !== '' ) { $files[] = $main_file_directory . '/' . $original_image; } /** @var array */ $backup_sizes = get_post_meta( $attachment_id, '_wp_attachment_backup_sizes', true ); if ( $backup_sizes ) { foreach ( $backup_sizes as $size => $sizeinfo ) { // Backup sizes only store the backup filename, which is relative to the // main attached file, unlike the metadata sizes array. $files[] = $main_file_directory . '/' . $sizeinfo['file']; } } $files = apply_filters( 's3_uploads_get_attachment_files', $files, $attachment_id ); return $files; } /** * Add the S3 signed params onto an image for for a given attachment. * * This function determines whether the attachment needs a signed URL, so is safe to * pass any URL. * * @param string $url * @param integer $post_id * @return string */ public function add_s3_signed_params_to_attachment_url( string $url, int $post_id ) : string { if ( ! $this->is_private_attachment( $post_id ) ) { return $url; } $path = $this->get_s3_location_for_url( $url ); if ( ! $path ) { return $url; } $cmd = $this->s3()->getCommand( 'GetObject', [ 'Bucket' => $path['bucket'], 'Key' => $path['key'], ] ); $presigned_url_expires = apply_filters( 's3_uploads_private_attachment_url_expiry', '+6 hours', $post_id ); $query = $this->s3()->createPresignedRequest( $cmd, $presigned_url_expires )->getUri()->getQuery(); // The URL could have query params on it already (such as being an already signed URL), // but query params will mean the S3 signed URL will become corrupt. So, we have to // remove all query params. $url = strtok( $url, '?' ) . '?' . $query; $url = apply_filters( 's3_uploads_presigned_url', $url, $post_id ); return $url; } /** * Add the S3 signed params to an image src array. * * @param array{0: string, 1: int, 2: int}|false $image * @param integer|"" $post_id The post id, due to WordPress hook, this can be "", so can't just hint as int. * @return array{0: string, 1: int, 2: int}|false */ public function add_s3_signed_params_to_attachment_image_src( $image, $post_id ) { if ( $image === false || $post_id === '' || $post_id === 0 ) { return $image; } $image[0] = $this->add_s3_signed_params_to_attachment_url( $image[0], $post_id ); return $image; } /** * Add the S3 signed params to the image srcset (response image) sizes. * * @param array{url: string, descriptor: string, value: int}[] $sources * @param array $sizes * @param string $src * @param array $meta * @param integer $post_id * @return array{url: string, descriptor: string, value: int}[] */ public function add_s3_signed_params_to_attachment_image_srcset( array $sources, array $sizes, string $src, array $meta, int $post_id ) : array { foreach ( $sources as &$source ) { $source['url'] = $this->add_s3_signed_params_to_attachment_url( $source['url'], $post_id ); } return $sources; } /** * Whenever attachment metadata is generated, set the attachment files to private if it's a private attachment. * * @param array $metadata The attachment metadata. * @param int $attachment_id The attachment ID * @return array */ public function set_attachment_private_on_generate_attachment_metadata( array $metadata, int $attachment_id ) : array { if ( $this->is_private_attachment( $attachment_id ) ) { $this->set_attachment_files_acl( $attachment_id, 'private' ); } return $metadata; } /** * Override the files used for wp_unique_filename() comparisons * * @param array|null $files * @param string $dir * @return array */ public function get_files_for_unique_filename_file_list( ?array $files, string $dir, string $filename ) : array { $name = pathinfo( $filename, PATHINFO_FILENAME ); // The s3:// streamwrapper support listing by partial prefixes with wildcards. // For example, scandir( s3://bucket/2019/06/my-image* ) $scandir = scandir( trailingslashit( $dir ) . $name . '*' ); if ( $scandir === false ) { $scandir = []; // Set as empty array for return } return $scandir; } }