532 lines
16 KiB
PHP
Executable File
532 lines
16 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 (!$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 - 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;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|