From d7e7d248cbe41cd7a9ef9c2fb022b6c4831f99a3 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Sun, 31 May 2026 15:22:56 +0000
Subject: [PATCH] =jakevan complete

---
 inc/rest/RestRouteManager.php |  876 ++++++++++++++++++++++++++++++++++++++++++++--------------
 1 files changed, 665 insertions(+), 211 deletions(-)

diff --git a/inc/rest/RestRouteManager.php b/inc/rest/RestRouteManager.php
index 8f1b1ba..ff72f13 100644
--- a/inc/rest/RestRouteManager.php
+++ b/inc/rest/RestRouteManager.php
@@ -1,11 +1,13 @@
 <?php
 namespace JVBase\rest;
 
-use JVBase\JVB;
-use JVBase\rest\RateLimiter;
+use DateTime;
+use DateTimeZone;
+use JVBase\registrar\Registrar;
 use JVBase\managers\OperationQueue;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\managers\NotificationManager;
+use JVBase\base\Site;
 use WP_REST_Request;
 use WP_Error;
 use Exception;
@@ -16,6 +18,7 @@
 }
 
 /**
+ * @deprecated use Rest.php
  * Handles route registration and high-level coordination
  */
 abstract class RestRouteManager
@@ -27,16 +30,16 @@
     protected string $route;
     protected string $base;
     protected string $content_type; //the registered post type
-    protected string $type; //post, user, term, for MetaManager
+    protected string $type; //post, user, term, for Meta
     protected string $action = ''; //optional additional nonce to check
     protected array $callback; //route->callback array
     protected string $operation_type; // from QueueManager.js and OperationQueue.php
     protected OperationQueue $queue;
-    protected CacheManager $cache;
+    protected Cache $cache;
     protected NotificationManager $notifications;
     protected string $cache_name ='';
     protected int $cache_ttl = 3600; //1 hour default
-
+	protected array $response_headers = [];
 
     // Error code constants for consistency
     const ERROR_MISSING_PARAMS = 'missing_parameters';
@@ -50,7 +53,7 @@
         $this->base = BASE;
         $this->rate_limiter = new RateLimiter();
         if ($this->cache_name !== '') {
-            $this->cache = CacheManager::for($this->cache_name, $this->cache_ttl);
+            $this->cache = Cache::for($this->cache_name, $this->cache_ttl);
         }
         add_action('rest_api_init', [$this, 'registerRoutes']);
     }
@@ -114,13 +117,48 @@
         return true;
     }
 
+	public function checkRateLimit(WP_REST_Request $request):bool|WP_Error
+	{
+		if (!$this->rate_limiter->checkLimit($request)) {
+			error_log('Rate Limit Reached');
+			return new WP_Error(
+				'rate_limit',
+				'Too many attempts. Please wait a moment before trying again.',
+				['status' => 429]
+			);
+		}
+		return true;
+	}
+
+	/**
+	 * Convert MySQL datetime to ISO 8601 timestamp with proper timezone
+	 */
+	public function formatTimestamp(?string $mysql_datetime): ?string
+	{
+		if (empty($mysql_datetime)) {
+			return null;
+		}
+
+		try {
+			// Get WordPress timezone - dates are stored in this timezone
+			$wp_timezone = wp_timezone();
+
+			// Parse the datetime in WordPress timezone
+			$date = new DateTime($mysql_datetime, $wp_timezone);
+
+			// Convert to UTC for API consistency
+			$date->setTimezone(new DateTimeZone('UTC'));
+
+			// Return ISO 8601 format
+			return $date->format('c');
+		} catch (Exception $e) {
+			return null;
+		}
+	}
+
 	protected function checkContent(string $content, bool $bool = false):string|bool
 	{
-		$result = JVB_CONTENT[$content]??JVB_TAXONOMY[$content]??JVB_USER[$content]??'';
-		if ($bool) {
-			return $result !== '';
-		}
-		return $result;
+		return (bool)Registrar::getInstance($content);
 	}
 
 
@@ -187,11 +225,11 @@
 	 */
 	protected function checkUser(int $userID): bool
 	{
-		$cache = CacheManager::for('users');
+		$cache = Cache::for('checkUser', DAY_IN_SECONDS)->connect('user');
 
-		return $cache->remember("user_exists_{$userID}", function() use ($userID) {
+		return $cache->remember($userID, function() use ($userID) {
 			return (bool)get_userdata($userID);
-		}, DAY_IN_SECONDS);
+		});
 	}
 
 	/**
@@ -199,11 +237,11 @@
 	 */
 	protected function checkShop(int $shopID): bool
 	{
-		$cache = CacheManager::for('shop');
+		$cache = Cache::for('checkShop',DAY_IN_SECONDS)->connect('taxonomy');
 
-		return $cache->remember("shop_exists_{$shopID}", function() use ($shopID) {
+		return $cache->remember($shopID, function() use ($shopID) {
 			return (bool)term_exists($shopID, BASE . 'shop');
-		}, DAY_IN_SECONDS);
+		});
 	}
 
 	/**
@@ -222,11 +260,11 @@
 		}
 
 		$taxonomy = jvbCheckBase($taxonomy);
-		$cache = CacheManager::for($taxonomy);
+		$cache = Cache::for('checkTerm', DAY_IN_SECONDS)->connect('taxonomy');
 
-		return $cache->remember("term_exists_{$termID}", function() use ($termID, $taxonomy) {
+		return $cache->remember($termID, function() use ($termID, $taxonomy) {
 			return (bool)term_exists($termID, $taxonomy);
-		}, DAY_IN_SECONDS);
+		});
 	}
 
 	/**
@@ -234,63 +272,101 @@
 	 */
 	public function isVerifiedUser(int $user_id): bool
 	{
-		$cache = CacheManager::forUser($user_id);
+		$cache = Cache::for('verifiedUsers', DAY_IN_SECONDS)->connect('user');
 
-		return $cache->remember('is_verified', function() use ($user_id) {
+		return $cache->remember($user_id, function() use ($user_id) {
 			return user_can($user_id, 'skip_moderation');
-		}, DAY_IN_SECONDS);
+		});
 	}
 
-    protected function applyTaxonomyFilters(array $args, array $data):array
-    {
-        $taxQuery = [];
-		foreach($data['taxonomies']??[] as $taxonomy => $terms) {
-			if (array_key_exists(jvbNoBase($taxonomy), JVB_TAXONOMY)) {
-				$taxQuery[] = [
-					'taxonomy'	=> jvbCheckBase($taxonomy),
-					'terms'		=> array_map(
-						'absint',
-						is_array($terms) ? $terms : explode(',', $terms)
-					)
-				];
-			}
+	/**
+	 * @deprecated
+	 * @param array $args
+	 * @param array $data
+	 * @return array
+	 */
+	protected function applyTaxonomyFilters(array $args, array $data):array
+	{
+		// Handle JSON-encoded taxonomy data
+		if (array_key_exists('taxonomy', $data) && is_string($data['taxonomy'])) {
+			$data['taxonomy'] = json_decode($data['taxonomy'], true);
 		}
 
-        if (!empty($taxQuery)) {
-            $args['tax_query'] = array_merge([
-                'relation'  => (array_key_exists('match', $data)) ? 'AND' : 'OR',
-            ], $taxQuery);
-        }
+		$taxonomies = $data['taxonomies'] ?? $data['taxonomy'] ?? [];
+		$taxQuery = [];
 
-        $authorQuery = [];
-        foreach (jvbAuthorUsers() as $type) {
-            if (array_key_exists($type, $data)) {
-                $artist_ids = array_map(
-                    'absint',
-                    is_array($data[$type]) ?
-                        $data[$type] :
-                        explode(',', $data[$type])
-                );
-                $authorQuery = array_merge($authorQuery, $artist_ids);
-            }
-        }
-        if (!empty($authorQuery)) {
-            $args['author__in'] = array_unique($authorQuery);
-        }
+		foreach($taxonomies as $taxonomy => $terms) {
+			// Better validation: check if taxonomy actually exists
+			if (!taxonomy_exists(jvbCheckBase($taxonomy))) {
+				continue;
+			}
 
-        return $args;
-    }
+			$taxQuery[] = [
+				'taxonomy'	=> jvbCheckBase($taxonomy),
+				'field'     => 'term_id',
+				'terms'		=> array_map(
+					'absint',
+					is_array($terms) ? $terms : explode(',', $terms)
+				),
+				'operator'  => 'IN'
+			];
+		}
+
+		if (!empty($taxQuery)) {
+			// Match 'all' = AND, anything else = OR
+			$relation = ($data['match'] ?? 'all') === 'all' ? 'AND' : 'OR';
+
+			$args['tax_query'] = array_merge([
+				'relation'  => $relation,
+			], $taxQuery);
+		}
+
+		// Keep existing author filtering logic
+		$authorQuery = [];
+		foreach (Registrar::getFeatured('can_create', 'user') as $type) {
+			if (array_key_exists($type, $data)) {
+				$artist_ids = array_map(
+					'absint',
+					is_array($data[$type]) ?
+						$data[$type] :
+						explode(',', $data[$type])
+				);
+				$authorQuery = array_merge($authorQuery, $artist_ids);
+			}
+		}
+		if (!empty($authorQuery)) {
+			$args['author__in'] = array_unique($authorQuery);
+		}
+
+		return $args;
+	}
 
     protected function applyOrderFilters(array $args, array $data):array
     {
+		// Check for custom order first
+		$customArgs = $this->applyCustomOrder($args, $data);
+		if ($customArgs !== null) {
+			$order = (array_key_exists('order', $data)) ? strtoupper($data['order']) : 'DESC';
+			$customArgs['order'] = (in_array($order, ['ASC', 'DESC'])) ? $order : 'DESC';
+			return $customArgs;
+		}
+
+		//Handle random
         if (array_key_exists('orderby', $data) && $data['orderby'] === 'random') {
-            $current_seed = jvbGetRandomSeed();
+            $current_seed = floor(time() / 1800);
             $args['orderby'] = 'RAND(' . $current_seed . ')';
             unset($args['order']);
             return $args;
         }
-        if (in_array($data['orderby'], ['date', 'title', 'alphabetical'])) {
-            $args['orderby'] = ($data['orderby'] === 'alphabetical') ? 'title' : $data['orderby'];
+
+        if (in_array($data['orderby'], ['date', 'modified', 'title', 'alphabetical'])) {
+			if ($data['orderby'] === 'date' && $this->isTimeline($args, $data)) {
+				$args['meta_key'] = BASE . 'latest_date';
+				$args['orderby'] = 'meta_value_num';
+			} else {
+				$args['orderby'] = ($data['orderby'] === 'alphabetical') ? 'title' : $data['orderby'];
+			}
+
         } else {
             switch ($data['orderby']) {
                 case 'popularity':
@@ -301,8 +377,18 @@
                     $args['meta_key'] = BASE.'karma';
                     $args['orderby'] = 'meta_value_num';
                     break;
-                default:
-                    $args['orderby'] = 'date';
+				case 'unpopularity':
+					$args['meta_key'] = BASE.'downvotes';
+					$args['orderby'] = 'meta_value_num';
+					break;
+				case 'favourites':
+					$args['meta_key'] = BASE.'total_favourites';
+					$args['orderby'] = 'meta_value_num';
+					break;
+				case 'date':
+				default:
+					$args['orderby'] = 'date';
+					break;
             }
         }
 		$order = (array_key_exists('order', $data)) ? strtoupper($data['order']) : 'DESC';
@@ -311,6 +397,92 @@
         return $args;
     }
 
+	/**
+	 * Apply custom order if defined in content/taxonomy/user config
+	 *
+	 * @param array $args WP_Query args
+	 * @param array $data Request data
+	 * @return array|null Modified args if custom order found, null otherwise
+	 */
+	protected function applyCustomOrder(array $args, array $data): ?array
+	{
+		$orderby = $data['orderby'] ?? '';
+
+		// Skip if no orderby or it's a standard order
+		if (empty($orderby) || in_array($orderby, ['date', 'modified', 'title', 'alphabetical', 'random', 'popularity', 'karma', 'unpopularity', 'favourites'])) {
+			return null;
+		}
+
+		// Determine content type
+		$post_type = is_array($args['post_type']) ? $args['post_type'][0] : $args['post_type'];
+		$content = jvbNoBase($post_type);
+
+		// Get config for this content type
+		$config = Registrar::getInstance($content);
+		if (!$config) {
+			return null;
+		}
+
+		// Check if this orderby is a custom order
+		$customOrders = $config->custom_order ?? [];
+		if (empty($customOrders) || !isset($customOrders[$orderby])) {
+			return null;
+		}
+
+		// Get field definition
+		$fields = $config->getFields()??[];
+		if (!isset($fields[$orderby])) {
+			return null;
+		}
+
+		$field = $fields[$orderby];
+
+		// Set meta_key
+		$args['meta_key'] = BASE . $orderby;
+
+		// Determine orderby and meta_type based on field type
+		$fieldType = $field['type'] ?? 'text';
+		$subtype = $field['subtype'] ?? '';
+
+		switch ($fieldType) {
+			case 'number':
+				$args['orderby'] = 'meta_value_num';
+				break;
+
+			case 'text':
+				$args['orderby'] = ($subtype === 'number') ? 'meta_value_num' : 'meta_value';
+				break;
+
+			case 'date':
+				$args['orderby'] = 'meta_value';
+				$args['meta_type'] = 'DATE';
+				break;
+
+			case 'datetime':
+				$args['orderby'] = 'meta_value';
+				$args['meta_type'] = 'DATETIME';
+				break;
+
+			case 'true_false':
+			case 'checkbox':
+				$args['orderby'] = 'meta_value';
+				$args['meta_type'] = 'BINARY';
+				break;
+
+			default:
+				$args['orderby'] = 'meta_value';
+		}
+
+		return $args;
+	}
+
+	protected function isTimeline($args, $data):bool
+	{
+		$post_types = is_array($args['post_type']) ? $args['post_type'] : [$args['post_type']];
+		$areTimeline = array_map(function($type) { return BASE.$type; },Registrar::getFeatured('is_timeline', 'post'));
+		return !empty(array_intersect($post_types, $areTimeline));
+	}
+
     protected function applyDateFilters(array $args, array $data):array
     {
         if (!array_key_exists('date-filter', $data) && !array_key_exists('dateFrom', $data)) {
@@ -402,55 +574,52 @@
 	 */
 	protected function checkHeaders(
 		WP_REST_Request $request,
-		string|array $content_types,
-		array $additional_params = []
-	): WP_REST_Response|null {
-
-		// Get latest timestamp for the content type(s)
-		$last_modified = CacheManager::getTimestamp($content_types);
-
-		// Generate ETag from request params + timestamp
-		$etag = $this->generateETag($request->get_params(), $additional_params, $last_modified);
-
-		// Check If-None-Match (ETag) header
-		$if_none_match = $request->get_header('If-None-Match');
-		if ($if_none_match === $etag) {
-			return $this->createNotModifiedResponse($etag, $last_modified);
-		}
-
-		// Check If-Modified-Since header
-		$if_modified_since = $request->get_header('If-Modified-Since');
-		if ($if_modified_since) {
-			$if_modified_timestamp = strtotime($if_modified_since);
-			if ($last_modified <= $if_modified_timestamp) {
-				return $this->createNotModifiedResponse($etag, $last_modified);
-			}
-		}
-
-		// Content has changed - store headers to add to successful response
-		$this->response_headers = $this->buildCacheHeaders($etag, $last_modified);
-
-		return null; // Continue processing
-	}
-
-	/**
-	 * Generate ETag from request parameters and timestamp
-	 *
-	 * @param array $params Request parameters
-	 * @param array $additional Additional parameters for uniqueness
-	 * @param int $timestamp Last modified timestamp
-	 * @return string ETag value with quotes
-	 */
-	private function generateETag(array $params, array $additional, int $timestamp): string
+		int|string|array $key,
+		string|array $group = ''
+	): WP_REST_Response|false
 	{
-		// Combine all data that makes this response unique
-		$etag_data = array_merge(
-			$params,
-			$additional,
-			['t' => $timestamp]
-		);
+		$group = ($group!=='') ? $group : $this->cache_name;
+		$cache = $this->cache_name !== $group ? Cache::for($group) : $this->cache;
+		if (!$cache) {
+			return false;
+		}
+		if (is_array($key)) {
+			$key = $cache->generateKey($key);
+		}
 
-		return '"' . md5(serialize($etag_data)) . '"';
+		// Prefer tag freshness if available
+		$tags = $cache->getTags();
+
+		$lastModified = $tags
+			? $cache->getLastModifiedForTags($tags)
+			: $cache::lastModified($group);
+
+		if (!$lastModified) {
+			return false;
+		}
+
+
+		$etag = '"' . sha1($group . ':' . $key . ':' . $lastModified) . '"';
+
+		// ETag check
+		$ifNoneMatch = $request->get_header('if-none-match');
+		if ($ifNoneMatch && trim($ifNoneMatch) === $etag) {
+			return new WP_REST_Response(null, 304);
+		}
+
+		// Last-Modified check
+		$ifModifiedSince = $request->get_header('if-modified-since');
+		if ($ifModifiedSince && strtotime($ifModifiedSince) >= $lastModified) {
+			return new WP_REST_Response(null, 304);
+		}
+
+		// Store headers for response phase
+		$this->response_headers = [
+			'ETag' => $etag,
+			'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified) . ' GMT',
+		];
+
+		return false;
 	}
 
 	/**
@@ -493,115 +662,400 @@
 	protected function addCacheHeaders(WP_REST_Response $response): WP_REST_Response
 	{
 		if (!empty($this->response_headers)) {
-			$response->set_headers($this->response_headers);
-			$this->response_headers = []; // Clear after use
+			foreach ($this->response_headers as $name => $value) {
+				$response->header($name, $value);
+			}
+			$this->response_headers = [];
 		}
+
 		return $response;
 	}
 
 	/**
-	 * Helper: Check headers for user-specific endpoints
-	 * Automatically includes user_id in ETag
+	 * Helper to return error response
+	 */
+	protected function error(string $message, string $code, int $status = 400, ?string $field = null): WP_REST_Response
+	{
+		$data = [
+			'success'	=> false,
+			'message' => $message,
+			'code' => $code
+		];
+
+		if ($field) {
+			$data['field'] = $field;
+		}
+
+		return new WP_REST_Response($data, $status);
+	}
+	/**
+	 * Helper to return success response
+	 */
+	protected function success(array $data, int $status = 200): WP_REST_Response
+	{
+		$data['success'] = true;
+		return new WP_REST_Response($data, $status);
+	}
+
+	/************************************************************
+		SESSION FINGERPRINT
+	 ************************************************************/
+	/**
+	 * Store session fingerprint for hijacking detection
+	 */
+	protected function storeSessionFingerprint(int $user_id, WP_REST_Request $request): void
+	{
+		if (!defined('JVB_SESSION_FINGERPRINT') || !JVB_SESSION_FINGERPRINT) {
+			return;
+		}
+
+		$fingerprint = $this->generateSessionFingerprint($request);
+		update_user_meta($user_id, BASE . 'session_fingerprint', $fingerprint);
+		update_user_meta($user_id, BASE . 'session_timestamp', time());
+	}
+	/**
+	 * Generate session fingerprint for hijacking detection
 	 *
 	 * @param WP_REST_Request $request The REST request
-	 * @param int $user_id User ID
-	 * @param string|array $content_types Content type(s)
-	 * @return WP_REST_Response|null
+	 * @return string Hashed fingerprint
 	 */
-	protected function checkUserHeaders(
-		WP_REST_Request $request,
-		int $user_id,
-		string|array $content_types = 'user'
-	): WP_REST_Response|null {
+	protected function generateSessionFingerprint(WP_REST_Request $request): string
+	{
+		return hash('sha256', implode('|', [
+			$request->get_header('User-Agent') ?? '',
+			// Use IP class instead of full IP to allow for mobile network changes
+			$this->getIPClass(
+				$request->get_header('X-Forwarded-For')
+					?: $request->get_header('X-Real-IP')
+					?: $_SERVER['REMOTE_ADDR'] ?? ''
+			)
+		]));
+	}
 
-		// Include user-specific timestamp
-		$types = is_array($content_types) ? $content_types : [$content_types];
-		$types[] = "user_{$user_id}";
+	/**
+	 * Get IP class (first 3 octets) for session validation
+	 * This allows for minor IP changes (common with mobile networks)
+	 *
+	 * @param string $ip IP address
+	 * @return string First 3 octets
+	 */
+	protected function getIPClass(string $ip): string
+	{
+		$parts = explode('.', $ip);
+		return implode('.', array_slice($parts, 0, 3));
+	}
 
-		return $this->checkHeaders($request, $types, ['user_id' => $user_id]);
+	/**
+	 * Validate session fingerprint against stored value
+	 *
+	 * @param int $user_id User ID to validate
+	 * @param WP_REST_Request $request Current request
+	 * @return bool True if valid, false if potential hijacking
+	 */
+	protected function validateSessionFingerprint(int $user_id, WP_REST_Request $request): bool
+	{
+		// Only enforce if enabled in config
+		if (!defined('JVB_SESSION_FINGERPRINT') || !JVB_SESSION_FINGERPRINT) {
+			return true;
+		}
+
+		$stored = get_user_meta($user_id, BASE . 'session_fingerprint', true);
+		$current = $this->generateSessionFingerprint($request);
+
+		if (empty($stored)) {
+			// First request - store fingerprint
+			update_user_meta($user_id, BASE . 'session_fingerprint', $current);
+			update_user_meta($user_id, BASE . 'session_timestamp', time());
+			return true;
+		}
+
+		// Compare using timing-safe comparison
+		return hash_equals($stored, $current);
+	}
+
+	/**
+	 * Clear session fingerprint (call on logout)
+	 *
+	 * @param int $user_id User ID
+	 * @return void
+	 */
+	protected function clearSessionFingerprint(int $user_id): void
+	{
+		delete_user_meta($user_id, BASE . 'session_fingerprint');
+		delete_user_meta($user_id, BASE . 'session_timestamp');
+	}
+
+	/******************************************************************
+	 *  CSRF PROTECTION
+	******************************************************************/
+	/**
+	 * Generate CSRF token for a user
+	 *
+	 * @param int $user_id User ID
+	 * @return string CSRF token
+	 */
+	protected function generateCSRFToken(int $user_id): string
+	{
+		$token = wp_generate_password(32, false);
+		set_transient(BASE . 'csrf_' . $user_id, $token, HOUR_IN_SECONDS);
+		return $token;
+	}
+
+	/**
+	 * Validate CSRF token from request header
+	 *
+	 * @param WP_REST_Request $request The REST request
+	 * @return bool True if valid or not required
+	 */
+	protected function validateCSRFToken(WP_REST_Request $request): bool
+	{
+		// Only for authenticated requests
+		if (!is_user_logged_in()) {
+			return true;
+		}
+
+		// Only for state-changing operations
+		if (in_array($request->get_method(), ['GET', 'HEAD', 'OPTIONS'])) {
+			return true;
+		}
+
+		$user_id = get_current_user_id();
+		$token = $request->get_header('X-CSRF-Token');
+		$stored = get_transient(BASE . 'csrf_' . $user_id);
+
+		if (empty($stored) || empty($token)) {
+			return false;
+		}
+
+		return hash_equals($stored, $token);
+	}
+
+	/**
+	 * Get current CSRF token for user (for frontend to use)
+	 *
+	 * @param int $user_id User ID
+	 * @return string|null CSRF token or null
+	 */
+	protected function getCSRFToken(int $user_id): ?string
+	{
+		$token = get_transient(BASE . 'csrf_' . $user_id);
+
+		if (!$token) {
+			$token = $this->generateCSRFToken($user_id);
+		}
+
+		return $token;
+	}
+
+	/***********************************************************
+	 * REQUEST SIGNATURE VERIFICATION
+	***********************************************************/
+	/**
+	 * Verify request signature for sensitive operations
+	 * Adds an additional layer of security for critical endpoints
+	 *
+	 * @param WP_REST_Request $request The REST request
+	 * @param array $sensitive_params Params to include in signature
+	 * @return bool True if valid signature
+	 */
+	protected function verifyRequestSignature(WP_REST_Request $request, array $sensitive_params): bool
+	{
+		$signature = $request->get_header('X-Request-Signature');
+		if (empty($signature)) {
+			return false;
+		}
+
+		// Get user's signing key (rotated on each login)
+		$user_id = get_current_user_id();
+		if (!$user_id) {
+			return false;
+		}
+
+		$signing_key = get_user_meta($user_id, BASE . 'signing_key', true);
+		if (empty($signing_key)) {
+			// Generate new key if missing
+			$signing_key = wp_generate_password(64, false);
+			update_user_meta($user_id, BASE . 'signing_key', $signing_key);
+		}
+
+		// Build signature from sensitive params
+		ksort($sensitive_params);
+		$message = json_encode($sensitive_params);
+		$expected = hash_hmac('sha256', $message, $signing_key);
+
+		return hash_equals($expected, $signature);
+	}
+
+	/**
+	 * Rotate signing key (call on login)
+	 *
+	 * @param int $user_id User ID
+	 * @return string New signing key
+	 */
+	protected function rotateSigningKey(int $user_id): string
+	{
+		$new_key = wp_generate_password(64, false);
+		update_user_meta($user_id, BASE . 'signing_key', $new_key);
+		return $new_key;
+	}
+	/**********************************************************
+	 * AUDIT LOGGING
+	**********************************************************/
+	/**
+	 * Log security-relevant events
+	 *
+	 * @param string $event Event name
+	 * @param array $data Additional event data
+	 * @return void
+	 */
+	protected function auditLog(string $event, array $data = []): void
+	{
+		// Add standard context
+		$context = array_merge($data, [
+			'timestamp' => current_time('mysql'),
+			'user_id' => get_current_user_id() ?: 0,
+			'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
+			'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
+		]);
+
+		// Use existing error handler
+		try {
+			JVB()->error()->log(
+				'security_audit',
+				$event,
+				$context,
+				'info'
+			);
+		} catch (\Exception $e) {
+			// Fallback to error_log if handler fails
+			error_log("Security Audit: {$event} - " . json_encode($context));
+		}
+	}
+
+	/***************************************************************
+	 * SANITIZATION HELPERS
+	***************************************************************/
+	/**
+	 * Sanitize array of IDs
+	 *
+	 * @param array $ids Array of IDs
+	 * @return array Sanitized array of integers
+	 */
+	protected function sanitizeIDs(array $ids): array
+	{
+		return array_map('absint', array_filter($ids, function($id) {
+			return is_numeric($id) && $id > 0;
+		}));
+	}
+
+	/***************************************************************
+	 * RESPONSE HELPERS
+	***************************************************************/
+	/**
+	 * Return validation error response
+	 *
+	 * @param array $errors Validation errors (field => message)
+	 * @return WP_REST_Response 422 response with errors
+	 */
+	protected function validationError(array $errors): WP_REST_Response
+	{
+		return new WP_REST_Response([
+			'success' => false,
+			'message' => 'Validation failed',
+			'errors' => $errors
+		], 422);
+	}
+
+	/**
+	 * Return unauthorized error response
+	 *
+	 * @param string $message Error message
+	 * @return WP_REST_Response 401 response
+	 */
+	protected function unauthorized(string $message = 'Unauthorized'): WP_REST_Response
+	{
+		return new WP_REST_Response([
+			'success' => false,
+			'message' => $message
+		], 401);
+	}
+
+	/**
+	 * Return forbidden error response
+	 *
+	 * @param string $message Error message
+	 * @return WP_REST_Response 403 response
+	 */
+	protected function forbidden(string $message = 'Forbidden'): WP_REST_Response
+	{
+		return new WP_REST_Response([
+			'success' => false,
+			'message' => $message
+		], 403);
+	}
+
+	/**
+	 * Return not found error response
+	 *
+	 * @param string $message Error message
+	 * @return WP_REST_Response 404 response
+	 */
+	protected function notFound(string $message = 'Not found'): WP_REST_Response
+	{
+		return new WP_REST_Response([
+			'success' => false,
+			'message' => $message
+		], 404);
+	}
+
+	/*****************************************************************
+	 * CONCURRENT CHECKS
+	*****************************************************************/
+	/**
+	 * Prevent concurrent requests for the same operation
+	 * Useful for preventing double-submissions
+	 *
+	 * @param string $operation_key Unique key for this operation
+	 * @param int $lock_duration Lock duration in seconds
+	 * @return bool True if lock acquired, false if already locked
+	 */
+	protected function acquireOperationLock(string $operation_key, int $lock_duration = 5): bool
+	{
+		$lock_key = 'op_lock_' . md5($operation_key);
+
+		// Try to acquire lock
+		$locked = get_transient($lock_key);
+		if ($locked) {
+			return false; // Already locked
+		}
+
+		// Set lock
+		set_transient($lock_key, true, $lock_duration);
+		return true;
+	}
+
+	/**
+	 * Release operation lock
+	 *
+	 * @param string $operation_key Unique key for this operation
+	 * @return void
+	 */
+	protected function releaseOperationLock(string $operation_key): void
+	{
+		$lock_key = 'op_lock_' . md5($operation_key);
+		delete_transient($lock_key);
+	}
+
+	protected function verifyTurnstile(string $token): bool
+	{
+		if (!Site::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) {
+			return true;
+		}
+
+		if (empty($token)) {
+			return false;
+		}
+
+		return JVB()->connect('cloudflare')->verifyTurnstile($token);
 	}
 }
-//
-//Simple example:
-//public function getTattoos(WP_REST_Request $request): WP_REST_Response
-//{
-//	// Check HTTP cache headers first
-//	$cache_check = $this->checkHeaders($request, 'tattoo');
-//	if ($cache_check) {
-//		return $cache_check; // Returns 304 Not Modified
-//	}
-//
-//	// Get data (use CacheManager for data caching too!)
-//	$filters = $request->get_params();
-//	$cache = CacheManager::for('tattoo');
-//
-//	$tattoos = $cache->remember($filters, function() use ($filters) {
-//		return $this->queryTattoos($filters);
-//	}, 300);
-//
-//	$response = new WP_REST_Response(['items' => $tattoos]);
-//	return $this->addCacheHeaders($response); // Add ETag and Last-Modified
-//}
-//
-//Multiple Content Types:
-//public function getTermsWithContent(WP_REST_Request $request): WP_REST_Response
-//{
-//	$taxonomy = $request->get_param('taxonomy');
-//
-//	// Check both taxonomy and its content types
-//	$cache_check = $this->checkHeaders($request, [$taxonomy, 'tattoo', 'artwork']);
-//	if ($cache_check) {
-//		return $cache_check;
-//	}
-//
-//	// ... fetch data ...
-//
-//	$response = new WP_REST_Response($data);
-//	return $this->addCacheHeaders($response);
-//}
-//
-//User-specific:
-//public function getUserFavorites(WP_REST_Request $request): WP_REST_Response
-//{
-//	$user_id = $request->get_param('user');
-//
-//	// Automatically checks user_{$user_id} timestamp + includes user_id in ETag
-//	$cache_check = $this->checkUserHeaders($request, $user_id);
-//	if ($cache_check) {
-//		return $cache_check;
-//	}
-//
-//	// Get user's favorites (cached per user)
-//	$favorites = CacheManager::forUser($user_id)->remember('favorites', function() use ($user_id) {
-//		return $this->getUserFavorites($user_id);
-//	}, 1800);
-//
-//	$response = new WP_REST_Response(['items' => $favorites]);
-//	return $this->addCacheHeaders($response);
-//}
-//
-//Complex with additional params:
-//public function getFilteredContent(WP_REST_Request $request): WP_REST_Response
-//{
-//	$user_id = get_current_user_id();
-//	$filters = $request->get_params();
-//
-//	// Include custom params in ETag for uniqueness
-//	$cache_check = $this->checkHeaders(
-//		$request,
-//		'tattoo',
-//		[
-//			'user_id' => $user_id,
-//			'is_verified' => $this->isVerifiedUser($user_id)
-//		]
-//	);
-//
-//	if ($cache_check) {
-//		return $cache_check;
-//	}
-//
-//	// ... fetch filtered data ...
-//
-//	$response = new WP_REST_Response($data);
-//	return $this->addCacheHeaders($response);
-//}

--
Gitblit v1.10.0