├── .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 |
4 | S3 Uploads
5 | Lightweight "drop-in" for storing WordPress uploads on Amazon S3 instead of the local filesystem.
6 | |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | |
18 |
19 |
20 |
21 | A Human Made project. Maintained by @joehoyle.
22 | |
23 |
24 |
25 | |
26 |
27 |
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 |