Changed source root directory

This commit is contained in:
2026-03-05 16:30:11 +01:00
parent dc85447ee1
commit 538f85d7a2
5868 changed files with 749734 additions and 99 deletions

View File

@@ -0,0 +1,139 @@
<?php
namespace S3_Uploads;
use Imagick;
use WP_Error;
use WP_Image_Editor_Imagick;
class Image_Editor_Imagick extends WP_Image_Editor_Imagick {
/**
* @var ?Imagick
*/
protected $image;
/**
* @var ?string
*/
protected $file;
/**
* @var ?array{width: int, height: int}
*/
protected $size;
/**
* @var ?string
*/
protected $remote_filename = null;
/**
* Hold on to a reference of all temp local files.
*
* These are cleaned up on __destruct.
*
* @var array
*/
protected $temp_files_to_cleanup = [];
/**
* Loads image from $this->file into new Imagick Object.
*
* @return true|WP_Error True if loaded; WP_Error on failure.
*/
public function load() {
if ( $this->image instanceof Imagick ) {
return true;
}
if ( $this->file !== null && $this->file !== '' && ! is_file( $this->file ) && ! preg_match( '|^https?://|', $this->file ) ) {
return new WP_Error( 'error_loading_image', __( 'File doesn&#8217;t exist?' ), $this->file );
}
$upload_dir = wp_upload_dir();
if ( $this->file === null || $this->file === '' || strpos( $this->file, $upload_dir['basedir'] ) !== 0 ) {
return parent::load();
}
$temp_filename = tempnam( get_temp_dir(), 's3-uploads' );
$this->temp_files_to_cleanup[] = $temp_filename;
copy( $this->file, $temp_filename );
$this->remote_filename = $this->file;
$this->file = $temp_filename;
$result = parent::load();
$this->file = $this->remote_filename;
return $result;
}
/**
* Imagick by default can't handle s3:// paths
* for saving images. We have instead save it to a file file,
* then copy it to the s3:// path as a workaround.
*
* @param Imagick $image
* @param ?string $filename
* @param ?string $mime_type
* @return WP_Error|array{path: string, file: string, width: int, height: int, mime-type: string}
*/
protected function _save( $image, $filename = null, $mime_type = null ) {
list( $filename, $extension, $mime_type ) = $this->get_output_format( $filename, $mime_type );
if ( ! $filename ) {
$filename = $this->generate_filename( null, null, $extension );
}
$upload_dir = wp_upload_dir();
if ( strpos( $filename, $upload_dir['basedir'] ) === 0 ) {
$temp_filename = tempnam( get_temp_dir(), 's3-uploads' );
} else {
$temp_filename = false;
}
/**
* @var WP_Error|array{path: string, file: string, width: int, height: int, mime-type: string}
*/
$parent_call = parent::_save( $image, $temp_filename !== false ? $temp_filename : $filename, $mime_type );
if ( is_wp_error( $parent_call ) ) {
if ( $temp_filename !== false ) {
unlink( $temp_filename );
}
return $parent_call;
} else {
$save = $parent_call;
}
$copy_result = copy( $save['path'], $filename );
unlink( $save['path'] );
if ( $temp_filename !== false ) {
unlink( $temp_filename );
}
if ( ! $copy_result ) {
return new WP_Error( 'unable-to-copy-to-s3', 'Unable to copy the temp image to S3' );
}
$response = [
'path' => $filename,
'file' => wp_basename( apply_filters( 'image_make_intermediate_size', $filename ) ),
'width' => $this->size['width'] ?? 0,
'height' => $this->size['height'] ?? 0,
'mime-type' => $mime_type,
];
return $response;
}
public function __destruct() {
array_map( 'unlink', $this->temp_files_to_cleanup );
parent::__destruct();
}
}

View File

@@ -0,0 +1,572 @@
<?php
namespace S3_Uploads;
// phpcs:disable WordPress.NamingConventions.ValidVariableName.MemberNotSnakeCase
// phpcs:disable WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
// phpcs:disable WordPress.NamingConventions.ValidVariableName.NotSnakeCaseMemberVar
// phpcs:disable WordPress.NamingConventions.ValidHookName.NotLowercase
// phpcs:disable WordPress.NamingConventions.ValidVariableName.NotSnakeCase
// phpcs:disable WordPress.WP.AlternativeFunctions
/**
* Local stream wrapper that writes files to the upload dir
*
* This is for the most part taken from Drupal, with some modifications.
*/
class Local_Stream_Wrapper {
/**
* Stream context resource.
*
* @var ?resource
*/
public $context;
/**
* A generic resource handle.
*
* @var ?resource
*/
public $handle = null;
/**
* Instance URI (stream).
*
* A stream is referenced as "scheme://target".
*
* @var ?string
*/
protected $uri;
/**
* Gets the path that the wrapper is responsible for.
*
* @return string String specifying the path.
*/
static function getDirectoryPath() : string {
$upload_dir = Plugin::get_instance()->get_original_upload_dir();
return $upload_dir['basedir'] . '/s3';
}
function setUri( string $uri ) : void {
$this->uri = $uri;
}
function getUri() : string {
return $this->uri ?? '';
}
/**
* Returns the local writable target of the resource within the stream.
*
* This function should be used in place of calls to realpath() or similar
* functions when attempting to determine the location of a file. While
* functions like realpath() may return the location of a read-only file, this
* method may return a URI or path suitable for writing that is completely
* separate from the URI used for reading.
*
* @param string $uri
* Optional URI.
*
* @return string
* Returns a string representing a location suitable for writing of a file,
* or FALSE if unable to write to the file such as with read-only streams.
*/
protected function getTarget( $uri = null ) : string {
if ( ! isset( $uri ) ) {
$uri = $this->uri ?? '';
}
$parts = explode( '://', $uri, 2 );
$target = $parts[1] ?? '';
// Remove erroneous leading or trailing, forward-slashes and backslashes.
return trim( $target, '\/' );
}
/**
* Get mime type for URI
*
* @param string $uri
* @param array{extensions?: string[], mimetypes: array<string,string>} $mapping
* @return string
*/
static function getMimeType( string $uri, ?array $mapping = null ) : string {
$extension = '';
$file_parts = explode( '.', basename( $uri ) );
// Remove the first part: a full filename should not match an extension.
array_shift( $file_parts );
// Iterate over the file parts, trying to find a match.
// For my.awesome.image.jpeg, we try:
// - jpeg
// - image.jpeg, and
// - awesome.image.jpeg
while ( $additional_part = array_pop( $file_parts ) ) {
$extension = strtolower( $additional_part . ( $extension ? '.' . $extension : '' ) );
if ( isset( $mapping['extensions'][ $extension ] ) ) {
return $mapping['mimetypes'][ $mapping['extensions'][ $extension ] ];
}
}
return 'application/octet-stream';
}
function chmod( int $mode ) : bool {
$output = @chmod( $this->getLocalPath(), $mode ); // // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
// We are modifying the underlying file here, so we have to clear the stat
// cache so that PHP understands that URI has changed too.
clearstatcache( true, $this->getLocalPath() );
return $output;
}
function realpath() : string {
return $this->getLocalPath();
}
/**
* Returns the canonical absolute path of the URI, if possible.
*
* @param string $uri
* (optional) The stream wrapper URI to be converted to a canonical
* absolute path. This may point to a directory or another type of file.
*
* @return string
* If $uri is not set, returns the canonical absolute path of the URI
* previously. If $uri is set and valid for this class, returns its canonical absolute
* path, as determined by the realpath() function. If $uri is set but not
* valid, returns empty string.
*/
protected function getLocalPath( $uri = null ) : string {
if ( ! isset( $uri ) ) {
$uri = $this->uri;
}
$path = $this->getDirectoryPath() . '/' . $this->getTarget( $uri );
$realpath = str_replace( '/', DIRECTORY_SEPARATOR, $path ); // ensure check against realpath passes on Windows machines
$directory = realpath( $this->getDirectoryPath() );
if ( $directory === false || strpos( $realpath, $directory ) !== 0 ) {
return '';
}
return $realpath;
}
/**
* Support for fopen(), file_get_contents(), file_put_contents() etc.
*
* @param string $uri
* A string containing the URI to the file to open.
* @param string $mode
* The file mode ("r", "wb" etc.).
* @param int $options
* A bit mask of STREAM_USE_PATH and STREAM_REPORT_ERRORS.
* @param string $opened_path
* A string containing the path actually opened.
* @param-out string $opened_path
*
* @return bool
* Returns TRUE if file was opened successfully.
*
* @see http://php.net/manual/streamwrapper.stream-open.php
*/
public function stream_open( $uri, $mode, $options, &$opened_path ) {
$this->uri = $uri;
$path = $this->getLocalPath();
$this->handle = ( $options & STREAM_REPORT_ERRORS ) ? fopen( $path, $mode ) : @fopen( $path, $mode );
if ( (bool) $this->handle && $options & STREAM_USE_PATH ) {
$opened_path = $path;
}
return (bool) $this->handle;
}
/**
* Support for chmod(), chown(), etc.
*
* @return bool
* Returns TRUE on success or FALSE on failure.
*
* @see http://php.net/manual/streamwrapper.stream-metadata.php
*/
public function stream_metadata() {
return true;
}
/**
* Support for flock().
*
* @param int $operation
* One of the following:
* - LOCK_SH to acquire a shared lock (reader).
* - LOCK_EX to acquire an exclusive lock (writer).
* - LOCK_UN to release a lock (shared or exclusive).
* - LOCK_NB if you don't want flock() to block while locking (not
* supported on Windows).
*
* @return bool
* Always returns TRUE at the present time.
*
* @see http://php.net/manual/streamwrapper.stream-lock.php
*/
public function stream_lock( $operation ) {
if ( in_array( $operation, [ LOCK_SH, LOCK_EX, LOCK_UN, LOCK_NB ] ) && $this->handle ) {
return flock( $this->handle, $operation );
}
return true;
}
/**
* Support for fread(), file_get_contents() etc.
*
* @param int $count
* Maximum number of bytes to be read.
*
* @return string|bool
* The string that was read, or FALSE in case of an error.
*
* @see http://php.net/manual/streamwrapper.stream-read.php
*/
public function stream_read( $count ) {
if ( ! $this->handle ) {
return false;
}
return fread( $this->handle, $count );
}
/**
* Support for fwrite(), file_put_contents() etc.
*
* @param string $data
* The string to be written.
*
* @return int
* The number of bytes written.
*
* @see http://php.net/manual/streamwrapper.stream-write.php
*/
public function stream_write( $data ) {
if ( ! $this->handle ) {
return 0;
}
return fwrite( $this->handle, $data );
}
/**
* Support for feof().
*
* @return bool
* TRUE if end-of-file has been reached.
*
* @see http://php.net/manual/streamwrapper.stream-eof.php
*/
public function stream_eof() {
if ( ! $this->handle ) {
return false;
}
return feof( $this->handle );
}
/**
* Support for fseek().
*
* @param int $offset
* The byte offset to got to.
* @param int $whence
* SEEK_SET, SEEK_CUR, or SEEK_END.
*
* @return bool
* TRUE on success.
*
* @see http://php.net/manual/streamwrapper.stream-seek.php
*/
public function stream_seek( $offset, $whence ) {
if ( ! $this->handle ) {
return false;
}
// fseek returns 0 on success and -1 on a failure.
// stream_seek 1 on success and 0 on a failure.
return ! fseek( $this->handle, $offset, $whence );
}
/**
* Support for fflush().
*
* @return bool
* TRUE if data was successfully stored (or there was no data to store).
*
* @see http://php.net/manual/streamwrapper.stream-flush.php
*/
public function stream_flush() {
if ( ! $this->handle ) {
return false;
}
$result = fflush( $this->handle );
$params = [
'Bucket' => S3_UPLOADS_BUCKET,
'Key' => trim( str_replace( S3_UPLOADS_BUCKET, '', $this->getTarget() ), '/' ),
];
/**
* Action when a new object has been uploaded to s3.
*
* @param array $params S3Client::putObject parameters.
*/
do_action( 's3_uploads_putObject', $params );
return $result;
}
/**
* Support for ftell().
*
* @return false|int
* The current offset in bytes from the beginning of file.
*
* @see http://php.net/manual/streamwrapper.stream-tell.php
*/
public function stream_tell() {
if ( ! $this->handle ) {
return false;
}
return ftell( $this->handle );
}
/**
* Support for fstat().
*
* @return array|false
* An array with file status, or FALSE in case of an error - see fstat()
* for a description of this array.
*
* @see http://php.net/manual/streamwrapper.stream-stat.php
*/
public function stream_stat() {
if ( ! $this->handle ) {
return false;
}
return fstat( $this->handle );
}
/**
* Support for fclose().
*
* @return bool
* TRUE if stream was successfully closed.
*
* @see http://php.net/manual/streamwrapper.stream-close.php
*/
public function stream_close() {
if ( ! $this->handle ) {
return false;
}
$resource = $this->handle;
return fclose( $resource );
}
/**
* Gets the underlying stream resource for stream_select().
*
* @param int $cast_as
* Can be STREAM_CAST_FOR_SELECT or STREAM_CAST_AS_STREAM.
*
* @return resource|false
* The underlying stream resource or FALSE if stream_select() is not
* supported.
*
* @see http://php.net/manual/streamwrapper.stream-cast.php
*/
public function stream_cast( $cast_as ) {
return false;
}
/**
* Support for unlink().
*
* @param string $uri
* A string containing the URI to the resource to delete.
*
* @return bool
* TRUE if resource was successfully deleted.
*
* @see http://php.net/manual/streamwrapper.unlink.php
*/
public function unlink( $uri ) {
$this->uri = $uri;
return unlink( $this->getLocalPath() );
}
/**
* Support for rename().
*
* @param string $from_uri,
* The URI to the file to rename.
* @param string $to_uri
* The new URI for file.
*
* @return bool
* TRUE if file was successfully renamed.
*
* @see http://php.net/manual/streamwrapper.rename.php
*/
public function rename( $from_uri, $to_uri ) {
return rename( $this->getLocalPath( $from_uri ), $this->getLocalPath( $to_uri ) );
}
/**
* Support for mkdir().
*
* @param string $uri
* A string containing the URI to the directory to create.
* @param int $mode
* Permission flags - see mkdir().
* @param int $options
* A bit mask of STREAM_REPORT_ERRORS and STREAM_MKDIR_RECURSIVE.
*
* @return bool
* TRUE if directory was successfully created.
*
* @see http://php.net/manual/streamwrapper.mkdir.php
*/
public function mkdir( $uri, $mode, $options ) {
$this->uri = $uri;
$recursive = (bool) ( $options & STREAM_MKDIR_RECURSIVE );
if ( $recursive ) {
// $this->getLocalPath() fails if $uri has multiple levels of directories
// that do not yet exist.
$localpath = $this->getDirectoryPath() . '/' . $this->getTarget( $uri );
} else {
$localpath = $this->getLocalPath( $uri );
}
if ( $options & STREAM_REPORT_ERRORS ) {
return mkdir( $localpath, $mode, $recursive );
} else {
return @mkdir( $localpath, $mode, $recursive );
}
}
/**
* Support for rmdir().
*
* @param string $uri
* A string containing the URI to the directory to delete.
* @param int $options
* A bit mask of STREAM_REPORT_ERRORS.
*
* @return bool
* TRUE if directory was successfully removed.
*
* @see http://php.net/manual/streamwrapper.rmdir.php
*/
public function rmdir( $uri, $options ) {
$this->uri = $uri;
if ( $options & STREAM_REPORT_ERRORS ) {
return rmdir( $this->getLocalPath() );
} else {
return @rmdir( $this->getLocalPath() );
}
}
/**
* Support for stat().
*
* @param string $uri
* A string containing the URI to get information about.
* @param int $flags
* A bit mask of STREAM_URL_STAT_LINK and STREAM_URL_STAT_QUIET.
*
* @return array
* An array with file status, or FALSE in case of an error - see fstat()
* for a description of this array.
*
* @see http://php.net/manual/streamwrapper.url-stat.php
*/
public function url_stat( $uri, $flags ) {
$this->uri = $uri;
$path = $this->getLocalPath();
// Suppress warnings if requested or if the file or directory does not
// exist. This is consistent with PHP's plain filesystem stream wrapper.
if ( $flags & STREAM_URL_STAT_QUIET || ! file_exists( $path ) ) {
return @stat( $path );
} else {
return stat( $path );
}
}
/**
* Support for opendir().
*
* @param string $uri
* A string containing the URI to the directory to open.
* @param int $options
* Unknown (parameter is not documented in PHP Manual).
*
* @return bool
* TRUE on success.
*
* @see http://php.net/manual/streamwrapper.dir-opendir.php
*/
public function dir_opendir( $uri, $options ) {
$this->uri = $uri;
$this->handle = opendir( $this->getLocalPath() );
return (bool) $this->handle;
}
/**
* Support for readdir().
*
* @return string
* The next filename, or FALSE if there are no more files in the directory.
*
* @see http://php.net/manual/streamwrapper.dir-readdir.php
*/
public function dir_readdir() {
if ( ! $this->handle ) {
return '';
}
return readdir( $this->handle );
}
/**
* Support for rewinddir().
*
* @return bool
* TRUE on success.
*
* @see http://php.net/manual/streamwrapper.dir-rewinddir.php
*/
public function dir_rewinddir() {
if ( ! $this->handle ) {
return false;
}
rewinddir( $this->handle );
// We do not really have a way to signal a failure as rewinddir() does not
// have a return value and there is no way to read a directory handler
// without advancing to the next file.
return true;
}
/**
* Support for closedir().
*
* @return bool
* TRUE on success.
*
* @see http://php.net/manual/streamwrapper.dir-closedir.php
*/
public function dir_closedir() {
if ( ! $this->handle ) {
return false;
}
closedir( $this->handle );
// We do not really have a way to signal a failure as closedir() does not
// have a return value.
return true;
}
}

View File

@@ -0,0 +1,691 @@
<?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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,360 @@
<?php
namespace S3_Uploads;
use Aws\Command;
use Aws\S3\Transfer;
use Exception;
use WP_CLI;
class WP_CLI_Command extends \WP_CLI_Command {
/**
* Verifies the API keys entered will work for writing and deleting from S3.
*
* @subcommand verify
*/
public function verify_api_keys() : void {
// Verify first that we have the necessary access keys to connect to S3.
if ( ! $this->verify_s3_access_constants() ) {
return;
}
// Get S3 Upload instance.
Plugin::get_instance();
// Create a path in the base directory, with a random file name to avoid potentially overwriting existing data.
$upload_dir = wp_upload_dir();
$s3_path = $upload_dir['basedir'] . '/' . wp_rand() . '.txt';
// Attempt to copy the local Canola test file to the generated path on S3.
WP_CLI::print_value( 'Attempting to upload file ' . $s3_path );
$copy = copy(
dirname( dirname( __FILE__ ) ) . '/verify.txt',
$s3_path
);
// Check that the copy worked.
if ( ! $copy ) {
WP_CLI::error( 'Failed to copy / write to S3 - check your policy?' );
return;
}
WP_CLI::print_value( 'File uploaded to S3 successfully.' );
// Delete the file off S3.
WP_CLI::print_value( 'Attempting to delete file. ' . $s3_path );
$delete = unlink( $s3_path );
// Check that the delete worked.
if ( ! $delete ) {
WP_CLI::error( 'Failed to delete ' . $s3_path );
return;
}
WP_CLI::print_value( 'File deleted from S3 successfully.' );
WP_CLI::success( 'Looks like your configuration is correct.' );
}
private function get_iam_policy() : string {
$bucket = strtok( S3_UPLOADS_BUCKET, '/' );
$path = null;
if ( strpos( S3_UPLOADS_BUCKET, '/' ) !== false ) {
$path = str_replace( strtok( S3_UPLOADS_BUCKET, '/' ) . '/', '', S3_UPLOADS_BUCKET );
}
return '{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Stmt1392016154000",
"Effect": "Allow",
"Action": [
"s3:AbortMultipartUpload",
"s3:DeleteObject",
"s3:GetBucketAcl",
"s3:GetBucketLocation",
"s3:GetBucketPolicy",
"s3:GetObject",
"s3:GetObjectAcl",
"s3:ListBucket",
"s3:ListBucketMultipartUploads",
"s3:ListMultipartUploadParts",
"s3:PutObject",
"s3:PutObjectAcl"
],
"Resource": [
"arn:aws:s3:::' . S3_UPLOADS_BUCKET . '/*"
]
},
{
"Sid": "AllowRootAndHomeListingOfBucket",
"Action": ["s3:ListBucket"],
"Effect": "Allow",
"Resource": ["arn:aws:s3:::' . $bucket . '"],
"Condition":{"StringLike":{"s3:prefix":["' . ( $path !== null ? $path . '/' : '' ) . '*"]}}
}
]
}';
}
/**
* Create AWS IAM Policy that S3 Uploads requires
*
* It's typically not a good idea to use access keys that have full access to your S3 account,
* as if the keys are compromised through the WordPress site somehow, you don't
* want to give full control via those keys.
*
* @subcommand generate-iam-policy
*/
public function generate_iam_policy() : void {
WP_Cli::print_value( $this->get_iam_policy() );
}
/**
* List files in the S3 bucket
*
* @synopsis [<path>]
*
* @param array{0: string} $args
*/
public function ls( array $args ) : void {
$s3 = Plugin::get_instance()->s3();
$prefix = '';
if ( strpos( S3_UPLOADS_BUCKET, '/' ) !== false ) {
$prefix = trailingslashit( str_replace( strtok( S3_UPLOADS_BUCKET, '/' ) . '/', '', S3_UPLOADS_BUCKET ) );
}
if ( isset( $args[0] ) ) {
$prefix .= trailingslashit( ltrim( $args[0], '/' ) );
}
try {
$objects = $s3->getIterator(
'ListObjectsV2', [
'Bucket' => strtok( S3_UPLOADS_BUCKET, '/' ),
'Prefix' => $prefix,
]
);
/** @var array{Key: string} $object */
foreach ( $objects as $object ) {
WP_CLI::line( str_replace( $prefix, '', $object['Key'] ) );
}
} catch ( Exception $e ) {
WP_CLI::error( $e->getMessage() );
}
}
/**
* Copy files to / from the uploads directory. Use s3://bucket/location for S3
*
* @synopsis <from> <to>
*
* @param array{0: string, 1: string} $args
*/
public function cp( array $args ) : void {
$from = $args[0];
$to = $args[1];
if ( is_dir( $from ) ) {
$this->recurse_copy( $from, $to );
} else {
copy( $from, $to );
}
WP_CLI::success( sprintf( 'Completed copy from %s to %s', $from, $to ) );
}
/**
* Upload a directory to S3
*
* @subcommand upload-directory
* @synopsis <from> [<to>] [--concurrency=<concurrency>] [--verbose]
*
* @param array{0: string, 1: string} $args
* @param array{concurrency?: int, verbose?: bool} $args_assoc
*/
public function upload_directory( array $args, array $args_assoc ) : void {
$from = $args[0];
$to = '';
if ( isset( $args[1] ) ) {
$to = $args[1];
}
$s3 = Plugin::get_instance()->s3();
$args_assoc = wp_parse_args(
$args_assoc, [
'concurrency' => 5,
'verbose' => false,
]
);
$transfer_args = [
'concurrency' => $args_assoc['concurrency'],
'debug' => (bool) $args_assoc['verbose'],
'before' => function ( Command $command ) : void {
if ( in_array( $command->getName(), [ 'PutObject', 'CreateMultipartUpload' ], true ) ) {
$acl = defined( 'S3_UPLOADS_OBJECT_ACL' ) ? S3_UPLOADS_OBJECT_ACL : 'public-read';
$command['ACL'] = $acl;
}
},
];
try {
$manager = new Transfer( $s3, $from, 's3://' . S3_UPLOADS_BUCKET . '/' . $to, $transfer_args );
$manager->transfer();
} catch ( Exception $e ) {
WP_CLI::error( $e->getMessage() );
}
}
/**
* Delete files from S3
*
* @synopsis <path> [--regex=<regex>]
*
* @param array{0: string} $args
* @param array{regex?: string} $args_assoc
*/
public function rm( array $args, array $args_assoc ) : void {
$s3 = Plugin::get_instance()->s3();
$prefix = '';
$regex = isset( $args_assoc['regex'] ) ? $args_assoc['regex'] : '';
if ( strpos( S3_UPLOADS_BUCKET, '/' ) !== false ) {
$prefix = trailingslashit( str_replace( strtok( S3_UPLOADS_BUCKET, '/' ) . '/', '', S3_UPLOADS_BUCKET ) );
}
if ( isset( $args[0] ) ) {
$prefix .= ltrim( $args[0], '/' );
if ( strpos( $args[0], '.' ) === false ) {
$prefix = trailingslashit( $prefix );
}
}
try {
$s3->deleteMatchingObjects(
strtok( S3_UPLOADS_BUCKET, '/' ),
$prefix,
$regex,
[
'before_delete',
function() {
WP_CLI::line( 'Deleting file' );
},
]
);
} catch ( Exception $e ) {
WP_CLI::error( $e->getMessage() );
}
WP_CLI::success( sprintf( 'Successfully deleted %s', $prefix ) );
}
/**
* Enable the auto-rewriting of media links to S3
*/
public function enable() : void {
update_option( 's3_uploads_enabled', 'enabled' );
WP_CLI::success( 'Media URL rewriting enabled.' );
}
/**
* Disable the auto-rewriting of media links to S3
*/
public function disable() : void {
delete_option( 's3_uploads_enabled' );
WP_CLI::success( 'Media URL rewriting disabled.' );
}
/**
* List all files for a given attachment.
*
* Useful for debugging.
*
* @subcommand get-attachment-files
* @synopsis <attachment-id>
*
* @param array{0: int} $args
*/
public function get_attachment_files( array $args ) : void {
WP_CLI::print_value( Plugin::get_attachment_files( $args[0] ) );
}
/**
* Update the ACL of all files for an attachment.
*
* Useful for debugging.
*
* @subcommand set-attachment-acl
* @synopsis <attachment-id> <acl>
*
* @param array{0: int, 1: 'public-read'|'private'} $args
*/
public function set_attachment_acl( array $args ) : void {
$result = Plugin::get_instance()->set_attachment_files_acl( $args[0], $args[1] );
WP_CLI::print_value( $result );
}
private function recurse_copy( string $src, string $dst ) : void {
$dir = opendir( $src );
@mkdir( $dst );
while ( false !== ( $file = readdir( $dir ) ) ) {
if ( ( '.' !== $file ) && ( '..' !== $file ) ) {
if ( is_dir( $src . '/' . $file ) ) {
$this->recurse_copy( $src . '/' . $file, $dst . '/' . $file );
} else {
WP_CLI::line( sprintf( 'Copying from %s to %s', $src . '/' . $file, $dst . '/' . $file ) );
copy( $src . '/' . $file, $dst . '/' . $file );
}
}
}
closedir( $dir );
}
/**
* Verify that the required constants for the S3 connections are set.
*
* @return bool true if all constants are set, else false.
*/
private function verify_s3_access_constants() {
$required_constants = [
'S3_UPLOADS_BUCKET',
];
// Credentials do not need to be set when using AWS Instance Profiles.
if ( ! defined( 'S3_UPLOADS_USE_INSTANCE_PROFILE' ) || ! S3_UPLOADS_USE_INSTANCE_PROFILE ) {
array_push( $required_constants, 'S3_UPLOADS_KEY', 'S3_UPLOADS_SECRET' );
}
$all_set = true;
foreach ( $required_constants as $constant ) {
if ( ! defined( $constant ) ) {
WP_CLI::error( sprintf( 'The required constant %s is not defined.', $constant ), false );
$all_set = false;
}
}
return $all_set;
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace S3_Uploads;
function init() : void {
// Ensure the AWS SDK can be loaded.
if ( ! class_exists( '\\Aws\\S3\\S3Client' ) ) {
trigger_error( 'S3 Uploads requires the AWS SDK. Ensure Composer dependencies have been loaded.', E_USER_WARNING );
return;
}
if ( ! check_requirements() ) {
return;
}
if ( ! defined( 'S3_UPLOADS_BUCKET' ) ) {
return;
}
if ( ( ! defined( 'S3_UPLOADS_KEY' ) || ! defined( 'S3_UPLOADS_SECRET' ) ) && ! defined( 'S3_UPLOADS_USE_INSTANCE_PROFILE' ) ) {
return;
}
if ( ! enabled() ) {
return;
}
if ( ! defined( 'S3_UPLOADS_REGION' ) ) {
wp_die( 'S3_UPLOADS_REGION constant is required. Please define it in your wp-config.php' );
}
if ( defined( 'WP_CLI' ) && WP_CLI ) {
\WP_CLI::add_command( 's3-uploads', 'S3_Uploads\\WP_CLI_Command' );
}
$instance = Plugin::get_instance();
$instance->setup();
// Add filters to "wrap" the wp_privacy_personal_data_export_file function call as we need to
// switch out the personal_data directory to a local temp folder, and then upload after it's
// complete, as Core tries to write directly to the ZipArchive which won't work with the
// S3 streamWrapper.
add_action( 'wp_privacy_personal_data_export_file', __NAMESPACE__ . '\\before_export_personal_data', 9, 0 );
add_action( 'wp_privacy_personal_data_export_file', __NAMESPACE__ . '\\after_export_personal_data', 11, 0 );
add_action( 'wp_privacy_personal_data_export_file_created', __NAMESPACE__ . '\\move_temp_personal_data_to_s3', 1000 );
}
/**
* Check whether the environment meets the plugin's requirements, like the minimum PHP version.
*
* @return bool True if the requirements are met, else false.
*/
function check_requirements() : bool {
global $wp_version;
if ( version_compare( PHP_VERSION, '7.4', '<' ) ) {
if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
add_action( 'admin_notices', __NAMESPACE__ . '\\outdated_php_version_notice', 10, 0 );
}
return false;
}
if ( version_compare( $wp_version, '5.3.0', '<' ) ) {
if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
add_action( 'admin_notices', __NAMESPACE__ . '\\outdated_wp_version_notice', 10, 0 );
}
return false;
}
if ( ini_get( 'allow_url_fopen' ) === false || ini_get( 'allow_url_fopen' ) === '' ) {
if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
add_action( 'admin_notices', __NAMESPACE__ . '\\url_fopen_disabled_notice', 10, 0 );
}
return false;
}
return true;
}
/**
* Print an admin notice when the PHP version is not high enough.
*
* This has to be a named function for compatibility with PHP 5.2.
*/
function outdated_php_version_notice() : void {
printf(
'<div class="error"><p>The S3 Uploads plugin requires PHP version 7.4 or higher. Your server is running PHP version %s.</p></div>',
PHP_VERSION
);
}
/**
* Print an admin notice when the PHP version is not high enough.
*
* This has to be a named function for compatibility with PHP 5.2.
*/
function url_fopen_disabled_notice() : void {
printf( '<div class="error"><p>The S3 Uploads plugin requires PHP option allow_url_fopen to be enabled. <a href="%s" target="_blank" rel="noopener noreferrer">Learn more</a>.</p></div>',
'https://www.php.net/manual/en/filesystem.configuration.php#ini.allow-url-fopen'
);
}
/**
* Print an admin notice when the WP version is not high enough.
*
* This has to be a named function for compatibility with PHP 5.2.
*/
function outdated_wp_version_notice() : void {
global $wp_version;
printf(
'<div class="error"><p>The S3 Uploads plugin requires WordPress version 5.3 or higher. Your server is running WordPress version %s.</p></div>',
esc_html( $wp_version )
);
}
/**
* Check if URL rewriting is enabled.
*
* Define S3_UPLOADS_AUTOENABLE to false in your wp-config to disable, or use the
* s3_uploads_enabled option.
*
* @return bool
*/
function enabled() : bool {
// Make sure the plugin is enabled when autoenable is on
$constant_autoenable_off = ( defined( 'S3_UPLOADS_AUTOENABLE' ) && false === S3_UPLOADS_AUTOENABLE );
if ( $constant_autoenable_off && 'enabled' !== get_option( 's3_uploads_enabled' ) ) { // If the plugin is not enabled, skip
return false;
}
return true;
}
/**
* Setup the filters for wp_privacy_exports_dir to use a temp folder location.
*/
function before_export_personal_data() : void {
add_filter( 'wp_privacy_exports_dir', __NAMESPACE__ . '\\set_wp_privacy_exports_dir' );
}
/**
* Remove the filters for wp_privacy_exports_dir as we only want it added in some cases.
*/
function after_export_personal_data() : void {
remove_filter( 'wp_privacy_exports_dir', __NAMESPACE__ . '\\set_wp_privacy_exports_dir' );
}
/**
* Override the wp_privacy_exports_dir location
*
* We don't want to use the default uploads folder location, as with S3 Uploads this is
* going to the a s3:// custom URL handler, which is going to fail with the use of ZipArchive.
* Instead we set to to WP's get_temp_dir and move the fail in the wp_privacy_personal_data_export_file_created
* hook.
*
* @param string $dir
* @return string
*/
function set_wp_privacy_exports_dir( string $dir ) {
if ( strpos( $dir, 's3://' ) !== 0 ) {
return $dir;
}
$dir = get_temp_dir() . 'wp_privacy_exports_dir/';
if ( ! is_dir( $dir ) ) {
mkdir( $dir );
file_put_contents( $dir . 'index.html', '' ); // @codingStandardsIgnoreLine FS write is ok.
}
return $dir;
}
/**
* Move the tmp personal data file to the true uploads location
*
* Once a personal data file has been written, move it from the overridden "temp"
* location to the S3 location where it should have been stored all along, and where
* the "natural" Core URL is going to be pointing to.
*/
function move_temp_personal_data_to_s3( string $archive_pathname ) : void {
if ( strpos( $archive_pathname, get_temp_dir() ) !== 0 ) {
return;
}
$upload_dir = wp_upload_dir();
$exports_dir = trailingslashit( $upload_dir['basedir'] ) . 'wp-personal-data-exports/';
$destination = $exports_dir . pathinfo( $archive_pathname, PATHINFO_FILENAME ) . '.' . pathinfo( $archive_pathname, PATHINFO_EXTENSION );
copy( $archive_pathname, $destination );
unlink( $archive_pathname );
}