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
@@ -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;
}
/**