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 (is_wp_error($source_path)) { // Handle specific errors if ($source_path->get_error_code() === 'rate_limited') { $this->logger->warning('MLS Image: Rate limited by MLS Grid', array( 'listing_key' => $listing_key, 'index' => $index, )); $this->send_429(); return; } $this->send_404(); return; } if (!$source_path) { $this->logger->error('MLS Image: Source not found', array( 'listing_key' => $listing_key, 'index' => $index, )); $this->send_404(); return; } // Generate thumbnail from cached source $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 * * Source images are cached in the thumbnails directory (mls-thumbnails) * alongside generated thumbnails so they don't get garbage collected. * * This method handles: * 1. Returning cached source from thumbnails directory * 2. Falling back to media handler cache (mls-listings) if available * 3. Fetching from MLS Grid on demand and caching source locally */ private function get_source_image($listing_key, $index) { global $wpdb; $plugin = mls_plugin(); $db = $plugin->get_db(); // First check for cached source in thumbnails directory (won't be garbage collected) $source_path = $this->get_cached_source_path($listing_key, $index); if ($source_path) { return $source_path; } // 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 source exists in media handler cache (mls-listings directories) // These may have been garbage collected, but check anyway $found_file = null; if ($media->local_path) { $filename = basename($media->local_path); $webp_filename = preg_replace('/\.[^.]+$/', '.webp', $filename); $found_file = $this->media_handler->find_cached_file($listing_key, $webp_filename); if (!$found_file) { $found_file = $this->media_handler->find_cached_file($listing_key, $filename); } } if (!$found_file) { $extensions = array('webp', 'jpg', 'jpeg', 'png', 'gif'); foreach ($extensions as $ext) { $pattern_file = $index . '.' . $ext; $found_file = $this->media_handler->find_cached_file($listing_key, $pattern_file); if ($found_file) { break; } } } // If found in mls-listings, copy to thumbnails directory for future use if ($found_file) { $copied_path = $this->copy_source_to_cache($found_file['path'], $listing_key, $index); if ($copied_path) { return $copied_path; } return $found_file['path']; } // If media URL has expired, refresh the entire property on demand if ($this->media_handler->is_url_expired($media->media_url)) { $this->logger->debug('Media URL expired, attempting on-demand refresh', array( 'listing_key' => $listing_key, 'index' => $index, )); if ($this->refresh_property_on_demand($listing_key)) { $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 || $this->media_handler->is_url_expired($media->media_url)) { return null; } } else { return null; } } // Fetch from MLS and cache source directly to thumbnails directory return $this->fetch_and_cache_source($media, $listing_key, $index); } /** * Get cached source path from thumbnails directory * * @param string $listing_key Listing key * @param int $index Image index * @return string|null Path if found, null otherwise */ private function get_cached_source_path($listing_key, $index) { $cache_dir = $this->get_cache_dir($listing_key); $extensions = array('webp', 'jpg', 'jpeg', 'png', 'gif'); foreach ($extensions as $ext) { $path = $cache_dir . '/' . $index . '-source.' . $ext; if (file_exists($path)) { return $path; } } return null; } /** * Copy source file to thumbnails cache directory * * @param string $source_path Original source path * @param string $listing_key Listing key * @param int $index Image index * @return string|null New path if copied, null on failure */ private function copy_source_to_cache($source_path, $listing_key, $index) { $cache_dir = $this->get_cache_dir($listing_key); if (!file_exists($cache_dir)) { wp_mkdir_p($cache_dir); } $ext = strtolower(pathinfo($source_path, PATHINFO_EXTENSION)); $dest_path = $cache_dir . '/' . $index . '-source.' . $ext; if (copy($source_path, $dest_path)) { return $dest_path; } return null; } /** * Fetch image from MLS and cache source to thumbnails directory * * @param object $media Media record from database * @param string $listing_key Listing key * @param int $index Image index * @return string|WP_Error Path to cached source, or error */ private function fetch_and_cache_source($media, $listing_key, $index) { if (empty($media->media_url)) { return null; } // Check rate limiter $rate_limiter = mls_plugin()->get_rate_limiter(); if (!$rate_limiter->can_fetch_image()) { $this->logger->warning('Daily data budget exhausted, skipping image fetch', array( 'listing_key' => $listing_key, 'index' => $index, )); return null; } // Fetch image from MLS $request_args = array('timeout' => 30); if (defined('MLS_SKIP_SSL_VERIFY') && MLS_SKIP_SSL_VERIFY) { $request_args['sslverify'] = false; } $response = wp_remote_get($media->media_url, $request_args); if (is_wp_error($response)) { $this->logger->warning('Source fetch failed', array( 'listing_key' => $listing_key, 'index' => $index, 'error' => $response->get_error_message(), )); return null; } $status_code = wp_remote_retrieve_response_code($response); if ($status_code === 429) { return new WP_Error('rate_limited', 'MLS Grid rate limit exceeded', array('status' => 429)); } if ($status_code !== 200) { $this->logger->warning('Source fetch HTTP error', array( 'listing_key' => $listing_key, 'index' => $index, 'status' => $status_code, )); return null; } $body = wp_remote_retrieve_body($response); if (empty($body)) { return null; } // Record bytes downloaded $rate_limiter->record_data_transfer(strlen($body)); // Determine extension from content type $content_type = wp_remote_retrieve_header($response, 'content-type'); $ext = $this->get_extension_from_content_type($content_type); // Save to thumbnails cache directory $cache_dir = $this->get_cache_dir($listing_key); if (!file_exists($cache_dir)) { wp_mkdir_p($cache_dir); } $source_path = $cache_dir . '/' . $index . '-source.' . $ext; if (file_put_contents($source_path, $body) === false) { $this->logger->error('Failed to write source file', array( 'path' => $source_path, )); return null; } $this->logger->debug('Source fetched and cached', array( 'listing_key' => $listing_key, 'index' => $index, 'path' => $source_path, 'size' => strlen($body), )); return $source_path; } /** * Get file extension from content type * * @param string $content_type Content-Type header * @return string Extension */ private function get_extension_from_content_type($content_type) { $content_type = strtolower($content_type); if (strpos($content_type, 'jpeg') !== false || strpos($content_type, 'jpg') !== false) { return 'jpg'; } elseif (strpos($content_type, 'png') !== false) { return 'png'; } elseif (strpos($content_type, 'gif') !== false) { return 'gif'; } elseif (strpos($content_type, 'webp') !== false) { return 'webp'; } return 'jpg'; // Default } /** * Refresh a property on demand when media URLs have expired * * Uses MySQL advisory lock to prevent multiple simultaneous refreshes * of the same property. Includes a 4 second delay to respect API rate limits. * * @param string $listing_key Property listing key * @return bool True if refresh succeeded, false otherwise */ private function refresh_property_on_demand($listing_key) { global $wpdb; // Get the listing_id for API lookup $db = mls_plugin()->get_db(); $property = $wpdb->get_row($wpdb->prepare( "SELECT listing_id FROM {$db->properties_table()} WHERE listing_key = %s", $listing_key )); if (!$property || empty($property->listing_id)) { $this->logger->warning('Cannot refresh property: listing_id not found', array( 'listing_key' => $listing_key, )); return false; } // Advisory lock to prevent concurrent refreshes of the same property $lock_name = 'mls_property_refresh_' . $listing_key; $lock_timeout = 0; // Non-blocking - return immediately if lock not available $lock_acquired = $wpdb->get_var($wpdb->prepare( "SELECT GET_LOCK(%s, %d)", $lock_name, $lock_timeout )); if ($lock_acquired !== '1') { // Another request is already refreshing this property $this->logger->debug('Property refresh already in progress', array( 'listing_key' => $listing_key, )); return false; } try { // Fetch fresh property data from API $api_client = mls_plugin()->get_api_client(); $property_data = $api_client->get_property_media($property->listing_id); if (is_wp_error($property_data)) { $this->logger->warning('Failed to refresh property from API', array( 'listing_key' => $listing_key, 'error' => $property_data->get_error_message(), )); return false; } if (empty($property_data)) { $this->logger->warning('Property not found in API', array( 'listing_key' => $listing_key, )); return false; } // Update media records with fresh URLs if (isset($property_data['Media']) && is_array($property_data['Media'])) { $this->media_handler->sync_property_media($listing_key, $property_data['Media']); $this->logger->info('Property media refreshed on demand', array( 'listing_key' => $listing_key, 'media_count' => count($property_data['Media']), )); return true; } return false; } finally { // Always release the lock $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lock_name)); } } /** * 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 - remove any no-cache headers WordPress may have added header_remove('Pragma'); header('Pragma: public'); 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=3600'); // 1 hour header('Expires: ' . gmdate('D, d M Y H:i:s', time() + 3600) . ' 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; } /** * Send 429 Too Many Requests response */ private function send_429() { status_header(429); header('Retry-After: 5'); 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') { // Handle manual properties - return WordPress attachment URL directly if (strpos($listing_key, 'MANUAL-') === 0) { $post_id = (int) str_replace('MANUAL-', '', $listing_key); if ($post_id) { $gallery = get_field('gallery', $post_id); if (!empty($gallery) && is_array($gallery)) { // $index is 1-based, convert to 0-based array index $idx = max(0, $index - 1); if (isset($gallery[$idx])) { // Use WordPress image size based on requested size $wp_size = ($size === 'full') ? 'large' : 'medium_large'; $image_url = wp_get_attachment_image_url($gallery[$idx]['ID'], $wp_size); return $image_url ?: $gallery[$idx]['url']; } } } return ''; } $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; } }