$state MIME, attached file, and metadata. * @return string|\WP_Error Snapshot directory. */ public static function snapshot( $attachment_id, $files, $state ) { $attachment_id = (int) $attachment_id; $token = wp_generate_password( 12, false, false ); $relative = 'flipwebp-tmp/' . $attachment_id . '-' . $token; $dir = Filesystem::ensure_private_dir( $relative ); if ( '' === $dir ) { return new \WP_Error( 'flipwebp_snapshot', __( 'A temporary working folder could not be created in uploads.', 'flipwebp' ) ); } $manifest_files = array(); foreach ( $files as $file ) { $relative_file = Filesystem::relative_from_absolute( $file ); if ( '' === $relative_file || ! is_file( $file ) || is_link( $file ) ) { continue; } $dest = wp_normalize_path( $dir . '/files/' . $relative_file ); if ( ! Filesystem::copy_file( $file, $dest ) ) { self::discard( $dir ); return new \WP_Error( 'flipwebp_snapshot', __( 'The original image could not be copied before conversion.', 'flipwebp' ) ); } $manifest_files[] = array( 'stored' => $relative_file, 'relative' => $relative_file, ); } if ( empty( $manifest_files ) ) { self::discard( $dir ); return new \WP_Error( 'flipwebp_snapshot', __( 'The original image could not be copied before conversion.', 'flipwebp' ) ); } $manifest = array( 'version' => 1, 'attachment_id' => $attachment_id, 'mime' => isset( $state['mime'] ) ? (string) $state['mime'] : '', 'attached_file' => isset( $state['attached_file'] ) ? (string) $state['attached_file'] : '', 'metadata' => ( isset( $state['metadata'] ) && is_array( $state['metadata'] ) ) ? $state['metadata'] : array(), 'files' => $manifest_files, 'urls' => array(), ); if ( ! self::write_manifest( $dir, $manifest ) ) { self::discard( $dir ); return new \WP_Error( 'flipwebp_snapshot', __( 'The original image could not be copied before conversion.', 'flipwebp' ) ); } return $dir; } /** * Copy a snapshot back over the original upload paths. * * @param string $dir Snapshot directory. * @return bool */ public static function restore_snapshot( $dir ) { $manifest = self::read_manifest( $dir ); if ( is_wp_error( $manifest ) ) { return false; } foreach ( $manifest['files'] as $file ) { $source = wp_normalize_path( $dir . '/files/' . $file['stored'] ); $dest = Filesystem::absolute_from_relative( $file['relative'] ); if ( '' === $dest || ! is_file( $source ) || is_link( $source ) ) { return false; } if ( ! Filesystem::copy_file( $source, $dest ) ) { return false; } } return true; } /** * Keep a successful snapshot as the restorable backup for this attachment. * * @param int $attachment_id Attachment ID. * @param string $dir Snapshot directory. * @param array> $urls URL pairs. * @return bool */ public static function persist( $attachment_id, $dir, $urls ) { $attachment_id = (int) $attachment_id; $manifest = self::read_manifest( $dir ); if ( is_wp_error( $manifest ) || (int) $manifest['attachment_id'] !== $attachment_id ) { return false; } $manifest['urls'] = self::clean_url_pairs( $urls ); if ( ! self::write_manifest( $dir, $manifest ) ) { return false; } $previous = (string) get_post_meta( $attachment_id, self::META_TOKEN, true ); $token = wp_basename( $dir ); if ( ! self::is_snapshot_name( $token, $attachment_id ) ) { return false; } $backup_root = Filesystem::ensure_private_dir( 'flipwebp-backups' ); if ( '' === $backup_root ) { return false; } $destination = wp_normalize_path( $backup_root . '/' . $token ); if ( is_dir( $destination ) ) { Filesystem::delete_tree( $destination, $backup_root ); } if ( ! self::copy_tree( $dir, $destination, $backup_root ) ) { if ( is_dir( $destination ) ) { Filesystem::delete_tree( $destination, $backup_root ); } return false; } update_post_meta( $attachment_id, self::META_TOKEN, $token ); if ( '' !== $previous && $previous !== $token && self::is_snapshot_name( $previous, $attachment_id ) ) { $old = wp_normalize_path( $backup_root . '/' . $previous ); if ( is_dir( $old ) ) { Filesystem::delete_tree( $old, $backup_root ); } } return true; } /** * Whether this attachment has a readable backup. * * @param int $attachment_id Attachment ID. * @return bool */ public static function exists( $attachment_id ) { return '' !== self::directory( (int) $attachment_id ); } /** * Absolute backup directory for an attachment. * * @param int $attachment_id Attachment ID. * @return string */ public static function directory( $attachment_id ) { $attachment_id = (int) $attachment_id; $token = (string) get_post_meta( $attachment_id, self::META_TOKEN, true ); if ( ! self::is_snapshot_name( $token, $attachment_id ) ) { return ''; } $root = Filesystem::uploads_dir(); if ( '' === $root ) { return ''; } $dir = wp_normalize_path( $root . '/flipwebp-backups/' . $token ); if ( ! is_dir( $dir ) || ! self::is_inside_backups( $dir ) || ! is_file( $dir . '/manifest.json' ) ) { return ''; } return $dir; } /** * Read and validate a backup manifest. * * @param string $dir Snapshot or backup directory. * @return array|\WP_Error */ public static function read_manifest( $dir ) { $dir = wp_normalize_path( (string) $dir ); $path = wp_normalize_path( $dir . '/manifest.json' ); if ( ! self::is_inside_plugin_storage( $dir ) || ! is_file( $path ) || is_link( $path ) ) { return new \WP_Error( 'flipwebp_manifest', __( 'The backup could not be read.', 'flipwebp' ) ); } $size = filesize( $path ); if ( false === $size || $size < 2 || $size > 2 * MB_IN_BYTES ) { return new \WP_Error( 'flipwebp_manifest', __( 'The backup could not be read.', 'flipwebp' ) ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local manifest inside the plugin storage directory. $json = file_get_contents( $path ); $data = is_string( $json ) ? json_decode( $json, true ) : null; if ( ! is_array( $data ) ) { return new \WP_Error( 'flipwebp_manifest', __( 'The backup could not be read.', 'flipwebp' ) ); } return self::validate_manifest( $data ); } /** * Remove a temporary snapshot. * * @param string $dir Snapshot directory. * @return void */ public static function discard( $dir ) { $root = self::storage_root( 'flipwebp-tmp' ); if ( '' === $root || ! is_dir( $dir ) ) { return; } Filesystem::delete_tree( $dir, $root ); } /** * Delete temporary snapshots older than a day. Limited so a page view stays fast. * * @return void */ public static function cleanup_tmp() { $root = self::storage_root( 'flipwebp-tmp' ); if ( '' === $root || ! is_dir( $root ) ) { return; } $items = scandir( $root ); if ( ! is_array( $items ) ) { return; } $removed = 0; $cutoff = time() - DAY_IN_SECONDS; foreach ( $items as $item ) { if ( $removed >= 20 || '.' === $item || '..' === $item ) { continue; } if ( ! preg_match( '/^[0-9]+-[A-Za-z0-9]+$/', $item ) ) { continue; } $path = wp_normalize_path( $root . '/' . $item ); $mtime = filemtime( $path ); if ( is_dir( $path ) && ! is_link( $path ) && false !== $mtime && $mtime < $cutoff ) { Filesystem::delete_tree( $path, $root ); ++$removed; } } } /** * Restore originals from the permanent backup and return the manifest. * * @param int $attachment_id Attachment ID. * @return array|\WP_Error */ public static function restore_files( $attachment_id ) { $dir = self::directory( (int) $attachment_id ); if ( '' === $dir ) { return new \WP_Error( 'flipwebp_backup_missing', __( 'No backup is available for this image.', 'flipwebp' ) ); } $manifest = self::read_manifest( $dir ); if ( is_wp_error( $manifest ) ) { return $manifest; } if ( (int) $manifest['attachment_id'] !== (int) $attachment_id ) { return new \WP_Error( 'flipwebp_backup_missing', __( 'No backup is available for this image.', 'flipwebp' ) ); } if ( ! self::restore_snapshot( $dir ) ) { return new \WP_Error( 'flipwebp_restore_failed', __( 'The original image could not be restored.', 'flipwebp' ) ); } return $manifest; } /** * Write a manifest atomically inside a storage directory. * * @param string $dir Directory. * @param array $manifest Manifest. * @return bool */ public static function write_manifest( $dir, $manifest ) { if ( ! self::is_inside_plugin_storage( $dir ) ) { return false; } $json = wp_json_encode( $manifest ); if ( ! is_string( $json ) ) { return false; } $dest = wp_normalize_path( $dir . '/manifest.json' ); $tmp = wp_normalize_path( $dir . '/manifest.json.tmp' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents $written = file_put_contents( $tmp, $json, LOCK_EX ); if ( false === $written ) { return false; } // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename $moved = rename( $tmp, $dest ); if ( ! $moved && is_file( $tmp ) ) { wp_delete_file( $tmp ); } return $moved && is_file( $dest ); } /** * @param mixed $data Decoded JSON. * @return array|\WP_Error */ private static function validate_manifest( $data ) { $mime = isset( $data['mime'] ) ? (string) $data['mime'] : ''; $file = isset( $data['attached_file'] ) ? (string) $data['attached_file'] : ''; $id = isset( $data['attachment_id'] ) ? (int) $data['attachment_id'] : 0; if ( 1 !== (int) ( $data['version'] ?? 0 ) || $id < 1 || ! Converter::is_manageable_mime( $mime ) || ! Filesystem::is_safe_relative( $file ) ) { return new \WP_Error( 'flipwebp_manifest', __( 'The backup could not be read.', 'flipwebp' ) ); } if ( empty( $data['metadata'] ) || ! is_array( $data['metadata'] ) || empty( $data['files'] ) || ! is_array( $data['files'] ) ) { return new \WP_Error( 'flipwebp_manifest', __( 'The backup could not be read.', 'flipwebp' ) ); } $files = array(); foreach ( $data['files'] as $file_row ) { if ( ! is_array( $file_row ) ) { return new \WP_Error( 'flipwebp_manifest', __( 'The backup could not be read.', 'flipwebp' ) ); } $stored = isset( $file_row['stored'] ) ? (string) $file_row['stored'] : ''; $relative = isset( $file_row['relative'] ) ? (string) $file_row['relative'] : ''; if ( ! Filesystem::is_safe_relative( $stored ) || ! Filesystem::is_safe_relative( $relative ) ) { return new \WP_Error( 'flipwebp_manifest', __( 'The backup could not be read.', 'flipwebp' ) ); } $files[] = array( 'stored' => $stored, 'relative' => $relative, ); } return array( 'version' => 1, 'attachment_id' => $id, 'mime' => $mime, 'attached_file' => $file, 'metadata' => $data['metadata'], 'files' => $files, 'urls' => self::clean_url_pairs( isset( $data['urls'] ) ? $data['urls'] : array() ), ); } /** * Keep only URL pairs that are specific to an uploads image. * * @param mixed $pairs Proposed pairs. * @return array> */ public static function clean_url_pairs( $pairs ) { if ( ! is_array( $pairs ) ) { return array(); } $clean = array(); foreach ( $pairs as $pair ) { if ( ! is_array( $pair ) ) { continue; } $old = isset( $pair['old'] ) ? (string) $pair['old'] : ''; $new = isset( $pair['new'] ) ? (string) $pair['new'] : ''; if ( ! self::is_specific_image_url( $old ) || ! self::is_specific_image_url( $new ) || $old === $new ) { continue; } $clean[] = array( 'old' => $old, 'new' => $new, ); if ( count( $clean ) >= 80 ) { break; } } return $clean; } /** * Whether a string is a specific local-looking image URL or path. * * @param string $value URL or path. * @return bool */ public static function is_specific_image_url( $value ) { if ( ! is_string( $value ) || strlen( $value ) < 16 || strlen( $value ) > 2048 ) { return false; } if ( 1 === preg_match( '/[\\r\\n\\0<>"\']/', $value ) ) { return false; } if ( 1 === preg_match( '#^[a-z][a-z0-9+.-]*:#i', $value ) && 1 !== preg_match( '#^https?://#i', $value ) ) { return false; } return 1 === preg_match( '#(^|/)uploads/.+\\.(jpe?g|png|gif|webp)$#i', $value ); } /** * @param string $name Directory name. * @param int $attachment_id Expected attachment ID. * @return bool */ private static function is_snapshot_name( $name, $attachment_id ) { return 1 === preg_match( '/^' . (int) $attachment_id . '-[A-Za-z0-9]{12}$/', (string) $name ); } /** * @param string $relative flipwebp-tmp or flipwebp-backups. * @return string */ private static function storage_root( $relative ) { $root = Filesystem::uploads_dir(); if ( '' === $root ) { return ''; } $dir = wp_normalize_path( $root . '/' . $relative ); $real = realpath( $dir ); if ( false === $real || ! Filesystem::is_inside_uploads( $real ) || wp_basename( $real ) !== $relative ) { return ''; } return wp_normalize_path( $real ); } /** * @param string $dir Directory. * @return bool */ private static function is_inside_backups( $dir ) { $root = self::storage_root( 'flipwebp-backups' ); return '' !== $root && Filesystem::is_inside( Filesystem::normalize( (string) realpath( $dir ) ), Filesystem::normalize( $root ) ); } /** * @param string $dir Directory. * @return bool */ private static function is_inside_plugin_storage( $dir ) { $real = realpath( $dir ); if ( false === $real ) { return false; } $real = Filesystem::normalize( $real ); foreach ( array( 'flipwebp-tmp', 'flipwebp-backups' ) as $folder ) { $root = self::storage_root( $folder ); if ( '' !== $root && Filesystem::is_inside( $real, Filesystem::normalize( $root ) ) ) { return true; } } return false; } /** * Copy a snapshot into the backup root. * * @param string $source Source directory. * @param string $destination Destination directory. * @param string $root Backup root. * @return bool */ private static function copy_tree( $source, $destination, $root ) { if ( ! self::is_inside_plugin_storage( $source ) ) { return false; } wp_mkdir_p( $destination ); if ( ! Filesystem::is_safe_destination( $destination . '/manifest.json', $root ) ) { return false; } $manifest = wp_normalize_path( $source . '/manifest.json' ); $target = wp_normalize_path( $destination . '/manifest.json' ); if ( ! is_file( $manifest ) || ! self::copy_storage_file( $manifest, $target ) ) { return false; } $files = self::list_files( wp_normalize_path( $source . '/files' ) ); foreach ( $files as $file ) { $relative = ltrim( substr( Filesystem::normalize( $file ), strlen( Filesystem::normalize( $source . '/files' ) ) ), '/' ); if ( ! Filesystem::is_safe_relative( $relative ) ) { return false; } $dest = wp_normalize_path( $destination . '/files/' . $relative ); if ( ! self::copy_storage_file( $file, $dest ) ) { return false; } } return true; } /** * Copy a file that already lives in plugin storage. copy_file() requires uploads, * and these files do too, but the destination may not exist yet. * * @param string $source Source file. * @param string $dest Destination file. * @return bool */ private static function copy_storage_file( $source, $dest ) { if ( ! is_file( $source ) || is_link( $source ) || ! Filesystem::is_inside_uploads( $source ) ) { return false; } $root = Filesystem::uploads_dir(); if ( '' === $root ) { return false; } wp_mkdir_p( dirname( $dest ) ); if ( ! Filesystem::is_safe_destination( $dest, $root ) ) { return false; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_copy return copy( $source, $dest ) && is_file( $dest ); } /** * @param string $dir Directory. * @return string[] */ private static function list_files( $dir ) { if ( ! is_dir( $dir ) ) { return array(); } $found = array(); $items = scandir( $dir ); if ( ! is_array( $items ) ) { return array(); } foreach ( $items as $item ) { if ( '.' === $item || '..' === $item ) { continue; } $path = wp_normalize_path( $dir . '/' . $item ); if ( is_link( $path ) ) { continue; } if ( is_dir( $path ) ) { $found = array_merge( $found, self::list_files( $path ) ); continue; } if ( is_file( $path ) ) { $found[] = $path; } } return $found; } }