<?php
|
namespace JVBase\rest;
|
|
use DateTime;
|
use DateTimeZone;
|
use JVBase\registrar\Registrar;
|
use JVBase\managers\OperationQueue;
|
use JVBase\managers\Cache;
|
use JVBase\managers\NotificationManager;
|
use JVBase\base\Site;
|
use WP_REST_Request;
|
use WP_Error;
|
use Exception;
|
use WP_REST_Response;
|
|
if (!defined('ABSPATH')) {
|
exit; // Exit if accessed directly
|
}
|
|
/**
|
* @deprecated use Rest.php
|
* Handles route registration and high-level coordination
|
*/
|
abstract class RestRouteManager
|
{
|
protected string $namespace = 'jvb/v1';
|
|
protected RateLimiter|null $rate_limiter;
|
protected array $types;
|
protected string $route;
|
protected string $base;
|
protected string $content_type; //the registered post type
|
protected string $type; //post, user, term, for Meta
|
protected string $action = ''; //optional additional nonce to check
|
protected array $callback; //route->callback array
|
protected string $operation_type; // from QueueManager.js and OperationQueue.php
|
protected OperationQueue $queue;
|
protected 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');
|
});
|
}
|
|
/**
|
* @deprecated
|
* @param array $args
|
* @param array $data
|
* @return array
|
*/
|
protected function applyTaxonomyFilters(array $args, array $data):array
|
{
|
// Handle JSON-encoded taxonomy data
|
if (array_key_exists('taxonomy', $data) && is_string($data['taxonomy'])) {
|
$data['taxonomy'] = json_decode($data['taxonomy'], true);
|
}
|
|
$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;
|
}
|
|
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
|
$config = Registrar::getInstance($content);
|
if (!$config) {
|
return null;
|
}
|
|
// Check if this orderby is a custom order
|
$customOrders = $config->custom_order ?? [];
|
if (empty($customOrders) || !isset($customOrders[$orderby])) {
|
return null;
|
}
|
|
// Get field definition
|
$fields = $config->getFields()??[];
|
if (!isset($fields[$orderby])) {
|
return null;
|
}
|
|
$field = $fields[$orderby];
|
|
// Set meta_key
|
$args['meta_key'] = BASE . $orderby;
|
|
// Determine orderby and meta_type based on field type
|
$fieldType = $field['type'] ?? 'text';
|
$subtype = $field['subtype'] ?? '';
|
|
switch ($fieldType) {
|
case 'number':
|
$args['orderby'] = 'meta_value_num';
|
break;
|
|
case 'text':
|
$args['orderby'] = ($subtype === 'number') ? 'meta_value_num' : 'meta_value';
|
break;
|
|
case 'date':
|
$args['orderby'] = 'meta_value';
|
$args['meta_type'] = 'DATE';
|
break;
|
|
case 'datetime':
|
$args['orderby'] = 'meta_value';
|
$args['meta_type'] = 'DATETIME';
|
break;
|
|
case 'true_false':
|
case 'checkbox':
|
$args['orderby'] = 'meta_value';
|
$args['meta_type'] = 'BINARY';
|
break;
|
|
default:
|
$args['orderby'] = 'meta_value';
|
}
|
|
return $args;
|
}
|
|
protected function isTimeline($args, $data):bool
|
{
|
$post_types = is_array($args['post_type']) ? $args['post_type'] : [$args['post_type']];
|
$areTimeline = array_map(function($type) { return BASE.$type; },Registrar::getFeatured('is_timeline', 'post'));
|
return !empty(array_intersect($post_types, $areTimeline));
|
}
|
|
protected function applyDateFilters(array $args, array $data):array
|
{
|
if (!array_key_exists('date-filter', $data) && !array_key_exists('dateFrom', $data)) {
|
return $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 (!Site::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) {
|
return true;
|
}
|
|
if (empty($token)) {
|
return false;
|
}
|
|
return JVB()->connect('cloudflare')->verifyTurnstile($token);
|
}
|
}
|