From 2127b1bdd73ecd2423e443992da4b442f5a3c1a3 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Wed, 04 Feb 2026 21:19:25 +0000
Subject: [PATCH] =Major overhaul of MetaManager.php -> Meta.php and RestRouteManager.php -> Rest.php. Seems to work for JakeVan
---
inc/rest/RestRouteManager.php | 881 ++++++++++++++++++++++++++++++++++++++++++++++++++--------
1 files changed, 757 insertions(+), 124 deletions(-)
diff --git a/inc/rest/RestRouteManager.php b/inc/rest/RestRouteManager.php
index 91f76f2..e3122e6 100644
--- a/inc/rest/RestRouteManager.php
+++ b/inc/rest/RestRouteManager.php
@@ -1,11 +1,14 @@
<?php
namespace JVBase\rest;
+use DateTime;
+use DateTimeZone;
use JVBase\JVB;
use JVBase\rest\RateLimiter;
use JVBase\managers\OperationQueue;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
use JVBase\managers\NotificationManager;
+use JVBase\utility\Features;
use WP_REST_Request;
use WP_Error;
use Exception;
@@ -16,6 +19,7 @@
}
/**
+ * @deprecated use Rest.php
* Handles route registration and high-level coordination
*/
abstract class RestRouteManager
@@ -27,16 +31,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 +54,7 @@
$this->base = BASE;
$this->rate_limiter = new RateLimiter();
if ($this->cache_name !== '') {
- $this->cache = new CacheManager($this->cache_name, $this->cache_ttl);
+ $this->cache = Cache::for($this->cache_name, $this->cache_ttl);
}
add_action('rest_api_init', [$this, 'registerRoutes']);
}
@@ -94,6 +98,7 @@
{
// Check rate limits first
if (!$this->rate_limiter->checkLimit($request)) {
+ error_log('Rate Limit Reached');
return new WP_Error(
'rate_limit_reached',
'Rate limit reached',
@@ -102,6 +107,7 @@
}
$user_id = $request->get_param('user');
if (!empty($user_id) && !$this->userCheck($user_id)) {
+ error_log('Usercheck failed');
return false;
}
// Verify nonces
@@ -112,6 +118,45 @@
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]??'';
@@ -180,133 +225,147 @@
}
}
- /**
- * @param int $userID The user ID to check
- *
- * @return bool whether user exists
- */
- protected function checkUser(int $userID):bool
- {
- $checked = $this->cache->get($userID, 'checked_users');
- if ($checked) {
- return $checked;
- }
- $test = (bool)get_userdata($userID);
+ /**
+ * Check if user exists (cached)
+ */
+ protected function checkUser(int $userID): bool
+ {
+ $cache = Cache::for('checkUser', DAY_IN_SECONDS)->connect('user');
- $this->cache->set($userID, $test, null, 'checked_users');
- return $test;
- }
+ return $cache->remember($userID, function() use ($userID) {
+ return (bool)get_userdata($userID);
+ });
+ }
- /**
- * @param int $shopID the shop ID to check
- *
- * @return bool whether the shop exists
- */
- protected function checkShop(int $shopID):bool
- {
- $checked = $this->cache->get($shopID, 'checked_shops');
- if ($checked) {
- return (bool)$checked;
- }
- $test = term_exists($shopID, BASE . 'shop');
- $this->cache->set($shopID, (int)$test, null, 'checked_shops');
- return $test;
- }
+ /**
+ * Check if shop exists (cached)
+ */
+ protected function checkShop(int $shopID): bool
+ {
+ $cache = Cache::for('checkShop',DAY_IN_SECONDS)->connect('taxonomy');
- protected function checkTerm(array $args) {
- $termID = $args['to_term']??$args['term_id']??false;
+ return $cache->remember($shopID, function() use ($shopID) {
+ return (bool)term_exists($shopID, BASE . 'shop');
+ });
+ }
+
+ /**
+ * Check if term exists (cached)
+ */
+ protected function checkTerm(array $args): bool
+ {
+ $termID = $args['to_term'] ?? $args['term_id'] ?? false;
if (!$termID) {
return false;
}
- $taxonomy = $args['taxonomy']??false;
+
+ $taxonomy = $args['taxonomy'] ?? false;
if (!$taxonomy) {
return false;
}
- $checked = $this->cache->get($termID, 'checked_'.$taxonomy);
- if ($checked) {
- return (bool) $checked;
- }
- $test = term_exists($termID, jvbCheckBase($taxonomy));
- $this->cache->set($termID, (int)$test, null, 'checked_'.$taxonomy);
- return (bool)$test;
+
+ $taxonomy = jvbCheckBase($taxonomy);
+ $cache = Cache::for('checkTerm', DAY_IN_SECONDS)->connect('taxonomy');
+
+ return $cache->remember($termID, function() use ($termID, $taxonomy) {
+ return (bool)term_exists($termID, $taxonomy);
+ });
}
- /**
- * Check if an artist is verified
- *
- * @param int $user_id User ID
- * @return bool True if verified
- */
- public function isVerifiedUser(int $user_id):bool
- {
- // Cache result to avoid repeated checks
- $cache_key = "verified_users";
- $verified = $this->cache->get($cache_key, 'users');
- $verified = ($verified) ?: [];
- if (array_key_exists($user_id, $verified)) {
- return (bool) $verified[$user_id];
- }
+ /**
+ * Check if an artist is verified
+ */
+ public function isVerifiedUser(int $user_id): bool
+ {
+ $cache = Cache::for('verifiedUsers', DAY_IN_SECONDS)->connect('user');
- // Check if user has the skip_moderation capability
- $is_verified = user_can($user_id, 'skip_moderation');
+ return $cache->remember($user_id, function() use ($user_id) {
+ return user_can($user_id, 'skip_moderation');
+ });
+ }
- $verified[$user_id] = $is_verified;
- // Cache for a day
- $this->cache->set($cache_key, $verified, DAY_IN_SECONDS, 'users');
-
- return $is_verified;
- }
-
- 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)
- )
- ];
- }
+ 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 (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);
+ }
+
+ 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();
$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':
@@ -317,8 +376,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';
@@ -327,6 +396,96 @@
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 = Features::getConfig($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['fields'] ?? [];
+ 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']];
+ foreach ($post_types as $type) {
+ if (Features::forContent($type)->has('is_timeline')) {
+ return true;
+ }
+ }
+ return false;
+ }
+
protected function applyDateFilters(array $args, array $data):array
{
if (!array_key_exists('date-filter', $data) && !array_key_exists('dateFrom', $data)) {
@@ -405,27 +564,501 @@
return $wpdb->get_var("SHOW TABLES LIKE '{$tableName}'") !== $tableName;
}
- protected function ifModifiedSince($lastModified, $params, $request):WP_REST_Response|null {
- $etag = '"' . md5(serialize($params)) . '"';
- // Check ETag
- $if_none_match = $request->get_header('If-None-Match');
- if ($if_none_match && $if_none_match === $etag) {
+ // ========== HTTP CACHING METHODS ==========
+
+ /**
+ * Check HTTP caching headers (ETag and If-Modified-Since)
+ * Returns 304 Not Modified if content hasn't changed
+ *
+ * @param WP_REST_Request $request The REST request
+ * @param string|array $content_types Content type(s) to check timestamps for
+ * @param array $additional_params Additional params for ETag uniqueness (e.g., user_id, filters)
+ * @return WP_REST_Response|null Returns 304 response if not modified, null to continue processing
+ */
+ protected function checkHeaders(
+ WP_REST_Request $request,
+ int|string|array $key,
+ string|array $group = ''
+ ): WP_REST_Response|false
+ {
+ $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);
+ }
+
+ // 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);
}
- $if_modified_since = $request->get_header('If-Modified-Since');
- if ($if_modified_since && $lastModified) {
- $if_modified_timestamp = strtotime($if_modified_since);
- if ($lastModified <= $if_modified_timestamp) {
- 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);
}
- header('ETag: ' . $etag); // Add this line
- if ($lastModified) {
- header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $lastModified) . ' GMT');
+ // Store headers for response phase
+ $this->response_headers = [
+ 'ETag' => $etag,
+ 'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified) . ' GMT',
+ ];
+
+ return false;
+ }
+
+ /**
+ * Create 304 Not Modified response with proper headers
+ *
+ * @param string $etag ETag value
+ * @param int $last_modified Last modified timestamp
+ * @return WP_REST_Response 304 response
+ */
+ private function createNotModifiedResponse(string $etag, int $last_modified): WP_REST_Response
+ {
+ $response = new WP_REST_Response(null, 304);
+ $response->set_headers($this->buildCacheHeaders($etag, $last_modified));
+ return $response;
+ }
+
+ /**
+ * Build cache headers array
+ *
+ * @param string $etag ETag value
+ * @param int $last_modified Last modified timestamp
+ * @return array Headers array
+ */
+ private function buildCacheHeaders(string $etag, int $last_modified): array
+ {
+ return [
+ 'ETag' => $etag,
+ 'Last-Modified' => gmdate('D, d M Y H:i:s', $last_modified) . ' GMT',
+ 'Cache-Control' => 'private, max-age=60, must-revalidate'
+ ];
+ }
+
+ /**
+ * Add stored cache headers to a response
+ * Call this on your final WP_REST_Response before returning
+ *
+ * @param WP_REST_Response $response The response to add headers to
+ * @return WP_REST_Response The response with headers added
+ */
+ protected function addCacheHeaders(WP_REST_Response $response): WP_REST_Response
+ {
+ if (!empty($this->response_headers)) {
+ foreach ($this->response_headers as $name => $value) {
+ $response->header($name, $value);
+ }
+ $this->response_headers = [];
}
- header('Cache-Control: private, max-age=30');
- return null;
+
+ return $response;
+ }
+
+ /**
+ * 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
+ * @return string Hashed fingerprint
+ */
+ 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'] ?? ''
+ )
+ ]));
+ }
+
+ /**
+ * 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));
+ }
+
+ /**
+ * 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 (!Features::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) {
+ return true;
+ }
+
+ if (empty($token)) {
+ return false;
+ }
+
+ return JVB()->connect('cloudflare')->verifyTurnstile($token);
}
}
--
Gitblit v1.10.0