Add single MLS property view and image security improvements
- Add single-property-mls.php template with full gallery support - Route /properties/?listing=XXX to single property view - Add HMAC-signed URLs for image endpoint (bot protection) - Add MySQL advisory lock for image downloads (prevent stampede) - Add infinite scroll module for property list (desktop map view) - Load card images immediately on DOM ready (no scroll detection) - Add cards_only AJAX parameter for infinite scroll 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -5,10 +5,11 @@
|
||||
* 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}/
|
||||
* 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')) {
|
||||
@@ -33,6 +34,11 @@ class MLS_Image_Endpoint {
|
||||
*/
|
||||
const CACHE_SUBDIR = 'mls-thumbnails';
|
||||
|
||||
/**
|
||||
* Signature length (truncated HMAC for shorter URLs)
|
||||
*/
|
||||
const SIG_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* Media handler instance
|
||||
*/
|
||||
@@ -93,6 +99,7 @@ class MLS_Image_Endpoint {
|
||||
$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(
|
||||
@@ -104,6 +111,17 @@ class MLS_Image_Endpoint {
|
||||
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
|
||||
@@ -394,6 +412,15 @@ class MLS_Image_Endpoint {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send 403 Forbidden response
|
||||
*/
|
||||
private function send_403() {
|
||||
status_header(403);
|
||||
nocache_headers();
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get URL for an MLS image
|
||||
*
|
||||
@@ -403,7 +430,50 @@ class MLS_Image_Endpoint {
|
||||
* @return string Image URL
|
||||
*/
|
||||
public static function get_url($listing_key, $index = 1, $size = 'thumb') {
|
||||
return home_url("/mls-image/{$listing_key}/{$index}/{$size}/");
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -316,6 +316,9 @@ class MLS_Media_Handler {
|
||||
/**
|
||||
* Fetch image from MLS Grid and cache locally
|
||||
*
|
||||
* Uses MySQL advisory lock to ensure only one request downloads
|
||||
* a specific image at a time (prevents stampede on cold cache).
|
||||
*
|
||||
* @param object $media Media record
|
||||
* @return string|null Local URL on success, null on failure
|
||||
*/
|
||||
@@ -326,81 +329,121 @@ class MLS_Media_Handler {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Download the image
|
||||
$response = wp_remote_get($media->media_url, array(
|
||||
'timeout' => 30,
|
||||
// Advisory lock key - unique per media record
|
||||
$lock_name = 'mls_media_' . $media->id;
|
||||
$lock_timeout = 35; // Slightly longer than HTTP timeout
|
||||
|
||||
// Try to acquire lock (will wait up to $lock_timeout seconds)
|
||||
$lock_acquired = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT GET_LOCK(%s, %d)",
|
||||
$lock_name,
|
||||
$lock_timeout
|
||||
));
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
$this->logger->warning('Media fetch failed', array(
|
||||
if ($lock_acquired !== '1') {
|
||||
$this->logger->warning('Could not acquire media lock', array(
|
||||
'listing_key' => $media->listing_key,
|
||||
'media_key' => $media->media_key,
|
||||
'error' => $response->get_error_message(),
|
||||
'lock_result' => $lock_acquired,
|
||||
));
|
||||
return null;
|
||||
}
|
||||
|
||||
$status_code = wp_remote_retrieve_response_code($response);
|
||||
try {
|
||||
// Re-check if image was cached while we waited for lock
|
||||
$updated_media = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT local_path, local_url FROM {$this->db->media_table()} WHERE id = %d",
|
||||
$media->id
|
||||
));
|
||||
|
||||
if ($status_code !== 200) {
|
||||
$this->logger->warning('Media fetch HTTP error', array(
|
||||
if ($updated_media && $updated_media->local_path) {
|
||||
$file_path = $this->get_upload_dir() . '/' . $updated_media->local_path;
|
||||
if (file_exists($file_path)) {
|
||||
// Another request cached it while we waited
|
||||
return $updated_media->local_url;
|
||||
}
|
||||
}
|
||||
|
||||
// Download the image
|
||||
$response = wp_remote_get($media->media_url, array(
|
||||
'timeout' => 30,
|
||||
));
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
$this->logger->warning('Media fetch failed', array(
|
||||
'listing_key' => $media->listing_key,
|
||||
'media_key' => $media->media_key,
|
||||
'error' => $response->get_error_message(),
|
||||
));
|
||||
return null;
|
||||
}
|
||||
|
||||
$status_code = wp_remote_retrieve_response_code($response);
|
||||
|
||||
if ($status_code !== 200) {
|
||||
$this->logger->warning('Media fetch HTTP error', array(
|
||||
'listing_key' => $media->listing_key,
|
||||
'media_key' => $media->media_key,
|
||||
'status' => $status_code,
|
||||
));
|
||||
return null;
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body($response);
|
||||
if (empty($body)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine extension
|
||||
$content_type = wp_remote_retrieve_header($response, 'content-type');
|
||||
$extension = $this->get_extension_from_content_type($content_type, $media->media_url);
|
||||
|
||||
// Create directory
|
||||
$listing_dir = $this->get_listing_dir($media->listing_key);
|
||||
if (!file_exists($listing_dir)) {
|
||||
wp_mkdir_p($listing_dir);
|
||||
}
|
||||
|
||||
// Save file
|
||||
$filename = $media->media_order . '.' . $extension;
|
||||
$file_path = $listing_dir . '/' . $filename;
|
||||
|
||||
if (file_put_contents($file_path, $body) === false) {
|
||||
$this->logger->error('Failed to write media file', array(
|
||||
'path' => $file_path,
|
||||
));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update database
|
||||
$prefix = substr($media->listing_key, 0, 2);
|
||||
$relative_path = $prefix . '/' . $media->listing_key . '/' . $filename;
|
||||
$local_url = $this->get_upload_url() . '/' . $relative_path;
|
||||
|
||||
$wpdb->update(
|
||||
$this->db->media_table(),
|
||||
array(
|
||||
'local_path' => $relative_path,
|
||||
'local_url' => $local_url,
|
||||
'file_size' => strlen($body),
|
||||
'mime_type' => $content_type,
|
||||
'downloaded_at' => current_time('mysql'),
|
||||
),
|
||||
array('id' => $media->id)
|
||||
);
|
||||
|
||||
$this->logger->debug('Media fetched and cached', array(
|
||||
'listing_key' => $media->listing_key,
|
||||
'media_key' => $media->media_key,
|
||||
'status' => $status_code,
|
||||
'size' => strlen($body),
|
||||
));
|
||||
return null;
|
||||
|
||||
return $local_url;
|
||||
|
||||
} finally {
|
||||
// Always release the lock
|
||||
$wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lock_name));
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body($response);
|
||||
if (empty($body)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine extension
|
||||
$content_type = wp_remote_retrieve_header($response, 'content-type');
|
||||
$extension = $this->get_extension_from_content_type($content_type, $media->media_url);
|
||||
|
||||
// Create directory
|
||||
$listing_dir = $this->get_listing_dir($media->listing_key);
|
||||
if (!file_exists($listing_dir)) {
|
||||
wp_mkdir_p($listing_dir);
|
||||
}
|
||||
|
||||
// Save file
|
||||
$filename = $media->media_order . '.' . $extension;
|
||||
$file_path = $listing_dir . '/' . $filename;
|
||||
|
||||
if (file_put_contents($file_path, $body) === false) {
|
||||
$this->logger->error('Failed to write media file', array(
|
||||
'path' => $file_path,
|
||||
));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update database
|
||||
$prefix = substr($media->listing_key, 0, 2);
|
||||
$relative_path = $prefix . '/' . $media->listing_key . '/' . $filename;
|
||||
$local_url = $this->get_upload_url() . '/' . $relative_path;
|
||||
|
||||
$wpdb->update(
|
||||
$this->db->media_table(),
|
||||
array(
|
||||
'local_path' => $relative_path,
|
||||
'local_url' => $local_url,
|
||||
'file_size' => strlen($body),
|
||||
'mime_type' => $content_type,
|
||||
'downloaded_at' => current_time('mysql'),
|
||||
),
|
||||
array('id' => $media->id)
|
||||
);
|
||||
|
||||
$this->logger->debug('Media fetched and cached', array(
|
||||
'listing_key' => $media->listing_key,
|
||||
'media_key' => $media->media_key,
|
||||
'size' => strlen($body),
|
||||
));
|
||||
|
||||
return $local_url;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user