media_handler = $media_handler; $this->logger = $logger; } /** * Initialize hooks */ public function init() { add_action('init', array($this, 'add_rewrite_rules')); add_filter('query_vars', array($this, 'add_query_vars')); add_action('template_redirect', array($this, 'handle_request')); } /** * Add rewrite rules for image endpoint */ public function add_rewrite_rules() { add_rewrite_rule( '^mls-image/([^/]+)/([0-9]+)/(thumb|full)/?$', 'index.php?mls_image=1&mls_listing_key=$matches[1]&mls_image_index=$matches[2]&mls_image_size=$matches[3]', 'top' ); } /** * Add query vars */ public function add_query_vars($vars) { $vars[] = 'mls_image'; $vars[] = 'mls_listing_key'; $vars[] = 'mls_image_index'; $vars[] = 'mls_image_size'; return $vars; } /** * Handle image request */ public function handle_request() { if (!get_query_var('mls_image')) { return; } $listing_key = sanitize_text_field(get_query_var('mls_listing_key')); $index = absint(get_query_var('mls_image_index')); $size = sanitize_text_field(get_query_var('mls_image_size')); $signature = isset($_GET['sig']) ? sanitize_text_field($_GET['sig']) : ''; if (empty($listing_key) || !in_array($size, array('thumb', 'full'), true)) { $this->logger->error('MLS Image: Invalid params', array( 'listing_key' => $listing_key, 'index' => $index, 'size' => $size, )); $this->send_404(); return; } // Verify signature if (!self::verify_signature($listing_key, $signature)) { $this->logger->warning('MLS Image: Invalid signature', array( 'listing_key' => $listing_key, 'signature' => $signature, 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown', )); $this->send_403(); return; } $max_dimension = ($size === 'thumb') ? self::SIZE_THUMB : self::SIZE_FULL; // Try to serve from cache first $cached_path = $this->get_cached_path($listing_key, $index, $size); if (file_exists($cached_path)) { $this->serve_image($cached_path); return; } // Get the source image $source_path = $this->get_source_image($listing_key, $index); if (!$source_path) { $this->logger->error('MLS Image: Source not found', array( 'listing_key' => $listing_key, 'index' => $index, )); $this->send_404(); return; } // Generate thumbnail $result = $this->generate_thumbnail($source_path, $cached_path, $max_dimension); if (!$result) { // Fall back to serving original if conversion fails $this->serve_image($source_path, $this->get_mime_type($source_path)); return; } $this->serve_image($cached_path); } /** * Get source image path, fetching from MLS if needed * * This method handles: * 1. Returning cached local images if available * 2. Checking if media URL has expired and refreshing if needed * 3. Fetching images from MLS Grid on demand */ private function get_source_image($listing_key, $index) { global $wpdb; $plugin = mls_plugin(); $db = $plugin->get_db(); // Get media record for this index $media = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$db->media_table()} WHERE listing_key = %s AND media_order = %d LIMIT 1", $listing_key, $index )); if (!$media) { return null; } // Check if already cached locally if ($media->local_path) { $full_path = $this->media_handler->get_upload_dir() . '/' . $media->local_path; if (file_exists($full_path)) { return $full_path; } } // Check if the media URL has expired before trying to fetch if ($this->media_handler->is_url_expired($media->media_url)) { $this->logger->debug('Media URL expired, refreshing', array( 'listing_key' => $listing_key, 'index' => $index, )); // Refresh media URLs from API if ($this->media_handler->refresh_media_urls($listing_key)) { // Re-fetch the record with fresh URL $media = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$db->media_table()} WHERE listing_key = %s AND media_order = %d LIMIT 1", $listing_key, $index )); if (!$media) { return null; } } } // Fetch from MLS on demand $url = $this->media_handler->get_image_url($media, true); if (!$url) { return null; } // Re-fetch the record to get updated local_path $media = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$db->media_table()} WHERE listing_key = %s AND media_order = %d LIMIT 1", $listing_key, $index )); if ($media && $media->local_path) { $full_path = $this->media_handler->get_upload_dir() . '/' . $media->local_path; if (file_exists($full_path)) { return $full_path; } } return null; } /** * Get cached thumbnail path */ private function get_cached_path($listing_key, $index, $size) { $cache_dir = $this->get_cache_dir($listing_key); return $cache_dir . '/' . $index . '-' . $size . '.webp'; } /** * Get cache directory for a listing */ private function get_cache_dir($listing_key) { $upload_dir = wp_upload_dir(); $prefix = substr($listing_key, 0, 2); return $upload_dir['basedir'] . '/' . self::CACHE_SUBDIR . '/' . $prefix . '/' . $listing_key; } /** * Generate WebP thumbnail using ImageMagick */ private function generate_thumbnail($source_path, $dest_path, $max_dimension) { // Create destination directory $dest_dir = dirname($dest_path); if (!file_exists($dest_dir)) { wp_mkdir_p($dest_dir); } // Check for ImageMagick $convert_path = $this->get_imagemagick_path(); if (!$convert_path) { $this->logger->error('ImageMagick not found'); return false; } // Get source dimensions $size_info = @getimagesize($source_path); if (!$size_info) { $this->logger->error('Failed to get image dimensions', array('path' => $source_path)); return false; } $source_width = $size_info[0]; $source_height = $size_info[1]; // Only downsize, never upsize if ($source_width <= $max_dimension && $source_height <= $max_dimension) { // Just convert to WebP without resizing $resize_arg = ''; } else { // Resize maintaining aspect ratio, > means only shrink $resize_arg = '-resize ' . escapeshellarg("{$max_dimension}x{$max_dimension}>"); } // Build ImageMagick command // -strip: Remove metadata // -quality: WebP quality // -define webp:method=6: Best compression method $command = sprintf( '%s %s -strip %s -quality %d -define webp:method=6 %s 2>&1', escapeshellcmd($convert_path), escapeshellarg($source_path), $resize_arg, self::WEBP_QUALITY, escapeshellarg($dest_path) ); $output = array(); $return_var = 0; exec($command, $output, $return_var); if ($return_var !== 0) { $this->logger->error('ImageMagick conversion failed', array( 'command' => $command, 'output' => implode("\n", $output), 'return' => $return_var, )); return false; } return file_exists($dest_path); } /** * Get ImageMagick convert path */ private function get_imagemagick_path() { // Common paths $paths = array( '/usr/bin/convert', '/usr/local/bin/convert', '/opt/local/bin/convert', 'convert', // System PATH ); foreach ($paths as $path) { if ($path === 'convert') { // Check if available in PATH $output = array(); $return_var = 0; exec('which convert 2>/dev/null', $output, $return_var); if ($return_var === 0 && !empty($output[0])) { return trim($output[0]); } } elseif (file_exists($path) && is_executable($path)) { return $path; } } return null; } /** * Serve image file */ private function serve_image($path, $mime_type = 'image/webp') { if (!file_exists($path)) { $this->send_404(); return; } $file_size = filesize($path); $last_modified = filemtime($path); $etag = md5($path . $last_modified); // Check for conditional request $if_modified = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) ? strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) : false; $if_none_match = isset($_SERVER['HTTP_IF_NONE_MATCH']) ? trim($_SERVER['HTTP_IF_NONE_MATCH'], '"') : false; if (($if_modified && $if_modified >= $last_modified) || ($if_none_match && $if_none_match === $etag)) { header('HTTP/1.1 304 Not Modified'); exit; } // Send headers header('Content-Type: ' . $mime_type); header('Content-Length: ' . $file_size); header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $last_modified) . ' GMT'); header('ETag: "' . $etag . '"'); header('Cache-Control: public, max-age=31536000'); // 1 year header('Expires: ' . gmdate('D, d M Y H:i:s', time() + 31536000) . ' GMT'); // Stream file readfile($path); exit; } /** * Get MIME type for file */ private function get_mime_type($path) { $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); $types = array( 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', ); return isset($types[$ext]) ? $types[$ext] : 'application/octet-stream'; } /** * Send 404 response */ private function send_404() { status_header(404); nocache_headers(); exit; } /** * Send 403 Forbidden response */ private function send_403() { status_header(403); nocache_headers(); exit; } /** * Get URL for an MLS image * * @param string $listing_key Listing key * @param int $index Image index (1-based, matches media_order from MLS) * @param string $size 'thumb' or 'full' * @return string Image URL */ public static function get_url($listing_key, $index = 1, $size = 'thumb') { $sig = self::generate_signature($listing_key); return home_url("/mls-image/{$listing_key}/{$index}/{$size}/") . '?sig=' . $sig; } /** * Generate HMAC signature for a listing key * * @param string $listing_key Listing key * @return string Truncated HMAC signature */ public static function generate_signature($listing_key) { $secret = self::get_secret_key(); $hash = hash_hmac('sha256', $listing_key, $secret); return substr($hash, 0, self::SIG_LENGTH); } /** * Verify HMAC signature for a listing key * * @param string $listing_key Listing key * @param string $signature Signature to verify * @return bool True if valid */ public static function verify_signature($listing_key, $signature) { if (empty($signature)) { return false; } $expected = self::generate_signature($listing_key); return hash_equals($expected, $signature); } /** * Get the secret key for signing * * Uses MLS_IMAGE_SECRET if defined, otherwise falls back to AUTH_KEY * * @return string Secret key */ private static function get_secret_key() { if (defined('MLS_IMAGE_SECRET') && MLS_IMAGE_SECRET) { return MLS_IMAGE_SECRET; } // Fallback to WordPress AUTH_KEY (always defined) return AUTH_KEY; } /** * Clear thumbnail cache for a listing * * @param string $listing_key Listing key */ public function clear_cache($listing_key) { $cache_dir = $this->get_cache_dir($listing_key); if (is_dir($cache_dir)) { $files = glob($cache_dir . '/*'); foreach ($files as $file) { if (is_file($file)) { unlink($file); } } rmdir($cache_dir); } } /** * Get cache statistics * * @return array Cache stats */ public function get_cache_stats() { $upload_dir = wp_upload_dir(); $cache_base = $upload_dir['basedir'] . '/' . self::CACHE_SUBDIR; $stats = array( 'total_files' => 0, 'total_size' => 0, ); if (!is_dir($cache_base)) { return $stats; } $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($cache_base, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); foreach ($iterator as $file) { if ($file->isFile() && $file->getExtension() === 'webp') { $stats['total_files']++; $stats['total_size'] += $file->getSize(); } } return $stats; } }