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; } }