PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / trunk
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder vtrunk
2.12.0 2.11.1 2.11.0 2.10.0 2.9.0 2.7.4 2.7.5 2.7.6 2.7.7 2.8.0 2.8.1 2.9.1 trunk 1.0 1.0-beta1 1.0-beta2 1.0-beta3 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.2.0 1.2.1 All 78 releases
ablocks / includes / api / paste-controller.php

paste-controller.php in aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder trunk, at includes/api/paste-controller.php

667 lines 20.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace ABlocks\API;
4
5 use ABlocks\Helper;
6 use WP_Error;
7 use WP_REST_Request;
8 use WP_REST_Response;
9 use WP_REST_Server;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Media intake for editor pastes.
17 *
18 * Pasting a document into the block editor drops every embedded image into the
19 * clipboard as a `data:` URI. The editor turns each one into a File whose name
20 * comes from the MIME type alone — `image/png` becomes `image.png` — so a
21 * document with three pictures produces three uploads that all ask for the same
22 * filename. `wp_unique_filename()` cannot separate them because the uploads run
23 * concurrently and none has hit the disk when the others pick their name, so the
24 * last write wins and the earlier images are silently destroyed: three
25 * attachment rows, one file, the same picture repeated down the page.
26 *
27 * This endpoint takes the image before the editor can, and names it after its
28 * own bytes (`<slug>-<hash>.webp`). Two different images can never collide, and
29 * the same image pasted twice resolves to the attachment already in the library
30 * instead of a duplicate. Images are converted to WebP on the way in, which is
31 * the point of routing them through here rather than `wp/v2/media`.
32 */
33 class PasteController {
34
35 /**
36 * Attachment meta holding the sha1 of the bytes we ingested, used to
37 * recognise an image that is already in the library.
38 */
39 const HASH_META = '_ablocks_paste_hash';
40
41 /**
42 * Formats left untouched: WebP is already the target, GIF would lose its
43 * animation, and SVG is not a raster image at all.
44 */
45 const NO_CONVERT = [ 'image/webp', 'image/gif', 'image/svg+xml' ];
46
47 /**
48 * Lossy WebP quality. Only photographs ever reach it — anything flat enough
49 * for lossless to win is stored losslessly — so this leans towards detail
50 * rather than towards the smallest possible file.
51 */
52 const DEFAULT_QUALITY = 92;
53
54 public function register_routes() {
55 register_rest_route(
56 ABLOCKS_REST_NAMESPACE,
57 '/paste/image',
58 array(
59 'methods' => WP_REST_Server::CREATABLE,
60 'callback' => array( $this, 'ingest_image' ),
61 'permission_callback' => array( $this, 'can_upload' ),
62 'args' => array(
63 'url' => array(
64 'required' => false,
65 'type' => 'string',
66 'description' => 'Remote image URL to fetch. Ignored when a file is uploaded.',
67 'sanitize_callback' => 'esc_url_raw',
68 ),
69 'name' => array(
70 'required' => false,
71 'type' => 'string',
72 'description' => 'Filename hint, usually a slug of the alt text or nearest heading.',
73 'sanitize_callback' => 'sanitize_text_field',
74 ),
75 'alt' => array(
76 'required' => false,
77 'type' => 'string',
78 'description' => 'Alt text to store on the attachment.',
79 'sanitize_callback' => 'sanitize_text_field',
80 ),
81 'post_id' => array(
82 'required' => false,
83 'type' => 'integer',
84 'description' => 'Post the attachment should belong to.',
85 'sanitize_callback' => 'absint',
86 ),
87 ),
88 )
89 );
90 }
91
92 public function can_upload() {
93 return current_user_can( 'upload_files' );
94 }
95
96 /**
97 * Take one pasted image into the media library, as WebP where that helps.
98 *
99 * @param WP_REST_Request $request Multipart request carrying `file`, or a
100 * body carrying `url`.
101 *
102 * @return WP_REST_Response|WP_Error
103 */
104 public function ingest_image( WP_REST_Request $request ) {
105 $this->load_media_api();
106
107 $files = $request->get_file_params();
108 $post_id = (int) $request->get_param( 'post_id' );
109
110 if ( ! empty( $files['file'] ) ) {
111 $source = $this->stage_uploaded_file( $files['file'] );
112 } else {
113 $source = $this->stage_remote_file( (string) $request->get_param( 'url' ) );
114 }
115
116 if ( is_wp_error( $source ) ) {
117 return $source;
118 }
119
120 $hash = sha1_file( $source['path'] );
121 $existing = $this->find_by_hash( $hash );
122
123 if ( $existing ) {
124 $this->discard( $source['path'] );
125 return rest_ensure_response( $this->describe( $existing, true ) );
126 }
127
128 $prepared = $this->to_webp( $source['path'], $source['mime'] );
129 if ( is_wp_error( $prepared ) ) {
130 $this->discard( $source['path'] );
131 return $prepared;
132 }
133
134 $attachment_id = $this->store(
135 $prepared,
136 $this->build_filename( (string) $request->get_param( 'name' ), $hash, $prepared['mime'] ),
137 $post_id
138 );
139
140 if ( is_wp_error( $attachment_id ) ) {
141 $this->discard( $prepared['path'] );
142 return $attachment_id;
143 }
144
145 update_post_meta( $attachment_id, self::HASH_META, $hash );
146
147 $alt = (string) $request->get_param( 'alt' );
148 if ( '' !== $alt ) {
149 update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt );
150 }
151
152 return rest_ensure_response( $this->describe( $attachment_id, false ) );
153 }
154
155 /**
156 * Pull in the upload and image helpers.
157 *
158 * `wp_tempnam()`, `download_url()`, `wp_handle_sideload()` and
159 * `wp_generate_attachment_metadata()` all live under `wp-admin/includes`,
160 * which a REST request never loads on its own.
161 */
162 private function load_media_api() {
163 if ( ! function_exists( 'wp_tempnam' ) || ! function_exists( 'download_url' ) ) {
164 require_once ABSPATH . 'wp-admin/includes/file.php';
165 }
166 if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) {
167 require_once ABSPATH . 'wp-admin/includes/image.php';
168 }
169 if ( ! function_exists( 'media_handle_sideload' ) ) {
170 require_once ABSPATH . 'wp-admin/includes/media.php';
171 }
172 }
173
174 /**
175 * Move a multipart upload somewhere we can work on it, having checked that
176 * the bytes really are an image the site accepts.
177 *
178 * @param array $file One entry from `$_FILES`.
179 *
180 * @return array|WP_Error `[ path, mime ]`.
181 */
182 private function stage_uploaded_file( array $file ) {
183 if ( ! empty( $file['error'] ) || empty( $file['tmp_name'] ) ) {
184 return new WP_Error( 'ablocks_paste_upload_failed', __( 'The pasted image could not be read.', 'ablocks' ), array( 'status' => 400 ) );
185 }
186
187 $max = (int) apply_filters( 'ablocks/paste/max_image_bytes', 20 * MB_IN_BYTES );
188 if ( $max > 0 && filesize( $file['tmp_name'] ) > $max ) {
189 return new WP_Error( 'ablocks_paste_too_large', __( 'The pasted image is too large to import.', 'ablocks' ), array( 'status' => 413 ) );
190 }
191
192 $mime = $this->sniff_image_mime( $file['tmp_name'] );
193 if ( is_wp_error( $mime ) ) {
194 return $mime;
195 }
196
197 // The multipart temp file is cleaned up by PHP at request end, which is
198 // too early: WebP conversion and the sideload both still need it. Take
199 // our own copy so the lifetime is ours to manage.
200 $copy = wp_tempnam( 'ablocks-paste' );
201 if ( ! $copy || ! copy( $file['tmp_name'], $copy ) ) {
202 return new WP_Error( 'ablocks_paste_stage_failed', __( 'The pasted image could not be staged for import.', 'ablocks' ), array( 'status' => 500 ) );
203 }
204
205 return array(
206 'path' => $copy,
207 'mime' => $mime,
208 );
209 }
210
211 /**
212 * Download a remote image referenced by the pasted markup.
213 *
214 * @param string $url Absolute http(s) URL.
215 *
216 * @return array|WP_Error `[ path, mime ]`.
217 */
218 private function stage_remote_file( string $url ) {
219 if ( '' === $url || ! wp_http_validate_url( $url ) ) {
220 return new WP_Error( 'ablocks_paste_bad_url', __( 'No importable image was supplied.', 'ablocks' ), array( 'status' => 400 ) );
221 }
222
223 if ( ! $this->host_is_allowed( $url ) ) {
224 return new WP_Error( 'ablocks_paste_host_blocked', __( 'That image host is not allowed for pasted content.', 'ablocks' ), array( 'status' => 403 ) );
225 }
226
227 // download_url() goes through wp_safe_remote_get(), so redirects into
228 // the local network are rejected for us.
229 $temp = download_url( $url );
230 if ( is_wp_error( $temp ) ) {
231 return $temp;
232 }
233
234 $mime = $this->sniff_image_mime( $temp );
235 if ( is_wp_error( $mime ) ) {
236 $this->discard( $temp );
237 return $mime;
238 }
239
240 return array(
241 'path' => $temp,
242 'mime' => $mime,
243 );
244 }
245
246 /**
247 * Only fetch from the hosts a document paste legitimately points at. Pastes
248 * carry markup we did not author, so an arbitrary URL in an `<img>` is an
249 * instruction from an untrusted source, not from the user.
250 *
251 * @param string $url Absolute URL.
252 */
253 private function host_is_allowed( string $url ): bool {
254 $host = wp_parse_url( $url, PHP_URL_HOST );
255 if ( ! $host ) {
256 return false;
257 }
258 $host = strtolower( $host );
259
260 $allowed = apply_filters(
261 'ablocks/paste/allowed_image_hosts',
262 array( 'googleusercontent.com', 'google.com', 'gstatic.com' )
263 );
264
265 $site_host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
266 if ( $site_host && $host === $site_host ) {
267 return true;
268 }
269
270 foreach ( (array) $allowed as $domain ) {
271 $domain = strtolower( ltrim( (string) $domain, '.' ) );
272 if ( '' === $domain ) {
273 continue;
274 }
275 if ( $host === $domain || substr( $host, - ( strlen( $domain ) + 1 ) ) === '.' . $domain ) {
276 return true;
277 }
278 }
279
280 return false;
281 }
282
283 /**
284 * Decide the MIME from the bytes rather than from anything the client said,
285 * and refuse anything that is not an image the site would accept on upload.
286 *
287 * @param string $path Local file.
288 *
289 * @return string|WP_Error
290 */
291 private function sniff_image_mime( string $path ) {
292 $size = @getimagesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A non-image is an expected input here, not an error.
293 $mime = ! empty( $size['mime'] ) ? $size['mime'] : '';
294
295 if ( ! $mime ) {
296 $checked = wp_check_filetype_and_ext( $path, basename( $path ) );
297 $mime = ! empty( $checked['type'] ) ? $checked['type'] : '';
298 }
299
300 if ( ! $mime || 0 !== strpos( $mime, 'image/' ) ) {
301 return new WP_Error( 'ablocks_paste_not_an_image', __( 'The pasted file is not an image.', 'ablocks' ), array( 'status' => 415 ) );
302 }
303
304 if ( ! in_array( $mime, (array) get_allowed_mime_types(), true ) ) {
305 return new WP_Error( 'ablocks_paste_mime_blocked', __( 'That image type is not allowed on this site.', 'ablocks' ), array( 'status' => 415 ) );
306 }
307
308 return $mime;
309 }
310
311 /**
312 * Re-encode to WebP when that is both possible and worth it.
313 *
314 * A WebP that comes out heavier than its source — common for small flat-
315 * colour PNGs — is thrown away and the original kept, so the conversion can
316 * only ever shrink a page.
317 *
318 * @param string $path Staged file, replaced in place on success.
319 * @param string $mime MIME of the staged file.
320 *
321 * @return array|WP_Error `[ path, mime ]`.
322 */
323 private function to_webp( string $path, string $mime ) {
324 $unchanged = array(
325 'path' => $path,
326 'mime' => $mime,
327 );
328
329 $enabled = (bool) apply_filters(
330 'ablocks/paste/convert_webp',
331 (bool) Helper::get_settings( 'paste_convert_webp', true )
332 );
333
334 if ( ! $enabled || in_array( $mime, self::NO_CONVERT, true ) ) {
335 return $unchanged;
336 }
337
338 if ( ! wp_image_editor_supports( array( 'mime_type' => 'image/webp' ) ) ) {
339 return $unchanged;
340 }
341
342 $quality = (int) apply_filters(
343 'ablocks/paste/webp_quality',
344 (int) Helper::get_settings( 'paste_webp_quality', self::DEFAULT_QUALITY )
345 );
346 $quality = max( 1, min( 100, $quality ) );
347
348 // Encode both ways and keep whichever is smaller. Which one wins is
349 // decided entirely by the picture: a chart, diagram or screenshot is a
350 // few flat colours, so lossless comes out a fraction of the lossy file
351 // AND pixel-identical, while a photograph is the other way round by a
352 // wide margin. Guessing from a colour count would need a threshold to
353 // tune and would still be wrong sometimes; encoding twice costs about a
354 // second on the largest images and is right every time.
355 $candidates = array_filter( array(
356 $this->encode_webp( $path, $mime, true, $quality ),
357 $this->encode_webp( $path, $mime, false, $quality ),
358 ) );
359
360 if ( ! $candidates ) {
361 return $unchanged;
362 }
363
364 usort( $candidates, static function ( $a, $b ) {
365 return filesize( $a ) <=> filesize( $b );
366 } );
367
368 $converted = array_shift( $candidates );
369 foreach ( $candidates as $discard ) {
370 $this->discard( $discard );
371 }
372
373 // A tiny PNG of two flat colours can still beat anything WebP does with
374 // it. Keeping whichever file is smaller means the conversion can only
375 // ever shrink a page; set this filter false to have WebP unconditionally.
376 $prefer_smaller = (bool) apply_filters( 'ablocks/paste/keep_smaller_original', true );
377
378 if ( $prefer_smaller && filesize( $converted ) >= filesize( $path ) ) {
379 $this->discard( $converted );
380 return $unchanged;
381 }
382
383 $this->discard( $path );
384
385 return array(
386 'path' => $converted,
387 'mime' => 'image/webp',
388 );
389 }
390
391 /**
392 * Write one WebP, lossless or lossy.
393 *
394 * `WP_Image_Editor` cannot do this. It has no lossless mode at all, and its
395 * quality is not usable for a format conversion either: `get_output_format()`
396 * calls `set_quality()` with no argument whenever the output mime differs
397 * from the input, which throws away whatever was set and substitutes core's
398 * default of 86 — so `set_quality( 92 )` followed by `save( $file,
399 * 'image/webp' )` silently encodes at 86. Hence the direct encoders.
400 *
401 * @param string $path Source image.
402 * @param string $mime Source MIME, for picking the GD reader.
403 * @param bool $lossless Encode losslessly.
404 * @param int $quality Quality 1-100, lossy only.
405 *
406 * @return string|false Path to the encoded file, or false.
407 */
408 private function encode_webp( string $path, string $mime, bool $lossless, int $quality ) {
409 $target = wp_tempnam( 'ablocks-paste-webp' );
410 if ( ! $target ) {
411 return false;
412 }
413
414 $written = class_exists( 'Imagick' )
415 ? $this->encode_webp_imagick( $path, $target, $lossless, $quality )
416 : $this->encode_webp_gd( $path, $mime, $target, $lossless, $quality );
417
418 if ( ! $written || ! file_exists( $target ) || ! filesize( $target ) ) {
419 $this->discard( $target );
420 return false;
421 }
422
423 return $target;
424 }
425
426 /**
427 * @param string $path Source image.
428 * @param string $target File to write.
429 * @param bool $lossless Encode losslessly.
430 * @param int $quality Quality 1-100, lossy only.
431 */
432 private function encode_webp_imagick( string $path, string $target, bool $lossless, int $quality ): bool {
433 try {
434 $image = new \Imagick( $path );
435
436 // An animation would be flattened to its first frame; those are
437 // excluded upstream, but a multi-frame source could still arrive.
438 if ( $image->getNumberImages() > 1 ) {
439 $image->clear();
440 return false;
441 }
442
443 $image->setImageFormat( 'webp' );
444
445 if ( $lossless ) {
446 $image->setOption( 'webp:lossless', 'true' );
447 } else {
448 $image->setImageCompressionQuality( $quality );
449 }
450
451 $written = $image->writeImage( $target );
452 $image->clear();
453
454 return (bool) $written;
455 } catch ( \Exception $e ) {
456 return false;
457 }//end try
458 }
459
460 /**
461 * @param string $path Source image.
462 * @param string $mime Source MIME.
463 * @param string $target File to write.
464 * @param bool $lossless Encode losslessly.
465 * @param int $quality Quality 1-100, lossy only.
466 */
467 private function encode_webp_gd( string $path, string $mime, string $target, bool $lossless, int $quality ): bool {
468 if ( ! function_exists( 'imagewebp' ) ) {
469 return false;
470 }
471
472 // GD only learned the lossless flag in PHP 8.1.
473 if ( $lossless && ! defined( 'IMG_WEBP_LOSSLESS' ) ) {
474 return false;
475 }
476
477 $readers = array(
478 'image/png' => 'imagecreatefrompng',
479 'image/jpeg' => 'imagecreatefromjpeg',
480 'image/gif' => 'imagecreatefromgif',
481 'image/webp' => 'imagecreatefromwebp',
482 'image/bmp' => 'imagecreatefrombmp',
483 );
484
485 if ( empty( $readers[ $mime ] ) || ! function_exists( $readers[ $mime ] ) ) {
486 return false;
487 }
488
489 $image = @call_user_func( $readers[ $mime ], $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A malformed image is an expected input, not an error.
490 if ( ! $image ) {
491 return false;
492 }
493
494 // Transparency survives WebP, but only if GD is told to keep it.
495 imagepalettetotruecolor( $image );
496 imagealphablending( $image, false );
497 imagesavealpha( $image, true );
498
499 $written = imagewebp( $image, $target, $lossless ? IMG_WEBP_LOSSLESS : $quality );
500 imagedestroy( $image );
501
502 return (bool) $written;
503 }
504
505 /**
506 * Name the file after its own content.
507 *
508 * The slug keeps it findable in the media library; the hash fragment is what
509 * makes two different pasted images impossible to confuse, which is the
510 * whole reason this endpoint exists.
511 *
512 * @param string $hint Caller's suggestion, typically from alt text.
513 * @param string $hash sha1 of the source bytes.
514 * @param string $mime MIME of the file being stored.
515 */
516 private function build_filename( string $hint, string $hash, string $mime ): string {
517 $slug = sanitize_title( $hint );
518
519 // `image` is what the editor calls every clipboard image; it says nothing
520 // about the picture, so fall back to something that at least says where
521 // it came from.
522 if ( '' === $slug || 'image' === $slug ) {
523 $slug = 'pasted-image';
524 }
525
526 // Alt text is a sentence; cut it back to whole words so the filename
527 // still reads as something rather than ending mid-syllable.
528 if ( strlen( $slug ) > 60 ) {
529 $slug = substr( $slug, 0, 60 );
530 $break = strrpos( $slug, '-' );
531 if ( false !== $break && $break > 20 ) {
532 $slug = substr( $slug, 0, $break );
533 }
534 }
535
536 $ext = 'jpg';
537
538 $known = wp_get_mime_types();
539 foreach ( $known as $extensions => $type ) {
540 if ( $type === $mime ) {
541 $ext = strtok( $extensions, '|' );
542 break;
543 }
544 }
545
546 return sanitize_file_name( $slug . '-' . substr( $hash, 0, 8 ) . '.' . $ext );
547 }
548
549 /**
550 * Move a staged file into the uploads directory and register the attachment.
551 *
552 * @param array $prepared `[ path, mime ]`.
553 * @param string $filename Name to store it under.
554 * @param int $post_id Post to attach it to, 0 for none.
555 *
556 * @return int|WP_Error Attachment ID.
557 */
558 private function store( array $prepared, string $filename, int $post_id ) {
559 $file = array(
560 'name' => $filename,
561 'type' => $prepared['mime'],
562 'tmp_name' => $prepared['path'],
563 'error' => 0,
564 'size' => filesize( $prepared['path'] ),
565 );
566
567 $sideloaded = wp_handle_sideload(
568 $file,
569 array(
570 'test_form' => false,
571 'mimes' => array( pathinfo( $filename, PATHINFO_EXTENSION ) => $prepared['mime'] ),
572 )
573 );
574
575 if ( ! empty( $sideloaded['error'] ) ) {
576 return new WP_Error( 'ablocks_paste_sideload_failed', $sideloaded['error'], array( 'status' => 500 ) );
577 }
578
579 $attachment_id = wp_insert_attachment(
580 array(
581 'post_mime_type' => $sideloaded['type'],
582 'post_title' => preg_replace( '/\.[^.]+$/', '', wp_basename( $sideloaded['file'] ) ),
583 'post_content' => '',
584 'post_status' => 'inherit',
585 ),
586 $sideloaded['file'],
587 $post_id,
588 true
589 );
590
591 if ( is_wp_error( $attachment_id ) ) {
592 return $attachment_id;
593 }
594
595 wp_update_attachment_metadata(
596 $attachment_id,
597 wp_generate_attachment_metadata( $attachment_id, $sideloaded['file'] )
598 );
599
600 return (int) $attachment_id;
601 }
602
603 /**
604 * Find an attachment already holding these exact bytes.
605 *
606 * @param string $hash sha1 of the source bytes.
607 *
608 * @return int Attachment ID, or 0.
609 */
610 private function find_by_hash( string $hash ): int {
611 $found = get_posts(
612 array(
613 'post_type' => 'attachment',
614 'post_status' => 'inherit',
615 'posts_per_page' => 1,
616 'fields' => 'ids',
617 'no_found_rows' => true,
618 'update_post_term_cache' => false,
619 'meta_key' => self::HASH_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Indexed lookup on a single row; the alternative is a duplicate upload.
620 'meta_value' => $hash, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
621 )
622 );
623
624 if ( empty( $found ) ) {
625 return 0;
626 }
627
628 // A row can outlive its file if the upload was removed from disk by hand.
629 $id = (int) $found[0];
630 if ( ! get_attached_file( $id ) || ! file_exists( get_attached_file( $id ) ) ) {
631 return 0;
632 }
633
634 return $id;
635 }
636
637 /**
638 * Shape one attachment for the editor.
639 *
640 * @param int $attachment_id Attachment.
641 * @param bool $reused Whether it was already in the library.
642 */
643 private function describe( int $attachment_id, bool $reused ): array {
644 $meta = wp_get_attachment_metadata( $attachment_id );
645
646 return array(
647 'id' => $attachment_id,
648 'url' => wp_get_attachment_url( $attachment_id ),
649 'width' => isset( $meta['width'] ) ? (int) $meta['width'] : 0,
650 'height' => isset( $meta['height'] ) ? (int) $meta['height'] : 0,
651 'mime' => get_post_mime_type( $attachment_id ),
652 'alt' => (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ),
653 'reused' => $reused,
654 );
655 }
656
657 /**
658 * @param string $path Temp file to remove, if it is still there.
659 */
660 private function discard( string $path ) {
661 if ( $path && file_exists( $path ) ) {
662 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink
663 @unlink( $path );
664 }
665 }
666 }
667