10, 'display' => esc_html__( 'Every Ten Seconds', 'w3-total-cache' ), ); return $schedules; } /** * Remove cron job/event. * * @since 2.2.0 * @static */ public static function delete_cron() { $timestamp = wp_next_scheduled( 'w3tc_imageservice_cron' ); if ( $timestamp ) { wp_unschedule_event( $timestamp, 'w3tc_imageservice_cron' ); } } /** * Run the cron event. * * @since 2.2.0 * * @see Extension_ImageService_Plugin_Admin::get_imageservice_attachments() * @see Extension_ImageService_Plugin::get_api() * * @global $wp_filesystem WP_Filesystem. */ public static function run() { // Get all attachment post IDs with postmeta key "w3tc_imageservice". $results = Extension_ImageService_Plugin_Admin::get_imageservice_attachments(); // If there are matches, then load dependencies before use. if ( $results->have_posts() ) { require_once __DIR__ . '/Extension_ImageService_Plugin_Admin.php'; $wp_upload_dir = wp_upload_dir(); global $wp_filesystem; // Make sure that this file is included, as wp_generate_attachment_metadata() depends on it. require_once ABSPATH . 'wp-admin/includes/image.php'; } foreach ( $results->posts as $post_id ) { $postmeta = empty( $post_id ) ? null : get_post_meta( $post_id, 'w3tc_imageservice', true ); $status = isset( $postmeta['status'] ) ? $postmeta['status'] : null; // Handle new format with multiple jobs (processing_jobs) or old format (processing). $processing_jobs = isset( $postmeta['processing_jobs'] ) && is_array( $postmeta['processing_jobs'] ) ? $postmeta['processing_jobs'] : array(); /** * Backward compatibility: convert old format to new format. * Only do this for items still marked as processing to avoid re-checking completed jobs. */ if ( 'processing' === $status && empty( $processing_jobs ) && isset( $postmeta['processing']['job_id'] ) && isset( $postmeta['processing']['signature'] ) ) { $processing_jobs = array( 'webp' => array( 'job_id' => $postmeta['processing']['job_id'], 'signature' => $postmeta['processing']['signature'], 'mime_type' => 'image/webp', ), ); } /** * Process items that have jobs to check, regardless of overall status. * This ensures we continue processing even if some jobs complete and status changes. */ if ( ! empty( $processing_jobs ) ) { // Get the Image Service API object (singlton). $api = Extension_ImageService_Plugin::get_api(); // Process each job separately. $all_jobs_ready = true; $has_error = false; $has_notfound = false; $jobs_status = isset( $postmeta['jobs_status'] ) ? $postmeta['jobs_status'] : array(); // Initialize arrays for storing multiple formats before processing jobs. $post_children = isset( $postmeta['post_children'] ) ? $postmeta['post_children'] : array(); $downloads = isset( $postmeta['downloads'] ) ? $postmeta['downloads'] : array(); // Migrate legacy single-format data to multi-format storage when present. $legacy_child_id = isset( $postmeta['post_child'] ) ? $postmeta['post_child'] : null; $legacy_format = 'webp'; if ( $legacy_child_id ) { $legacy_post = get_post( $legacy_child_id ); if ( $legacy_post && isset( $legacy_post->post_mime_type ) ) { $legacy_format = strtolower( str_replace( 'image/', '', $legacy_post->post_mime_type ) ); } // Preserve legacy converted attachment reference. if ( empty( $post_children[ $legacy_format ] ) ) { $legacy_child_meta = get_post_meta( $legacy_child_id, 'w3tc_imageservice', true ); if ( ! empty( $legacy_child_meta['is_converted_file'] ) ) { $post_children[ $legacy_format ] = $legacy_child_id; } } } // Preserve legacy download headers for stats display. if ( ! empty( $postmeta['download'] ) && empty( $downloads[ $legacy_format ] ) ) { $downloads[ $legacy_format ] = $postmeta['download']; } // Track which jobs are complete and should be removed from processing_jobs. $completed_jobs = array(); foreach ( $processing_jobs as $format_key => $job ) { if ( ! isset( $job['job_id'] ) || ! isset( $job['signature'] ) ) { continue; } // Check the status of this job. $response = $api->get_status( $job['job_id'], $job['signature'] ); // Store status for this job. $jobs_status[ $format_key ] = $response; // Stop checking jobs that are no longer found. if ( ( isset( $response['code'] ) && 404 === (int) $response['code'] ) || ( isset( $response['status'] ) && 'notfound' === $response['status'] ) ) { $has_notfound = true; $all_jobs_ready = false; $completed_jobs[] = $format_key; continue; } // Check if this job is ready for pickup/download. if ( isset( $response['status'] ) && 'pickup' === $response['status'] ) { // Get mime_type from job or response. $mime_type = isset( $job['mime_type'] ) ? $job['mime_type'] : ( isset( $response['mime_type'] ) ? $response['mime_type'] : 'image/webp' ); /** * Download image for this format using this job's job_id and signature. * Pass mime_type_out to ensure API returns the correct format (e.g. AVIF vs WEBP). */ $download_response = $api->download( $job['job_id'], $job['signature'], $mime_type ); $download_headers = wp_remote_retrieve_headers( $download_response ); $is_error = isset( $download_response['error'] ); // Convert headers to array and normalize keys to lowercase for consistent access. $headers_array = array(); if ( ! $is_error && $download_headers ) { if ( is_object( $download_headers ) && method_exists( $download_headers, 'getAll' ) ) { // Requests_Utility_CaseInsensitiveDictionary - get all headers. $all_headers = $download_headers->getAll(); foreach ( $all_headers as $w3tc_key => $w3tc_value ) { $headers_array[ strtolower( $w3tc_key ) ] = $w3tc_value; } } else { // Already an array or other structure. $temp_headers = (array) $download_headers; foreach ( $temp_headers as $w3tc_key => $w3tc_value ) { // Skip special WordPress array keys. if ( "\0" !== substr( $w3tc_key, 0, 1 ) ) { $headers_array[ strtolower( $w3tc_key ) ] = $w3tc_value; } } // Also check for the special data structure. if ( isset( $temp_headers["\0*\0data"] ) ) { foreach ( $temp_headers["\0*\0data"] as $w3tc_key => $w3tc_value ) { $headers_array[ strtolower( $w3tc_key ) ] = $w3tc_value; } } } } // Determine if file size was reduced by comparing input and output sizes. $filesize_in = isset( $headers_array['x-filesize-in'] ) ? (int) $headers_array['x-filesize-in'] : 0; $filesize_out = isset( $headers_array['x-filesize-out'] ) ? (int) $headers_array['x-filesize-out'] : 0; $is_reduced = ! $is_error && $filesize_in > 0 && $filesize_out > 0 && $filesize_out < $filesize_in; // Determine actual output mime type/format from headers (API may return a different output than requested). $mime_type_out = isset( $headers_array['x-mime-type-out'] ) ? $headers_array['x-mime-type-out'] : ( isset( $headers_array['content-type'] ) ? $headers_array['content-type'] : $mime_type ); $format_key_out = strtolower( str_replace( 'image/', '', $mime_type_out ) ); // Delete existing converted file for the actual output format if it exists. if ( isset( $post_children[ $format_key_out ] ) ) { wp_delete_attachment( $post_children[ $format_key_out ], true ); unset( $post_children[ $format_key_out ] ); } /** * Store download information for this format (store normalized headers). * Always store download info, even if there's an error or no size reduction. */ $downloads[ $format_key_out ] = $is_error ? $download_response['error'] : $headers_array; // If the job key doesn't match the actual output, remove any stale entry keyed by the job key. if ( $format_key_out !== $format_key ) { unset( $downloads[ $format_key ] ); unset( $post_children[ $format_key ] ); } // Skip saving file if error or if converted image is larger, but continue to next job. if ( $is_error ) { $has_error = true; $all_jobs_ready = false; // Job completed (with error) - remove from processing_jobs. $completed_jobs[] = $format_key; continue; } if ( ! $is_reduced ) { // Image wasn't reduced (output is same size or larger), skip saving but continue to next job. $all_jobs_ready = false; // Job completed (no size reduction) - remove from processing_jobs. $completed_jobs[] = $format_key; continue; } /** * RT9-226: Allowlist the output extension AND content-sniff the * downloaded body before writing to disk. The original code took * `format_key_out` straight from the API's `x-mime-type-out` / * `content-type` header, so a compromised or MITM'd upstream * could yield extensions like `.php` / `.htaccess` and bytes * matching an arbitrary webshell. Both legs must agree: * * - Extension allowlist refuses everything outside the formats * we actually request — `webp`, `avif`, `jpeg`, `jpg`, `png`, * `gif`. (`jpg` accepted alongside `jpeg` because some clients * normalise differently.) * - Content-sniff via `getimagesizefromstring()` confirms the * bytes really are an image — PHP source begins `put_contents( $new_filepath, $body ); } else { Util_File::file_put_contents_atomic( $new_filepath, $body ); } // Insert as attachment post. $attachment_id = wp_insert_attachment( array( 'guid' => $new_filepath, 'post_mime_type' => $mime_type_out, 'post_title' => preg_replace( '/\.[^.]+$/', '', $new_filename ), 'post_content' => '', 'post_status' => 'inherit', 'post_parent' => $post_id, 'comment_status' => 'closed', ), $new_filepath, $post_id, false, false ); // Copy postmeta data to the new attachment. Extension_ImageService_Plugin_Admin::copy_postmeta( $post_id, $attachment_id ); // Store the attachment ID for this format. $post_children[ $format_key_out ] = $attachment_id; // Mark the downloaded file as the converted one. Extension_ImageService_Plugin_Admin::update_postmeta( $attachment_id, array( 'is_converted_file' => true ) ); // In order to filter/hide converted files in the media list, add a meta key. update_post_meta( $attachment_id, 'w3tc_imageservice_file', $w3tc_extension ); /** * Generate the metadata for the attachment, and update the database record. * On PHP < 8.1 `wp_getimagesize()` does not recognise AVIF, so * `wp_generate_attachment_metadata()` returns an empty array for AVIF * files; fall back to the original image dimensions and fill in the * "file" and "filesize" entries manually. */ $attach_data = wp_generate_attachment_metadata( $attachment_id, $new_filepath ); $attach_data['width'] = isset( $attach_data['width'] ) ? $attach_data['width'] : $original_size[0]; $attach_data['height'] = isset( $attach_data['height'] ) ? $attach_data['height'] : $original_size[1]; if ( empty( $attach_data['file'] ) ) { $attach_data['file'] = _wp_relative_upload_path( $new_filepath ); } if ( empty( $attach_data['filesize'] ) ) { $attach_data['filesize'] = \strlen( $body ); } wp_update_attachment_metadata( $attachment_id, $attach_data ); // Job successfully completed - remove from processing_jobs. $completed_jobs[] = $format_key; } elseif ( isset( $response['status'] ) && 'complete' === $response['status'] ) { // Job completed but no pickup - mark as error for this format. $has_error = true; $all_jobs_ready = false; // Job completed (even with error) - remove from processing_jobs. $completed_jobs[] = $format_key; } else { // Job still processing - keep in processing_jobs. $all_jobs_ready = false; } } // Remove completed jobs from processing_jobs. foreach ( $completed_jobs as $format_key ) { unset( $processing_jobs[ $format_key ] ); } // Save jobs status. Extension_ImageService_Plugin_Admin::update_postmeta( $post_id, array( 'jobs_status' => $jobs_status ) ); // Determine overall status based on all jobs. $has_converted = ! empty( $post_children ); $status = 'notconverted'; // Default status if no conversions were successful. if ( $has_converted ) { $status = 'converted'; } elseif ( $has_error ) { $status = 'error'; } if ( ! $has_converted && ! $has_error && $has_notfound && empty( $processing_jobs ) ) { $status = 'notfound'; } // If there are still jobs processing, keep status as 'processing'. if ( ! empty( $processing_jobs ) ) { $status = 'processing'; } // Save the download information, status, and remaining processing_jobs. Extension_ImageService_Plugin_Admin::update_postmeta( $post_id, array( 'post_children' => $post_children, 'downloads' => $downloads, 'status' => $status, 'processing_jobs' => $processing_jobs, ) ); // For backward compatibility, also set post_child to the first converted format. if ( ! empty( $post_children ) ) { $first_format = reset( $post_children ); Extension_ImageService_Plugin_Admin::update_postmeta( $post_id, array( 'post_child' => $first_format ) ); } } } } /** * Determine whether a download body is an AVIF image. * * `getimagesizefromstring()` only recognises AVIF on PHP 8.1+, so on older * PHP versions a valid AVIF download fails the content-sniff. Verify the * ISO-BMFF "ftyp" box signature manually: bytes 4-7 must be "ftyp" and * "avif"/"avis" must appear as the major brand (bytes 8-11) or among the * compatible brands within the "ftyp" box. * * @since 2.10.0 * @static * * @param string $body Download body. * @return bool */ public static function is_avif_body( $body ) { if ( ! \is_string( $body ) || \strlen( $body ) < 12 || 'ftyp' !== \substr( $body, 4, 4 ) ) { return false; } $major = \substr( $body, 8, 4 ); if ( 'avif' === $major || 'avis' === $major ) { return true; } // Compatible brands follow the 4-byte minor version (offset 12). $box_size = \unpack( 'N', $body )[1]; $box_size = \min( $box_size, \strlen( $body ) ); for ( $offset = 16; $offset + 4 <= $box_size; $offset += 4 ) { $brand = \substr( $body, $offset, 4 ); if ( 'avif' === $brand || 'avis' === $brand ) { return true; } } return false; } }