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:
Hanson.xyz Dev
2025-12-16 10:43:04 -06:00
parent acd606bb03
commit 15449b9131
10 changed files with 1459 additions and 88 deletions
@@ -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;
}
/**