absolute path.
* The untouched upload kept by WordPress for very large images
* ("original_image") is never sent or changed.
*
* @param int $attachment_id Attachment ID.
* @return array
*/
public static function files_for( $attachment_id ) {
$main = get_attached_file( $attachment_id );
if ( ! $main || ! is_file( $main ) || ! self::inside_uploads( $main ) ) {
return array();
}
$files = array( wp_basename( $main ) => $main );
$dir = trailingslashit( dirname( $main ) );
$meta = wp_get_attachment_metadata( $attachment_id );
if ( ! empty( $meta['sizes'] ) && is_array( $meta['sizes'] ) ) {
foreach ( $meta['sizes'] as $size ) {
if ( empty( $size['file'] ) ) {
continue;
}
$name = wp_basename( $size['file'] );
if ( ! isset( $files[ $name ] ) && is_file( $dir . $name ) ) {
$files[ $name ] = $dir . $name;
}
}
}
return $files;
}
/**
* Optimize every file of an attachment. Work already done is skipped, so
* the call can be repeated until it returns true.
*
* @param int $attachment_id Attachment ID.
* @param float $deadline Unix time to stop at (0 = no limit). At least one file is always processed.
* @return true|string|WP_Error true when finished, 'partial' when out of time, 'locked' when another request has it,
* WP_Error when the run should stop (quota, bad key, service down).
*/
public static function optimize_attachment( $attachment_id, $deadline = 0 ) {
$attachment_id = (int) $attachment_id;
if ( ! self::is_supported( $attachment_id ) ) {
return self::fail( $attachment_id, __( 'This file type is not supported.', 'lean-pixel' ) );
}
if ( self::is_excluded( $attachment_id ) ) {
update_post_meta( $attachment_id, self::STATUS, 'excluded' );
return true;
}
$files = self::files_for( $attachment_id );
if ( ! $files ) {
return self::fail( $attachment_id, __( 'The image file is missing from the server.', 'lean-pixel' ) );
}
if ( ! LeanPixel_Queue::lock( 'att_' . $attachment_id, 5 * MINUTE_IN_SECONDS ) ) {
return 'locked'; // Another request is working on this image right now.
}
$result = self::optimize_locked( $attachment_id, $files, $deadline );
LeanPixel_Queue::unlock( 'att_' . $attachment_id );
return $result;
}
private static function optimize_locked( $attachment_id, array $files, $deadline ) {
$settings = LeanPixel_Settings::all();
$data = self::data( $attachment_id );
$worked = false;
foreach ( $files as $name => $path ) {
if ( isset( $data['files'][ $name ] ) ) {
continue;
}
if ( $worked && $deadline && microtime( true ) > $deadline ) {
update_post_meta( $attachment_id, self::DATA, $data );
return 'partial';
}
$record = self::process_file( $path, $settings, wp_basename( get_attached_file( $attachment_id ) ) === $name, self::image_id( $attachment_id ) );
if ( is_wp_error( $record ) ) {
if ( in_array( $record->get_error_code(), self::STOP_CODES, true ) ) {
update_post_meta( $attachment_id, self::DATA, $data );
return $record;
}
$record = array(
'before' => (int) filesize( $path ),
'after' => (int) filesize( $path ),
'skip' => $record->get_error_message(),
);
} else {
LeanPixel_Settings::record_stats( $record, 1 );
}
$data['files'][ $name ] = $record;
$worked = true;
// Saved after every file so an interrupted request loses nothing.
update_post_meta( $attachment_id, self::DATA, $data );
}
$data['level'] = $settings['level'];
$data['time'] = time();
unset( $data['error'] );
$skipped = array();
foreach ( $data['files'] as $record ) {
if ( ! empty( $record['skip'] ) ) {
$skipped[] = $record['skip'];
}
}
if ( count( $skipped ) === count( $data['files'] ) ) {
$data['error'] = $skipped[0];
update_post_meta( $attachment_id, self::DATA, $data );
update_post_meta( $attachment_id, self::STATUS, 'error' );
return true;
}
update_post_meta( $attachment_id, self::DATA, $data );
update_post_meta( $attachment_id, self::STATUS, 'optimized' );
self::refresh_filesizes( $attachment_id );
return true;
}
/**
* Resize (if asked), compress one file (through the service, or on this
* server in local mode) and write back the result.
*
* @param string $path Absolute path.
* @param array $settings Plugin settings.
* @param bool $is_main Whether this is the attachment's full-size file (only that one is ever resized).
* @param string $image Id shared by every file of the image, so the service counts it once.
* @return array|WP_Error Record for the file.
*/
private static function process_file( $path, array $settings, $is_main = false, $image = '' ) {
$type = wp_check_filetype( $path );
$mime = $type['type'];
if ( ! in_array( $mime, self::mimes(), true ) ) {
return new WP_Error( 'unsupported_type', __( 'This file type is not supported.', 'lean-pixel' ) );
}
if ( ! wp_is_writable( $path ) || ! wp_is_writable( dirname( $path ) ) ) {
return new WP_Error( 'not_writable', __( 'The file is not writable.', 'lean-pixel' ) );
}
// A kept original that is already there (from an interrupted earlier
// attempt, or from before a resize was requested) is the true "before".
$backup = self::backup_path( $path );
$has_backup = $backup && is_file( $backup );
$before = $has_backup ? (int) filesize( $backup ) : (int) filesize( $path );
$record = array(
'before' => $before,
'after' => (int) filesize( $path ),
'webp' => 0,
'avif' => 0,
'backup' => $has_backup ? 1 : 0,
);
// Step 1: scale an oversized full-size image down.
$max = $is_main ? (int) $settings['max_dimension'] : 0;
if ( $max > 0 && 'image/gif' !== $mime ) {
$source = $has_backup ? $backup : $path;
$size = wp_getimagesize( $source );
if ( $size && max( $size[0], $size[1] ) > $max && self::can_load( $size[0], $size[1] ) ) {
if ( $settings['keep_originals'] && ! $has_backup ) {
$kept = self::keep_original( $path, $backup );
if ( is_wp_error( $kept ) ) {
return $kept;
}
$has_backup = true;
$record['backup'] = 1;
}
if ( true === self::resize( $source, $path, $max, $mime ) ) {
$record['resized'] = array( (int) $size[0], (int) $size[1] );
}
clearstatcache( true, $path );
$record['after'] = (int) filesize( $path );
}
}
// Step 2: compress.
$current = (int) filesize( $path );
$result = empty( $settings['local'] ) ? LeanPixel_API::optimize( $path, $mime, $image ) : LeanPixel_Local::optimize( $path, $mime, $settings );
if ( is_wp_error( $result ) ) {
return $result;
}
$replaced = false;
$optimized = self::decode( isset( $result['optimized'] ) ? $result['optimized'] : null );
if ( null !== $optimized && strlen( $optimized ) < $current && self::looks_like( $optimized, $mime ) && self::same_picture( $optimized, $path, $mime ) ) {
if ( $settings['keep_originals'] && ! $has_backup ) {
$kept = self::keep_original( $path, $backup );
if ( is_wp_error( $kept ) ) {
return $kept;
}
$record['backup'] = 1;
}
if ( ! self::write( $path, $optimized ) ) {
return new WP_Error( 'write_failed', __( 'The optimized file could not be saved.', 'lean-pixel' ) );
}
$record['after'] = strlen( $optimized );
$current = $record['after'];
$replaced = true;
}
unset( $optimized );
// The service leaves a file alone when compressing it would gain very
// little. Its camera and location details still come out, unless they
// are to be kept. (Local mode does this itself.)
if ( ! $replaced && 'image/jpeg' === $mime && empty( $settings['keep_metadata'] ) && empty( $settings['local'] ) ) {
$stripped = LeanPixel_Local::without_details( $path );
if ( null !== $stripped && strlen( $stripped ) < $current && self::looks_like( $stripped, $mime ) && self::same_picture( $stripped, $path, $mime ) ) {
if ( $settings['keep_originals'] && ! $has_backup ) {
$kept = self::keep_original( $path, $backup );
if ( is_wp_error( $kept ) ) {
return $kept;
}
$record['backup'] = 1;
}
if ( self::write( $path, $stripped ) ) {
$record['after'] = strlen( $stripped );
}
}
unset( $stripped );
}
foreach ( array( 'webp', 'avif' ) as $format ) {
if ( ! $settings[ $format ] ) {
continue;
}
$bytes = self::decode( isset( $result[ $format ] ) ? $result[ $format ] : null );
if ( null !== $bytes
&& strlen( $bytes ) < $before
&& self::looks_like( $bytes, 'image/' . $format )
&& self::same_picture( $bytes, $path, 'image/' . $format )
&& self::write( $path . '.' . $format, $bytes ) ) {
$record[ $format ] = strlen( $bytes );
}
}
return $record;
}
/**
* Copy a file into the kept-originals folder before it is changed.
*
* @param string $path File about to be changed.
* @param string|false $backup Where its copy goes.
* @return true|WP_Error
*/
private static function keep_original( $path, $backup ) {
$error = new WP_Error( 'backup_failed', __( 'The original could not be backed up, so the file was left unchanged.', 'lean-pixel' ) );
if ( ! $backup ) {
return $error;
}
if ( is_file( $backup ) ) {
return true;
}
self::protect_backup_dir();
if ( ! wp_mkdir_p( dirname( $backup ) ) || ! copy( $path, $backup ) ) {
return $error;
}
return true;
}
/**
* Whether PHP has room to open an image of this size. Running out of
* memory cannot be caught, so an image that will not fit is left at its size.
*/
public static function can_load( $width, $height ) {
wp_raise_memory_limit( 'image' );
$limit = wp_convert_hr_to_bytes( ini_get( 'memory_limit' ) );
if ( $limit <= 0 ) {
return true; // No limit set.
}
// Source and scaled copy in memory at 4 bytes a pixel, plus working room.
$needed = $width * $height * 4 * 1.8;
return $needed < ( $limit - memory_get_usage( true ) );
}
/**
* Scale an image so its longest side is $max pixels, using WordPress's own
* image editor, and save it over $path. The file name does not change, so
* existing links keep working.
*
* @param string $source File to read (the kept original when there is one).
* @param string $path File to replace.
* @param int $max Longest side in pixels.
* @param string $mime Mime type to save as.
* @return true|WP_Error
*/
private static function resize( $source, $path, $max, $mime ) {
$editor = wp_get_image_editor( $source );
if ( is_wp_error( $editor ) ) {
return $editor;
}
if ( method_exists( $editor, 'maybe_exif_rotate' ) ) {
$editor->maybe_exif_rotate(); // The scaled copy loses its camera orientation flag, so apply it now.
}
$editor->set_quality( 90 ); // High, because the service compresses the result next.
$scaled = $editor->resize( $max, $max, false );
if ( is_wp_error( $scaled ) ) {
return $scaled;
}
$ext = pathinfo( $path, PATHINFO_EXTENSION );
$tmp = dirname( $path ) . '/' . wp_basename( $path, '.' . $ext ) . '-leanpixel-' . strtolower( wp_generate_password( 8, false, false ) ) . '.' . $ext;
$perms = fileperms( $path ) & 0777;
$saved = $editor->save( $tmp, $mime );
if ( is_wp_error( $saved ) || empty( $saved['path'] ) || ! is_file( $saved['path'] ) ) {
return is_wp_error( $saved ) ? $saved : new WP_Error( 'resize_failed', __( 'The image could not be resized.', 'lean-pixel' ) );
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- atomic replace in the uploads folder.
if ( ! rename( $saved['path'], $path ) ) {
wp_delete_file( $saved['path'] );
return new WP_Error( 'resize_failed', __( 'The image could not be resized.', 'lean-pixel' ) );
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- keep the original permissions.
chmod( $path, $perms );
return true;
}
/**
* Put the originals back and remove the WebP/AVIF copies.
*
* @param int $attachment_id Attachment ID.
* @return true|WP_Error
*/
public static function restore_attachment( $attachment_id ) {
$attachment_id = (int) $attachment_id;
$data = self::data( $attachment_id );
$files = self::files_for( $attachment_id );
// Checked before anything is touched, so a restore is all or nothing.
if ( ! self::can_restore( $attachment_id, $data ) ) {
return new WP_Error( 'no_backup', __( 'The originals are no longer kept for this image, so it cannot be restored.', 'lean-pixel' ) );
}
if ( ! LeanPixel_Queue::lock( 'att_' . $attachment_id, 5 * MINUTE_IN_SECONDS ) ) {
return new WP_Error( 'locked', __( 'This image is being processed right now. Please try again in a moment.', 'lean-pixel' ) );
}
$result = self::restore_locked( $attachment_id, $data, $files );
LeanPixel_Queue::unlock( 'att_' . $attachment_id );
return $result;
}
private static function restore_locked( $attachment_id, array $data, array $files ) {
foreach ( $data['files'] as $name => $record ) {
if ( ! isset( $files[ $name ] ) ) {
continue;
}
$path = $files[ $name ];
$backup = self::backup_path( $path );
if ( ! empty( $record['backup'] ) && $backup && is_file( $backup ) ) {
if ( ! copy( $backup, $path ) ) {
return new WP_Error( 'restore_failed', __( 'A file could not be restored. Check the folder permissions.', 'lean-pixel' ) );
}
wp_delete_file( $backup );
}
self::delete_siblings( $path );
LeanPixel_Settings::record_stats( $record, -1 );
}
delete_post_meta( $attachment_id, self::DATA );
update_post_meta( $attachment_id, self::STATUS, 'restored' );
self::refresh_filesizes( $attachment_id );
return true;
}
/**
* Remove WebP/AVIF copies and backups when an attachment is deleted.
*
* @param int $attachment_id Attachment ID.
*/
public static function on_delete( $attachment_id ) {
if ( ! self::is_supported( $attachment_id ) ) {
return;
}
foreach ( self::files_for( $attachment_id ) as $path ) {
self::delete_siblings( $path );
$backup = self::backup_path( $path );
if ( $backup && is_file( $backup ) ) {
wp_delete_file( $backup );
}
}
}
/**
* Whether every file this plugin changed still has its original on disk.
*
* @param int $attachment_id Attachment ID.
* @param array $data The attachment's record.
* @return bool
*/
public static function can_restore( $attachment_id, array $data ) {
$main = get_attached_file( $attachment_id );
if ( ! $main || ! $data['files'] ) {
return false;
}
$dir = trailingslashit( dirname( $main ) );
$changed = 0;
foreach ( $data['files'] as $name => $record ) {
if ( ! isset( $record['before'], $record['after'] ) || ( $record['after'] >= $record['before'] && empty( $record['resized'] ) ) ) {
continue; // The file itself was never replaced.
}
$backup = self::backup_path( $dir . wp_basename( $name ) );
if ( empty( $record['backup'] ) || ! $backup || ! is_file( $backup ) ) {
return false;
}
++$changed;
}
return $changed > 0;
}
/**
* Name of the folder (inside uploads) that holds the kept originals. It
* carries a random suffix so the copies cannot be found by guessing the
* address, which matters on servers that ignore .htaccess files.
*/
public static function backup_folder() {
$name = get_option( self::BACKUP_OPTION );
if ( ! is_string( $name ) || ! preg_match( '/^leanpixel-backup(-[a-z0-9]{12})?$/', $name ) ) {
$name = self::OLD_BACKUP_DIR . '-' . strtolower( wp_generate_password( 12, false, false ) );
update_option( self::BACKUP_OPTION, $name, false );
}
return $name;
}
public static function backup_dir() {
$uploads = wp_get_upload_dir();
return trailingslashit( $uploads['basedir'] ) . self::backup_folder();
}
/**
* Block web access to the kept originals. They can hold camera and
* location details that the public, optimized copies no longer have.
*/
public static function protect_backup_dir() {
$dir = self::backup_dir();
if ( ! wp_mkdir_p( $dir ) ) {
return;
}
$files = array(
'.htaccess' => "\nRequire all denied\n\n\nOrder allow,deny\nDeny from all\n\n",
'index.php' => " $contents ) {
if ( ! is_file( $dir . '/' . $name ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- small guard file in uploads.
file_put_contents( $dir . '/' . $name, $contents );
}
}
}
/**
* Move kept originals from the old, guessable folder name to the new one
* and protect them. Runs once after an update.
*/
public static function migrate_backup_dir() {
$uploads = wp_get_upload_dir();
$old = trailingslashit( $uploads['basedir'] ) . self::OLD_BACKUP_DIR;
if ( is_dir( $old ) && ! get_option( self::BACKUP_OPTION ) ) {
$name = self::backup_folder();
// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename, WordPress.PHP.NoSilencedErrors.Discouraged -- same filesystem, inside uploads.
if ( ! @rename( $old, trailingslashit( $uploads['basedir'] ) . $name ) ) {
update_option( self::BACKUP_OPTION, self::OLD_BACKUP_DIR, false ); // Could not move: keep the old name, still protected below.
}
}
if ( is_dir( self::backup_dir() ) ) {
self::protect_backup_dir();
}
}
/** Whether a path really is inside the uploads folder (no "..", symlinks resolved). */
private static function inside_uploads( $path ) {
if ( false !== strpos( wp_normalize_path( $path ), '/../' ) ) {
return false;
}
$uploads = wp_get_upload_dir();
$base = realpath( $uploads['basedir'] );
$real = realpath( $path );
return $base && $real && 0 === strpos( wp_normalize_path( $real ), trailingslashit( wp_normalize_path( $base ) ) );
}
/**
* Disk space used by the kept originals.
*
* @param float $seconds Stop counting after this long on very large folders.
* @return array { bytes, files, complete }
*/
public static function backup_usage( $seconds = 1.5 ) {
$usage = array(
'bytes' => 0,
'files' => 0,
'complete' => true,
);
$dir = self::backup_dir();
if ( ! is_dir( $dir ) ) {
return $usage;
}
$stop = microtime( true ) + $seconds;
$walk = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ) );
foreach ( $walk as $file ) {
if ( $file->isFile() && ! in_array( $file->getFilename(), array( '.htaccess', 'index.php' ), true ) ) {
$usage['bytes'] += $file->getSize();
++$usage['files'];
}
if ( 0 === $usage['files'] % 200 && microtime( true ) > $stop ) {
$usage['complete'] = false;
break;
}
}
return $usage;
}
/**
* Delete the kept originals. Works in slices so a very large folder
* cannot time out; call again until 'done' is true.
*
* @param float $deadline Unix time to stop at.
* @return array { done, deleted }
*/
public static function delete_backups( $deadline ) {
$dir = self::backup_dir();
$deleted = 0;
if ( ! is_dir( $dir ) ) {
return array(
'done' => true,
'deleted' => 0,
);
}
$walk = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ( $walk as $item ) {
if ( $item->isDir() && ! $item->isLink() ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- empty folder inside uploads.
@rmdir( $item->getPathname() );
} else {
wp_delete_file( $item->getPathname() );
++$deleted;
}
if ( 0 === $deleted % 100 && microtime( true ) > $deadline ) {
return array(
'done' => false,
'deleted' => $deleted,
);
}
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- empty folder inside uploads.
@rmdir( $dir );
return array(
'done' => true,
'deleted' => $deleted,
);
}
/**
* Find optimized images whose full-size file is larger than the resize
* setting and queue them again, so the resize is applied. Only the
* full-size file is redone; thumbnails are left as they are.
*
* @param int $after_id Continue after this attachment ID (0 to start).
* @param float $deadline Unix time to stop at.
* @return array { done, cursor, queued }
*/
public static function requeue_oversized( $after_id, $deadline ) {
global $wpdb;
$max = (int) LeanPixel_Settings::get( 'max_dimension' );
$queued = 0;
if ( $max <= 0 ) {
return array(
'done' => true,
'cursor' => 0,
'queued' => 0,
);
}
do {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- paging through this plugin's own records.
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s AND post_id > %d ORDER BY post_id LIMIT 200",
self::STATUS,
'optimized',
$after_id
)
);
foreach ( $ids as $id ) {
$id = (int) $id;
$after_id = $id;
$meta = wp_get_attachment_metadata( $id );
$main = get_attached_file( $id );
if ( ! $main || ! is_array( $meta ) || empty( $meta['width'] ) || empty( $meta['height'] ) || 'image/gif' === get_post_mime_type( $id ) ) {
continue;
}
if ( max( (int) $meta['width'], (int) $meta['height'] ) <= $max ) {
continue;
}
$data = self::data( $id );
$name = wp_basename( $main );
if ( isset( $data['files'][ $name ] ) ) {
LeanPixel_Settings::record_stats( $data['files'][ $name ], -1 );
unset( $data['files'][ $name ] );
update_post_meta( $id, self::DATA, $data );
}
update_post_meta( $id, self::STATUS, 'queued' );
++$queued;
}
} while ( count( $ids ) === 200 && microtime( true ) < $deadline );
return array(
'done' => count( $ids ) < 200,
'cursor' => $after_id,
'queued' => $queued,
);
}
/** Totals for one attachment: [before, after, files, webp count, avif count]. */
public static function totals( array $data ) {
$t = array(
'before' => 0,
'after' => 0,
'files' => 0,
'webp' => 0,
'avif' => 0,
);
foreach ( $data['files'] as $record ) {
if ( ! empty( $record['skip'] ) ) {
continue;
}
$t['before'] += (int) $record['before'];
$t['after'] += (int) $record['after'];
$t['files']++;
$t['webp'] += empty( $record['webp'] ) ? 0 : 1;
$t['avif'] += empty( $record['avif'] ) ? 0 : 1;
}
return $t;
}
/* ---------------------------------------------------------------- */
private static function fail( $attachment_id, $message ) {
$data = self::data( $attachment_id );
$data['error'] = $message;
update_post_meta( $attachment_id, self::DATA, $data );
update_post_meta( $attachment_id, self::STATUS, 'error' );
return true;
}
/**
* Where the untouched copy of a file is kept:
* uploads//.
*
* @param string $path Absolute path inside the uploads folder.
* @return string|false
*/
public static function backup_path( $path ) {
$uploads = wp_get_upload_dir();
$base = trailingslashit( wp_normalize_path( $uploads['basedir'] ) );
$path = wp_normalize_path( $path );
if ( 0 !== strpos( $path, $base ) || false !== strpos( $path, '/../' ) ) {
return false;
}
return $base . self::backup_folder() . '/' . substr( $path, strlen( $base ) );
}
private static function delete_siblings( $path ) {
foreach ( array( '.webp', '.avif' ) as $ext ) {
if ( is_file( $path . $ext ) ) {
wp_delete_file( $path . $ext );
}
}
}
private static function decode( $part ) {
if ( ! is_array( $part ) ) {
return null;
}
if ( isset( $part['bytes'] ) && is_string( $part['bytes'] ) ) {
$bytes = $part['bytes']; // Bundle reply: already raw.
} elseif ( ! empty( $part['data'] ) && is_string( $part['data'] ) ) {
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- image bytes from the service.
$bytes = base64_decode( $part['data'], true );
} else {
return null;
}
if ( false === $bytes || '' === $bytes ) {
return null;
}
if ( isset( $part['size'] ) && (int) $part['size'] !== strlen( $bytes ) ) {
return null;
}
return $bytes;
}
/** Check the first bytes so only real image data is ever written. */
private static function looks_like( $bytes, $mime ) {
switch ( $mime ) {
case 'image/jpeg':
return 0 === strpos( $bytes, "\xFF\xD8\xFF" );
case 'image/png':
return 0 === strpos( $bytes, "\x89PNG\r\n\x1A\n" );
case 'image/gif':
return 0 === strpos( $bytes, 'GIF8' );
case 'image/webp':
return 'RIFF' === substr( $bytes, 0, 4 ) && 'WEBP' === substr( $bytes, 8, 4 );
case 'image/avif':
return 'ftyp' === substr( $bytes, 4, 4 ) && in_array( substr( $bytes, 8, 4 ), array( 'avif', 'avis' ), true );
}
return false;
}
/**
* The service is trusted to compress, not to decide what the picture is:
* what comes back must decode as the expected format and have exactly the
* dimensions of the file it replaces or accompanies.
*/
private static function same_picture( $bytes, $path, $mime ) {
$original = wp_getimagesize( $path );
if ( ! $original ) {
return false;
}
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- returns false on undecodable data.
$returned = @getimagesizefromstring( $bytes );
if ( ! $returned ) {
// PHP before 8.1 cannot read AVIF dimensions; the header check has already passed.
return 'image/avif' === $mime && version_compare( PHP_VERSION, '8.1', '<' );
}
return $returned['mime'] === $mime
&& (int) $returned[0] === (int) $original[0]
&& (int) $returned[1] === (int) $original[1];
}
/** Write through a temporary file so a failed write never leaves a broken image. */
private static function write( $path, $bytes ) {
$tmp = $path . '.' . strtolower( wp_generate_password( 8, false, false ) ) . '.leanpixel-tmp';
$perms = is_file( $path ) ? ( fileperms( $path ) & 0777 ) : ( fileperms( dirname( $path ) ) & 0666 );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- uploads folder.
if ( strlen( $bytes ) !== file_put_contents( $tmp, $bytes ) ) {
wp_delete_file( $tmp );
return false;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- atomic replace in the uploads folder.
if ( ! rename( $tmp, $path ) ) {
wp_delete_file( $tmp );
return false;
}
if ( $perms ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- keep the original permissions.
chmod( $path, $perms );
}
clearstatcache( true, $path );
return true;
}
/** Keep the file sizes WordPress stores in the attachment metadata accurate. */
private static function refresh_filesizes( $attachment_id ) {
$meta = wp_get_attachment_metadata( $attachment_id );
if ( ! is_array( $meta ) ) {
return;
}
$files = self::files_for( $attachment_id );
$main = get_attached_file( $attachment_id );
$changed = false;
clearstatcache();
if ( isset( $meta['filesize'] ) && $main && is_file( $main ) ) {
$meta['filesize'] = (int) filesize( $main );
$changed = true;
}
// A resize, or restoring from one, changes the full-size dimensions.
$size = ( $main && is_file( $main ) ) ? wp_getimagesize( $main ) : false;
if ( $size && isset( $meta['width'], $meta['height'] ) && ( (int) $meta['width'] !== (int) $size[0] || (int) $meta['height'] !== (int) $size[1] ) ) {
$meta['width'] = (int) $size[0];
$meta['height'] = (int) $size[1];
$changed = true;
}
if ( ! empty( $meta['sizes'] ) ) {
foreach ( $meta['sizes'] as $key => $size ) {
$name = isset( $size['file'] ) ? wp_basename( $size['file'] ) : '';
if ( isset( $size['filesize'], $files[ $name ] ) ) {
$meta['sizes'][ $key ]['filesize'] = (int) filesize( $files[ $name ] );
$changed = true;
}
}
}
if ( $changed ) {
wp_update_attachment_metadata( $attachment_id, $meta );
}
}
}