| | |
| | | <?php |
| | | namespace JVBase\rest; |
| | | |
| | | use JVBase\managers\Cache; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\registrar\Registrar; |
| | | use JVBase\base\Site; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | | use Exception; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Base REST Route Manager |
| | | * |
| | | * Provides shared utilities for route handlers. Route registration |
| | | * should use the Route builder class. |
| | | * |
| | | * Responsibilities: |
| | | * - Cache management (headers, invalidation) |
| | | * - Query building helpers |
| | | * - Validation utilities |
| | | * - Audit logging |
| | | */ |
| | | abstract class Rest |
| | | { |
| | | protected string $namespace = 'jvb/v1'; |
| | | protected ?Cache $cache = null; |
| | | protected string $cacheName = ''; |
| | | protected int $cacheTtl = 3600; |
| | | protected static ?string $action; |
| | | |
| | | public function __construct() |
| | | { |
| | | if ($this->cacheName !== '') { |
| | | $this->cache = Cache::for($this->cacheName, $this->cacheTtl); |
| | | } |
| | | |
| | | add_action('rest_api_init', [$this, 'registerRoutes']); |
| | | } |
| | | |
| | | /** |
| | | * Register routes - implement using Route builder |
| | | */ |
| | | abstract public function registerRoutes(): void; |
| | | |
| | | // ========================================================================= |
| | | // RESPONSE HELPERS |
| | | // ========================================================================= |
| | | |
| | | protected function success(array $data = [], int $status = 200): WP_REST_Response |
| | | { |
| | | return Response::success($data, $status); |
| | | } |
| | | |
| | | protected function error(string $message, string $code = 'error', int $status = 400, ?string $field = null): WP_REST_Response |
| | | { |
| | | return Response::error($message, $code, $status, $field); |
| | | } |
| | | |
| | | protected function validationError(array $errors): WP_REST_Response |
| | | { |
| | | return Response::validationError($errors); |
| | | } |
| | | |
| | | protected function notFound(string $message = 'Not found'): WP_REST_Response |
| | | { |
| | | return Response::notFound($message); |
| | | } |
| | | |
| | | protected function forbidden(string $message = 'Forbidden'): WP_REST_Response |
| | | { |
| | | return Response::forbidden($message); |
| | | } |
| | | |
| | | protected function unauthorized(string $message = 'Unauthorized'): WP_REST_Response |
| | | { |
| | | return Response::unauthorized($message); |
| | | } |
| | | |
| | | protected function queued(string $operationId, string $message = 'Queued for processing'): WP_REST_Response |
| | | { |
| | | return Response::queued($operationId, $message); |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // CACHE MANAGEMENT |
| | | // ========================================================================= |
| | | |
| | | protected function checkCache(string $key, $request):WP_REST_Response|false |
| | | { |
| | | // Check HTTP cache headers with the specific content type |
| | | $cache_check = $this->checkHeaders($request, $key); |
| | | if ($cache_check) { |
| | | return $cache_check; |
| | | } |
| | | |
| | | $cache = $this->cache->get($key); |
| | | if ($cache) { |
| | | $response = Response::success($cache); |
| | | return $this->addCacheHeaders($response); |
| | | } |
| | | return false; |
| | | } |
| | | /** |
| | | * Check request headers for conditional caching (ETag, If-Modified-Since) |
| | | */ |
| | | protected function checkHeaders(WP_REST_Request $request, string $cacheKey): ?WP_REST_Response |
| | | { |
| | | if (!$this->cache) { |
| | | return null; |
| | | } |
| | | |
| | | $cached = $this->cache->get($cacheKey); |
| | | if (!$cached) { |
| | | return null; |
| | | } |
| | | |
| | | $etag = $request->get_header('If-None-Match'); |
| | | $cachedEtag = $cached['etag'] ?? null; |
| | | |
| | | if ($etag && $cachedEtag && $etag === $cachedEtag) { |
| | | return new WP_REST_Response(null, 304); |
| | | } |
| | | |
| | | $ifModifiedSince = $request->get_header('If-Modified-Since'); |
| | | $lastModified = $cached['last_modified'] ?? null; |
| | | |
| | | if ($ifModifiedSince && $lastModified) { |
| | | if (strtotime($ifModifiedSince) >= strtotime($lastModified)) { |
| | | return new WP_REST_Response(null, 304); |
| | | } |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Add cache headers to response |
| | | */ |
| | | protected function addCacheHeaders(WP_REST_Response $response, int $maxAge = 300): WP_REST_Response |
| | | { |
| | | $response->header('Cache-Control', "private, max-age={$maxAge}"); |
| | | $response->header('Vary', 'Cookie'); |
| | | $response->header('ETag', '"' . md5(serialize($response->get_data())) . '"'); |
| | | $response->header('Last-Modified', gmdate('D, d M Y H:i:s') . ' GMT'); |
| | | |
| | | return $response; |
| | | } |
| | | |
| | | /** |
| | | * Store response in cache with metadata |
| | | */ |
| | | protected function cacheResponse(string $key, array $data): void |
| | | { |
| | | if (!$this->cache) { |
| | | return; |
| | | } |
| | | |
| | | $this->cache->set($key, [ |
| | | 'data' => $data, |
| | | 'etag' => '"' . md5(serialize($data)) . '"', |
| | | 'last_modified' => gmdate('D, d M Y H:i:s') . ' GMT', |
| | | ]); |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // TIMESTAMP FORMATTING |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Convert MySQL datetime to ISO 8601 timestamp |
| | | */ |
| | | protected function formatTimestamp(?string $mysqlDatetime): ?string |
| | | { |
| | | return Response::formatTimestamp($mysqlDatetime); |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // QUERY BUILDING |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Apply taxonomy filters to WP_Query args |
| | | */ |
| | | 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); |
| | | } |
| | | |
| | | $taxonomies = $data['taxonomies'] ?? $data['taxonomy'] ?? []; |
| | | $taxQuery = []; |
| | | |
| | | foreach($taxonomies as $taxonomy => $terms) { |
| | | // Better validation: check if taxonomy actually exists |
| | | if (!taxonomy_exists(jvbCheckBase($taxonomy))) { |
| | | continue; |
| | | } |
| | | |
| | | $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; |
| | | } |
| | | |
| | | /** |
| | | * Apply order/sort filters to WP_Query 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 = floor(time() / 1800); |
| | | $args['orderby'] = 'RAND(' . $current_seed . ')'; |
| | | unset($args['order']); |
| | | return $args; |
| | | } |
| | | |
| | | 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.'upvotes'; |
| | | $args['orderby'] = 'meta_value_num'; |
| | | break; |
| | | case 'karma': |
| | | $args['meta_key'] = BASE.'karma'; |
| | | $args['orderby'] = 'meta_value_num'; |
| | | break; |
| | | 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'; |
| | | $args['order'] = (in_array($order, ['ASC', 'DESC'])) ? $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 |
| | | $registrar = Registrar::getInstance($content); |
| | | if (!$registrar) { |
| | | return null; |
| | | } |
| | | |
| | | // Check if this orderby is a custom order |
| | | $customOrders = $registrar->custom_order??[]; |
| | | if (empty($customOrders) || !isset($customOrders[$orderby])) { |
| | | return null; |
| | | } |
| | | |
| | | // Get field definition |
| | | $fields = $registrar->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 applyDateFilters(array $args, array $data):array |
| | | { |
| | | if (!array_key_exists('date-filter', $data) && !array_key_exists('dateFrom', $data)) { |
| | | return $args; |
| | | } |
| | | if (array_key_exists('dateFrom', $data)) { |
| | | $dateFrom = strtotime(sanitize_text_field($data['dateFrom'])); |
| | | $dateTo = strtotime(sanitize_text_field($data['dateTo'])); |
| | | if ($dateFrom && $dateTo) { |
| | | $args['date_query'] = [ |
| | | [ |
| | | 'after' => date('c', $dateFrom), |
| | | 'before' => date('c', $dateTo), |
| | | 'inclusive' => true, |
| | | ] |
| | | ]; |
| | | } |
| | | } else { |
| | | switch ($data['date-filter']) { |
| | | case 'today': |
| | | $args['date_query'] = [['after' => '1 day ago']]; |
| | | break; |
| | | case 'week': |
| | | $args['date_query'] = [['after' => '1 week ago']]; |
| | | break; |
| | | case 'month': |
| | | $args['date_query'] = [['after' => '1 month ago']]; |
| | | break; |
| | | case 'year': |
| | | $args['date_query'] = [['after' => '1 year ago']]; |
| | | break; |
| | | } |
| | | } |
| | | return $args; |
| | | } |
| | | |
| | | protected function applyCalendarFilters(array $args, array $data):array |
| | | { |
| | | $meta_query = []; |
| | | $today = date('Y-m-d'); |
| | | if (in_array('future', $args['post_status'])) { |
| | | $meta_query[] = [ |
| | | 'key' => 'jvb_start_date', |
| | | 'value' => $today, |
| | | 'compare' => '>=', |
| | | 'type' => 'DATE' |
| | | ]; |
| | | } |
| | | if (in_array('past', $args['post_status'])) { |
| | | $meta_query[] = [ |
| | | 'key' => 'jvb_end_date', |
| | | 'value' => $today, |
| | | 'compare' => '<', |
| | | 'type' => 'DATE' |
| | | ]; |
| | | } |
| | | if (in_array('recurring', $args['post_status'])) { |
| | | $meta_query[] = [ |
| | | 'key' => 'jvb_is_recurring', |
| | | 'value' => true, |
| | | 'compare' => '=' |
| | | ]; |
| | | } |
| | | if (!empty($meta_query)) { |
| | | $args['meta_query'] = (array_key_exists('meta_query', $args)) ? array_merge($args['meta_query'], $meta_query) : $meta_query; |
| | | } |
| | | return $args; |
| | | |
| | | } |
| | | |
| | | /** |
| | | * Apply pagination to WP_Query args |
| | | */ |
| | | protected function applyPagination(array $args, array $data): array |
| | | { |
| | | $args['posts_per_page'] = min(absint($data['per_page'] ?? 20), 100); |
| | | $args['paged'] = max(absint($data['page'] ?? 1), 1); |
| | | return $args; |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // VALIDATION |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Check if user ID matches current logged-in user |
| | | */ |
| | | protected function userCheck(int $userId): bool |
| | | { |
| | | return $userId === get_current_user_id(); |
| | | } |
| | | |
| | | /** |
| | | * Check if user exists (cached) |
| | | */ |
| | | protected function checkUser(int $userId): bool |
| | | { |
| | | $cache = Cache::for('checkUser', DAY_IN_SECONDS)->connect('user'); |
| | | return $cache->remember($userId, fn() => (bool) get_userdata($userId)); |
| | | } |
| | | |
| | | /** |
| | | * Check if term exists (cached) |
| | | */ |
| | | protected function checkTerm(array $args): bool |
| | | { |
| | | $termId = $args['term_id'] ?? $args['to_term'] ?? false; |
| | | $taxonomy = $args['taxonomy'] ?? false; |
| | | |
| | | if (!$termId || !$taxonomy) { |
| | | return false; |
| | | } |
| | | |
| | | $cache = Cache::for('checkTerm', DAY_IN_SECONDS)->connect('taxonomy'); |
| | | return $cache->remember($termId, fn() => (bool) term_exists($termId, jvbCheckBase($taxonomy))); |
| | | } |
| | | |
| | | /** |
| | | * Check if user is verified |
| | | */ |
| | | protected function isVerifiedUser(int $userId): bool |
| | | { |
| | | $cache = Cache::for('verifiedUsers', DAY_IN_SECONDS)->connect('user'); |
| | | return $cache->remember($userId, fn() => user_can($userId, 'skip_moderation')); |
| | | } |
| | | |
| | | /** |
| | | * Sanitize array of IDs |
| | | */ |
| | | protected function sanitizeIds(array $ids): array |
| | | { |
| | | return array_values(array_filter(array_map('absint', $ids), fn($id) => $id > 0)); |
| | | } |
| | | |
| | | /** |
| | | * Get and validate meta values |
| | | */ |
| | | protected function getMetaValues(mixed $value): mixed |
| | | { |
| | | $decoded = is_string($value) ? json_decode($value, true) : $value; |
| | | |
| | | if (!is_array($decoded)) { |
| | | return $value; |
| | | } |
| | | |
| | | return array_map(fn($item) => is_object($item) ? (array) $item : $item, $decoded); |
| | | } |
| | | |
| | | /*************************************************************************** |
| | | * UTILITY |
| | | ***************************************************************************/ |
| | | protected function isTimeline($args, $data):bool |
| | | { |
| | | if (!array_key_exists('post_type', $args)) { |
| | | return false; |
| | | } |
| | | $post_types = is_array($args['post_type']) ? $args['post_type'] : [$args['post_type']]; |
| | | $hasTimeline = array_map(function($item) { return jvbCheckBase($item); },Registrar::getFeatured('is_timeline', 'post')); |
| | | return !empty(array_intersect($post_types, $hasTimeline)); |
| | | } |
| | | // ========================================================================= |
| | | // SECURITY |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Verify Cloudflare Turnstile token |
| | | */ |
| | | protected function verifyTurnstile(string $token): bool |
| | | { |
| | | if (!Site::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) { |
| | | return true; |
| | | } |
| | | |
| | | return !empty($token) && JVB()->connect('cloudflare')->verifyTurnstile($token); |
| | | } |
| | | |
| | | /** |
| | | * Generate CSRF token for user |
| | | */ |
| | | protected function generateCsrfToken(int $userId): string |
| | | { |
| | | $token = wp_generate_password(32, false); |
| | | set_transient(BASE . 'csrf_' . $userId, $token, HOUR_IN_SECONDS); |
| | | return $token; |
| | | } |
| | | |
| | | /** |
| | | * Validate CSRF token from request header |
| | | */ |
| | | protected function validateCsrfToken(WP_REST_Request $request): bool |
| | | { |
| | | if (!is_user_logged_in() || in_array($request->get_method(), ['GET', 'HEAD', 'OPTIONS'])) { |
| | | return true; |
| | | } |
| | | |
| | | $userId = get_current_user_id(); |
| | | $token = $request->get_header('X-CSRF-Token'); |
| | | $stored = get_transient(BASE . 'csrf_' . $userId); |
| | | |
| | | return !empty($stored) && !empty($token) && hash_equals($stored, $token); |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // OPERATION LOCKING |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Prevent concurrent requests for the same operation |
| | | */ |
| | | protected function acquireOperationLock(string $key, int $duration = 5): bool |
| | | { |
| | | $lockKey = 'op_lock_' . md5($key); |
| | | |
| | | if (get_transient($lockKey)) { |
| | | return false; |
| | | } |
| | | |
| | | set_transient($lockKey, true, $duration); |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Release operation lock |
| | | */ |
| | | protected function releaseOperationLock(string $key): void |
| | | { |
| | | delete_transient('op_lock_' . md5($key)); |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // LOGGING |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Log security-relevant events |
| | | */ |
| | | protected function auditLog(string $event, array $data = []): void |
| | | { |
| | | $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'] ?? '', |
| | | ]); |
| | | |
| | | try { |
| | | JVB()->error()->log('security_audit', $event, $context, 'info'); |
| | | } catch (Exception $e) { |
| | | error_log("Security Audit: {$event} - " . json_encode($context)); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Log errors with proper context |
| | | */ |
| | | protected function logError(string $message, array $context = [], string $severity = 'error'): void |
| | | { |
| | | try { |
| | | JVB()->error()->log(static::class, $message, $context, $severity); |
| | | } catch (Exception $e) { |
| | | error_log(static::class . " Error: {$message} - " . json_encode($context)); |
| | | } |
| | | } |
| | | |
| | | /************************************************************ |
| | | 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'); |
| | | } |
| | | |
| | | /******************************* |
| | | * META HELPERS |
| | | *******************************/ |
| | | public function getFieldsOfType(array $fields, string|array $type, Meta $meta, array $subType = []):array |
| | | { |
| | | $gotFields = []; |
| | | if (is_string($type)) { |
| | | $type = [$type]; |
| | | } |
| | | foreach ($fields as $field => $value) { |
| | | //Skip empty values |
| | | if (empty($value)) { |
| | | continue; |
| | | } |
| | | $config = $meta->config($field); |
| | | if (in_array($config['type'], ['group', 'repeater', 'tagList'])) { |
| | | foreach ($config['fields'] as $subfield => $subConfig) { |
| | | if (is_numeric($subfield) && array_key_exists('name', $subConfig)) { |
| | | $subfield = $subConfig['name']; |
| | | } |
| | | if (is_numeric($subfield)) continue; |
| | | if (array_key_exists('type', $subConfig) && in_array($subConfig['type'], $type)) { |
| | | $gotFields[] = $field.':'.$subfield; |
| | | } |
| | | } |
| | | } elseif (in_array($config['type'], $type)) { |
| | | $gotFields[] = $field; |
| | | } else if ((!empty($subType) && in_array($config['type'], array_keys($subType)) && in_array($config['subtype'], array_values($subType)))) { |
| | | $gotFields[] = $field; |
| | | } |
| | | } |
| | | return $gotFields; |
| | | } |
| | | |
| | | |
| | | protected function extractImages(array $fields, Meta $meta):array |
| | | { |
| | | $images = []; |
| | | $get = $this->getFieldsOfType($fields, ['upload', 'gallery','image'], $meta); |
| | | if (!empty($get)) { |
| | | $baseFields = array_map(function($fieldName) { |
| | | return (str_contains($fieldName, ':')) ? strtok($fieldName, ':') : $fieldName; |
| | | }, $get); |
| | | |
| | | $temp = array_map( |
| | | function($item) { |
| | | return explode(':', $item); |
| | | }, |
| | | array_filter($get, function($fieldName) { |
| | | return str_contains($fieldName, ':'); |
| | | }) |
| | | ); |
| | | $complex = []; |
| | | foreach ($temp as $tmp) { |
| | | $complex[$tmp[0]] = $tmp[1]; |
| | | } |
| | | |
| | | $fields = array_filter($fields, function ($field) use ($baseFields) { |
| | | return in_array($field, $baseFields); |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | |
| | | foreach ($fields as $fieldName => $value) { |
| | | //Check if it's a complex field |
| | | if (array_key_exists($fieldName, $complex)) { |
| | | $check = $complex[$fieldName]; |
| | | foreach ($value as $row) { |
| | | foreach ($row as $fName => $fValue) { |
| | | if ($fName === $check && !empty($fValue)) { |
| | | $images = $this->addImages($fValue, $images); |
| | | } |
| | | } |
| | | } |
| | | } else { |
| | | $images = $this->addImages($value, $images); |
| | | } |
| | | } |
| | | } |
| | | return $images; |
| | | } |
| | | public function addImages(string $imgs, array $images):array |
| | | { |
| | | $temp = explode(',', $imgs); |
| | | foreach ($temp as $img) { |
| | | if (is_numeric($img) && !array_key_exists($img, $images) && $img > 0) { |
| | | $images[$img] = jvbImageData((int)$img); |
| | | } |
| | | } |
| | | return $images; |
| | | } |
| | | |
| | | protected function extractTerms(array $fields, Meta $meta):array |
| | | { |
| | | $terms = []; |
| | | $get = $this->getFieldsOfType($fields, ['taxonomy'], $meta, ['selector' => 'taxonomy']); |
| | | if (!empty($get)) { |
| | | $baseFields = array_map(function($fieldName) { |
| | | return (str_contains($fieldName, ':')) ? strtok($fieldName, ':') : $fieldName; |
| | | }, $get); |
| | | |
| | | $complex = array_map( |
| | | function($item) { |
| | | return explode(':', $item); |
| | | }, |
| | | array_filter($get, function($fieldName) { |
| | | return str_contains($fieldName, ':'); |
| | | }) |
| | | ); |
| | | |
| | | $fields = array_filter($fields, function ($field) use ($baseFields) { |
| | | return in_array($field, $baseFields); |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | |
| | | foreach ($fields as $fieldName => $value) { |
| | | $config = $meta->config($fieldName); |
| | | //Check if it's a complex field |
| | | if (array_key_exists($fieldName, $complex)) { |
| | | foreach ($value as $row) { |
| | | foreach ($row as $fName => $fValue) { |
| | | if (in_array($fName, $complex[$fieldName])) { |
| | | $terms = $this->addTerms($fValue, $terms, $config); |
| | | } |
| | | } |
| | | } |
| | | } else { |
| | | |
| | | $terms = $this->addTerms($value, $terms, $config); |
| | | } |
| | | } |
| | | } |
| | | return $terms; |
| | | |
| | | } |
| | | |
| | | protected function addTerms(string $value, array $terms, array $config):array |
| | | { |
| | | $taxonomy = jvbNoBase($config['taxonomy']); |
| | | if (empty($value)) { |
| | | return $terms; |
| | | } |
| | | $ids = array_map('absint', explode(',',$value)); |
| | | $cache = Cache::for('term_data')->connect('taxonomy'); |
| | | $cache->flush(); |
| | | if (!array_key_exists($taxonomy, $terms)) { |
| | | $terms[$taxonomy] = []; |
| | | $registrar = Registrar::getInstance($taxonomy); |
| | | $terms[$taxonomy]['icon'] = $registrar ? $registrar->getIcon() : jvbDefaultIcon();; |
| | | } |
| | | foreach ($ids as $id) { |
| | | $data = $cache->remember( |
| | | $id, |
| | | function () use ($id, $taxonomy) { |
| | | $term = get_term($id, $taxonomy); |
| | | if ($term && !is_wp_error($term)) { |
| | | return [ |
| | | 'id' => $term->term_id, |
| | | 'name' => $term->name, |
| | | 'slug' => $term->slug, |
| | | 'parent' => $term->parent, |
| | | 'path' => JVB()->routes('term')->getTermPath($term->term_id, $term->name, $taxonomy), |
| | | 'taxonomy' => jvbNoBase($term->taxonomy), |
| | | 'count' => $term->count, |
| | | ]; |
| | | } |
| | | return []; |
| | | } |
| | | ); |
| | | if (!empty($data)) { |
| | | $terms[$taxonomy][$id] = $data; |
| | | } |
| | | } |
| | | return $terms; |
| | | } |
| | | } |