├── .gitattributes ├── .github ├── hmlinter.yml └── workflows │ └── ci.yml ├── .phpcsignore ├── CHANGELOG.md ├── README.md ├── codecov.yml ├── composer.json ├── inc ├── class-image-editor-imagick.php ├── class-local-stream-wrapper.php ├── class-plugin.php ├── class-stream-wrapper.php ├── class-wp-cli-command.php └── namespace.php ├── psalm.xml ├── psalm └── stubs │ ├── constants.php │ └── imagick.php ├── s3-uploads.php └── verify.txt /.gitattributes: -------------------------------------------------------------------------------- 1 | /tests export-ignore 2 | /bin export-ignore 3 | /.gitignore export-ignore 4 | /.travis.yml export-ignore 5 | /phpunit.xml.dist export-ignore 6 | -------------------------------------------------------------------------------- /.github/hmlinter.yml: -------------------------------------------------------------------------------- 1 | # By default, the version is set to "latest". This can be set to any version 2 | # from 0.4.2 and above, but you MUST include the full version number. 3 | # If you wish to increase the security releases automatically set the 4 | # version to be 'X.Y', otherwise it will be 'X.Y.Z'. 5 | version: 0.8.0 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master, v3-branch ] 6 | pull_request: 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | 15 | - name: Install dependencies 16 | run: docker run --rm -v $PWD:/code --entrypoint='' humanmade/plugin-tester composer install 17 | 18 | - name: Run tests 19 | run: ./tests/run-tests.sh --coverage-clover=coverage.xml 20 | 21 | - name: Upload coverage to Codecov 22 | run: bash <(curl -s https://codecov.io/bash) 23 | env: 24 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 25 | -------------------------------------------------------------------------------- /.phpcsignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | psalm/ 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.1.0-RC2 4 | 5 | - Remove `lib/aws-sdk` from repository [#305](https://github.com/humanmade/S3-Uploads/pull/305) 6 | - Fix delete files on delete attachment [#307](https://github.com/humanmade/S3-Uploads/pull/307) 7 | - Fix Imagick with s3 compatibility [#306](https://github.com/humanmade/S3-Uploads/pull/306) 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 18 | 19 | 20 | 23 | 26 | 27 |
4 | S3 Uploads
5 | Lightweight "drop-in" for storing WordPress uploads on Amazon S3 instead of the local filesystem. 6 |
8 | 9 | Psalm coverage 10 | 11 | 12 | CI 13 | 14 | 15 | 16 | 17 |
21 | A Human Made project. Maintained by @joehoyle. 22 | 24 | 25 |
28 | 29 | S3 Uploads is a WordPress plugin to store uploads on S3. S3 Uploads aims to be a lightweight "drop-in" for storing uploads on Amazon S3 instead of the local filesystem. 30 | 31 | It's focused on providing a highly robust S3 interface with no "bells and whistles", WP-Admin UI or much otherwise. It comes with some helpful WP-CLI commands for generating IAM users, listing files on S3 and Migrating your existing library to S3. 32 | 33 | ## Requirements 34 | 35 | - PHP >= 7.4 36 | - WordPress >= 5.3 37 | 38 | ## Getting Set Up 39 | 40 | S3 Uploads requires installation via Composer: 41 | 42 | ``` 43 | composer require humanmade/s3-uploads 44 | ``` 45 | 46 | **Note:** [Composer's autoloader](https://getcomposer.org/doc/01-basic-usage.md#autoloading) must be loaded before S3 Uploads is loaded. We recommend loading it in your `wp-config.php` before `wp-settings.php` is loaded as shown below. 47 | 48 | ```php 49 | require_once __DIR__ . '/vendor/autoload.php'; 50 | ``` 51 | 52 | ## Configuration 53 | 54 | Once you've installed the plugin, add the following constants to your `wp-config.php`: 55 | 56 | ```PHP 57 | define( 'S3_UPLOADS_BUCKET', 'my-bucket' ); 58 | define( 'S3_UPLOADS_REGION', '' ); // the s3 bucket region (excluding the rest of the URL) 59 | 60 | // You can set key and secret directly: 61 | define( 'S3_UPLOADS_KEY', '' ); 62 | define( 'S3_UPLOADS_SECRET', '' ); 63 | 64 | // Or if using IAM instance profiles, you can use the instance's credentials: 65 | define( 'S3_UPLOADS_USE_INSTANCE_PROFILE', true ); 66 | ``` 67 | Please refer to this region list http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region for the S3_UPLOADS_REGION values. 68 | 69 | Use of path prefix after the bucket name is allowed and is optional. For example, if you want to upload all files to 'my-folder' inside a bucket called 'my-bucket', you can use: 70 | 71 | ```PHP 72 | define( 'S3_UPLOADS_BUCKET', 'my-bucket/my-folder' ); 73 | ``` 74 | 75 | You must then enable the plugin. To do this via WP-CLI use command: 76 | 77 | ``` 78 | wp plugin activate S3-Uploads 79 | ``` 80 | 81 | The plugin name must match the directory you have cloned S3 Uploads into; 82 | If you're using Composer, use 83 | ``` 84 | wp plugin activate s3-uploads 85 | ``` 86 | 87 | 88 | The next thing that you should do is to verify your setup. You can do this using the `verify` command 89 | like so: 90 | 91 | ``` 92 | wp s3-uploads verify 93 | ``` 94 | 95 | You will need to create your IAM user yourself, or attach the necessary permissions to an existing user, you can output the policy via `wp s3-uploads generate-iam-policy` 96 | 97 | 98 | ## Listing files on S3 99 | 100 | S3-Uploads comes with a WP-CLI command for listing files in the S3 bucket for debugging etc. 101 | 102 | ``` 103 | wp s3-uploads ls [] 104 | ``` 105 | 106 | ## Uploading files to S3 107 | 108 | If you have an existing media library with attachment files, use the below command to copy them all to S3 from local disk. 109 | 110 | ``` 111 | wp s3-uploads upload-directory [--verbose] 112 | ``` 113 | 114 | For example, to migrate your whole uploads directory to S3, you'd run: 115 | 116 | ``` 117 | wp s3-uploads upload-directory /path/to/uploads/ uploads 118 | ``` 119 | 120 | There is also an all purpose `cp` command for arbitrary copying to and from S3. 121 | 122 | ``` 123 | wp s3-uploads cp 124 | ``` 125 | 126 | Note: as either `` or `` can be S3 or local locations, you must specify the full S3 location via `s3://mybucket/mydirectory` for example `cp ./test.txt s3://mybucket/test.txt`. 127 | 128 | ## Private Uploads 129 | 130 | WordPress (and therefore S3 Uploads) default behaviour is that all uploaded media files are publicly accessible. In certain cases which may not be desireable. S3 Uploads supports setting S3 Objects to a `private` ACL and providing temporarily signed URLs for all files that are marked as private. 131 | 132 | S3 Uploads does not make assumptions or provide UI for marking attachments as private, instead you should integrate the `s3_uploads_is_attachment_private` WordPress filter to control the behaviour. For example, to mark _all_ attachments as private: 133 | 134 | ```php 135 | add_filter( 's3_uploads_is_attachment_private', '__return_true' ); 136 | ``` 137 | 138 | Private uploads can be transitioned to public by calling `S3_Uploads::set_attachment_files_acl( $id, 'public-read' )` or vica-versa. For example: 139 | 140 | ```php 141 | S3_Uploads::get_instance()->set_attachment_files_acl( 15, 'public-read' ); 142 | ``` 143 | 144 | The default expiry for all private file URLs is 6 hours. You can modify this by using the `s3_uploads_private_attachment_url_expiry` WordPress filter. The value can be any string interpreted by `strtotime`. For example: 145 | 146 | ```php 147 | add_filter( 's3_uploads_private_attachment_url_expiry', function ( $expiry ) { 148 | return '+1 hour'; 149 | } ); 150 | ``` 151 | 152 | If you're using [Stream](https://wordpress.org/plugins/stream/) for audit logs, [S3 Uploads Audit](https://github.com/humanmade/s3-uploads-audit) is an add-on plugin which supports logging some S3 Uploads actions e.g any setting of ACL for files of an attachment. So you can install it for such audit functionality. 153 | 154 | ## Cache Control 155 | 156 | You can define the default HTTP `Cache-Control` header for uploaded media using the 157 | following constant: 158 | 159 | ```PHP 160 | define( 'S3_UPLOADS_HTTP_CACHE_CONTROL', 30 * 24 * 60 * 60 ); 161 | // will expire in 30 days time 162 | ``` 163 | 164 | You can also configure the `Expires` header using the `S3_UPLOADS_HTTP_EXPIRES` constant 165 | For instance if you wanted to set an asset to effectively not expire, you could 166 | set the Expires header way off in the future. For example: 167 | 168 | ```PHP 169 | define( 'S3_UPLOADS_HTTP_EXPIRES', gmdate( 'D, d M Y H:i:s', time() + (10 * 365 * 24 * 60 * 60) ) .' GMT' ); 170 | // will expire in 10 years time 171 | ``` 172 | 173 | ## Default Behaviour 174 | 175 | As S3 Uploads is a plug and play plugin, activating it will start rewriting image URLs to S3, and also put 176 | new uploads on S3. Sometimes this isn't required behaviour as a site owner may want to upload a large 177 | amount of media to S3 using the `wp-cli` commands before enabling S3 Uploads to direct all uploads requests 178 | to S3. In this case one can define the `S3_UPLOADS_AUTOENABLE` to `false`. For example, place the following 179 | in your `wp-config.php`: 180 | 181 | ```PHP 182 | define( 'S3_UPLOADS_AUTOENABLE', false ); 183 | ``` 184 | 185 | To then enable S3 Uploads rewriting, use the wp-cli command: `wp s3-uploads enable` / `wp s3-uploads disable` 186 | to toggle the behaviour. 187 | 188 | ## URL Rewrites 189 | 190 | By default, S3 Uploads will use the canonical S3 URIs for referencing the uploads, i.e. `[bucket name].s3.amazonaws.com/uploads/[file path]`. If you want to use another URL to serve the images from (for instance, if you [wish to use S3 as an origin for CloudFlare](https://support.cloudflare.com/hc/en-us/articles/200168926-How-do-I-use-CloudFlare-with-Amazon-s-S3-Service-)), you should define `S3_UPLOADS_BUCKET_URL` in your `wp-config.php`: 191 | 192 | ```PHP 193 | // Define the base bucket URL (without trailing slash) 194 | define( 'S3_UPLOADS_BUCKET_URL', 'https://your.origin.url.example/path' ); 195 | ``` 196 | S3 Uploads' URL rewriting feature can be disabled if the current website does not require it, nginx proxy to s3 etc. In this case the plugin will only upload files to the S3 bucket. 197 | ```PHP 198 | // disable URL rewriting alltogether 199 | define( 'S3_UPLOADS_DISABLE_REPLACE_UPLOAD_URL', true ); 200 | ``` 201 | 202 | ## S3 Object Permissions 203 | 204 | The object permission of files uploaded to S3 by this plugin can be controlled by setting the `S3_UPLOADS_OBJECT_ACL` 205 | constant. The default setting if not specified is `public-read` to allow objects to be read by anyone. If you don't 206 | want the uploads to be publicly readable then you can define `S3_UPLOADS_OBJECT_ACL` as one of `private` or `authenticated-read` 207 | in you wp-config file: 208 | 209 | ```PHP 210 | // Set the S3 object permission to private 211 | define('S3_UPLOADS_OBJECT_ACL', 'private'); 212 | ``` 213 | 214 | For more information on S3 permissions please see the Amazon S3 permissions documentation. 215 | 216 | ## Custom Endpoints 217 | 218 | Depending on your requirements you may wish to use an alternative S3 compatible object storage system such as Minio, Ceph, 219 | Digital Ocean Spaces, Scaleway and others. 220 | 221 | You can configure the endpoint by adding the following code to a file in the `wp-content/mu-plugins/` directory, for example `wp-content/mu-plugins/s3-endpoint.php`: 222 | 223 | ```php 224 | file into new Imagick Object. 42 | * 43 | * @return true|WP_Error True if loaded; WP_Error on failure. 44 | */ 45 | public function load() { 46 | if ( $this->image instanceof Imagick ) { 47 | return true; 48 | } 49 | 50 | if ( $this->file && ! is_file( $this->file ) && ! preg_match( '|^https?://|', $this->file ) ) { 51 | return new WP_Error( 'error_loading_image', __( 'File doesn’t exist?' ), $this->file ); 52 | } 53 | 54 | $upload_dir = wp_upload_dir(); 55 | 56 | if ( ! $this->file || strpos( $this->file, $upload_dir['basedir'] ) !== 0 ) { 57 | return parent::load(); 58 | } 59 | 60 | $temp_filename = tempnam( get_temp_dir(), 's3-uploads' ); 61 | $this->temp_files_to_cleanup[] = $temp_filename; 62 | 63 | copy( $this->file, $temp_filename ); 64 | $this->remote_filename = $this->file; 65 | $this->file = $temp_filename; 66 | 67 | $result = parent::load(); 68 | 69 | $this->file = $this->remote_filename; 70 | return $result; 71 | } 72 | 73 | /** 74 | * Imagick by default can't handle s3:// paths 75 | * for saving images. We have instead save it to a file file, 76 | * then copy it to the s3:// path as a workaround. 77 | * 78 | * @param Imagick $image 79 | * @param ?string $filename 80 | * @param ?string $mime_type 81 | * @return WP_Error|array{path: string, file: string, width: int, height: int, mime-type: string} 82 | */ 83 | protected function _save( $image, $filename = null, $mime_type = null ) { 84 | /** 85 | * @var ?string $filename 86 | * @var string $extension 87 | * @var string $mime_type 88 | */ 89 | list( $filename, $extension, $mime_type ) = $this->get_output_format( $filename, $mime_type ); 90 | 91 | if ( ! $filename ) { 92 | $filename = $this->generate_filename( null, null, $extension ); 93 | } 94 | 95 | $upload_dir = wp_upload_dir(); 96 | 97 | if ( strpos( $filename, $upload_dir['basedir'] ) === 0 ) { 98 | /** @var false|string */ 99 | $temp_filename = tempnam( get_temp_dir(), 's3-uploads' ); 100 | } else { 101 | $temp_filename = false; 102 | } 103 | 104 | /** 105 | * @var WP_Error|array{path: string, file: string, width: int, height: int, mime-type: string} 106 | */ 107 | $parent_call = parent::_save( $image, $temp_filename ?: $filename, $mime_type ); 108 | 109 | if ( is_wp_error( $parent_call ) ) { 110 | if ( $temp_filename ) { 111 | unlink( $temp_filename ); 112 | } 113 | 114 | return $parent_call; 115 | } else { 116 | /** 117 | * @var array{path: string, file: string, width: int, height: int, mime-type: string} $save 118 | */ 119 | $save = $parent_call; 120 | } 121 | 122 | $copy_result = copy( $save['path'], $filename ); 123 | 124 | unlink( $save['path'] ); 125 | if ( $temp_filename ) { 126 | unlink( $temp_filename ); 127 | } 128 | 129 | if ( ! $copy_result ) { 130 | return new WP_Error( 'unable-to-copy-to-s3', 'Unable to copy the temp image to S3' ); 131 | } 132 | 133 | $response = [ 134 | 'path' => $filename, 135 | 'file' => wp_basename( apply_filters( 'image_make_intermediate_size', $filename ) ), 136 | 'width' => $this->size['width'] ?? 0, 137 | 'height' => $this->size['height'] ?? 0, 138 | 'mime-type' => $mime_type, 139 | ]; 140 | 141 | return $response; 142 | } 143 | 144 | public function __destruct() { 145 | array_map( 'unlink', $this->temp_files_to_cleanup ); 146 | parent::__destruct(); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /inc/class-local-stream-wrapper.php: -------------------------------------------------------------------------------- 1 | get_original_upload_dir(); 48 | return $upload_dir['basedir'] . '/s3'; 49 | } 50 | 51 | function setUri( string $uri ) { 52 | $this->uri = $uri; 53 | } 54 | 55 | function getUri() : string { 56 | return $this->uri ?? ''; 57 | } 58 | 59 | /** 60 | * Returns the local writable target of the resource within the stream. 61 | * 62 | * This function should be used in place of calls to realpath() or similar 63 | * functions when attempting to determine the location of a file. While 64 | * functions like realpath() may return the location of a read-only file, this 65 | * method may return a URI or path suitable for writing that is completely 66 | * separate from the URI used for reading. 67 | * 68 | * @param string $uri 69 | * Optional URI. 70 | * 71 | * @return string 72 | * Returns a string representing a location suitable for writing of a file, 73 | * or FALSE if unable to write to the file such as with read-only streams. 74 | */ 75 | protected function getTarget( $uri = null ) : string { 76 | if ( ! isset( $uri ) ) { 77 | $uri = $this->uri ?: ''; 78 | } 79 | 80 | list( $scheme, $target) = explode( '://', $uri, 2 ); 81 | 82 | // Remove erroneous leading or trailing, forward-slashes and backslashes. 83 | return trim( $target, '\/' ); 84 | } 85 | 86 | /** 87 | * Get mime type for URI 88 | * 89 | * @param string $uri 90 | * @param array{extensions?: string[], mimetypes: array} $mapping 91 | * @return string 92 | */ 93 | static function getMimeType( string $uri, array $mapping = null ) : string { 94 | 95 | $extension = ''; 96 | $file_parts = explode( '.', basename( $uri ) ); 97 | 98 | // Remove the first part: a full filename should not match an extension. 99 | array_shift( $file_parts ); 100 | 101 | // Iterate over the file parts, trying to find a match. 102 | // For my.awesome.image.jpeg, we try: 103 | // - jpeg 104 | // - image.jpeg, and 105 | // - awesome.image.jpeg 106 | while ( $additional_part = array_pop( $file_parts ) ) { 107 | $extension = strtolower( $additional_part . ( $extension ? '.' . $extension : '' ) ); 108 | if ( isset( $mapping['extensions'][ $extension ] ) ) { 109 | return $mapping['mimetypes'][ $mapping['extensions'][ $extension ] ]; 110 | } 111 | } 112 | 113 | return 'application/octet-stream'; 114 | } 115 | 116 | function chmod( int $mode ) : bool { 117 | $output = @chmod( $this->getLocalPath(), $mode ); // // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged 118 | // We are modifying the underlying file here, so we have to clear the stat 119 | // cache so that PHP understands that URI has changed too. 120 | clearstatcache( true, $this->getLocalPath() ); 121 | return $output; 122 | } 123 | 124 | function realpath() : string { 125 | return $this->getLocalPath(); 126 | } 127 | 128 | /** 129 | * Returns the canonical absolute path of the URI, if possible. 130 | * 131 | * @param string $uri 132 | * (optional) The stream wrapper URI to be converted to a canonical 133 | * absolute path. This may point to a directory or another type of file. 134 | * 135 | * @return string 136 | * If $uri is not set, returns the canonical absolute path of the URI 137 | * previously. If $uri is set and valid for this class, returns its canonical absolute 138 | * path, as determined by the realpath() function. If $uri is set but not 139 | * valid, returns empty string. 140 | */ 141 | protected function getLocalPath( $uri = null ) : string { 142 | if ( ! isset( $uri ) ) { 143 | $uri = $this->uri; 144 | } 145 | $path = $this->getDirectoryPath() . '/' . $this->getTarget( $uri ); 146 | $realpath = $path; 147 | 148 | $directory = realpath( $this->getDirectoryPath() ); 149 | 150 | if ( ! $directory || strpos( $realpath, $directory ) !== 0 ) { 151 | return ''; 152 | } 153 | return $realpath; 154 | } 155 | 156 | /** 157 | * Support for fopen(), file_get_contents(), file_put_contents() etc. 158 | * 159 | * @param string $uri 160 | * A string containing the URI to the file to open. 161 | * @param string $mode 162 | * The file mode ("r", "wb" etc.). 163 | * @param int $options 164 | * A bit mask of STREAM_USE_PATH and STREAM_REPORT_ERRORS. 165 | * @param string $opened_path 166 | * A string containing the path actually opened. 167 | * @param-out string $opened_path 168 | * 169 | * @return bool 170 | * Returns TRUE if file was opened successfully. 171 | * 172 | * @see http://php.net/manual/streamwrapper.stream-open.php 173 | */ 174 | public function stream_open( $uri, $mode, $options, &$opened_path ) { 175 | $this->uri = $uri; 176 | $path = $this->getLocalPath(); 177 | $this->handle = ( $options & STREAM_REPORT_ERRORS ) ? fopen( $path, $mode ) : @fopen( $path, $mode ); 178 | 179 | if ( (bool) $this->handle && $options & STREAM_USE_PATH ) { 180 | $opened_path = $path; 181 | } 182 | 183 | return (bool) $this->handle; 184 | } 185 | 186 | /** 187 | * Support for chmod(), chown(), etc. 188 | * 189 | * @return bool 190 | * Returns TRUE on success or FALSE on failure. 191 | * 192 | * @see http://php.net/manual/streamwrapper.stream-metadata.php 193 | */ 194 | public function stream_metadata() { 195 | return true; 196 | } 197 | 198 | /** 199 | * Support for flock(). 200 | * 201 | * @param int $operation 202 | * One of the following: 203 | * - LOCK_SH to acquire a shared lock (reader). 204 | * - LOCK_EX to acquire an exclusive lock (writer). 205 | * - LOCK_UN to release a lock (shared or exclusive). 206 | * - LOCK_NB if you don't want flock() to block while locking (not 207 | * supported on Windows). 208 | * 209 | * @return bool 210 | * Always returns TRUE at the present time. 211 | * 212 | * @see http://php.net/manual/streamwrapper.stream-lock.php 213 | */ 214 | public function stream_lock( $operation ) { 215 | if ( in_array( $operation, [ LOCK_SH, LOCK_EX, LOCK_UN, LOCK_NB ] ) && $this->handle ) { 216 | return flock( $this->handle, $operation ); 217 | } 218 | 219 | return true; 220 | } 221 | 222 | /** 223 | * Support for fread(), file_get_contents() etc. 224 | * 225 | * @param int $count 226 | * Maximum number of bytes to be read. 227 | * 228 | * @return string|bool 229 | * The string that was read, or FALSE in case of an error. 230 | * 231 | * @see http://php.net/manual/streamwrapper.stream-read.php 232 | */ 233 | public function stream_read( $count ) { 234 | if ( ! $this->handle ) { 235 | return false; 236 | } 237 | return fread( $this->handle, $count ); 238 | } 239 | 240 | /** 241 | * Support for fwrite(), file_put_contents() etc. 242 | * 243 | * @param string $data 244 | * The string to be written. 245 | * 246 | * @return int 247 | * The number of bytes written. 248 | * 249 | * @see http://php.net/manual/streamwrapper.stream-write.php 250 | */ 251 | public function stream_write( $data ) { 252 | if ( ! $this->handle ) { 253 | return 0; 254 | } 255 | return fwrite( $this->handle, $data ); 256 | } 257 | 258 | /** 259 | * Support for feof(). 260 | * 261 | * @return bool 262 | * TRUE if end-of-file has been reached. 263 | * 264 | * @see http://php.net/manual/streamwrapper.stream-eof.php 265 | */ 266 | public function stream_eof() { 267 | if ( ! $this->handle ) { 268 | return false; 269 | } 270 | return feof( $this->handle ); 271 | } 272 | 273 | /** 274 | * Support for fseek(). 275 | * 276 | * @param int $offset 277 | * The byte offset to got to. 278 | * @param int $whence 279 | * SEEK_SET, SEEK_CUR, or SEEK_END. 280 | * 281 | * @return bool 282 | * TRUE on success. 283 | * 284 | * @see http://php.net/manual/streamwrapper.stream-seek.php 285 | */ 286 | public function stream_seek( $offset, $whence ) { 287 | if ( ! $this->handle ) { 288 | return false; 289 | } 290 | // fseek returns 0 on success and -1 on a failure. 291 | // stream_seek 1 on success and 0 on a failure. 292 | return ! fseek( $this->handle, $offset, $whence ); 293 | } 294 | 295 | /** 296 | * Support for fflush(). 297 | * 298 | * @return bool 299 | * TRUE if data was successfully stored (or there was no data to store). 300 | * 301 | * @see http://php.net/manual/streamwrapper.stream-flush.php 302 | */ 303 | public function stream_flush() { 304 | if ( ! $this->handle ) { 305 | return false; 306 | } 307 | $result = fflush( $this->handle ); 308 | 309 | $params = [ 310 | 'Bucket' => S3_UPLOADS_BUCKET, 311 | 'Key' => trim( str_replace( S3_UPLOADS_BUCKET, '', $this->getTarget() ), '/' ), 312 | ]; 313 | 314 | /** 315 | * Action when a new object has been uploaded to s3. 316 | * 317 | * @param array $params S3Client::putObject parameters. 318 | */ 319 | do_action( 's3_uploads_putObject', $params ); 320 | 321 | return $result; 322 | } 323 | 324 | /** 325 | * Support for ftell(). 326 | * 327 | * @return false|int 328 | * The current offset in bytes from the beginning of file. 329 | * 330 | * @see http://php.net/manual/streamwrapper.stream-tell.php 331 | */ 332 | public function stream_tell() { 333 | if ( ! $this->handle ) { 334 | return false; 335 | } 336 | return ftell( $this->handle ); 337 | } 338 | 339 | /** 340 | * Support for fstat(). 341 | * 342 | * @return array|false 343 | * An array with file status, or FALSE in case of an error - see fstat() 344 | * for a description of this array. 345 | * 346 | * @see http://php.net/manual/streamwrapper.stream-stat.php 347 | */ 348 | public function stream_stat() { 349 | if ( ! $this->handle ) { 350 | return false; 351 | } 352 | return fstat( $this->handle ); 353 | } 354 | 355 | /** 356 | * Support for fclose(). 357 | * 358 | * @return bool 359 | * TRUE if stream was successfully closed. 360 | * 361 | * @see http://php.net/manual/streamwrapper.stream-close.php 362 | */ 363 | public function stream_close() { 364 | if ( ! $this->handle ) { 365 | return false; 366 | } 367 | $resource = $this->handle; 368 | return fclose( $resource ); 369 | } 370 | 371 | /** 372 | * Gets the underlying stream resource for stream_select(). 373 | * 374 | * @param int $cast_as 375 | * Can be STREAM_CAST_FOR_SELECT or STREAM_CAST_AS_STREAM. 376 | * 377 | * @return resource|false 378 | * The underlying stream resource or FALSE if stream_select() is not 379 | * supported. 380 | * 381 | * @see http://php.net/manual/streamwrapper.stream-cast.php 382 | */ 383 | public function stream_cast( $cast_as ) { 384 | return false; 385 | } 386 | 387 | /** 388 | * Support for unlink(). 389 | * 390 | * @param string $uri 391 | * A string containing the URI to the resource to delete. 392 | * 393 | * @return bool 394 | * TRUE if resource was successfully deleted. 395 | * 396 | * @see http://php.net/manual/streamwrapper.unlink.php 397 | */ 398 | public function unlink( $uri ) { 399 | $this->uri = $uri; 400 | return unlink( $this->getLocalPath() ); 401 | } 402 | 403 | /** 404 | * Support for rename(). 405 | * 406 | * @param string $from_uri, 407 | * The URI to the file to rename. 408 | * @param string $to_uri 409 | * The new URI for file. 410 | * 411 | * @return bool 412 | * TRUE if file was successfully renamed. 413 | * 414 | * @see http://php.net/manual/streamwrapper.rename.php 415 | */ 416 | public function rename( $from_uri, $to_uri ) { 417 | return rename( $this->getLocalPath( $from_uri ), $this->getLocalPath( $to_uri ) ); 418 | } 419 | 420 | /** 421 | * Support for mkdir(). 422 | * 423 | * @param string $uri 424 | * A string containing the URI to the directory to create. 425 | * @param int $mode 426 | * Permission flags - see mkdir(). 427 | * @param int $options 428 | * A bit mask of STREAM_REPORT_ERRORS and STREAM_MKDIR_RECURSIVE. 429 | * 430 | * @return bool 431 | * TRUE if directory was successfully created. 432 | * 433 | * @see http://php.net/manual/streamwrapper.mkdir.php 434 | */ 435 | public function mkdir( $uri, $mode, $options ) { 436 | $this->uri = $uri; 437 | $recursive = (bool) ( $options & STREAM_MKDIR_RECURSIVE ); 438 | if ( $recursive ) { 439 | // $this->getLocalPath() fails if $uri has multiple levels of directories 440 | // that do not yet exist. 441 | $localpath = $this->getDirectoryPath() . '/' . $this->getTarget( $uri ); 442 | } else { 443 | $localpath = $this->getLocalPath( $uri ); 444 | } 445 | if ( $options & STREAM_REPORT_ERRORS ) { 446 | return mkdir( $localpath, $mode, $recursive ); 447 | } else { 448 | return @mkdir( $localpath, $mode, $recursive ); 449 | } 450 | } 451 | 452 | /** 453 | * Support for rmdir(). 454 | * 455 | * @param string $uri 456 | * A string containing the URI to the directory to delete. 457 | * @param int $options 458 | * A bit mask of STREAM_REPORT_ERRORS. 459 | * 460 | * @return bool 461 | * TRUE if directory was successfully removed. 462 | * 463 | * @see http://php.net/manual/streamwrapper.rmdir.php 464 | */ 465 | public function rmdir( $uri, $options ) { 466 | $this->uri = $uri; 467 | if ( $options & STREAM_REPORT_ERRORS ) { 468 | return rmdir( $this->getLocalPath() ); 469 | } else { 470 | return @rmdir( $this->getLocalPath() ); 471 | } 472 | } 473 | 474 | /** 475 | * Support for stat(). 476 | * 477 | * @param string $uri 478 | * A string containing the URI to get information about. 479 | * @param int $flags 480 | * A bit mask of STREAM_URL_STAT_LINK and STREAM_URL_STAT_QUIET. 481 | * 482 | * @return array 483 | * An array with file status, or FALSE in case of an error - see fstat() 484 | * for a description of this array. 485 | * 486 | * @see http://php.net/manual/streamwrapper.url-stat.php 487 | */ 488 | public function url_stat( $uri, $flags ) { 489 | $this->uri = $uri; 490 | $path = $this->getLocalPath(); 491 | // Suppress warnings if requested or if the file or directory does not 492 | // exist. This is consistent with PHP's plain filesystem stream wrapper. 493 | if ( $flags & STREAM_URL_STAT_QUIET || ! file_exists( $path ) ) { 494 | return @stat( $path ); 495 | } else { 496 | return stat( $path ); 497 | } 498 | } 499 | 500 | /** 501 | * Support for opendir(). 502 | * 503 | * @param string $uri 504 | * A string containing the URI to the directory to open. 505 | * @param int $options 506 | * Unknown (parameter is not documented in PHP Manual). 507 | * 508 | * @return bool 509 | * TRUE on success. 510 | * 511 | * @see http://php.net/manual/streamwrapper.dir-opendir.php 512 | */ 513 | public function dir_opendir( $uri, $options ) { 514 | $this->uri = $uri; 515 | $this->handle = opendir( $this->getLocalPath() ); 516 | 517 | return (bool) $this->handle; 518 | } 519 | 520 | /** 521 | * Support for readdir(). 522 | * 523 | * @return string 524 | * The next filename, or FALSE if there are no more files in the directory. 525 | * 526 | * @see http://php.net/manual/streamwrapper.dir-readdir.php 527 | */ 528 | public function dir_readdir() { 529 | if ( ! $this->handle ) { 530 | return ''; 531 | } 532 | return readdir( $this->handle ); 533 | } 534 | 535 | /** 536 | * Support for rewinddir(). 537 | * 538 | * @return bool 539 | * TRUE on success. 540 | * 541 | * @see http://php.net/manual/streamwrapper.dir-rewinddir.php 542 | */ 543 | public function dir_rewinddir() { 544 | if ( ! $this->handle ) { 545 | return false; 546 | } 547 | rewinddir( $this->handle ); 548 | // We do not really have a way to signal a failure as rewinddir() does not 549 | // have a return value and there is no way to read a directory handler 550 | // without advancing to the next file. 551 | return true; 552 | } 553 | 554 | /** 555 | * Support for closedir(). 556 | * 557 | * @return bool 558 | * TRUE on success. 559 | * 560 | * @see http://php.net/manual/streamwrapper.dir-closedir.php 561 | */ 562 | public function dir_closedir() { 563 | if ( ! $this->handle ) { 564 | return false; 565 | } 566 | closedir( $this->handle ); 567 | // We do not really have a way to signal a failure as closedir() does not 568 | // have a return value. 569 | return true; 570 | } 571 | } 572 | -------------------------------------------------------------------------------- /inc/class-plugin.php: -------------------------------------------------------------------------------- 1 | bucket = $bucket; 94 | $this->key = $key; 95 | $this->secret = $secret; 96 | $this->bucket_url = $bucket_url; 97 | $this->region = $region; 98 | } 99 | 100 | /** 101 | * Setup the hooks, urls filtering etc for S3 Uploads 102 | */ 103 | public function setup() { 104 | $this->register_stream_wrapper(); 105 | 106 | add_filter( 'upload_dir', [ $this, 'filter_upload_dir' ] ); 107 | add_filter( 'wp_image_editors', [ $this, 'filter_editors' ], 9 ); 108 | add_action( 'delete_attachment', [ $this, 'delete_attachment_files' ] ); 109 | add_filter( 'wp_read_image_metadata', [ $this, 'wp_filter_read_image_metadata' ], 10, 2 ); 110 | add_filter( 'wp_resource_hints', [ $this, 'wp_filter_resource_hints' ], 10, 2 ); 111 | 112 | add_action( 'wp_handle_sideload_prefilter', [ $this, 'filter_sideload_move_temp_file_to_s3' ] ); 113 | add_filter( 'wp_generate_attachment_metadata', [ $this, 'set_filesize_in_attachment_meta' ], 10, 2 ); 114 | 115 | add_action( 'wp_get_attachment_url', [ $this, 'add_s3_signed_params_to_attachment_url' ], 10, 2 ); 116 | add_action( 'wp_get_attachment_image_src', [ $this, 'add_s3_signed_params_to_attachment_image_src' ], 10, 2 ); 117 | add_action( 'wp_calculate_image_srcset', [ $this, 'add_s3_signed_params_to_attachment_image_srcset' ], 10, 5 ); 118 | 119 | add_filter( 'wp_generate_attachment_metadata', [ $this, 'set_attachment_private_on_generate_attachment_metadata' ], 10, 2 ); 120 | 121 | add_filter( 'pre_wp_unique_filename_file_list', [ $this, 'get_files_for_unique_filename_file_list' ], 10, 3 ); 122 | } 123 | 124 | /** 125 | * Tear down the hooks, url filtering etc for S3 Uploads 126 | */ 127 | public function tear_down() { 128 | 129 | stream_wrapper_unregister( 's3' ); 130 | remove_filter( 'upload_dir', [ $this, 'filter_upload_dir' ] ); 131 | remove_filter( 'wp_image_editors', [ $this, 'filter_editors' ], 9 ); 132 | remove_filter( 'wp_handle_sideload_prefilter', [ $this, 'filter_sideload_move_temp_file_to_s3' ] ); 133 | remove_filter( 'wp_generate_attachment_metadata', [ $this, 'set_filesize_in_attachment_meta' ] ); 134 | 135 | remove_action( 'wp_get_attachment_url', [ $this, 'add_s3_signed_params_to_attachment_url' ] ); 136 | remove_action( 'wp_get_attachment_image_src', [ $this, 'add_s3_signed_params_to_attachment_image_src' ] ); 137 | remove_action( 'wp_calculate_image_srcset', [ $this, 'add_s3_signed_params_to_attachment_image_srcset' ] ); 138 | 139 | remove_filter( 'wp_generate_attachment_metadata', [ $this, 'set_attachment_private_on_generate_attachment_metadata' ] ); 140 | } 141 | 142 | /** 143 | * Register the stream wrapper for s3 144 | */ 145 | public function register_stream_wrapper() { 146 | if ( defined( 'S3_UPLOADS_USE_LOCAL' ) && S3_UPLOADS_USE_LOCAL ) { 147 | stream_wrapper_register( 's3', 'S3_Uploads\Local_Stream_Wrapper', STREAM_IS_URL ); 148 | } else { 149 | Stream_Wrapper::register( $this ); 150 | $acl = defined( 'S3_UPLOADS_OBJECT_ACL' ) ? S3_UPLOADS_OBJECT_ACL : 'public-read'; 151 | stream_context_set_option( stream_context_get_default(), 's3', 'ACL', $acl ); 152 | } 153 | 154 | stream_context_set_option( stream_context_get_default(), 's3', 'seekable', true ); 155 | } 156 | 157 | /** 158 | * Get the s3:// path for the bucket. 159 | */ 160 | public function get_s3_path() : string { 161 | return 's3://' . $this->bucket; 162 | } 163 | 164 | /** 165 | * Overwrite the default wp_upload_dir. 166 | * 167 | * @param array{path: string, basedir: string, baseurl: string, url: string} $dirs 168 | * @return array{path: string, basedir: string, baseurl: string, url: string} 169 | */ 170 | public function filter_upload_dir( array $dirs ) : array { 171 | 172 | $this->original_upload_dir = $dirs; 173 | $s3_path = $this->get_s3_path(); 174 | 175 | $dirs['path'] = str_replace( WP_CONTENT_DIR, $s3_path, $dirs['path'] ); 176 | $dirs['basedir'] = str_replace( WP_CONTENT_DIR, $s3_path, $dirs['basedir'] ); 177 | 178 | if ( ! defined( 'S3_UPLOADS_DISABLE_REPLACE_UPLOAD_URL' ) || ! S3_UPLOADS_DISABLE_REPLACE_UPLOAD_URL ) { 179 | 180 | if ( defined( 'S3_UPLOADS_USE_LOCAL' ) && S3_UPLOADS_USE_LOCAL ) { 181 | $dirs['url'] = str_replace( $s3_path, $dirs['baseurl'] . '/s3/' . $this->bucket, $dirs['path'] ); 182 | $dirs['baseurl'] = str_replace( $s3_path, $dirs['baseurl'] . '/s3/' . $this->bucket, $dirs['basedir'] ); 183 | 184 | } else { 185 | $dirs['url'] = str_replace( $s3_path, $this->get_s3_url(), $dirs['path'] ); 186 | $dirs['baseurl'] = str_replace( $s3_path, $this->get_s3_url(), $dirs['basedir'] ); 187 | } 188 | } 189 | 190 | return $dirs; 191 | } 192 | 193 | /** 194 | * Delete all attachment files from S3 when an attachment is deleted. 195 | * 196 | * WordPress Core's handling of deleting files for attachments via 197 | * wp_delete_attachment_files is not compatible with remote streams, as 198 | * it makes many assumptions about local file paths. The hooks also do 199 | * not exist to be able to modify their behavior. As such, we just clean 200 | * up the s3 files when an attachment is removed, and leave WordPress to try 201 | * a failed attempt at mangling the s3:// urls. 202 | * 203 | * @param int $post_id 204 | */ 205 | public function delete_attachment_files( int $post_id ) { 206 | $meta = wp_get_attachment_metadata( $post_id ); 207 | $file = get_attached_file( $post_id ); 208 | if ( ! $file ) { 209 | return; 210 | } 211 | 212 | if ( ! empty( $meta['sizes'] ) ) { 213 | foreach ( $meta['sizes'] as $sizeinfo ) { 214 | $intermediate_file = str_replace( basename( $file ), $sizeinfo['file'], $file ); 215 | wp_delete_file( $intermediate_file ); 216 | } 217 | } 218 | 219 | wp_delete_file( $file ); 220 | } 221 | 222 | /** 223 | * Get the S3 URL base for uploads. 224 | * 225 | * @return string 226 | */ 227 | public function get_s3_url() : string { 228 | if ( $this->bucket_url ) { 229 | return $this->bucket_url; 230 | } 231 | 232 | $bucket = strtok( $this->bucket, '/' ); 233 | $path = substr( $this->bucket, strlen( $bucket ) ); 234 | 235 | return apply_filters( 's3_uploads_bucket_url', 'https://' . $bucket . '.s3.amazonaws.com' . $path ); 236 | } 237 | 238 | /** 239 | * Get the S3 bucket name 240 | * 241 | * @return string 242 | */ 243 | public function get_s3_bucket() : string { 244 | return strtok( $this->bucket, '/' ); 245 | } 246 | 247 | /** 248 | * Get the region of the S3 bucket. 249 | * 250 | * @return string 251 | */ 252 | public function get_s3_bucket_region() : ?string { 253 | return $this->region; 254 | } 255 | 256 | /** 257 | * Get the original upload directory before it was replaced by S3 uploads. 258 | * 259 | * @return array{path: string, basedir: string, baseurl: string, url: string} 260 | */ 261 | public function get_original_upload_dir() : array { 262 | 263 | if ( empty( $this->original_upload_dir ) ) { 264 | wp_upload_dir(); 265 | } 266 | 267 | /** 268 | * @var array{path: string, basedir: string, baseurl: string, url: string} 269 | */ 270 | $upload_dir = $this->original_upload_dir; 271 | return $upload_dir; 272 | } 273 | 274 | /** 275 | * Reverse a file url in the uploads directory to the params needed for S3. 276 | * 277 | * @param string $url 278 | * @return array{bucket: string, key: string, query: string|null}|null 279 | */ 280 | public function get_s3_location_for_url( string $url ) : ?array { 281 | $s3_url = 'https://' . $this->get_s3_bucket() . '.s3.amazonaws.com/'; 282 | if ( strpos( $url, $s3_url ) === 0 ) { 283 | $parsed = wp_parse_url( $url ); 284 | return [ 285 | 'bucket' => $this->get_s3_bucket(), 286 | 'key' => isset( $parsed['path'] ) ? ltrim( $parsed['path'], '/' ) : '', 287 | 'query' => $parsed['query'] ?? null, 288 | ]; 289 | } 290 | $upload_dir = wp_upload_dir(); 291 | 292 | if ( strpos( $url, $upload_dir['baseurl'] ) === false ) { 293 | return null; 294 | } 295 | 296 | $path = str_replace( $upload_dir['baseurl'], $upload_dir['basedir'], $url ); 297 | $parsed = wp_parse_url( $path ); 298 | if ( ! isset( $parsed['host'] ) || ! isset( $parsed['path'] ) ) { 299 | return null; 300 | } 301 | return [ 302 | 'bucket' => $parsed['host'], 303 | 'key' => ltrim( $parsed['path'], '/' ), 304 | 'query' => $parsed['query'] ?? null, 305 | ]; 306 | } 307 | 308 | /** 309 | * Reverse a file path in the uploads directory to the params needed for S3. 310 | * 311 | * @param string $url 312 | * @return array{key: string, bucket: string} 313 | */ 314 | public function get_s3_location_for_path( string $path ) : ?array { 315 | $parsed = wp_parse_url( $path ); 316 | if ( ! isset( $parsed['path'] ) || ! isset( $parsed['host'] ) || ! isset( $parsed['scheme'] ) || $parsed['scheme'] !== 's3' ) { 317 | return null; 318 | } 319 | return [ 320 | 'bucket' => $parsed['host'], 321 | 'key' => ltrim( $parsed['path'], '/' ), 322 | ]; 323 | } 324 | 325 | /** 326 | * @return Aws\S3\S3Client 327 | */ 328 | public function s3() : Aws\S3\S3Client { 329 | 330 | if ( ! empty( $this->s3 ) ) { 331 | return $this->s3; 332 | } 333 | 334 | $this->s3 = $this->get_aws_sdk()->createS3(); 335 | return $this->s3; 336 | } 337 | 338 | /** 339 | * Get the AWS Sdk. 340 | * 341 | * @return Aws\Sdk 342 | */ 343 | public function get_aws_sdk() : Aws\Sdk { 344 | /** @var null|Aws\Sdk */ 345 | $sdk = apply_filters( 's3_uploads_aws_sdk', null, $this ); 346 | if ( $sdk ) { 347 | return $sdk; 348 | } 349 | 350 | $params = [ 'version' => 'latest' ]; 351 | 352 | if ( $this->key && $this->secret ) { 353 | $params['credentials']['key'] = $this->key; 354 | $params['credentials']['secret'] = $this->secret; 355 | } 356 | 357 | if ( $this->region ) { 358 | $params['signature'] = 'v4'; 359 | $params['region'] = $this->region; 360 | } 361 | 362 | if ( defined( 'WP_PROXY_HOST' ) && defined( 'WP_PROXY_PORT' ) ) { 363 | $proxy_auth = ''; 364 | $proxy_address = WP_PROXY_HOST . ':' . WP_PROXY_PORT; 365 | 366 | if ( defined( 'WP_PROXY_USERNAME' ) && defined( 'WP_PROXY_PASSWORD' ) ) { 367 | $proxy_auth = WP_PROXY_USERNAME . ':' . WP_PROXY_PASSWORD . '@'; 368 | } 369 | 370 | $params['request.options']['proxy'] = $proxy_auth . $proxy_address; 371 | } 372 | 373 | $params = apply_filters( 's3_uploads_s3_client_params', $params ); 374 | 375 | $sdk = new Aws\Sdk( $params ); 376 | return $sdk; 377 | } 378 | 379 | public function filter_editors( array $editors ) : array { 380 | $position = array_search( 'WP_Image_Editor_Imagick', $editors ); 381 | if ( $position !== false ) { 382 | unset( $editors[ $position ] ); 383 | } 384 | 385 | array_unshift( $editors, __NAMESPACE__ . '\\Image_Editor_Imagick' ); 386 | 387 | return $editors; 388 | } 389 | /** 390 | * Copy the file from /tmp to an s3 dir so handle_sideload doesn't fail due to 391 | * trying to do a rename() on the file cross streams. This is somewhat of a hack 392 | * to work around the core issue https://core.trac.wordpress.org/ticket/29257 393 | * 394 | * @param array{tmp_name: string} $file File array 395 | * @return array{tmp_name: string} 396 | */ 397 | public function filter_sideload_move_temp_file_to_s3( array $file ) { 398 | $upload_dir = wp_upload_dir(); 399 | $new_path = $upload_dir['basedir'] . '/tmp/' . basename( $file['tmp_name'] ); 400 | 401 | copy( $file['tmp_name'], $new_path ); 402 | unlink( $file['tmp_name'] ); 403 | $file['tmp_name'] = $new_path; 404 | 405 | return $file; 406 | } 407 | 408 | /** 409 | * Store the attachment filesize in the attachment meta array. 410 | * 411 | * Getting the filesize of an image in S3 involves a remote HEAD request, 412 | * which is a bit slower than a local filesystem operation would be. As a 413 | * result, operations like `wp_prepare_attachments_for_js' take substantially 414 | * longer to complete against s3 uploads than if they were performed with a 415 | * local filesystem.i 416 | * 417 | * Saving the filesize in the attachment metadata when the image is 418 | * uploaded allows core to skip this stat when retrieving and formatting it. 419 | * 420 | * @param array{file?: string} $metadata Attachment metadata. 421 | * @param int $attachment_id Attachment ID. 422 | * @return array{file?: string, filesize?: int} Attachment metadata array, with "filesize" value added. 423 | */ 424 | function set_filesize_in_attachment_meta( array $metadata, int $attachment_id ) : array { 425 | $file = get_attached_file( $attachment_id ); 426 | if ( ! $file ) { 427 | return $metadata; 428 | } 429 | if ( ! isset( $metadata['filesize'] ) && file_exists( $file ) ) { 430 | $metadata['filesize'] = filesize( $file ); 431 | } 432 | 433 | return $metadata; 434 | } 435 | 436 | /** 437 | * Filters wp_read_image_metadata. exif_read_data() doesn't work on 438 | * file streams so we need to make a temporary local copy to extract 439 | * exif data from. 440 | * 441 | * @param array $meta 442 | * @param string $file 443 | * @return array|bool 444 | */ 445 | public function wp_filter_read_image_metadata( array $meta, string $file ) { 446 | remove_filter( 'wp_read_image_metadata', [ $this, 'wp_filter_read_image_metadata' ], 10 ); 447 | $temp_file = $this->copy_image_from_s3( $file ); 448 | $meta = wp_read_image_metadata( $temp_file ); 449 | add_filter( 'wp_read_image_metadata', [ $this, 'wp_filter_read_image_metadata' ], 10, 2 ); 450 | unlink( $temp_file ); 451 | return $meta; 452 | } 453 | 454 | /** 455 | * Add the DNS address for the S3 Bucket to list for DNS prefetch. 456 | * 457 | * @param array $hints 458 | * @param string $relation_type 459 | * @return array 460 | */ 461 | function wp_filter_resource_hints( array $hints, string $relation_type ) : array { 462 | if ( 463 | ( defined( 'S3_UPLOADS_DISABLE_REPLACE_UPLOAD_URL' ) && S3_UPLOADS_DISABLE_REPLACE_UPLOAD_URL ) || 464 | ( defined( 'S3_UPLOADS_USE_LOCAL' ) && S3_UPLOADS_USE_LOCAL ) 465 | ) { 466 | return $hints; 467 | } 468 | 469 | if ( 'dns-prefetch' === $relation_type ) { 470 | $hints[] = $this->get_s3_url(); 471 | } 472 | 473 | return $hints; 474 | } 475 | 476 | /** 477 | * Get a local copy of the file. 478 | * 479 | * @param string $file 480 | * @return string 481 | */ 482 | public function copy_image_from_s3( string $file ) : string { 483 | if ( ! function_exists( 'wp_tempnam' ) ) { 484 | require_once( ABSPATH . 'wp-admin/includes/file.php' ); 485 | } 486 | $temp_filename = wp_tempnam( $file ); 487 | copy( $file, $temp_filename ); 488 | return $temp_filename; 489 | } 490 | 491 | /** 492 | * Check if the attachment is private. 493 | * 494 | * @param integer $attachment_id 495 | * @return boolean 496 | */ 497 | public function is_private_attachment( int $attachment_id ) : bool { 498 | /** 499 | * Filters whether an attachment should be private. 500 | * 501 | * @param bool Whether the attachment is private. 502 | * @param int The attachment ID. 503 | */ 504 | $private = apply_filters( 's3_uploads_is_attachment_private', false, $attachment_id ); 505 | return $private; 506 | } 507 | 508 | /** 509 | * Update the ACL (Access Control List) for an attachments files. 510 | * 511 | * @param integer $attachment_id 512 | * @param 'public-read'|'private' $acl public-read|private 513 | * @return WP_Error|null 514 | */ 515 | public function set_attachment_files_acl( int $attachment_id, string $acl ) : ?WP_Error { 516 | $files = static::get_attachment_files( $attachment_id ); 517 | $locations = array_map( [ $this, 'get_s3_location_for_path' ], $files ); 518 | // Remove any null items in the array from get_s3_location_for_path(). 519 | $locations = array_filter( $locations ); 520 | $s3 = $this->s3(); 521 | $commands = []; 522 | foreach ( $locations as $location ) { 523 | $commands[] = $s3->getCommand( 'putObjectAcl', [ 524 | 'Bucket' => $location['bucket'], 525 | 'Key' => $location['key'], 526 | 'ACL' => $acl, 527 | ] ); 528 | } 529 | 530 | try { 531 | Aws\CommandPool::batch( $s3, $commands ); 532 | } catch ( Exception $e ) { 533 | return new WP_Error( $e->getCode(), $e->getMessage() ); 534 | } 535 | 536 | /** 537 | * Fires after ACL of files of an attachment is set. 538 | * 539 | * @param int $attachment_id Attachment whose ACL has been changed. 540 | * @param string $acl The new ACL that's been set. 541 | * @psalm-suppress TooManyArguments -- Currently do_action doesn't detect variable number of arguments. 542 | */ 543 | do_action( 's3_uploads_set_attachment_files_acl', $attachment_id, $acl ); 544 | 545 | return null; 546 | } 547 | 548 | /** 549 | * Get all the files stored for a given attachment. 550 | * 551 | * @param integer $attachment_id 552 | * @return list Array of all full paths to the attachment's files. 553 | */ 554 | public static function get_attachment_files( int $attachment_id ) : array { 555 | $uploadpath = wp_get_upload_dir(); 556 | /** @var string */ 557 | $main_file = get_attached_file( $attachment_id ); 558 | $files = [ $main_file ]; 559 | 560 | $meta = wp_get_attachment_metadata( $attachment_id ); 561 | if ( isset( $meta['sizes'] ) ) { 562 | foreach ( $meta['sizes'] as $size => $sizeinfo ) { 563 | $files[] = $uploadpath['basedir'] . $sizeinfo['file']; 564 | } 565 | } 566 | 567 | /** @var string|false */ 568 | $original_image = get_post_meta( $attachment_id, 'original_image', true ); 569 | if ( $original_image ) { 570 | $files[] = $uploadpath['basedir'] . $original_image; 571 | } 572 | 573 | /** @var array */ 574 | $backup_sizes = get_post_meta( $attachment_id, '_wp_attachment_backup_sizes', true ); 575 | if ( $backup_sizes ) { 576 | foreach ( $backup_sizes as $size => $sizeinfo ) { 577 | // Backup sizes only store the backup filename, which is relative to the 578 | // main attached file, unlike the metadata sizes array. 579 | $files[] = path_join( dirname( $main_file ), $sizeinfo['file'] ); 580 | } 581 | } 582 | 583 | $files = apply_filters( 's3_uploads_get_attachment_files', $files, $attachment_id ); 584 | 585 | return $files; 586 | } 587 | 588 | /** 589 | * Add the S3 signed params onto an image for for a given attachment. 590 | * 591 | * This function determines whether the attachment needs a signed URL, so is safe to 592 | * pass any URL. 593 | * 594 | * @param string $url 595 | * @param integer $post_id 596 | * @return string 597 | */ 598 | public function add_s3_signed_params_to_attachment_url( string $url, int $post_id ) : string { 599 | if ( ! $this->is_private_attachment( $post_id ) ) { 600 | return $url; 601 | } 602 | $path = $this->get_s3_location_for_url( $url ); 603 | if ( ! $path ) { 604 | return $url; 605 | } 606 | $cmd = $this->s3()->getCommand( 607 | 'GetObject', 608 | [ 609 | 'Bucket' => $path['bucket'], 610 | 'Key' => $path['key'], 611 | ] 612 | ); 613 | 614 | $presigned_url_expires = apply_filters( 's3_uploads_private_attachment_url_expiry', '+6 hours', $post_id ); 615 | $query = $this->s3()->createPresignedRequest( $cmd, $presigned_url_expires )->getUri()->getQuery(); 616 | 617 | // The URL could have query params on it already (such as being an already signed URL), 618 | // but query params will mean the S3 signed URL will become corrupt. So, we have to 619 | // remove all query params. 620 | $url = strtok( $url, '?' ) . '?' . $query; 621 | $url = apply_filters( 's3_uploads_presigned_url', $url, $post_id ); 622 | 623 | return $url; 624 | } 625 | 626 | /** 627 | * Add the S3 signed params to an image src array. 628 | * 629 | * @param array{0: string, 1: int, 2: int}|false $image 630 | * @param integer|"" $post_id The post id, due to WordPress hook, this can be "", so can't just hint as int. 631 | * @return array{0: string, 1: int, 2: int}|false 632 | */ 633 | public function add_s3_signed_params_to_attachment_image_src( $image, $post_id ) { 634 | if ( ! $image || ! $post_id ) { 635 | return $image; 636 | } 637 | 638 | $image[0] = $this->add_s3_signed_params_to_attachment_url( $image[0], $post_id ); 639 | return $image; 640 | } 641 | 642 | /** 643 | * Add the S3 signed params to the image srcset (response image) sizes. 644 | * 645 | * @param array{url: string, descriptor: string, value: int}[] $sources 646 | * @param array $sizes 647 | * @param string $src 648 | * @param array $meta 649 | * @param integer $post_id 650 | * @return array{url: string, descriptor: string, value: int}[] 651 | */ 652 | public function add_s3_signed_params_to_attachment_image_srcset( array $sources, array $sizes, string $src, array $meta, int $post_id ) : array { 653 | foreach ( $sources as &$source ) { 654 | $source['url'] = $this->add_s3_signed_params_to_attachment_url( $source['url'], $post_id ); 655 | } 656 | return $sources; 657 | } 658 | 659 | /** 660 | * Whenever attachment metadata is generated, set the attachment files to private if it's a private attachment. 661 | * 662 | * @param array $metadata The attachment metadata. 663 | * @param int $attachment_id The attachment ID 664 | * @return array 665 | */ 666 | public function set_attachment_private_on_generate_attachment_metadata( array $metadata, int $attachment_id ) : array { 667 | if ( $this->is_private_attachment( $attachment_id ) ) { 668 | $this->set_attachment_files_acl( $attachment_id, 'private' ); 669 | } 670 | 671 | return $metadata; 672 | } 673 | 674 | /** 675 | * Override the files used for wp_unique_filename() comparisons 676 | * 677 | * @param array|null $files 678 | * @param string $dir 679 | * @return array 680 | */ 681 | public function get_files_for_unique_filename_file_list( ?array $files, string $dir, string $filename ) : array { 682 | $name = pathinfo( $filename, PATHINFO_FILENAME ); 683 | // The s3:// streamwrapper support listing by partial prefixes with wildcards. 684 | // For example, scandir( s3://bucket/2019/06/my-image* ) 685 | return (array) scandir( trailingslashit( $dir ) . $name . '*' ); 686 | } 687 | } 688 | -------------------------------------------------------------------------------- /inc/class-stream-wrapper.php: -------------------------------------------------------------------------------- 1 | /" files with PHP 26 | * streams, supporting "r", "w", "a", "x". 27 | * 28 | * # Opening "r" (read only) streams: 29 | * 30 | * Read only streams are truly streaming by default and will not allow you to 31 | * seek. This is because data read from the stream is not kept in memory or on 32 | * the local filesystem. You can force a "r" stream to be seekable by setting 33 | * the "seekable" stream context option true. This will allow true streaming of 34 | * data from Amazon S3, but will maintain a buffer of previously read bytes in 35 | * a 'php://temp' stream to allow seeking to previously read bytes from the 36 | * stream. 37 | * 38 | * You may pass any GetObject parameters as 's3' stream context options. These 39 | * options will affect how the data is downloaded from Amazon S3. 40 | * 41 | * # Opening "w" and "x" (write only) streams: 42 | * 43 | * Because Amazon S3 requires a Content-Length header, write only streams will 44 | * maintain a 'php://temp' stream to buffer data written to the stream until 45 | * the stream is flushed (usually by closing the stream with fclose). 46 | * 47 | * You may pass any PutObject parameters as 's3' stream context options. These 48 | * options will affect how the data is uploaded to Amazon S3. 49 | * 50 | * When opening an "x" stream, the file must exist on Amazon S3 for the stream 51 | * to open successfully. 52 | * 53 | * # Opening "a" (write only append) streams: 54 | * 55 | * Similar to "w" streams, opening append streams requires that the data be 56 | * buffered in a "php://temp" stream. Append streams will attempt to download 57 | * the contents of an object in Amazon S3, seek to the end of the object, then 58 | * allow you to append to the contents of the object. The data will then be 59 | * uploaded using a PutObject operation when the stream is flushed (usually 60 | * with fclose). 61 | * 62 | * You may pass any GetObject and/or PutObject parameters as 's3' stream 63 | * context options. These options will affect how the data is downloaded and 64 | * uploaded from Amazon S3. 65 | * 66 | * Stream context options: 67 | * 68 | * - "seekable": Set to true to create a seekable "r" (read only) stream by 69 | * using a php://temp stream buffer 70 | * - For "unlink" only: Any option that can be passed to the DeleteObject 71 | * operation 72 | * 73 | * @psalm-type StatArray = array{0: int, 1: int, 2: int|string, 3: int, 4: int, 5: int, 6: int, 7: int, 8: int, 9: int, 10: int, 11: int, 12: int, dev: int, ino: int, mode: int|string, nlink: int, uid: int, gid: int, rdev: int, size: int, atime: int, mtime: int, ctime: int, blksize: int, blocks: int} 74 | * @psalm-type S3ObjectResultArray = array{ContentLength: int, Size: int, LastModified: string, Key: string, Prefix?: string} 75 | * @psalm-type OptionsArray = array{plugin?: Plugin, cache?: CacheInterface, Bucket: string, Key: string, acl: string, seekable?: bool} 76 | */ 77 | class Stream_Wrapper { 78 | 79 | /** @var ?resource Stream context (this is set by PHP) */ 80 | public $context; 81 | 82 | /** @var ?StreamInterface Underlying stream resource */ 83 | private $body; 84 | 85 | /** @var ?int Size of the body that is opened */ 86 | private $size; 87 | 88 | /** @var array Hash of opened stream parameters */ 89 | private $params = []; 90 | 91 | /** @var ?string Mode in which the stream was opened */ 92 | private $mode; 93 | 94 | /** @var ?\Iterator Iterator used with opendir() related calls */ 95 | private $objectIterator; 96 | 97 | /** @var ?string The bucket that was opened when opendir() was called */ 98 | private $openedBucket; 99 | 100 | /** @var ?string The prefix of the bucket that was opened with opendir() */ 101 | private $openedBucketPrefix; 102 | 103 | /** @var ?string Opened bucket path */ 104 | private $openedPath; 105 | 106 | /** @var ?CacheInterface Cache for object and dir lookups */ 107 | private $cache; 108 | 109 | /** @var string The opened protocol (e.g., "s3") */ 110 | private $protocol = 's3'; 111 | 112 | /** 113 | * Register the 's3://' stream wrapper 114 | * 115 | * @param S3ClientInterface $client Client to use with the stream wrapper 116 | * @param string $protocol Protocol to register as. 117 | * @param CacheInterface $cache Default cache for the protocol. 118 | */ 119 | public static function register( 120 | Plugin $plugin, 121 | $protocol = 's3', 122 | CacheInterface $cache = null 123 | ) { 124 | if ( in_array( $protocol, stream_get_wrappers() ) ) { 125 | stream_wrapper_unregister( $protocol ); 126 | } 127 | 128 | // Set the client passed in as the default stream context client 129 | stream_wrapper_register( $protocol, get_called_class(), STREAM_IS_URL ); 130 | /** @var array{s3: array} */ 131 | $default = stream_context_get_options( stream_context_get_default() ); 132 | $default[ $protocol ]['plugin'] = $plugin; 133 | 134 | if ( $cache ) { 135 | $default[ $protocol ]['cache'] = $cache; 136 | } elseif ( ! isset( $default[ $protocol ]['cache'] ) ) { 137 | // Set a default cache adapter. 138 | $default[ $protocol ]['cache'] = new LruArrayCache(); 139 | } 140 | 141 | stream_context_set_default( $default ); 142 | } 143 | 144 | public function stream_close() { 145 | $this->body = null; 146 | $this->cache = null; 147 | } 148 | 149 | /** 150 | * Undocumented function 151 | * 152 | * @param string $path 153 | * @param string $mode 154 | * @param array $options 155 | * @param string $opened_path 156 | * @return bool 157 | */ 158 | public function stream_open( $path, $mode, $options, &$opened_path ) { 159 | $this->initProtocol( $path ); 160 | $this->params = $this->getBucketKey( $path ); 161 | $this->mode = rtrim( $mode, 'bt' ); 162 | 163 | $errors = $this->validate( $path, $this->mode ); 164 | if ( $errors ) { 165 | return $this->triggerError( $errors ); 166 | } 167 | 168 | return $this->boolCall( 169 | function() : bool { 170 | switch ( $this->mode ) { 171 | case 'r': 172 | return $this->openReadStream(); 173 | case 'a': 174 | return $this->openAppendStream(); 175 | default: 176 | /** 177 | * As we open a temp stream, we don't actually know if we have writing ability yet. 178 | * This means functions like copy() will not fail correctly, as the write to s3 179 | * is only attempted on stream_flush() which is too late to report to copy() 180 | * et al that the write has failed. 181 | * 182 | * As a work around, we attempt to write an empty object. 183 | * 184 | * Added by Joe Hoyle 185 | */ 186 | try { 187 | $p = $this->params; 188 | $p['Body'] = ''; 189 | $p = apply_filters( 's3_uploads_putObject_params', $p ); 190 | $this->getClient()->putObject( $p ); 191 | } catch ( Exception $e ) { 192 | return $this->triggerError( $e->getMessage() ); 193 | } 194 | 195 | return $this->openWriteStream(); 196 | } 197 | } 198 | ); 199 | } 200 | 201 | public function stream_eof() : bool { 202 | if ( ! $this->body ) { 203 | return true; 204 | } 205 | return $this->body->eof(); 206 | } 207 | 208 | public function stream_flush() : bool { 209 | if ( $this->mode == 'r' ) { 210 | return false; 211 | } 212 | 213 | if ( ! $this->body ) { 214 | return false; 215 | } 216 | 217 | if ( $this->body->isSeekable() ) { 218 | $this->body->seek( 0 ); 219 | } 220 | $params = $this->getOptions( true ); 221 | $params['Body'] = $this->body; 222 | 223 | // Attempt to guess the ContentType of the upload based on the 224 | // file extension of the key. Added by Joe Hoyle 225 | if ( ! isset( $params['ContentType'] ) && MimeType::fromFilename( $params['Key'] ) ) { 226 | $params['ContentType'] = MimeType::fromFilename( $params['Key'] ); 227 | } 228 | 229 | /// Expires: 230 | if ( defined( 'S3_UPLOADS_HTTP_EXPIRES' ) ) { 231 | $params['Expires'] = S3_UPLOADS_HTTP_EXPIRES; 232 | } 233 | // Cache-Control: 234 | if ( defined( 'S3_UPLOADS_HTTP_CACHE_CONTROL' ) ) { 235 | /** 236 | * @psalm-suppress RedundantCondition 237 | */ 238 | if ( is_numeric( S3_UPLOADS_HTTP_CACHE_CONTROL ) ) { 239 | $params['CacheControl'] = 'max-age=' . S3_UPLOADS_HTTP_CACHE_CONTROL; 240 | } else { 241 | $params['CacheControl'] = S3_UPLOADS_HTTP_CACHE_CONTROL; 242 | } 243 | } 244 | 245 | /** 246 | * Filter the parameters passed to S3 247 | * Theses are the parameters passed to S3Client::putObject() 248 | * See; http://docs.aws.amazon.com/aws-sdk-php/latest/class-Aws.S3.S3Client.html#_putObject 249 | * 250 | * @param array $params S3Client::putObject parameters. 251 | */ 252 | $params = apply_filters( 's3_uploads_putObject_params', $params ); 253 | 254 | $this->clearCacheKey( "s3://{$params['Bucket']}/{$params['Key']}" ); 255 | return $this->boolCall( 256 | function () use ( $params ) { 257 | $bool = (bool) $this->getClient()->putObject( $params ); 258 | 259 | /** 260 | * Action when a new object has been uploaded to s3. 261 | * 262 | * @param array $params S3Client::putObject parameters. 263 | */ 264 | do_action( 's3_uploads_putObject', $params ); 265 | 266 | return $bool; 267 | } 268 | ); 269 | } 270 | 271 | public function stream_read( int $count ) : ?string { 272 | if ( ! $this->body ) { 273 | return null; 274 | } 275 | return $this->body->read( $count ); 276 | } 277 | 278 | public function stream_seek( int $offset, int $whence = SEEK_SET ) : bool { 279 | if ( ! $this->body ) { 280 | return false; 281 | } 282 | return ! $this->body->isSeekable() 283 | ? false 284 | : $this->boolCall( 285 | function () use ( $offset, $whence ) { 286 | if ( ! $this->body ) { 287 | return false; 288 | } 289 | $this->body->seek( $offset, $whence ); 290 | return true; 291 | } 292 | ); 293 | } 294 | 295 | /** 296 | * @param string $path 297 | * @param mixed $option 298 | * @param mixed $value 299 | * @return boolean 300 | */ 301 | public function stream_metadata( string $path, $option, $value ) : bool { 302 | return false; 303 | } 304 | 305 | /** 306 | * @return bool|int 307 | */ 308 | public function stream_tell() { 309 | return $this->boolCall( 310 | function() { 311 | if ( ! $this->body ) { 312 | return false; 313 | } 314 | return $this->body->tell(); 315 | } 316 | ); 317 | } 318 | 319 | public function stream_write( string $data ) : int { 320 | if ( ! $this->body ) { 321 | return 0; 322 | } 323 | return $this->body->write( $data ); 324 | } 325 | 326 | public function unlink( string $path ) : bool { 327 | $this->initProtocol( $path ); 328 | 329 | return $this->boolCall( 330 | function () use ( $path ) { 331 | $this->clearCacheKey( $path ); 332 | $this->getClient()->deleteObject( $this->withPath( $path ) ); 333 | return true; 334 | } 335 | ); 336 | } 337 | 338 | /** 339 | * @return StatArray 340 | */ 341 | public function stream_stat() { 342 | $stat = $this->getStatTemplate(); 343 | $stat[7] = $this->getSize() ?? 0; 344 | $stat['size'] = $stat[7]; 345 | $stat[2] = $this->mode ?? 0; 346 | $stat['mode'] = $stat[2]; 347 | 348 | return $stat; 349 | } 350 | 351 | /** 352 | * Provides information for is_dir, is_file, filesize, etc. Works on 353 | * buckets, keys, and prefixes. 354 | * @link http://www.php.net/manual/en/streamwrapper.url-stat.php 355 | * 356 | * @return StatArray|bool 357 | */ 358 | public function url_stat( string $path, int $flags ) { 359 | $this->initProtocol( $path ); 360 | 361 | $extension = pathinfo( $path, PATHINFO_EXTENSION ); 362 | /** 363 | * If the file is actually just a path to a directory 364 | * then return it as always existing. This is to work 365 | * around wp_upload_dir doing file_exists checks on 366 | * the uploads directory on every page load. 367 | * 368 | * Added by Joe Hoyle 369 | */ 370 | if ( ! $extension ) { 371 | return [ 372 | 0 => 0, 373 | 'dev' => 0, 374 | 1 => 0, 375 | 'ino' => 0, 376 | 2 => 16895, 377 | 'mode' => 16895, 378 | 3 => 0, 379 | 'nlink' => 0, 380 | 4 => 0, 381 | 'uid' => 0, 382 | 5 => 0, 383 | 'gid' => 0, 384 | 6 => -1, 385 | 'rdev' => -1, 386 | 7 => 0, 387 | 'size' => 0, 388 | 8 => 0, 389 | 'atime' => 0, 390 | 9 => 0, 391 | 'mtime' => 0, 392 | 10 => 0, 393 | 'ctime' => 0, 394 | 11 => -1, 395 | 'blksize' => -1, 396 | 12 => -1, 397 | 'blocks' => -1, 398 | ]; 399 | } 400 | 401 | // Some paths come through as S3:// for some reason. 402 | $split = explode( '://', $path ); 403 | $path = strtolower( $split[0] ) . '://' . $split[1]; 404 | 405 | // Check if this path is in the url_stat cache 406 | /** @var StatArray|null */ 407 | $value = $this->getCacheStorage()->get( $path ); 408 | if ( $value ) { 409 | return $value; 410 | } 411 | 412 | $stat = $this->createStat( $path, $flags ); 413 | 414 | if ( is_array( $stat ) ) { 415 | $this->getCacheStorage()->set( $path, $stat ); 416 | } 417 | 418 | return $stat; 419 | } 420 | 421 | /** 422 | * Parse the protocol out of the given path. 423 | * 424 | * @param $path 425 | */ 426 | private function initProtocol( string $path ) { 427 | $parts = explode( '://', $path, 2 ); 428 | $this->protocol = $parts[0] ?: 's3'; 429 | } 430 | 431 | /** 432 | * 433 | * @param string $path 434 | * @param integer $flags 435 | * @return StatArray|bool 436 | */ 437 | private function createStat( string $path, int $flags ) { 438 | $this->initProtocol( $path ); 439 | $parts = $this->withPath( $path ); 440 | 441 | if ( ! $parts['Key'] ) { 442 | return $this->statDirectory( $parts, $path, $flags ); 443 | } 444 | 445 | return $this->boolCall( 446 | function () use ( $parts, $path ) { 447 | try { 448 | $result = $this->getClient()->headObject( $parts ); 449 | if ( substr( $parts['Key'], -1, 1 ) == '/' && 450 | $result['ContentLength'] == 0 451 | ) { 452 | // Return as if it is a bucket to account for console 453 | // bucket objects (e.g., zero-byte object "foo/") 454 | return $this->formatUrlStat( $path ); 455 | } else { 456 | // Attempt to stat and cache regular object 457 | /** @var S3ObjectResultArray */ 458 | $result_array = $result->toArray(); 459 | return $this->formatUrlStat( $result_array ); 460 | } 461 | } catch ( S3Exception $e ) { 462 | // Maybe this isn't an actual key, but a prefix. Do a prefix 463 | // listing of objects to determine. 464 | $result = $this->getClient()->listObjectsV2( 465 | [ 466 | 'Bucket' => $parts['Bucket'], 467 | 'Prefix' => rtrim( $parts['Key'], '/' ) . '/', 468 | 'MaxKeys' => 1, 469 | ] 470 | ); 471 | if ( ! $result['Contents'] && ! $result['CommonPrefixes'] ) { 472 | throw new \Exception( "File or directory not found: $path" ); 473 | } 474 | return $this->formatUrlStat( $path ); 475 | } 476 | }, $flags 477 | ); 478 | } 479 | 480 | /** 481 | * @param array{Bucket: string, Key: string|null} $parts 482 | * @param string $path 483 | * @param int $flags 484 | * @return StatArray|bool 485 | */ 486 | private function statDirectory( $parts, $path, $flags ) { 487 | // Stat "directories": buckets, or "s3://" 488 | if ( ! $parts['Bucket'] || 489 | $this->getClient()->doesBucketExistV2( $parts['Bucket'], false ) 490 | ) { 491 | return $this->formatUrlStat( $path ); 492 | } 493 | 494 | return $this->triggerError( "File or directory not found: $path", $flags ); 495 | } 496 | 497 | /** 498 | * Support for mkdir(). 499 | * 500 | * @param string $path Directory which should be created. 501 | * @param int $mode Permissions. 700-range permissions map to 502 | * ACL_PUBLIC. 600-range permissions map to 503 | * ACL_AUTH_READ. All other permissions map to 504 | * ACL_PRIVATE. Expects octal form. 505 | * @param int $options A bitwise mask of values, such as 506 | * STREAM_MKDIR_RECURSIVE. 507 | * 508 | * @return bool 509 | * @link http://www.php.net/manual/en/streamwrapper.mkdir.php 510 | */ 511 | public function mkdir( string $path, int $mode, $options ) : bool { 512 | $this->initProtocol( $path ); 513 | $params = $this->withPath( $path ); 514 | $this->clearCacheKey( $path ); 515 | if ( ! $params['Bucket'] ) { 516 | return false; 517 | } 518 | 519 | if ( ! isset( $params['ACL'] ) ) { 520 | $params['ACL'] = $this->determineAcl( $mode ); 521 | } 522 | 523 | return empty( $params['Key'] ) 524 | ? $this->createBucket( $path, $params ) 525 | : $this->createSubfolder( $path, $params ); 526 | } 527 | 528 | /** 529 | * @param string $path 530 | * @param mixed $options 531 | * @return bool 532 | */ 533 | public function rmdir( string $path, $options ) : bool { 534 | $this->initProtocol( $path ); 535 | $this->clearCacheKey( $path ); 536 | $params = $this->withPath( $path ); 537 | $client = $this->getClient(); 538 | 539 | if ( ! $params['Bucket'] ) { 540 | return $this->triggerError( 'You must specify a bucket' ); 541 | } 542 | 543 | return $this->boolCall( 544 | function () use ( $params, $path, $client ) { 545 | if ( ! $params['Key'] ) { 546 | $client->deleteBucket( [ 'Bucket' => $params['Bucket'] ] ); 547 | return true; 548 | } 549 | return $this->deleteSubfolder( $path, $params ); 550 | } 551 | ); 552 | } 553 | 554 | /** 555 | * Support for opendir(). 556 | * 557 | * The opendir() method of the Amazon S3 stream wrapper supports a stream 558 | * context option of "listFilter". listFilter must be a callable that 559 | * accepts an associative array of object data and returns true if the 560 | * object should be yielded when iterating the keys in a bucket. 561 | * 562 | * @param string $path The path to the directory 563 | * (e.g. "s3://dir[]") 564 | * @param string|null $options Unused option variable 565 | * 566 | * @return bool true on success 567 | * @see http://www.php.net/manual/en/function.opendir.php 568 | */ 569 | public function dir_opendir( $path, $options ) { 570 | $this->initProtocol( $path ); 571 | $this->openedPath = $path; 572 | $params = $this->withPath( $path ); 573 | /** @var string|null */ 574 | $delimiter = $this->getOption( 'delimiter' ); 575 | /** @var callable|null $filterFn */ 576 | $filterFn = $this->getOption( 'listFilter' ); 577 | $op = [ 'Bucket' => $params['Bucket'] ]; 578 | $this->openedBucket = $params['Bucket']; 579 | 580 | if ( $delimiter === null ) { 581 | $delimiter = '/'; 582 | } 583 | 584 | if ( $delimiter ) { 585 | $op['Delimiter'] = $delimiter; 586 | } 587 | 588 | if ( $params['Key'] ) { 589 | // Support paths ending in "*" to allow listing of arbitrary prefixes. 590 | if ( substr( $params['Key'], -1, 1 ) === '*' ) { 591 | $params['Key'] = rtrim( $params['Key'], '*' ); 592 | // Set the opened bucket prefix to be the directory. This is because $this->openedBucketPrefix 593 | // will be removed from the resulting keys, and we want to return all files in the directory 594 | // of the wildcard. 595 | $this->openedBucketPrefix = substr( $params['Key'], 0, ( strrpos( $params['Key'], '/' ) ?: 0 ) + 1 ); 596 | } else { 597 | $params['Key'] = rtrim( $params['Key'], $delimiter ) . $delimiter; 598 | $this->openedBucketPrefix = $params['Key']; 599 | } 600 | $op['Prefix'] = $params['Key']; 601 | } 602 | 603 | // WordPress attempts to scan whole directories via wp_unique_filename(), which can be very slow 604 | // when there are thousands of files in a single uploads sub directory. This is due to behaviour 605 | // introduced in https://core.trac.wordpress.org/changeset/46822/. Essentially when a file is uploaded, 606 | // it's not enough to make sure no filename already exists (and append a `-1` to the end), because 607 | // image sizes of that image could also conflict with already existing files too. Because image sizes 608 | // (in the form of -800x600.jpg) can be arbitrary integers, it's not possible to iterate the filesystem 609 | // for all possible matching / colliding file names. WordPress core uses a preg-match on all files that 610 | // might conflict with the given filename. 611 | // 612 | // Fortunately, we can make use of S3 arbitrary prefixes to optimize this query. The WordPress regex 613 | // done via _wp_check_existing_file_names() is essentially `^$filename-...`, so we can modify the prefix 614 | // to include the filename, therefore only return a subset of files from S3 that are more likely to match 615 | // the preg_match() call. 616 | // 617 | // Essentially, wp_unique_filename( my-file.jpg ) doing a `scandir( s3://bucket/2019/04/ )` will actually result in an s3 618 | // listObjectsV2 query for `s3://bucket/2019/04/my-file` which means even if there are millions of files in `2019/04/` we only 619 | // return a much smaller subset. 620 | // 621 | // Anyone reading this far, brace yourselves for a mighty horrible hack. 622 | $backtrace = debug_backtrace( 0, 3 ); // phpcs:ignore PHPCompatibility.FunctionUse.ArgumentFunctionsReportCurrentValue.NeedsInspection 623 | if ( isset( $backtrace[1]['function'] ) 624 | && $backtrace[1]['function'] === 'scandir' 625 | && isset( $backtrace[2]['function'] ) 626 | && $backtrace[2]['function'] === 'wp_unique_filename' && isset( $backtrace[2]['args'][1] ) 627 | && isset( $op['Prefix'] ) 628 | ) { 629 | /** @var string $filename */ 630 | $filename = $backtrace[2]['args'][1]; 631 | $name = pathinfo( $filename, PATHINFO_FILENAME ); 632 | $op['Prefix'] .= $name; 633 | } 634 | 635 | // Filter our "/" keys added by the console as directories, and ensure 636 | // that if a filter function is provided that it passes the filter. 637 | $this->objectIterator = \Aws\flatmap( 638 | $this->getClient()->getPaginator( 'ListObjectsV2', $op ), 639 | function ( Result $result ) use ( $filterFn ) { 640 | /** @var list */ 641 | $contentsAndPrefixes = $result->search( '[Contents[], CommonPrefixes[]][]' ); 642 | // Filter out dir place holder keys and use the filter fn. 643 | return array_filter( 644 | $contentsAndPrefixes, 645 | function ( $key ) use ( $filterFn ) { 646 | return ( ! $filterFn || call_user_func( $filterFn, $key ) ) 647 | && ( ! isset( $key['Key'] ) || substr( $key['Key'], -1, 1 ) !== '/' ); 648 | } 649 | ); 650 | } 651 | ); 652 | 653 | return true; 654 | } 655 | 656 | /** 657 | * Close the directory listing handles 658 | * 659 | * @return bool true on success 660 | */ 661 | public function dir_closedir() : bool { 662 | $this->objectIterator = null; 663 | gc_collect_cycles(); 664 | 665 | return true; 666 | } 667 | 668 | /** 669 | * This method is called in response to rewinddir() 670 | * 671 | * @return bool true on success 672 | */ 673 | public function dir_rewinddir() { 674 | return $this->boolCall( 675 | function() { 676 | if ( ! $this->openedPath ) { 677 | return false; 678 | } 679 | $this->objectIterator = null; 680 | $this->dir_opendir( $this->openedPath, null ); 681 | return true; 682 | } 683 | ); 684 | } 685 | 686 | /** 687 | * This method is called in response to readdir() 688 | * 689 | * @return string|bool Should return a string representing the next filename, or 690 | * false if there is no next file. 691 | * @link http://www.php.net/manual/en/function.readdir.php 692 | */ 693 | public function dir_readdir() { 694 | // Skip empty result keys 695 | if ( ! $this->objectIterator || ! $this->objectIterator->valid() ) { 696 | return false; 697 | } 698 | 699 | // First we need to create a cache key. This key is the full path to 700 | // then object in s3: protocol://bucket/key. 701 | // Next we need to create a result value. The result value is the 702 | // current value of the iterator without the opened bucket prefix to 703 | // emulate how readdir() works on directories. 704 | // The cache key and result value will depend on if this is a prefix 705 | // or a key. 706 | /** @var S3ObjectResultArray */ 707 | $cur = $this->objectIterator->current(); 708 | if ( isset( $cur['Prefix'] ) ) { 709 | // Include "directories". Be sure to strip a trailing "/" 710 | // on prefixes. 711 | $result = rtrim( $cur['Prefix'], '/' ); 712 | $key = $this->formatKey( $result ); 713 | $stat = $this->formatUrlStat( $key ); 714 | } else { 715 | $result = $cur['Key']; 716 | $key = $this->formatKey( $cur['Key'] ); 717 | $stat = $this->formatUrlStat( $cur ); 718 | } 719 | 720 | // Cache the object data for quick url_stat lookups used with 721 | // RecursiveDirectoryIterator. 722 | $this->getCacheStorage()->set( $key, $stat ); 723 | $this->objectIterator->next(); 724 | 725 | // Remove the prefix from the result to emulate other stream wrappers. 726 | $retVal = $this->openedBucketPrefix 727 | ? substr( $result, strlen( $this->openedBucketPrefix ) ) 728 | : $result; 729 | if ( $retVal === '' ) { 730 | $retVal = false; 731 | } 732 | return $retVal; 733 | } 734 | 735 | private function formatKey( string $key ) : string { 736 | $protocol = explode( '://', $this->openedPath ?? '' )[0]; 737 | return "{$protocol}://{$this->openedBucket}/{$key}"; 738 | } 739 | 740 | /** 741 | * Called in response to rename() to rename a file or directory. Currently 742 | * only supports renaming objects. 743 | * 744 | * @param string $path_from the path to the file to rename 745 | * @param string $path_to the new path to the file 746 | * 747 | * @return bool true if file was successfully renamed 748 | * @link http://www.php.net/manual/en/function.rename.php 749 | */ 750 | public function rename( $path_from, $path_to ) { 751 | // PHP will not allow rename across wrapper types, so we can safely 752 | // assume $path_from and $path_to have the same protocol 753 | $this->initProtocol( $path_from ); 754 | $partsFrom = $this->withPath( $path_from ); 755 | $partsTo = $this->withPath( $path_to ); 756 | $this->clearCacheKey( $path_from ); 757 | $this->clearCacheKey( $path_to ); 758 | 759 | if ( ! $partsFrom['Key'] || ! $partsTo['Key'] ) { 760 | return $this->triggerError( 761 | 'The Amazon S3 stream wrapper only ' 762 | . 'supports copying objects' 763 | ); 764 | } 765 | 766 | return $this->boolCall( 767 | function () use ( $partsFrom, $partsTo ) { 768 | $options = $this->getOptions( true ); 769 | // Copy the object and allow overriding default parameters if 770 | // desired, but by default copy metadata 771 | $this->getClient()->copy( 772 | $partsFrom['Bucket'], 773 | $partsFrom['Key'], 774 | $partsTo['Bucket'], 775 | $partsTo['Key'], 776 | isset( $options['acl'] ) ? $options['acl'] : 'private', 777 | $options 778 | ); 779 | // Delete the original object 780 | $this->getClient()->deleteObject( 781 | [ 782 | 'Bucket' => $partsFrom['Bucket'], 783 | 'Key' => $partsFrom['Key'], 784 | ] + $options 785 | ); 786 | return true; 787 | } 788 | ); 789 | } 790 | 791 | public function stream_cast( int $cast_as ) : bool { 792 | return false; 793 | } 794 | 795 | /** 796 | * Validates the provided stream arguments for fopen and returns an array 797 | * of errors. 798 | * 799 | * @param string $path 800 | * @param string $mode 801 | * @return string[] 802 | */ 803 | private function validate( $path, $mode ) { 804 | $errors = []; 805 | 806 | if ( ! $this->getOption( 'Key' ) ) { 807 | $errors[] = 'Cannot open a bucket. You must specify a path in the ' 808 | . 'form of s3://bucket/key'; 809 | } 810 | 811 | if ( ! in_array( $mode, [ 'r', 'w', 'a', 'x' ] ) ) { 812 | $errors[] = "Mode not supported: {$mode}. " 813 | . "Use one 'r', 'w', 'a', or 'x'."; 814 | } 815 | 816 | // When using mode "x" validate if the file exists before attempting 817 | // to read 818 | /** @var string */ 819 | $bucket = $this->getOption( 'Bucket' ); 820 | /** @var string */ 821 | $key = $this->getOption( 'Key' ); 822 | if ( $mode == 'x' && 823 | $this->getClient()->doesObjectExistV2( 824 | $bucket, 825 | $key, 826 | false, 827 | $this->getOptions( true ) 828 | ) 829 | ) { 830 | $errors[] = "{$path} already exists on Amazon S3"; 831 | } 832 | 833 | return $errors; 834 | } 835 | 836 | /** 837 | * Get the stream context options available to the current stream 838 | * 839 | * @param bool $removeContextData Set to true to remove contextual kvp's 840 | * like 'client' from the result. 841 | * 842 | * @return OptionsArray 843 | */ 844 | private function getOptions( $removeContextData = false ) { 845 | // Context is not set when doing things like stat 846 | if ( $this->context === null ) { 847 | $options = []; 848 | } else { 849 | $options = stream_context_get_options( $this->context ); 850 | /** @var array{client?: S3ClientInterface, cache?: CacheInterface, Bucket: string, Key: string, acl: string, seekable?: bool} */ 851 | $options = isset( $options[ $this->protocol ] ) 852 | ? $options[ $this->protocol ] 853 | : []; 854 | } 855 | 856 | $default = stream_context_get_options( stream_context_get_default() ); 857 | /** @var array{client?: S3ClientInterface, cache?: CacheInterface, Bucket: string, Key: string, acl: string, seekable?: bool} */ 858 | $default = isset( $default[ $this->protocol ] ) 859 | ? $default[ $this->protocol ] 860 | : []; 861 | /** @var array{client?: S3ClientInterface, cache?: CacheInterface, Bucket: string, Key: string, acl: string, seekable?: bool} */ 862 | $result = $this->params + $options + $default; 863 | 864 | if ( $removeContextData ) { 865 | unset( $result['client'], $result['seekable'], $result['cache'] ); 866 | } 867 | 868 | return $result; 869 | } 870 | 871 | /** 872 | * Get a specific stream context option 873 | * 874 | * @param string $name 875 | * @return mixed 876 | */ 877 | private function getOption( $name ) { 878 | $options = $this->getOptions(); 879 | return $options[ $name ] ?? null; 880 | } 881 | 882 | /** 883 | * Gets the client from the stream context 884 | * 885 | * @return S3ClientInterface 886 | * @throws \RuntimeException if no client has been configured 887 | */ 888 | private function getClient() : S3ClientInterface { 889 | /** @var Plugin|null */ 890 | $plugin = $this->getOption( 'plugin' ); 891 | if ( ! $plugin ) { 892 | throw new \RuntimeException( 'No plugin in stream context' ); 893 | } 894 | 895 | return $plugin->s3(); 896 | } 897 | 898 | /** 899 | * Get the bucket and key for a given path. 900 | * 901 | * @param string $path 902 | * @return array{Bucket: string, Key: string|null} 903 | */ 904 | private function getBucketKey( string $path ) : array { 905 | // Remove the protocol 906 | $parts = explode( '://', $path ); 907 | // Get the bucket, key 908 | $parts = explode( '/', $parts[1], 2 ); 909 | 910 | return [ 911 | 'Bucket' => $parts[0], 912 | 'Key' => isset( $parts[1] ) ? $parts[1] : null, 913 | ]; 914 | } 915 | 916 | /** 917 | * Get the bucket and key from the passed path (e.g. s3://bucket/key) 918 | * 919 | * @param string $path Path passed to the stream wrapper 920 | * 921 | * @return array{Bucket: string, Key: string|null} Hash of 'Bucket', 'Key', and custom params from the context 922 | */ 923 | private function withPath( $path ) { 924 | $params = $this->getOptions( true ); 925 | 926 | return $this->getBucketKey( $path ) + $params; 927 | } 928 | 929 | private function openReadStream() : bool { 930 | $client = $this->getClient(); 931 | $command = $client->getCommand( 'GetObject', $this->getOptions( true ) ); 932 | if ( is_array( $command['@http'] ) ) { 933 | $command['@http']['stream'] = true; 934 | } 935 | /** @var array{Body: StreamInterface, ContentLength: int} */ 936 | $result = $client->execute( $command ); 937 | $this->size = $result['ContentLength']; 938 | $this->body = $result['Body']; 939 | 940 | // Wrap the body in a caching entity body if seeking is allowed 941 | if ( $this->getOption( 'seekable' ) && ! $this->body->isSeekable() ) { 942 | $this->body = new CachingStream( $this->body ); 943 | } 944 | 945 | return true; 946 | } 947 | 948 | private function openWriteStream() : bool { 949 | $this->body = new Stream( fopen( 'php://temp', 'r+' ) ); 950 | return true; 951 | } 952 | 953 | private function openAppendStream() : bool { 954 | try { 955 | // Get the body of the object and seek to the end of the stream 956 | $client = $this->getClient(); 957 | /** @var array */ 958 | $request = $this->getOptions( true ); 959 | /** @var StreamInterface */ 960 | $this->body = $client->getObject( $request )['Body']; 961 | $this->body->seek( 0, SEEK_END ); 962 | return true; 963 | } catch ( S3Exception $e ) { 964 | // The object does not exist, so use a simple write stream 965 | return $this->openWriteStream(); 966 | } 967 | } 968 | 969 | /** 970 | * Trigger one or more errors 971 | * 972 | * @param string[]|string $errors Errors to trigger 973 | * @param int $flags If set to STREAM_URL_STAT_QUIET, then no 974 | * error or exception occurs 975 | * 976 | * @return bool Returns false 977 | */ 978 | private function triggerError( $errors, $flags = null ) { 979 | // This is triggered with things like file_exists() 980 | if ( $flags && $flags & STREAM_URL_STAT_QUIET ) { 981 | return false; 982 | } 983 | 984 | // This is triggered when doing things like lstat() or stat() 985 | trigger_error( implode( "\n", (array) $errors ), E_USER_WARNING ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped 986 | 987 | return false; 988 | } 989 | 990 | /** 991 | * Prepare a url_stat result array 992 | * 993 | * @param S3ObjectResultArray|null|false|string $result Data to add 994 | * 995 | * @return StatArray Returns the modified url_stat result 996 | */ 997 | private function formatUrlStat( $result = null ) { 998 | $stat = $this->getStatTemplate(); 999 | switch ( gettype( $result ) ) { 1000 | case 'NULL': 1001 | case 'string': 1002 | // Directory with 0777 access - see "man 2 stat". 1003 | $stat[2] = 0040777; 1004 | $stat['mode'] = 0040777; 1005 | break; 1006 | case 'array': 1007 | // Regular file with 0777 access - see "man 2 stat". 1008 | $stat[2] = 0100777; 1009 | $stat['mode'] = 0100777; 1010 | // Pluck the content-length if available. 1011 | if ( isset( $result['ContentLength'] ) ) { 1012 | $stat[7] = $result['ContentLength']; 1013 | $stat['size'] = $result['ContentLength']; 1014 | } elseif ( isset( $result['Size'] ) ) { 1015 | $stat[7] = $result['Size']; 1016 | $stat['size'] = $stat[7]; 1017 | } 1018 | if ( isset( $result['LastModified'] ) ) { 1019 | // ListObjects or HeadObject result 1020 | $stat[10] = strtotime( $result['LastModified'] ); 1021 | $stat['ctime'] = $stat[10]; 1022 | $stat[9] = $stat[10]; 1023 | $stat['mtime'] = $stat[10]; 1024 | } 1025 | } 1026 | 1027 | return $stat; 1028 | } 1029 | 1030 | /** 1031 | * Creates a bucket for the given parameters. 1032 | * 1033 | * @param string $path Stream wrapper path 1034 | * @param array{Bucket: string} $params A result of StreamWrapper::withPath() 1035 | * 1036 | * @return bool Returns true on success or false on failure 1037 | */ 1038 | private function createBucket( $path, array $params ) { 1039 | if ( $this->getClient()->doesBucketExistV2( $params['Bucket'], false ) ) { 1040 | return $this->triggerError( "Bucket already exists: {$path}" ); 1041 | } 1042 | 1043 | return $this->boolCall( 1044 | function () use ( $params, $path ) { 1045 | $this->getClient()->createBucket( $params ); 1046 | $this->clearCacheKey( $path ); 1047 | return true; 1048 | } 1049 | ); 1050 | } 1051 | 1052 | /** 1053 | * Creates a pseudo-folder by creating an empty "/" suffixed key 1054 | * 1055 | * @param string $path Stream wrapper path 1056 | * @param array{Key: string, Bucket: string} $params A result of StreamWrapper::withPath() 1057 | * 1058 | * @return bool 1059 | */ 1060 | private function createSubfolder( string $path, array $params ) { 1061 | // Ensure the path ends in "/" and the body is empty. 1062 | $params['Key'] = rtrim( $params['Key'], '/' ) . '/'; 1063 | $params['Body'] = ''; 1064 | 1065 | // Fail if this pseudo directory key already exists 1066 | if ( $this->getClient()->doesObjectExistV2( 1067 | $params['Bucket'], 1068 | $params['Key'] 1069 | ) 1070 | ) { 1071 | return $this->triggerError( "Subfolder already exists: {$path}" ); 1072 | } 1073 | 1074 | return $this->boolCall( 1075 | function () use ( $params, $path ) { 1076 | $this->getClient()->putObject( $params ); 1077 | $this->clearCacheKey( $path ); 1078 | return true; 1079 | } 1080 | ); 1081 | } 1082 | 1083 | /** 1084 | * Deletes a nested subfolder if it is empty. 1085 | * 1086 | * @param string $path Path that is being deleted (e.g., 's3://a/b/c') 1087 | * @param array{Bucket: string, Key: string} $params A result of StreamWrapper::withPath() 1088 | * 1089 | * @return bool 1090 | */ 1091 | private function deleteSubfolder( string $path, array $params ) : bool { 1092 | // Use a key that adds a trailing slash if needed. 1093 | $prefix = rtrim( $params['Key'], '/' ) . '/'; 1094 | /** @var array{Contents: list, CommonPrefixes:array} */ 1095 | $result = $this->getClient()->listObjectsV2( 1096 | [ 1097 | 'Bucket' => $params['Bucket'], 1098 | 'Prefix' => $prefix, 1099 | 'MaxKeys' => 1, 1100 | ] 1101 | ); 1102 | 1103 | // Check if the bucket contains keys other than the placeholder 1104 | $contents = $result['Contents']; 1105 | if ( $contents ) { 1106 | return ( count( $contents ) > 1 || $contents[0]['Key'] != $prefix ) 1107 | ? $this->triggerError( 'Subfolder is not empty' ) 1108 | : $this->unlink( rtrim( $path, '/' ) . '/' ); 1109 | } 1110 | 1111 | return $result['CommonPrefixes'] 1112 | ? $this->triggerError( 'Subfolder contains nested folders' ) 1113 | : true; 1114 | } 1115 | 1116 | /** 1117 | * Determine the most appropriate ACL based on a file mode. 1118 | * 1119 | * @param int $mode File mode 1120 | * 1121 | * @return 'public-read'|'authenticated-read'|'private' 1122 | */ 1123 | private function determineAcl( int $mode ) : string { 1124 | switch ( substr( decoct( $mode ), 0, 1 ) ) { 1125 | case '7': 1126 | return 'public-read'; 1127 | case '6': 1128 | return 'authenticated-read'; 1129 | default: 1130 | return 'private'; 1131 | } 1132 | } 1133 | 1134 | /** 1135 | * Gets a URL stat template with default values 1136 | * 1137 | * @return StatArray 1138 | * 1139 | */ 1140 | private function getStatTemplate() { 1141 | return [ 1142 | 0 => 0, 1143 | 'dev' => 0, 1144 | 1 => 0, 1145 | 'ino' => 0, 1146 | 2 => 0, 1147 | 'mode' => 0, 1148 | 3 => 0, 1149 | 'nlink' => 0, 1150 | 4 => 0, 1151 | 'uid' => 0, 1152 | 5 => 0, 1153 | 'gid' => 0, 1154 | 6 => -1, 1155 | 'rdev' => -1, 1156 | 7 => 0, 1157 | 'size' => 0, 1158 | 8 => 0, 1159 | 'atime' => 0, 1160 | 9 => 0, 1161 | 'mtime' => 0, 1162 | 10 => 0, 1163 | 'ctime' => 0, 1164 | 11 => -1, 1165 | 'blksize' => -1, 1166 | 12 => -1, 1167 | 'blocks' => -1, 1168 | ]; 1169 | } 1170 | 1171 | /** 1172 | * Invokes a callable and triggers an error if an exception occurs while 1173 | * calling the function. 1174 | * 1175 | * @psalm-template T 1176 | * @psalm-param callable():T $fn 1177 | * @param int $flags 1178 | * 1179 | * @psalm-return T|bool 1180 | */ 1181 | private function boolCall( callable $fn, $flags = null ) { 1182 | try { 1183 | return $fn(); 1184 | } catch ( \Exception $e ) { 1185 | return $this->triggerError( $e->getMessage(), $flags ); 1186 | } 1187 | } 1188 | 1189 | /** 1190 | * @return CacheInterface 1191 | */ 1192 | private function getCacheStorage() : CacheInterface { 1193 | if ( ! $this->cache ) { 1194 | /** @var CacheInterface */ 1195 | $this->cache = $this->getOption( 'cache' ) ?: new LruArrayCache(); 1196 | } 1197 | 1198 | return $this->cache; 1199 | } 1200 | 1201 | /** 1202 | * Clears a specific stat cache value from the stat cache and LRU cache. 1203 | * 1204 | * @param string $key S3 path (s3://bucket/key). 1205 | */ 1206 | private function clearCacheKey( $key ) { 1207 | clearstatcache( true, $key ); 1208 | $this->getCacheStorage()->remove( $key ); 1209 | } 1210 | 1211 | /** 1212 | * Returns the size of the opened object body. 1213 | * 1214 | * @return int|null 1215 | */ 1216 | private function getSize() { 1217 | if ( ! $this->body ) { 1218 | return null; 1219 | } 1220 | $size = $this->body->getSize(); 1221 | 1222 | return $size !== null ? $size : $this->size; 1223 | } 1224 | } 1225 | -------------------------------------------------------------------------------- /inc/class-wp-cli-command.php: -------------------------------------------------------------------------------- 1 | verify_s3_access_constants() ) { 20 | return; 21 | } 22 | 23 | // Get S3 Upload instance. 24 | $instance = Plugin::get_instance(); 25 | 26 | // Create a path in the base directory, with a random file name to avoid potentially overwriting existing data. 27 | $upload_dir = wp_upload_dir(); 28 | $s3_path = $upload_dir['basedir'] . '/' . wp_rand() . '.txt'; 29 | 30 | // Attempt to copy the local Canola test file to the generated path on S3. 31 | WP_CLI::print_value( 'Attempting to upload file ' . $s3_path ); 32 | 33 | $copy = copy( 34 | dirname( dirname( __FILE__ ) ) . '/verify.txt', 35 | $s3_path 36 | ); 37 | 38 | // Check that the copy worked. 39 | if ( ! $copy ) { 40 | WP_CLI::error( 'Failed to copy / write to S3 - check your policy?' ); 41 | 42 | return; 43 | } 44 | 45 | WP_CLI::print_value( 'File uploaded to S3 successfully.' ); 46 | 47 | // Delete the file off S3. 48 | WP_CLI::print_value( 'Attempting to delete file. ' . $s3_path ); 49 | $delete = unlink( $s3_path ); 50 | 51 | // Check that the delete worked. 52 | if ( ! $delete ) { 53 | WP_CLI::error( 'Failed to delete ' . $s3_path ); 54 | 55 | return; 56 | } 57 | 58 | WP_CLI::print_value( 'File deleted from S3 successfully.' ); 59 | 60 | WP_CLI::success( 'Looks like your configuration is correct.' ); 61 | } 62 | 63 | private function get_iam_policy() : string { 64 | 65 | $bucket = strtok( S3_UPLOADS_BUCKET, '/' ); 66 | 67 | $path = null; 68 | 69 | if ( strpos( S3_UPLOADS_BUCKET, '/' ) ) { 70 | $path = str_replace( strtok( S3_UPLOADS_BUCKET, '/' ) . '/', '', S3_UPLOADS_BUCKET ); 71 | } 72 | 73 | return '{ 74 | "Version": "2012-10-17", 75 | "Statement": [ 76 | { 77 | "Sid": "Stmt1392016154000", 78 | "Effect": "Allow", 79 | "Action": [ 80 | "s3:AbortMultipartUpload", 81 | "s3:DeleteObject", 82 | "s3:GetBucketAcl", 83 | "s3:GetBucketLocation", 84 | "s3:GetBucketPolicy", 85 | "s3:GetObject", 86 | "s3:GetObjectAcl", 87 | "s3:ListBucket", 88 | "s3:ListBucketMultipartUploads", 89 | "s3:ListMultipartUploadParts", 90 | "s3:PutObject", 91 | "s3:PutObjectAcl" 92 | ], 93 | "Resource": [ 94 | "arn:aws:s3:::' . S3_UPLOADS_BUCKET . '/*" 95 | ] 96 | }, 97 | { 98 | "Sid": "AllowRootAndHomeListingOfBucket", 99 | "Action": ["s3:ListBucket"], 100 | "Effect": "Allow", 101 | "Resource": ["arn:aws:s3:::' . $bucket . '"], 102 | "Condition":{"StringLike":{"s3:prefix":["' . ( $path ? $path . '/' : '' ) . '*"]}} 103 | } 104 | ] 105 | }'; 106 | } 107 | 108 | /** 109 | * Create AWS IAM Policy that S3 Uploads requires 110 | * 111 | * It's typically not a good idea to use access keys that have full access to your S3 account, 112 | * as if the keys are compromised through the WordPress site somehow, you don't 113 | * want to give full control via those keys. 114 | * 115 | * @subcommand generate-iam-policy 116 | */ 117 | public function generate_iam_policy() { 118 | 119 | WP_Cli::print_value( $this->get_iam_policy() ); 120 | 121 | } 122 | 123 | /** 124 | * List files in the S3 bucket 125 | * 126 | * @synopsis [] 127 | * 128 | * @param array{0: string} $args 129 | */ 130 | public function ls( array $args ) { 131 | 132 | $s3 = Plugin::get_instance()->s3(); 133 | 134 | $prefix = ''; 135 | 136 | if ( strpos( S3_UPLOADS_BUCKET, '/' ) ) { 137 | $prefix = trailingslashit( str_replace( strtok( S3_UPLOADS_BUCKET, '/' ) . '/', '', S3_UPLOADS_BUCKET ) ); 138 | } 139 | 140 | if ( isset( $args[0] ) ) { 141 | $prefix .= trailingslashit( ltrim( $args[0], '/' ) ); 142 | } 143 | 144 | try { 145 | $objects = $s3->getIterator( 146 | 'ListObjectsV2', [ 147 | 'Bucket' => strtok( S3_UPLOADS_BUCKET, '/' ), 148 | 'Prefix' => $prefix, 149 | ] 150 | ); 151 | /** @var array{Key: string} $object */ 152 | foreach ( $objects as $object ) { 153 | WP_CLI::line( str_replace( $prefix, '', $object['Key'] ) ); 154 | } 155 | } catch ( Exception $e ) { 156 | WP_CLI::error( $e->getMessage() ); 157 | } 158 | 159 | } 160 | 161 | /** 162 | * Copy files to / from the uploads directory. Use s3://bucket/location for S3 163 | * 164 | * @synopsis 165 | * 166 | * @param array{0: string, 1: string} $args 167 | */ 168 | public function cp( array $args ) { 169 | 170 | $from = $args[0]; 171 | $to = $args[1]; 172 | 173 | if ( is_dir( $from ) ) { 174 | $this->recurse_copy( $from, $to ); 175 | } else { 176 | copy( $from, $to ); 177 | } 178 | 179 | WP_CLI::success( sprintf( 'Completed copy from %s to %s', $from, $to ) ); 180 | } 181 | 182 | /** 183 | * Upload a directory to S3 184 | * 185 | * @subcommand upload-directory 186 | * @synopsis [] [--concurrency=] [--verbose] 187 | * 188 | * @param array{0: string, 1: string} $args 189 | * @param array{concurrency?: int, verbose?: bool} $args_assoc 190 | */ 191 | public function upload_directory( array $args, array $args_assoc ) { 192 | 193 | $from = $args[0]; 194 | $to = ''; 195 | if ( isset( $args[1] ) ) { 196 | $to = $args[1]; 197 | } 198 | 199 | $s3 = Plugin::get_instance()->s3(); 200 | $args_assoc = wp_parse_args( 201 | $args_assoc, [ 202 | 'concurrency' => 5, 203 | 'verbose' => false, 204 | ] 205 | ); 206 | 207 | $transfer_args = [ 208 | 'concurrency' => $args_assoc['concurrency'], 209 | 'debug' => (bool) $args_assoc['verbose'], 210 | 'before' => function ( Command $command ) { 211 | if ( in_array( $command->getName(), [ 'PutObject', 'CreateMultipartUpload' ], true ) ) { 212 | $acl = defined( 'S3_UPLOADS_OBJECT_ACL' ) ? S3_UPLOADS_OBJECT_ACL : 'public-read'; 213 | $command['ACL'] = $acl; 214 | } 215 | }, 216 | ]; 217 | try { 218 | $manager = new Transfer( $s3, $from, 's3://' . S3_UPLOADS_BUCKET . '/' . $to, $transfer_args ); 219 | $manager->transfer(); 220 | } catch ( Exception $e ) { 221 | WP_CLI::error( $e->getMessage() ); 222 | } 223 | } 224 | 225 | /** 226 | * Delete files from S3 227 | * 228 | * @synopsis [--regex=] 229 | * 230 | * @param array{0: string} $args 231 | * @param array{regex?: string} $args_assoc 232 | */ 233 | public function rm( array $args, array $args_assoc ) { 234 | 235 | $s3 = Plugin::get_instance()->s3(); 236 | 237 | $prefix = ''; 238 | $regex = isset( $args_assoc['regex'] ) ? $args_assoc['regex'] : ''; 239 | 240 | if ( strpos( S3_UPLOADS_BUCKET, '/' ) ) { 241 | $prefix = trailingslashit( str_replace( strtok( S3_UPLOADS_BUCKET, '/' ) . '/', '', S3_UPLOADS_BUCKET ) ); 242 | } 243 | 244 | if ( isset( $args[0] ) ) { 245 | $prefix .= ltrim( $args[0], '/' ); 246 | 247 | if ( strpos( $args[0], '.' ) === false ) { 248 | $prefix = trailingslashit( $prefix ); 249 | } 250 | } 251 | 252 | try { 253 | $s3->deleteMatchingObjects( 254 | strtok( S3_UPLOADS_BUCKET, '/' ), 255 | $prefix, 256 | $regex, 257 | [ 258 | 'before_delete', 259 | function() { 260 | WP_CLI::line( sprintf( 'Deleting file' ) ); 261 | }, 262 | ] 263 | ); 264 | 265 | } catch ( Exception $e ) { 266 | WP_CLI::error( $e->getMessage() ); 267 | } 268 | 269 | WP_CLI::success( sprintf( 'Successfully deleted %s', $prefix ) ); 270 | } 271 | 272 | /** 273 | * Enable the auto-rewriting of media links to S3 274 | */ 275 | public function enable() { 276 | update_option( 's3_uploads_enabled', 'enabled' ); 277 | 278 | WP_CLI::success( 'Media URL rewriting enabled.' ); 279 | } 280 | 281 | /** 282 | * Disable the auto-rewriting of media links to S3 283 | */ 284 | public function disable() { 285 | delete_option( 's3_uploads_enabled' ); 286 | 287 | WP_CLI::success( 'Media URL rewriting disabled.' ); 288 | } 289 | 290 | /** 291 | * List all files for a given attachment. 292 | * 293 | * Useful for debugging. 294 | * 295 | * @subcommand get-attachment-files 296 | * @synopsis 297 | * 298 | * @param array{0: int} $args 299 | */ 300 | public function get_attachment_files( array $args ) { 301 | WP_CLI::print_value( Plugin::get_attachment_files( $args[0] ) ); 302 | } 303 | 304 | /** 305 | * Update the ACL of all files for an attachment. 306 | * 307 | * Useful for debugging. 308 | * 309 | * @subcommand set-attachment-acl 310 | * @synopsis 311 | * 312 | * @param array{0: int, 1: 'public-read'|'private'} $args 313 | */ 314 | public function set_attachment_acl( array $args ) { 315 | $result = Plugin::get_instance()->set_attachment_files_acl( $args[0], $args[1] ); 316 | WP_CLI::print_value( $result ); 317 | } 318 | 319 | private function recurse_copy( string $src, string $dst ) { 320 | $dir = opendir( $src ); 321 | @mkdir( $dst ); 322 | while ( false !== ( $file = readdir( $dir ) ) ) { 323 | if ( ( '.' !== $file ) && ( '..' !== $file ) ) { 324 | if ( is_dir( $src . '/' . $file ) ) { 325 | $this->recurse_copy( $src . '/' . $file, $dst . '/' . $file ); 326 | } else { 327 | WP_CLI::line( sprintf( 'Copying from %s to %s', $src . '/' . $file, $dst . '/' . $file ) ); 328 | copy( $src . '/' . $file, $dst . '/' . $file ); 329 | } 330 | } 331 | } 332 | closedir( $dir ); 333 | } 334 | 335 | /** 336 | * Verify that the required constants for the S3 connections are set. 337 | * 338 | * @return bool true if all constants are set, else false. 339 | */ 340 | private function verify_s3_access_constants() { 341 | $required_constants = [ 342 | 'S3_UPLOADS_BUCKET', 343 | ]; 344 | 345 | // Credentials do not need to be set when using AWS Instance Profiles. 346 | if ( ! defined( 'S3_UPLOADS_USE_INSTANCE_PROFILE' ) || ! S3_UPLOADS_USE_INSTANCE_PROFILE ) { 347 | array_push( $required_constants, 'S3_UPLOADS_KEY', 'S3_UPLOADS_SECRET' ); 348 | } 349 | 350 | $all_set = true; 351 | foreach ( $required_constants as $constant ) { 352 | if ( ! defined( $constant ) ) { 353 | WP_CLI::error( sprintf( 'The required constant %s is not defined.', $constant ), false ); 354 | $all_set = false; 355 | } 356 | } 357 | 358 | return $all_set; 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /inc/namespace.php: -------------------------------------------------------------------------------- 1 | setup(); 38 | 39 | // Add filters to "wrap" the wp_privacy_personal_data_export_file function call as we need to 40 | // switch out the personal_data directory to a local temp folder, and then upload after it's 41 | // complete, as Core tries to write directly to the ZipArchive which won't work with the 42 | // S3 streamWrapper. 43 | add_action( 'wp_privacy_personal_data_export_file', __NAMESPACE__ . '\\before_export_personal_data', 9 ); 44 | add_action( 'wp_privacy_personal_data_export_file', __NAMESPACE__ . '\\after_export_personal_data', 11 ); 45 | add_action( 'wp_privacy_personal_data_export_file_created', __NAMESPACE__ . '\\move_temp_personal_data_to_s3', 1000 ); 46 | } 47 | 48 | /** 49 | * Check whether the environment meets the plugin's requirements, like the minimum PHP version. 50 | * 51 | * @return bool True if the requirements are met, else false. 52 | */ 53 | function check_requirements() : bool { 54 | global $wp_version; 55 | 56 | if ( version_compare( PHP_VERSION, '7.1', '<' ) ) { 57 | if ( is_admin() && ! defined( 'DOING_AJAX' ) ) { 58 | add_action( 'admin_notices', __NAMESPACE__ . '\\outdated_php_version_notice' ); 59 | } 60 | 61 | return false; 62 | } 63 | 64 | if ( version_compare( $wp_version, '5.3.0', '<' ) ) { 65 | if ( is_admin() && ! defined( 'DOING_AJAX' ) ) { 66 | add_action( 'admin_notices', __NAMESPACE__ . '\\outdated_wp_version_notice' ); 67 | } 68 | 69 | return false; 70 | } 71 | 72 | return true; 73 | } 74 | 75 | /** 76 | * Print an admin notice when the PHP version is not high enough. 77 | * 78 | * This has to be a named function for compatibility with PHP 5.2. 79 | */ 80 | function outdated_php_version_notice() { 81 | printf( 82 | '

The S3 Uploads plugin requires PHP version 5.5.0 or higher. Your server is running PHP version %s.

', 83 | PHP_VERSION 84 | ); 85 | } 86 | 87 | /** 88 | * Print an admin notice when the WP version is not high enough. 89 | * 90 | * This has to be a named function for compatibility with PHP 5.2. 91 | */ 92 | function outdated_wp_version_notice() { 93 | global $wp_version; 94 | 95 | printf( 96 | '

The S3 Uploads plugin requires WordPress version 5.3 or higher. Your server is running WordPress version %s.

', 97 | esc_html( $wp_version ) 98 | ); 99 | } 100 | 101 | /** 102 | * Check if URL rewriting is enabled. 103 | * 104 | * Define S3_UPLOADS_AUTOENABLE to false in your wp-config to disable, or use the 105 | * s3_uploads_enabled option. 106 | * 107 | * @return bool 108 | */ 109 | function enabled() : bool { 110 | // Make sure the plugin is enabled when autoenable is on 111 | $constant_autoenable_off = ( defined( 'S3_UPLOADS_AUTOENABLE' ) && false === S3_UPLOADS_AUTOENABLE ); 112 | 113 | if ( $constant_autoenable_off && 'enabled' !== get_option( 's3_uploads_enabled' ) ) { // If the plugin is not enabled, skip 114 | return false; 115 | } 116 | 117 | return true; 118 | } 119 | 120 | /** 121 | * Setup the filters for wp_privacy_exports_dir to use a temp folder location. 122 | */ 123 | function before_export_personal_data() { 124 | add_filter( 'wp_privacy_exports_dir', __NAMESPACE__ . '\\set_wp_privacy_exports_dir' ); 125 | } 126 | 127 | /** 128 | * Remove the filters for wp_privacy_exports_dir as we only want it added in some cases. 129 | */ 130 | function after_export_personal_data() { 131 | remove_filter( 'wp_privacy_exports_dir', __NAMESPACE__ . '\\set_wp_privacy_exports_dir' ); 132 | } 133 | 134 | /** 135 | * Override the wp_privacy_exports_dir location 136 | * 137 | * We don't want to use the default uploads folder location, as with S3 Uploads this is 138 | * going to the a s3:// custom URL handler, which is going to fail with the use of ZipArchive. 139 | * Instead we set to to WP's get_temp_dir and move the fail in the wp_privacy_personal_data_export_file_created 140 | * hook. 141 | * 142 | * @param string $dir 143 | * @return string 144 | */ 145 | function set_wp_privacy_exports_dir( string $dir ) { 146 | if ( strpos( $dir, 's3://' ) !== 0 ) { 147 | return $dir; 148 | } 149 | $dir = get_temp_dir() . 'wp_privacy_exports_dir/'; 150 | if ( ! is_dir( $dir ) ) { 151 | mkdir( $dir ); 152 | file_put_contents( $dir . 'index.html', '' ); // @codingStandardsIgnoreLine FS write is ok. 153 | } 154 | return $dir; 155 | } 156 | 157 | /** 158 | * Move the tmp personal data file to the true uploads location 159 | * 160 | * Once a personal data file has been written, move it from the overridden "temp" 161 | * location to the S3 location where it should have been stored all along, and where 162 | * the "natural" Core URL is going to be pointing to. 163 | */ 164 | function move_temp_personal_data_to_s3( string $archive_pathname ) { 165 | if ( strpos( $archive_pathname, get_temp_dir() ) !== 0 ) { 166 | return; 167 | } 168 | $upload_dir = wp_upload_dir(); 169 | $exports_dir = trailingslashit( $upload_dir['basedir'] ) . 'wp-personal-data-exports/'; 170 | $destination = $exports_dir . pathinfo( $archive_pathname, PATHINFO_FILENAME ) . '.' . pathinfo( $archive_pathname, PATHINFO_EXTENSION ); 171 | copy( $archive_pathname, $destination ); 172 | unlink( $archive_pathname ); 173 | } 174 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /psalm/stubs/constants.php: -------------------------------------------------------------------------------- 1 | Makes an exact copy of the Imagick object 6 | * @link https://php.net/manual/en/class.imagick.php 7 | */ 8 | class Imagick implements Iterator, Countable { 9 | 10 | } 11 | } 12 | 13 | namespace WP_CLI { 14 | /** 15 | * 16 | * @param string $command 17 | * @param callable|class-string $class 18 | * @return void 19 | */ 20 | function add_command( string $command, $class ) { 21 | 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /s3-uploads.php: -------------------------------------------------------------------------------- 1 |