| | |
| | | <?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; |
| | |
| | | } |
| | | |
| | | /** |
| | | * @deprecated use Rest.php |
| | | * Handles route registration and high-level coordination |
| | | */ |
| | | abstract class RestRouteManager |
| | |
| | | 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'; |
| | |
| | | $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']); |
| | | } |
| | |
| | | { |
| | | // 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', |
| | |
| | | } |
| | | $user_id = $request->get_param('user'); |
| | | if (!empty($user_id) && !$this->userCheck($user_id)) { |
| | | error_log('Usercheck failed'); |
| | | return false; |
| | | } |
| | | // Verify nonces |
| | |
| | | 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); |
| | | } |
| | | |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * @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) |
| | | ) |
| | | ]; |
| | | } |
| | | /** |
| | | * @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::withFeature('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': |
| | |
| | | $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'; |
| | |
| | | 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::withFeature('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)) { |
| | |
| | | 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 (!Site::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) { |
| | | return true; |
| | | } |
| | | |
| | | if (empty($token)) { |
| | | return false; |
| | | } |
| | | |
| | | return JVB()->connect('cloudflare')->verifyTurnstile($token); |
| | | } |
| | | } |