From 2127b1bdd73ecd2423e443992da4b442f5a3c1a3 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Wed, 04 Feb 2026 21:19:25 +0000
Subject: [PATCH] =Major overhaul of MetaManager.php -> Meta.php and RestRouteManager.php -> Rest.php. Seems to work for JakeVan
---
inc/rest/Rest.php | 727 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 727 insertions(+), 0 deletions(-)
diff --git a/inc/rest/Rest.php b/inc/rest/Rest.php
index e69de29..6435ec8 100644
--- a/inc/rest/Rest.php
+++ b/inc/rest/Rest.php
@@ -0,0 +1,727 @@
+<?php
+namespace JVBase\rest;
+
+use JVBase\managers\Cache;
+use JVBase\utility\Features;
+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
+ // =========================================================================
+
+ /**
+ * 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 (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;
+ }
+
+ /**
+ * 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 = 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 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 content type exists
+ */
+ protected function checkContent(string $content, bool $returnBool = false): string|bool
+ {
+ $result = JVB_CONTENT[$content] ?? JVB_TAXONOMY[$content] ?? JVB_USER[$content] ?? '';
+ return $returnBool ? ($result !== '') : $result;
+ }
+
+ /**
+ * 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
+ {
+ $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;
+ }
+ // =========================================================================
+ // SECURITY
+ // =========================================================================
+
+ /**
+ * Verify Cloudflare Turnstile token
+ */
+ protected function verifyTurnstile(string $token): bool
+ {
+ if (!Features::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');
+ }
+}
--
Gitblit v1.10.0