load_settings(); } /** * Load settings from WordPress options. * * @since 1.0.0 */ private function load_settings() { // Default values. $default_size_limits = array( 'max_width' => GATEWELL_DEFAULT_MAX_WIDTH, 'max_height' => GATEWELL_DEFAULT_MAX_HEIGHT, 'max_filesize' => gatewell_default_max_filesize(), 'min_width' => GATEWELL_DEFAULT_MIN_WIDTH, 'min_height' => GATEWELL_DEFAULT_MIN_HEIGHT, ); // Get size limits with type checking. $size_limits = get_option( 'gatewell_size_limits', $default_size_limits ); if ( ! is_array( $size_limits ) ) { $size_limits = $default_size_limits; } // Ensure all required keys exist. $size_limits = wp_parse_args( $size_limits, $default_size_limits ); $this->max_width = absint( $size_limits['max_width'] ); $this->max_height = absint( $size_limits['max_height'] ); $this->max_file_size = absint( $size_limits['max_filesize'] ); $this->min_width = absint( $size_limits['min_width'] ); $this->min_height = absint( $size_limits['min_height'] ); // Get compression quality. $compression_settings = get_option( 'gatewell_compression_quality', array( 'quality' => GATEWELL_DEFAULT_COMPRESSION_QUALITY ) ); if ( ! is_array( $compression_settings ) ) { $compression_settings = array( 'quality' => GATEWELL_DEFAULT_COMPRESSION_QUALITY ); } $this->compression_quality = isset( $compression_settings['quality'] ) ? absint( $compression_settings['quality'] ) : GATEWELL_DEFAULT_COMPRESSION_QUALITY; // Get allowed MIME types using the Format_Validator utility. $this->allowed_mime_types = Format_Validator::get_allowed_mime_types(); } /** * Process uploaded image from base64 data. * * @param string $base64_data Base64 encoded image data. * @param string $original_mime_type Original MIME type from frontend (optional). * @return array Array with 'compressed' and 'original' file paths. * @throws \Exception On validation or processing error. * @since 1.0.0 */ public function process_upload( $base64_data, $original_mime_type = '' ) { // Validate and decode base64 data. $temp_file = $this->validate_base64( $base64_data ); try { // Validate MIME type. $mime_type = $this->validate_mime_type( $temp_file ); // Validate dimensions. $dimensions = $this->validate_dimensions( $temp_file ); // Validate file size. $this->validate_file_size( $temp_file ); // Determine output format based on settings and original MIME type. $output_format = $this->determine_output_format( $mime_type, $original_mime_type ); $extension = $this->get_extension_from_mime( $output_format ); // Compress image. $temp_dir = $this->get_temp_dir(); $compressed_file = $temp_dir . 'compressed-' . uniqid() . '.' . $extension; $this->compress_image( $temp_file, $compressed_file, $mime_type, $output_format ); // Return both original and compressed file paths. // The Upload_Handler will decide what to do with the original based on settings. return array( 'compressed' => $compressed_file, 'original' => $temp_file, ); } catch ( \Exception $e ) { // Clean up on error. $this->cleanup_temp_file( $temp_file ); throw $e; } } /** * Determine output format based on conversion settings and original MIME type. * * @param string $detected_mime_type MIME type detected from file. * @param string $original_mime_type Original MIME type from frontend. * @return string Output MIME type. * @since 1.0.0 */ private function determine_output_format( $detected_mime_type, $original_mime_type = '' ) { // Check cooperation mode - should we process format conversion? if ( ! $this->should_process_conversion() ) { // External plugin will handle conversion - preserve original format. Debug::log( 'Skipping format conversion (cooperation mode: external primary)', 'Gatewell' ); // Use original MIME type if available, otherwise detected. return ! empty( $original_mime_type ) ? $original_mime_type : $detected_mime_type; } // Get format conversion settings. $format_conversion = get_option( 'gatewell_format_conversion', array( 'mode' => 'preserve' ) ); $conversion_mode = isset( $format_conversion['mode'] ) ? $format_conversion['mode'] : 'preserve'; // HEIC/HEIF always converts to JPEG (not web-compatible). if ( $detected_mime_type === 'image/heic' || $detected_mime_type === 'image/heif' ) { return 'image/jpeg'; } // Determine target format based on conversion mode. $target_format = null; switch ( $conversion_mode ) { case 'jpeg': // Convert all to JPEG. $target_format = 'image/jpeg'; break; case 'webp': // Convert all to WebP. $target_format = 'image/webp'; break; case 'avif': // Convert all to AVIF. $target_format = 'image/avif'; break; case 'smart': // Smart conversion: detect transparency and choose best format. $target_format = $this->smart_format_selection( $detected_mime_type ); break; case 'preserve': default: // Preserve original format (use original_mime_type if available, fallback to detected). $mime_to_preserve = ! empty( $original_mime_type ) ? $original_mime_type : $detected_mime_type; // Ensure the format is supported for output. $supported_outputs = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif' ); if ( in_array( $mime_to_preserve, $supported_outputs, true ) ) { $target_format = $mime_to_preserve; } else { // Fallback to JPEG if format not supported for output. $target_format = 'image/jpeg'; } break; } // Check if target format is actually supported on this server. // If not, use fallback format. $format_short = str_replace( 'image/', '', $target_format ); if ( ! Format_Capabilities::supports_format( $format_short ) ) { $fallback_format = Format_Capabilities::get_fallback_format( $target_format, $detected_mime_type ); // Log the fallback for debugging. Debug::log( sprintf( 'Format %s not supported on server. Falling back to %s.', $target_format, $fallback_format ), 'Gatewell' ); return $fallback_format; } return $target_format; } /** * Smart format selection based on transparency detection. * * @param string $mime_type MIME type of image. * @return string Output MIME type. * @since 1.0.0 */ private function smart_format_selection( $mime_type ) { // Smart rules: // - Try AVIF first (best compression, supports transparency) // - Fallback to WebP (good compression, widely supported) // - Fallback to PNG/JPEG based on transparency // Check if image has transparency $has_transparency = in_array( $mime_type, array( 'image/png', 'image/gif', 'image/webp', 'image/avif' ), true ); if ( $has_transparency ) { // Prefer AVIF for transparency (best compression) if ( Format_Capabilities::supports_avif() ) { return 'image/avif'; } // Fallback to WebP if ( Format_Capabilities::supports_webp() ) { return 'image/webp'; } // Fallback to PNG return 'image/png'; } // For photos without transparency, prefer AVIF or JPEG if ( Format_Capabilities::supports_avif() ) { return 'image/avif'; } // Default to JPEG for photos return 'image/jpeg'; } /** * Check if ImageMagick supports AVIF format. * * @return bool True if AVIF is supported. * @since 1.2.0 */ private function imagick_supports_avif() { if ( ! extension_loaded( 'imagick' ) ) { return false; } try { $imagick = new \Imagick(); $formats = $imagick->queryFormats( 'AVIF' ); $imagick->clear(); $imagick->destroy(); return ! empty( $formats ); } catch ( \Exception $e ) { return false; } } /** * Get file extension from MIME type. * * @param string $mime_type MIME type. * @return string File extension. * @since 1.0.0 */ private function get_extension_from_mime( $mime_type ) { return Format_Validator::get_extension_from_mime( $mime_type ); } /** * Validate base64 data. * * @param string $base64_data Base64 encoded data. * @return string Path to temporary file. * @throws \Exception If invalid. * @since 1.0.0 */ private function validate_base64( $base64_data ) { // Check if data exists. if ( empty( $base64_data ) ) { throw new \Exception( 'No image data provided.' ); } // Remove data URI scheme if present (data:image/jpeg;base64,). if ( strpos( $base64_data, 'data:' ) === 0 ) { $base64_data = substr( $base64_data, strpos( $base64_data, ',' ) + 1 ); } // Decode base64 data. $decoded = base64_decode( $base64_data, true ); if ( $decoded === false ) { throw new \Exception( 'Invalid base64 image data.' ); } // Create temporary file. $temp_dir = $this->get_temp_dir(); $temp_file = $temp_dir . 'upload-' . uniqid() . '.tmp'; // Write decoded data to temp file. $result = file_put_contents( $temp_file, $decoded ); if ( $result === false ) { throw new \Exception( 'Failed to save temporary file.' ); } return $temp_file; } /** * Validate MIME type of image file. * * @param string $file_path Path to image file. * @return string MIME type if valid. * @throws \Exception If invalid MIME type. * @since 1.0.0 */ private function validate_mime_type( $file_path ) { // Check file exists. if ( ! file_exists( $file_path ) ) { throw new \Exception( 'File not found.' ); } // Get MIME type using finfo. $finfo = finfo_open( FILEINFO_MIME_TYPE ); $mime_type = finfo_file( $finfo, $file_path ); finfo_close( $finfo ); if ( $mime_type === false ) { throw new \Exception( 'Unable to determine file type.' ); } // Check against allowed MIME types. if ( ! in_array( $mime_type, $this->allowed_mime_types, true ) ) { throw new \Exception( 'Invalid file type. Allowed formats: JPEG, PNG, GIF, HEIC, HEIF, WebP.' ); } return $mime_type; } /** * Validate image dimensions. * * @param string $file_path Path to image file. * @return array Array with 'width' and 'height' keys. * @throws \Exception If dimensions exceed limits. * @since 1.0.0 */ private function validate_dimensions( $file_path ) { // Get image dimensions. $image_info = getimagesize( $file_path ); if ( $image_info === false ) { throw new \Exception( 'Unable to read image dimensions.' ); } $width = $image_info[0]; $height = $image_info[1]; // Check minimum dimensions. if ( $width < $this->min_width || $height < $this->min_height ) { throw new \Exception( sprintf( 'Image dimensions are below minimum of %dx%d pixels.', (int) $this->min_width, (int) $this->min_height ) ); } // Check maximum dimensions. if ( $width > $this->max_width || $height > $this->max_height ) { throw new \Exception( sprintf( 'Image dimensions exceed maximum of %dx%d pixels.', (int) $this->max_width, (int) $this->max_height ) ); } return array( 'width' => $width, 'height' => $height, ); } /** * Validate file size. * * @param string $file_path Path to image file. * @return int File size in bytes. * @throws \Exception If file size exceeds limit. * @since 1.0.0 */ private function validate_file_size( $file_path ) { // Get file size. $file_size = filesize( $file_path ); if ( $file_size === false ) { throw new \Exception( 'Unable to determine file size.' ); } // Check against size limit. if ( $file_size > $this->max_file_size ) { $max_mb = round( $this->max_file_size / 1048576, 1 ); throw new \Exception( sprintf( 'File size exceeds maximum of %sMB.', (float) $max_mb ) ); } return $file_size; } /** * Load image resource based on MIME type. * * @param string $file_path Path to image file. * @param string $mime_type MIME type of image. * @return resource|false GD image resource or false on failure. * @since 1.0.0 */ private function load_image_by_type( $file_path, $mime_type ) { switch ( $mime_type ) { case 'image/jpeg': return imagecreatefromjpeg( $file_path ); case 'image/png': return imagecreatefrompng( $file_path ); case 'image/gif': return imagecreatefromgif( $file_path ); case 'image/webp': // GD can be compiled without WebP support. if ( function_exists( 'imagecreatefromwebp' ) ) { return imagecreatefromwebp( $file_path ); } return false; case 'image/avif': // AVIF support requires PHP 8.1+ with GD or ImageMagick if ( function_exists( 'imagecreatefromavif' ) ) { return imagecreatefromavif( $file_path ); } // Fallback to ImageMagick if ( extension_loaded( 'imagick' ) ) { try { $imagick = new \Imagick( $file_path ); $imagick->setImageFormat( 'png' ); $temp_png = tempnam( sys_get_temp_dir(), 'gatewell_avif_' ) . '.png'; $imagick->writeImage( $temp_png ); $imagick->clear(); $imagick->destroy(); $image = imagecreatefrompng( $temp_png ); wp_delete_file( $temp_png ); return $image; } catch ( \Exception $e ) { throw new \Exception( 'Failed to process AVIF file. Server does not support AVIF format.' ); } } throw new \Exception( 'AVIF format requires PHP 8.1+ or ImageMagick extension.' ); case 'image/heic': case 'image/heif': // HEIC/HEIF requires ImageMagick or conversion if ( ! extension_loaded( 'imagick' ) ) { throw new \Exception( 'HEIC/HEIF format requires ImageMagick extension. Please install ImageMagick or convert your image to JPEG/PNG.' ); } try { $imagick = new \Imagick( $file_path ); // Check if HEIC delegate is available $formats = $imagick->queryFormats( 'HEIC' ); if ( empty( $formats ) ) { throw new \Exception( 'ImageMagick is installed but HEIC support is not enabled. Please convert your image to JPEG/PNG.' ); } $imagick->setImageFormat( 'jpeg' ); $temp_jpeg = tempnam( sys_get_temp_dir(), GATEWELL_HEIC_TEMP_PREFIX ) . '.jpg'; $imagick->writeImage( $temp_jpeg ); $imagick->clear(); $imagick->destroy(); $image = imagecreatefromjpeg( $temp_jpeg ); wp_delete_file( $temp_jpeg ); return $image; } catch ( \Exception $e ) { // Log detailed error for debugging (not shown to users). Debug::log( 'HEIC processing error: ' . $e->getMessage(), 'Gatewell' ); // Throw generic error message for users (no system information leaked) throw new \Exception( 'Failed to process HEIC/HEIF file. Please convert your image to JPEG or PNG format.' ); } default: return false; } } /** * Check if Gatewell should process compression based on cooperation mode. * * @return bool True if Gatewell should compress images. * @since 1.3.0 */ private function should_process_compression() { // Check if Plugin_Compatibility class exists (may not be loaded yet). if ( ! class_exists( 'Gatewell\Plugin_Compatibility' ) ) { return true; // Default to processing if class not available. } return Plugin_Compatibility::should_gatewell_compress(); } /** * Check if Gatewell should process format conversion based on cooperation mode. * * @return bool True if Gatewell should convert formats. * @since 1.3.0 */ private function should_process_conversion() { // Check if Plugin_Compatibility class exists (may not be loaded yet). if ( ! class_exists( 'Gatewell\Plugin_Compatibility' ) ) { return true; // Default to processing if class not available. } return Plugin_Compatibility::should_gatewell_convert(); } /** * Compress image using GD library with optimal quality settings. * * @param string $source_path Path to source image. * @param string $target_path Path for compressed image. * @param string $source_mime_type MIME type of source image. * @param string $output_mime_type MIME type for output image. * @return bool True on success. * @throws \Exception On compression failure. * @since 1.0.0 */ private function compress_image( $source_path, $target_path, $source_mime_type, $output_mime_type ) { // Check cooperation mode - should we process compression? if ( ! $this->should_process_compression() ) { // External plugin will handle compression - just copy the file. Debug::log( 'Skipping compression (cooperation mode: external primary)', 'Gatewell' ); return copy( $source_path, $target_path ); } // Get original dimensions $image_info = getimagesize( $source_path ); $orig_width = $image_info[0]; $orig_height = $image_info[1]; // Only scale down, never up $new_width = min($orig_width, $this->max_width); $new_height = min($orig_height, $this->max_height); // Load image with proper resampling $image = $this->load_image_by_type( $source_path, $source_mime_type ); if ( $image === false ) { throw new \Exception( 'Failed to load image for compression.' ); } // If no resizing needed, just save with quality settings if ( $new_width === $orig_width && $new_height === $orig_height ) { // No resize needed - just save with quality setting $result = $this->save_image_by_type( $image, $target_path, $output_mime_type ); imagedestroy( $image ); // Preserve metadata if enabled $this->preserve_metadata_if_enabled( $source_path, $target_path ); return $result; } // Create new image with bicubic resampling for best quality $new_image = imagecreatetruecolor( $new_width, $new_height ); // Preserve transparency for PNG, GIF, and WebP if ( $output_mime_type === 'image/png' || $output_mime_type === 'image/gif' || $output_mime_type === 'image/webp' ) { imagealphablending( $new_image, false ); imagesavealpha( $new_image, true ); // Set transparent background $transparent = imagecolorallocatealpha( $new_image, 0, 0, 0, 127 ); imagefill( $new_image, 0, 0, $transparent ); } // Use bicubic interpolation for highest quality resampling imagecopyresampled( $new_image, $image, 0, 0, 0, 0, $new_width, $new_height, $orig_width, $orig_height ); // Save with appropriate format $result = $this->save_image_by_type( $new_image, $target_path, $output_mime_type ); imagedestroy( $image ); imagedestroy( $new_image ); // Preserve metadata if enabled $this->preserve_metadata_if_enabled( $source_path, $target_path ); return $result; } /** * Save image resource to file based on MIME type. * * Implements graceful fallback if the requested format is not supported. * * @param resource $image GD image resource. * @param string $target_path Path to save image. * @param string $mime_type Output MIME type. * @return bool True on success. * @throws \Exception On save failure. * @since 1.0.0 */ private function save_image_by_type( $image, $target_path, $mime_type ) { // Try to save in requested format, with fallback on failure. try { switch ( $mime_type ) { case 'image/jpeg': return imagejpeg( $image, $target_path, $this->compression_quality ); case 'image/png': // PNG compression level: 0 (no compression) to 9 (max compression) // Convert quality (0-100) to compression (0-9) $png_compression = 9 - round( ( $this->compression_quality / 100 ) * 9 ); return imagepng( $image, $target_path, $png_compression ); case 'image/gif': return imagegif( $image, $target_path ); case 'image/webp': // Check if WebP is supported. if ( ! Format_Capabilities::supports_webp() ) { throw new \Exception( 'WebP format not supported on this server.' ); } return imagewebp( $image, $target_path, $this->compression_quality ); case 'image/avif': // Check if AVIF is supported. if ( ! Format_Capabilities::supports_avif() ) { throw new \Exception( 'AVIF format not supported on this server.' ); } // AVIF support requires PHP 8.1+ with GD or ImageMagick if ( function_exists( 'imageavif' ) ) { // Use GD's native AVIF support (PHP 8.1+) return imageavif( $image, $target_path, $this->compression_quality ); } // Fallback to ImageMagick if ( extension_loaded( 'imagick' ) ) { // Save as PNG first, then convert with ImageMagick $temp_png = tempnam( sys_get_temp_dir(), 'gatewell_avif_' ) . '.png'; imagepng( $image, $temp_png ); $imagick = new \Imagick( $temp_png ); $imagick->setImageFormat( 'avif' ); $imagick->setImageCompressionQuality( $this->compression_quality ); $imagick->writeImage( $target_path ); $imagick->clear(); $imagick->destroy(); wp_delete_file( $temp_png ); return true; } throw new \Exception( 'AVIF format requires PHP 8.1+ or ImageMagick extension.' ); default: throw new \Exception( 'Unsupported output format: ' . esc_html( $mime_type ) ); } } catch ( \Exception $e ) { // If save failed due to format not supported, try fallback format. if ( strpos( $e->getMessage(), 'not supported' ) !== false ) { $fallback_format = Format_Capabilities::get_fallback_format( $mime_type ); // Log the fallback event. Debug::log( sprintf( 'Failed to save as %s. Falling back to %s. Error: %s', $mime_type, $fallback_format, $e->getMessage() ), 'Gatewell' ); // Update target path extension if format changed. if ( $fallback_format !== $mime_type ) { $path_info = pathinfo( $target_path ); $new_extension = str_replace( 'image/', '', $fallback_format ); $target_path = $path_info['dirname'] . '/' . $path_info['filename'] . '.' . $new_extension; } // Retry save with fallback format (recursive call, but with JPEG fallback won't recurse further). return $this->save_image_by_type( $image, $target_path, $fallback_format ); } // Re-throw if it's not a format support issue. throw $e; } } /** * Cleanup temporary files. * * @param string $file_path Path to file to delete. * @return bool True on success. * @since 1.0.0 */ private function cleanup_temp_file( $file_path ) { // Delete file if it exists. if ( file_exists( $file_path ) ) { return wp_delete_file( $file_path ); } return true; } /** * Get temporary directory for uploads. * * @return string Path to temporary directory. * @since 1.0.0 */ private function get_temp_dir() { $upload_dir = wp_upload_dir(); $temp_dir = trailingslashit( $upload_dir['basedir'] ) . GATEWELL_TEMP_FOLDER . '/'; // Create temp directory if it doesn't exist. if ( ! file_exists( $temp_dir ) ) { wp_mkdir_p( $temp_dir ); } // Block direct web access, the same way gatewell-originals/ is protected. Upload_Handler::protect_directory( $temp_dir ); return $temp_dir; } /** * Preserve EXIF/IPTC metadata from source to target image if enabled in settings. * * @param string $source_path Path to source image with metadata. * @param string $target_path Path to target image to copy metadata to. * @return bool True on success or if disabled, false on failure. * @since 1.2.0 */ private function preserve_metadata_if_enabled( $source_path, $target_path ) { // Check if metadata preservation is enabled. $metadata_settings = get_option( 'gatewell_metadata_preservation', array( 'preserve' => false ) ); $preserve_enabled = isset( $metadata_settings['preserve'] ) && $metadata_settings['preserve']; if ( ! $preserve_enabled ) { return true; // Not enabled, skip silently. } // Check if exif_read_data function exists (not available on all PHP installations). if ( ! function_exists( 'exif_read_data' ) ) { return true; // Cannot preserve without exif_read_data, skip silently. } // Try to use ImageMagick if available (best for preserving metadata). if ( extension_loaded( 'imagick' ) ) { try { $source_imagick = new \Imagick( $source_path ); $target_imagick = new \Imagick( $target_path ); // Get profiles (EXIF, IPTC, XMP) from source. $profiles = $source_imagick->getImageProfiles( '*', true ); // Copy profiles to target. foreach ( $profiles as $name => $profile ) { $target_imagick->setImageProfile( $name, $profile ); } // Write target with metadata. $target_imagick->writeImage( $target_path ); $source_imagick->clear(); $source_imagick->destroy(); $target_imagick->clear(); $target_imagick->destroy(); return true; } catch ( \Exception $e ) { // ImageMagick failed, try fallback methods below. Debug::log( 'ImageMagick metadata preservation failed: ' . $e->getMessage(), 'Gatewell' ); } } /* * There is deliberately no shell fallback here. An earlier version shelled out * to the exiftool binary; the plugin now never invokes an external process. * ImageMagick's profile copy above is the only supported path, and the method * degrades quietly when it is unavailable. */ Debug::log( 'Metadata preservation requested but the ImageMagick extension is not available.', 'Gatewell' ); return false; } }