callback array protected string $operation_type; // from QueueManager.js and OperationQueue.php protected OperationQueue $queue; 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'; const ERROR_ACCESS_DENIED = 'access_denied'; const ERROR_NOT_FOUND = 'not_found'; const ERROR_INVALID_OPERATION = 'invalid_operation'; const ERROR_PROCESSING = 'processing_error'; public function __construct() { $this->base = BASE; $this->rate_limiter = new RateLimiter(); if ($this->cache_name !== '') { $this->cache = Cache::for($this->cache_name, $this->cache_ttl); } add_action('rest_api_init', [$this, 'registerRoutes']); } public function init() { //Replace in child classes, if necessary } /** * @param WP_REST_Request $request Rest Request object * @param string $nonceName nonce name to check * @param string|null $nonce optional additional nonce to check * * @return void|WP_Error */ public function verifyNonce(WP_REST_Request $request, string $nonceName = 'wp_rest', string|null $nonce = null):mixed { $nonce = (!is_null($nonce)) ? $nonce : $request->get_header('X-WP-Nonce'); if (!wp_verify_nonce($nonce, $nonceName)) { return new WP_Error( 'invalid_nonce', 'Invalid nonce', ['status' => 403] ); } return true; } abstract public function registerRoutes(); /** * @param WP_REST_Request $request the Request Object * * @return bool|WP_Error Whether or not we can proceed */ public function checkPermission(WP_REST_Request $request):bool { // 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', ['status' => 403] ); } $user_id = $request->get_param('user'); if (!empty($user_id) && !$this->userCheck($user_id)) { error_log('Usercheck failed'); return false; } // Verify nonces $this->verifyNonce($request, 'wp_rest'); if ($this->action!=='') { $this->verifyNonce($request, $this->action . $user_id, $request->get_header('action_nonce')); } 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; } public function formatError($code, $message, $status = 400) { return new WP_Error($code, $message, ['status' => $status]); } /** * @param int $userID the userID to check * * @return bool whether or not the $userID matches the currently logged in user */ protected function userCheck(int $userID) { return $userID === get_current_user_id(); } /** * Makes sure the value is in the format it needs to be * @param mixed $value * * @return mixed */ protected function getMetaValues(mixed $value):mixed { //get the repeater out of there $temp = json_decode($value); if (is_array($temp)) { $value = []; foreach ($temp as $t) { if (is_object($t)) { $t = (array)$t; } $value[] = $t; } } return $value; } /** * Log errors with proper context * @param string $message The error message * @param array $context Additional context * @param string $severity * @return void */ protected function logError(string $message, array $context = [], string $severity = 'error') { try { JVB()->error()->log( 'artist_invitations', // component $message, $context, $severity ); } catch (Exception $e) { // Fallback if error handler fails error_log("Invitations Error: {$message} - " . json_encode($context)); } } /** * Check if user exists (cached) */ protected function checkUser(int $userID): bool { $cache = Cache::for('checkUser', DAY_IN_SECONDS)->connect('user'); return $cache->remember($userID, function() use ($userID) { return (bool)get_userdata($userID); }); } /** * Check if shop exists (cached) */ protected function checkShop(int $shopID): bool { $cache = Cache::for('checkShop',DAY_IN_SECONDS)->connect('taxonomy'); 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; if (!$taxonomy) { return false; } $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 */ public function isVerifiedUser(int $user_id): bool { $cache = Cache::for('verifiedUsers', DAY_IN_SECONDS)->connect('user'); return $cache->remember($user_id, function() use ($user_id) { return user_can($user_id, 'skip_moderation'); }); } 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 (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', '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 $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)) { 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; } protected function table_exists($tableName, $wpdb = null) { if (!$wpdb) { global $wpdb; } return $wpdb->get_var("SHOW TABLES LIKE '{$tableName}'") !== $tableName; } // ========== 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); } // Last-Modified check $ifModifiedSince = $request->get_header('if-modified-since'); if ($ifModifiedSince && strtotime($ifModifiedSince) >= $lastModified) { return new WP_REST_Response(null, 304); } // Store headers for response phase $this->response_headers = [ 'ETag' => $etag, 'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified) . ' GMT', ]; return false; } /** * 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 = []; } 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); } }