b6df4dbb92
MLS plugin fixes from this session: - Fix silent insert failures: location column NOT NULL was rejecting wpdb->insert calls, causing ~18k new properties since Dec 2025 to be lost. Inserts now build raw SQL with ST_PointFromText so the spatial column is populated atomically. - Auto-refresh expired media URLs in MLS_Media_Handler::fetch_and_cache(), guarded by a property-level GET_LOCK so concurrent fetches share one API refresh. - Normalize WP_Error to null in mls_get_property_image() so callers can rely on the documented string|null contract. - Support comma-separated property_type filters in MLS_Query and MLS_Cluster so the homepage "View All Commercial" link (?property_type=Commercial+Sale,Land,Farm) actually filters correctly. - Incremental sync now looks back 10 minutes past the latest modification timestamp as a safety margin against missed records. - Smart sync exits silently (info-level, not warning) when a full sync is in progress. Operational: - New cron: weekly full sync Sundays at 3 AM (/usr/local/bin/mls-full-sync). - New cron: hourly 2GB cap on mls-thumbnails/ and cache/transformed-images/ (/usr/local/bin/mls-image-cache-cap). - Logrotate config for wp-content/debug.log (2-day retention, daily rotation, delaycompress). Repo policy: - CLAUDE.md updated with explicit "commit everything except build artifacts" policy. - .gitignore: untrack runtime image caches and debug.log rotations. Other modifications in this snapshot are pre-existing in-flight theme/plugin/db_content_updates work. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
828 lines
27 KiB
PHP
Executable File
828 lines
27 KiB
PHP
Executable File
<?php
|
|
/**
|
|
* MLS Image Endpoint
|
|
*
|
|
* Serves WebP thumbnails for MLS property images with on-demand conversion.
|
|
* Uses ImageMagick to convert and resize images.
|
|
*
|
|
* URL format: /mls-image/{listing_key}/{index}/{size}/?sig={signature}
|
|
* - listing_key: MLS listing key
|
|
* - index: Image index (0-based)
|
|
* - size: 'thumb' (800px) or 'full' (1800px)
|
|
* - sig: HMAC signature to prevent unauthorized access
|
|
*/
|
|
|
|
if (!defined('ABSPATH')) {
|
|
exit;
|
|
}
|
|
|
|
class MLS_Image_Endpoint {
|
|
|
|
/**
|
|
* Size configurations
|
|
*/
|
|
const SIZE_THUMB = 800;
|
|
const SIZE_FULL = 1800;
|
|
|
|
/**
|
|
* WebP quality (0-100)
|
|
*/
|
|
const WEBP_QUALITY = 82;
|
|
|
|
/**
|
|
* Cache subdirectory for thumbnails
|
|
*/
|
|
const CACHE_SUBDIR = 'mls-thumbnails';
|
|
|
|
/**
|
|
* Signature length (truncated HMAC for shorter URLs)
|
|
*/
|
|
const SIG_LENGTH = 16;
|
|
|
|
/**
|
|
* Media handler instance
|
|
*/
|
|
private $media_handler;
|
|
|
|
/**
|
|
* Logger instance
|
|
*/
|
|
private $logger;
|
|
|
|
/**
|
|
* Constructor
|
|
*/
|
|
public function __construct(MLS_Media_Handler $media_handler, MLS_Logger $logger) {
|
|
$this->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;
|
|
}
|
|
}
|