From 42fa8304ddb811b0f725f245130f70c0f5e86a6c Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 04 Nov 2025 06:12:02 +0000
Subject: [PATCH] =Refactored LoginManager to be more extensible and configurable, as well as an AjaxRateLimiter
---
inc/managers/CacheManager.php | 747 +++++++++++++++++++++++++++++++++++++++-----------------
1 files changed, 519 insertions(+), 228 deletions(-)
diff --git a/inc/managers/CacheManager.php b/inc/managers/CacheManager.php
index e6530da..c8a38b9 100644
--- a/inc/managers/CacheManager.php
+++ b/inc/managers/CacheManager.php
@@ -2,33 +2,230 @@
namespace JVBase\managers;
if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
+ exit;
}
+/**
+ * Manages HTTP cache timestamps and relationship-based invalidation
+ *
+ * Data caching: Use wrapper methods or wp_cache_get/set directly
+ * HTTP caching: This class manages timestamps for ETag/Last-Modified headers
+ */
class CacheManager
{
- private string $prefix = 'jvb_';
+ private string $prefix = BASE;
private string $group;
private int $cache_ttl;
private static ?bool $use_object_cache = null;
+ private static array $instances = []; // Cache instances per type
+ private static array $http_timestamps = []; // Request-level memory cache
+ private static array $relationships = []; // Type => [related types]
+ private static bool $relationships_loaded = false;
/**
- * @param string|null $group The group name for this cache instance
- * @param int|null $ttl The default ttl for this instance
+ * Private constructor - use for() factory method instead
*/
- public function __construct(?string $group = null, ?int $ttl = null)
+ private function __construct(string $group, ?int $ttl = null)
{
- $this->group = $group ?: 'jvb_default';
+ $this->group = jvbNoBase($group);
$this->cache_ttl = $ttl ?: 3600;
- // Check if Redis/Memcached is available
if (is_null(static::$use_object_cache)) {
- static::$use_object_cache = !is_null(wp_using_ext_object_cache());
-// error_log((static::$use_object_cache) ? 'Using Object Cache' : 'Not using Object Cache');
+ static::$use_object_cache = wp_using_ext_object_cache();
}
}
/**
+ * Get or create a cache manager instance for a content type
+ *
+ * @param string $type Content type (tattoo, style, etc.)
+ * @param int|null $ttl Optional TTL override
+ * @return self Fluent interface
+ */
+ public static function for(string $type, ?int $ttl = null): self
+ {
+ $type = jvbNoBase($type);
+ $key = $type . ($ttl ? "_{$ttl}" : '');
+
+ if (!isset(self::$instances[$key])) {
+ self::$instances[$key] = new self($type, $ttl);
+ }
+
+ return self::$instances[$key];
+ }
+
+ /**
+ * Get cache manager for a specific user
+ * Each user gets their own cache group for complete isolation
+ *
+ * @param int $user_id User ID
+ * @param int|null $ttl Optional TTL
+ * @return self
+ */
+ public static function forUser(int $user_id, ?int $ttl = null): self
+ {
+ return self::for("user_{$user_id}", $ttl);
+ }
+
+ /**
+ * Get HTTP cache timestamp for content type(s)
+ * Used for ETag and Last-Modified header generation
+ *
+ * @param string|array $types Single type or array of types
+ * @return int Latest timestamp (Unix time)
+ */
+ public static function getTimestamp(string|array $types): int
+ {
+ // Multiple types - return latest
+ if (is_array($types)) {
+ $latest = 0;
+ foreach ($types as $type) {
+ $timestamp = self::getTimestamp($type);
+ if ($timestamp > $latest) {
+ $latest = $timestamp;
+ }
+ }
+ return $latest ?: time();
+ }
+
+ $type = jvbNoBase($types);
+
+ // Check request-level cache
+ if (isset(self::$http_timestamps[$type])) {
+ return self::$http_timestamps[$type];
+ }
+
+ // Load from cache (Redis or transient - wp_cache handles it)
+ $timestamp = (int)wp_cache_get("http_ts_{$type}", 'jvb_timestamps') ?: time();
+
+ // Cache in memory for this request
+ self::$http_timestamps[$type] = $timestamp;
+
+ return $timestamp;
+ }
+
+ /**
+ * Update HTTP cache timestamp (marks content as modified)
+ *
+ * @param string $type Content type
+ * @return int The new timestamp
+ */
+ public static function updateTimestamp(string $type): int
+ {
+ $type = jvbNoBase($type);
+ $timestamp = time();
+
+ // Store (Redis or transient - wp_cache handles it)
+ wp_cache_set("http_ts_{$type}", $timestamp, 'jvb_timestamps', WEEK_IN_SECONDS);
+
+ // Update request cache
+ self::$http_timestamps[$type] = $timestamp;
+
+ do_action('jvb_http_timestamp_updated', $type, $timestamp);
+
+ return $timestamp;
+ }
+
+ /**
+ * Invalidate cache for a content type with automatic cascade
+ *
+ * @param string $type Content type to invalidate
+ * @param mixed $context Post/Term object or array with relationship data (for cascade)
+ * @param string|array|null $specific_keys Optional specific key(s) to delete without flushing group
+ * @return void
+ */
+ public static function invalidateAll(string $type, $context = null, $specific_keys = null): void
+ {
+ $type = jvbNoBase($type);
+
+ // Update HTTP timestamp
+ self::updateTimestamp($type);
+
+ // If specific keys provided, only delete those (don't flush whole group)
+ if ($specific_keys !== null) {
+ $instance = self::for($type);
+ if (is_array($specific_keys)) {
+ foreach ($specific_keys as $key) {
+ $instance->delete($key);
+ }
+ } else {
+ $instance->delete($specific_keys);
+ }
+ } else {
+ // No specific keys - flush the entire group
+ if (function_exists('wp_cache_flush_group')) {
+ wp_cache_flush_group($type);
+ } else {
+ // Fallback for older WP
+ wp_cache_flush();
+ }
+ }
+
+ // Cascade to related types if context provided
+ if ($context !== null) {
+ self::cascadeInvalidation($type, $context);
+ }
+
+ do_action('jvb_cache_invalidated', $type, $context);
+ }
+
+ /**
+ * Invalidate only specific keys for a type (doesn't flush group or update timestamp)
+ * Use this when you want surgical cache invalidation
+ *
+ * @param string $type Content type
+ * @param string|array $keys Key(s) to delete
+ * @return void
+ */
+ public static function invalidateKeys(string $type, string|array $keys): void
+ {
+ $instance = self::for($type);
+
+ if (is_array($keys)) {
+ foreach ($keys as $key) {
+ $instance->delete($key);
+ }
+ } else {
+ $instance->delete($keys);
+ }
+ }
+
+ /**
+ * Fluent instance method to invalidate this cache type
+ * Allows chaining: CacheManager::for('tattoo')->invalidate()->clear()
+ *
+ * @param mixed $context Optional context for cascade
+ * @param string|array|null $specific_keys Optional specific key(s)
+ * @return self For chaining
+ */
+ public function invalidate($context = null, $specific_keys = null): self
+ {
+ self::invalidateAll($this->group, $context, $specific_keys);
+ return $this;
+ }
+
+ /**
+ * Get the HTTP timestamp for this instance's type
+ *
+ * @return int
+ */
+ public function timestamp(): int
+ {
+ return self::getTimestamp($this->group);
+ }
+
+ /**
+ * Update the HTTP timestamp for this instance's type
+ *
+ * @return self For chaining
+ */
+ public function touch(): self
+ {
+ self::updateTimestamp($this->group);
+ return $this;
+ }
+
+ /**
* Get a value from the cache
* @param string|array $key The key to look up (auto-generates key from array of key=>values)
* @param string|null $group The group to get from. Defaults to current group
@@ -37,39 +234,10 @@
public function get(string|array $key, ?string $group = null): mixed
{
$group = $group ?: $this->group;
-
$key = $this->normalizeKey($key);
-
$cache_key = $this->buildKey($key);
- // Use appropriate cache method
- if (static::$use_object_cache) {
- $value = wp_cache_get($cache_key, $group);
- } else {
- // Fallback to transients for local development
- $value = get_transient($this->getTransientKey($cache_key, $group));
- }
-
- return (is_array($value) && array_key_exists('data', $value)) ? $value['data'] : $value;
- }
-
- public function getTimestamp(string|array $key, ?string $group = null): mixed
- {
- $group = $group ?: $this->group;
-
- $key = $this->normalizeKey($key);
-
- $cache_key = $this->buildKey($key);
-
- // Use appropriate cache method
- if (static::$use_object_cache) {
- $value = wp_cache_get($cache_key, $group);
- } else {
- // Fallback to transients for local development
- $value = get_transient($this->getTransientKey($cache_key, $group));
- }
-
- return (is_array($value) && array_key_exists('last_modified', $value)) ? $value['last_modified'] : false;
+ return wp_cache_get($cache_key, $group);
}
/**
@@ -84,23 +252,13 @@
{
$ttl = $ttl ?: $this->cache_ttl;
$group = $group ?: $this->group;
-
$key = $this->normalizeKey($key);
-
$cache_key = $this->buildKey($key);
- $temp = [
- 'data' => $value,
- 'last_modified' => time(),
- ];
- $value = $temp;
- // Use appropriate cache method
- if (static::$use_object_cache) {
- return wp_cache_set($cache_key, $value, $group, $ttl);
- } else {
- // Fallback to transients
- return set_transient($this->getTransientKey($cache_key, $group), $value, $ttl);
- }
+ // Update timestamp when setting new data
+ self::updateTimestamp($this->group);
+
+ return wp_cache_set($cache_key, $value, $group, $ttl);
}
/**
@@ -112,147 +270,28 @@
public function delete(string|array $key, ?string $group = null): bool
{
$group = $group ?: $this->group;
-
$key = $this->normalizeKey($key);
-
$cache_key = $this->buildKey($key);
- // Use appropriate cache method
- if (static::$use_object_cache) {
- return wp_cache_delete($cache_key, $group);
- } else {
- return delete_transient($this->getTransientKey($cache_key, $group));
- }
- }
-
- public function clear():bool
- {
- try {
- if (static::$use_object_cache) {
- // With Redis, this could be implemented with SCAN command
- // but wp_cache_* doesn't expose this, so we'd need direct Redis access
- // For now, just flush the group as a nuclear option
- if (function_exists('wp_cache_flush_group')) {
- wp_cache_flush_group($this->group);
- return true;
- }
- return false;
- } else {
- // For transients, search and delete
- global $wpdb;
-
- $prefix = self::getTransientPrefix($this->group);
- $sql = "SELECT option_name FROM {$wpdb->options}
- WHERE option_name LIKE %s
- AND option_name LIKE %s";
-
- $keys = $wpdb->get_col($wpdb->prepare(
- $sql,
- '_transient_' . $prefix . '%'
- ));
-
- foreach ($keys as $key) {
- $transient_key = str_replace('_transient_', '', $key);
- delete_transient($transient_key);
- }
- return true;
- }
- } catch (\Exception $e) {
-
- } finally {
- return false;
- }
+ return wp_cache_delete($cache_key, $group);
}
/**
- * Alias for delete() for backwards compatibility
- * @param string $key The key to look up (auto-generates key from array of key=>values)
- * @param string|null $group The group to delete from (defaults to current group))
- * @return void
- */
- public function invalidate(string $key, ?string $group = null): void
- {
- $this->delete($key, $group);
- }
-
- /**
- * Clear all cache entries for a group
- * @param string $group The group to clear
+ * Clear all cache for this group
* @return bool
*/
- public static function invalidateGroup(string $group): bool
+ public function clear(): bool
{
- $group = jvbNoBase($group);
-
- if (wp_using_ext_object_cache()) {
- // With Redis/Memcached, use native group flush
- if (function_exists('wp_cache_flush_group')) {
- return wp_cache_flush_group($group);
- } else {
- // Fallback for older WP versions - flush everything (not ideal)
- return wp_cache_flush();
- }
- } else {
- // For transients, we need to delete them from database
- global $wpdb;
-
- $prefix = self::getTransientPrefix($group);
-
- // Delete transients and their timeouts
- $sql = "DELETE FROM {$wpdb->options}
- WHERE option_name LIKE %s
- OR option_name LIKE %s";
-
- $result = $wpdb->query($wpdb->prepare(
- $sql,
- '_transient_' . $prefix . '%',
- '_transient_timeout_' . $prefix . '%'
- ));
-
- return $result !== false;
- }
- }
-
- /**
- * Clear cache entries by pattern (only works efficiently with Redis)
- * @param string $pattern
- * @return int
- */
- public function clearPattern(string $pattern): int
- {
- $count = 0;
-
- if (static::$use_object_cache) {
- // With Redis, this could be implemented with SCAN command
- // but wp_cache_* doesn't expose this, so we'd need direct Redis access
- // For now, just flush the group as a nuclear option
+ try {
if (function_exists('wp_cache_flush_group')) {
wp_cache_flush_group($this->group);
- return $count;
+ self::updateTimestamp($this->group);
+ return true;
}
- } else {
- // For transients, search and delete
- global $wpdb;
-
- $prefix = self::getTransientPrefix($this->group);
- $sql = "SELECT option_name FROM {$wpdb->options}
- WHERE option_name LIKE %s
- AND option_name LIKE %s";
-
- $keys = $wpdb->get_col($wpdb->prepare(
- $sql,
- '_transient_' . $prefix . '%',
- '%' . $pattern . '%'
- ));
-
- foreach ($keys as $key) {
- $transient_key = str_replace('_transient_', '', $key);
- delete_transient($transient_key);
- $count++;
- }
+ return false;
+ } catch (\Exception $e) {
+ return false;
}
-
- return $count;
}
/**
@@ -289,22 +328,18 @@
{
$group = $group ?: $this->group;
$ttl = $ttl ?: $this->cache_ttl;
-
$key = $this->normalizeKey($key);
$value = $this->get($key, $group);
+
if ($value === false) {
$value = $callback();
- if ($value !== false) {
- $value = [
- 'data' => $value,
- 'last_modified' => time(),
- ];
+ if ($value !== false && $value !== null) {
$this->set($key, $value, $ttl, $group);
}
}
- return (is_array($value) && array_key_exists('data', $value)) ? $value['data']: $value;
+ return $value;
}
/**
@@ -318,59 +353,315 @@
}
/**
- * Get transient key for fallback mode
- * @param string $key
- * @param string $group
- * @return string
+ * Get instance group name (for debugging)
*/
- private function getTransientKey(string $key, string $group): string
+ public function getGroup(): string
{
- // Transients have a 172 character limit
- $full_key = $group . '_' . $key;
+ return $this->group;
+ }
- if (strlen($full_key) > 160) {
- // Use hash for long keys, but keep group prefix for clearPattern()
- return substr($group, 0, 20) . '_' . md5($full_key);
+ // ===== RELATIONSHIP MANAGEMENT =====
+
+ /**
+ * Register cache relationship
+ * When $type is invalidated, these related types are also invalidated
+ *
+ * @param string $type Primary type
+ * @param array $config Relationship configuration
+ * - 'author' => bool - Invalidate user content caches
+ * - 'taxonomies' => array - List of taxonomy types to invalidate
+ * - 'content_types' => array - List of content types to invalidate
+ * - 'related' => array - Generic related types to invalidate
+ * - 'cascade' => callable - Custom cascade function
+ */
+ public static function registerRelationship(string $type, array $config): void
+ {
+ $type = jvbNoBase($type);
+
+ // Merge with existing relationships
+ self::$relationships[$type] = array_merge(
+ self::$relationships[$type] ?? [],
+ $config
+ );
+
+ // Build reverse relationships for bidirectional linking
+ self::buildReverseRelationships($type, $config);
+ }
+
+ /**
+ * Build reverse relationships (if A relates to B, B should know about A)
+ *
+ * @param string $type The type being registered
+ * @param array $config Its relationship config
+ */
+ private static function buildReverseRelationships(string $type, array $config): void
+ {
+ // If this type relates to taxonomies, those taxonomies should know about this type
+ if (!empty($config['taxonomies'])) {
+ foreach ($config['taxonomies'] as $taxonomy) {
+ $taxonomy = jvbNoBase($taxonomy);
+ self::$relationships[$taxonomy]['content_types'] =
+ array_unique(array_merge(
+ self::$relationships[$taxonomy]['content_types'] ?? [],
+ [$type]
+ ));
+ }
}
- return $full_key;
+ // If this type relates to content_types, those types should know about this taxonomy
+ if (!empty($config['content_types'])) {
+ foreach ($config['content_types'] as $content_type) {
+ $content_type = jvbNoBase($content_type);
+ self::$relationships[$content_type]['related'] =
+ array_unique(array_merge(
+ self::$relationships[$content_type]['related'] ?? [],
+ [$type]
+ ));
+ }
+ }
}
/**
- * Get transient prefix for a group
+ * Load relationships from JVB_CONTENT and JVB_TAXONOMY
*/
- private static function getTransientPrefix(string $group): string
+ private static function loadRelationships(): void
{
- return $group . '_jvb_';
- }
-
- /**
- * Check if using object cache
- */
- public function isUsingObjectCache(): bool
- {
- return static::$use_object_cache;
- }
-
-
- /**
- * Cleanup expired transients (maintenance method for non-Redis environments)
- */
- public static function cleanupExpiredTransients(): int
- {
- if (wp_using_ext_object_cache()) {
- return 0; // Not needed with Redis
+ if (self::$relationships_loaded) {
+ return;
}
- global $wpdb;
+ // Load post type relationships
+ if (defined('JVB_CONTENT')) {
+ foreach (JVB_CONTENT as $slug => $config) {
+ $relationships = [];
- // Delete expired transients
- $sql = "DELETE a, b FROM {$wpdb->options} a, {$wpdb->options} b
- WHERE a.option_name LIKE '_transient_%'
- AND a.option_name NOT LIKE '_transient_timeout_%'
- AND b.option_name = CONCAT('_transient_timeout_', SUBSTRING(a.option_name, 12))
- AND b.option_value < %d";
+ // Author relationship
+ if (!($config['no_author'] ?? false)) {
+ $relationships['author'] = true;
+ }
- return $wpdb->query($wpdb->prepare($sql, time()));
+ // Taxonomy relationships
+ if (!empty($config['taxonomies'])) {
+ $relationships['taxonomies'] = array_map('jvbNoBase', $config['taxonomies']);
+ }
+
+ // Custom relationships from config
+ if (!empty($config['cache_relationships'])) {
+ $relationships = array_merge($relationships, $config['cache_relationships']);
+ }
+
+ if (!empty($relationships)) {
+ self::registerRelationship($slug, $relationships);
+ }
+ }
+ }
+
+ // Load taxonomy relationships
+ if (defined('JVB_TAXONOMY')) {
+ foreach (JVB_TAXONOMY as $slug => $config) {
+ $relationships = [];
+
+ // Content type relationships
+ if (!empty($config['for_content'])) {
+ $relationships['content_types'] = array_map('jvbNoBase', $config['for_content']);
+ }
+
+ // Always include generic 'terms' cache
+ $relationships['related'] = ['terms'];
+
+ // Custom relationships from config
+ if (!empty($config['cache_relationships'])) {
+ $relationships = array_merge($relationships, $config['cache_relationships']);
+ }
+
+ if (!empty($relationships)) {
+ self::registerRelationship($slug, $relationships);
+ }
+ }
+ }
+
+ self::$relationships_loaded = true;
+
+ do_action('jvb_cache_relationships_loaded', self::$relationships);
+ }
+
+ /**
+ * Get relationships for a type (for debugging)
+ *
+ * @param string|null $type Specific type or null for all
+ * @return array Relationships
+ */
+ public static function getRelationships(?string $type = null): array
+ {
+ self::loadRelationships();
+
+ if ($type !== null) {
+ return self::$relationships[jvbNoBase($type)] ?? [];
+ }
+
+ return self::$relationships;
+ }
+
+ /**
+ * Cascade invalidation to related types based on relationships
+ *
+ * @param string $type Primary type being invalidated
+ * @param mixed $context Context with relationship data
+ */
+ /**
+ * Cascade invalidation to related types based on relationships
+ */
+ private static function cascadeInvalidation(string $type, $context): void
+ {
+ self::loadRelationships();
+
+ $relationships = self::$relationships[$type] ?? [];
+ if (empty($relationships)) {
+ return;
+ }
+
+ $data = self::extractContext($context);
+
+ // Author relationship - SIMPLIFIED
+ if (!empty($relationships['author'])) {
+ $user_ids = self::extractUserIds($data, $relationships['author']);
+
+ foreach ($user_ids as $user_id) {
+ // Single clean call - handles content, profile, everything
+ self::invalidateAll("user_{$user_id}");
+ }
+ }
+
+ // Taxonomy relationships
+ if (!empty($relationships['taxonomies']) && !empty($data['ID'])) {
+ foreach ($relationships['taxonomies'] as $taxonomy) {
+ $taxonomy_full = jvbCheckBase($taxonomy);
+ $terms = wp_get_post_terms($data['ID'], $taxonomy_full, ['fields' => 'ids']);
+
+ if (!empty($terms) && !is_wp_error($terms)) {
+ self::updateTimestamp($taxonomy);
+ wp_cache_flush_group($taxonomy);
+ }
+ }
+ }
+
+ // Content type relationships (for taxonomies)
+ if (!empty($relationships['content_types'])) {
+ foreach ($relationships['content_types'] as $content_type) {
+ self::updateTimestamp($content_type);
+ wp_cache_flush_group($content_type);
+ }
+ }
+
+ // Generic related caches
+ if (!empty($relationships['related'])) {
+ foreach ($relationships['related'] as $related_type) {
+ self::updateTimestamp($related_type);
+ wp_cache_flush_group($related_type);
+ }
+ }
+
+ // Custom cascade function
+ if (!empty($relationships['cascade']) && is_callable($relationships['cascade'])) {
+ call_user_func($relationships['cascade'], $type, $data);
+ }
+ }
+
+ /**
+ * Extract user IDs from context based on relationship config
+ * Supports multiple authors, contributors, etc.
+ *
+ * @param array $data Context data
+ * @param mixed $config Author relationship config (bool or array)
+ * @return array User IDs to invalidate
+ */
+ private static function extractUserIds(array $data, $config): array
+ {
+ $user_ids = [];
+
+ // Simple case: 'author' => true
+ if ($config === true) {
+ if (!empty($data['post_author'])) {
+ $user_ids[] = $data['post_author'];
+ }
+ return array_filter($user_ids);
+ }
+
+ // Advanced case: 'author' => ['post_author', 'contributors', 'linked_user']
+ if (is_array($config)) {
+ foreach ($config as $field) {
+ // Handle meta fields
+ if (str_starts_with($field, 'meta:') && !empty($data['ID'])) {
+ $meta_key = substr($field, 5);
+ $value = get_post_meta($data['ID'], BASE . $meta_key, true);
+
+ if (is_array($value)) {
+ $user_ids = array_merge($user_ids, $value);
+ } elseif ($value) {
+ $user_ids[] = $value;
+ }
+ }
+ // Handle direct data fields
+ elseif (!empty($data[$field])) {
+ if (is_array($data[$field])) {
+ $user_ids = array_merge($user_ids, $data[$field]);
+ } else {
+ $user_ids[] = $data[$field];
+ }
+ }
+ }
+ }
+
+ // Callable: 'author' => function($data) { return [...user_ids]; }
+ if (is_callable($config)) {
+ $result = call_user_func($config, $data);
+ if (is_array($result)) {
+ $user_ids = array_merge($user_ids, $result);
+ } elseif ($result) {
+ $user_ids[] = $result;
+ }
+ }
+
+ return array_unique(array_filter(array_map('intval', $user_ids)));
+ }
+
+ /**
+ * Extract context data from various formats
+ * Converts WP objects to arrays with relevant data
+ *
+ * @param mixed $context Post/Term object, array, or ID
+ * @return array Normalized context data
+ */
+ private static function extractContext($context): array
+ {
+ if (is_array($context)) {
+ return $context;
+ }
+
+ if ($context instanceof \WP_Post) {
+ return [
+ 'ID' => $context->ID,
+ 'post_author' => $context->post_author,
+ 'post_type' => $context->post_type,
+ 'post_status' => $context->post_status,
+ ];
+ }
+
+ if ($context instanceof \WP_Term) {
+ return [
+ 'term_id' => $context->term_id,
+ 'taxonomy' => $context->taxonomy,
+ 'parent' => $context->parent,
+ ];
+ }
+
+ if (is_numeric($context)) {
+ $post = get_post($context);
+ if ($post) {
+ return self::extractContext($post);
+ }
+ }
+
+ return [];
}
}
--
Gitblit v1.10.0