Files
LiveCartaWP/html/wp-content/plugins/s3-uploads/inc/class-plugin.php

692 lines
21 KiB
PHP

<?php
namespace S3_Uploads;
use Aws;
use Exception;
use WP_Error;
/**
* @psalm-consistent-constructor
*/
class Plugin {
/**
* The S3 bucket with path.
*
* @var string
*/
private $bucket;
/**
* The URL that resolves to the S3 bucket.
*
* @var ?string
*/
private $bucket_url;
/**
* AWS IAM access key used for S3 Access.
*
* @var ?string
*/
private $key;
/**
* AWS IAM access key secret used for S3 Access.
*
* @var ?string
*/
private $secret;
/**
* Original wp_upload_dir() before being replaced by S3 Uploads.
*
* @var ?array{path: string, basedir: string, baseurl: string, url: string, subdir: string, error: string|false}
*/
public $original_upload_dir;
/**
* @var ?string
*/
private $region = null;
/**
* @var ?Aws\S3\S3Client
*/
private $s3 = null;
/**
* @var ?static
*/
private static $instance = null;
/**
*
* @return static
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new static(
S3_UPLOADS_BUCKET,
defined( 'S3_UPLOADS_KEY' ) ? S3_UPLOADS_KEY : null,
defined( 'S3_UPLOADS_SECRET' ) ? S3_UPLOADS_SECRET : null,
defined( 'S3_UPLOADS_BUCKET_URL' ) ? S3_UPLOADS_BUCKET_URL : null,
S3_UPLOADS_REGION
);
}
return self::$instance;
}
/**
* Constructor.
*
* @param string $bucket
* @param ?string $key
* @param ?string $secret
* @param ?string $bucket_url
* @param ?string $region
*/
public function __construct( $bucket, $key, $secret, $bucket_url = null, $region = null ) {
$this->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<string, mixed> $metadata Attachment metadata.
* @param int $attachment_id Attachment ID.
* @return array<string, mixed> 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<string, mixed> $meta
* @param string $file
* @return array<string, mixed>|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<string> 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<string,array{file: string}> */
$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;
}
}