| @@ -141,11 +141,11 @@ | ||
| 141 | 141 | // Get matching transient keys |
| 142 | 142 | $transient_pattern = "_transient_{$pattern}"; |
| 143 | 143 | $cache_prefix_pattern = "_transient_{$this->cache_prefix}%"; |
| 144 | 144 | |
| 145 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Cache invalidation requires direct database access | |
| 145 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Cache invalidation requires direct database access | |
| 146 | 146 | $transients = $wpdb->get_col( |
| 147 | - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- wpdb->options is WordPress core table | |
| 147 | + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- wpdb->options is WordPress core table | |
| 148 | 148 | $wpdb->prepare( |
| 149 | 149 | "SELECT option_name FROM {$wpdb->options} |
| 150 | 150 | WHERE option_name LIKE %s |
| 151 | 151 | AND option_name LIKE %s", |
| @@ -174,9 +174,9 @@ | ||
| 174 | 174 | protected function get_cache_stats(): array { |
| 175 | 175 | global $wpdb; |
| 176 | 176 | |
| 177 | 177 | // Count total cache entries for this endpoint |
| 178 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Cache statistics require direct database access | |
| 178 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Cache statistics require direct database access | |
| 179 | 179 | $total_entries = $wpdb->get_var( |
| 180 | 180 | $wpdb->prepare( |
| 181 | 181 | "SELECT COUNT(*) FROM {$wpdb->options} |
| 182 | 182 | WHERE option_name LIKE %s", |
| @@ -184,9 +184,9 @@ | ||
| 184 | 184 | ) |
| 185 | 185 | ); |
| 186 | 186 | |
| 187 | 187 | // Get cache size (approximate) |
| 188 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Cache size calculation requires direct database access | |
| 188 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Cache size calculation requires direct database access | |
| 189 | 189 | $cache_size = $wpdb->get_var( |
| 190 | 190 | $wpdb->prepare( |
| 191 | 191 | "SELECT SUM(LENGTH(option_value)) FROM {$wpdb->options} |
| 192 | 192 | WHERE option_name LIKE %s", |
| @@ -216,13 +216,16 @@ | ||
| 216 | 216 | private function generate_cache_key(string $endpoint, array $params = [], ?int $user_id = null): string { |
| 217 | 217 | // Sort parameters for consistent key generation |
| 218 | 218 | ksort($params); |
| 219 | 219 | |
| 220 | - // Build key components | |
| 220 | + // Build key components. The prefix is stored with a trailing underscore | |
| 221 | + // so the "_transient_{$this->cache_prefix}%" LIKE patterns above keep a | |
| 222 | + // word boundary; implode() supplies the separator here, so trim it off | |
| 223 | + // rather than emitting keys like `thinkrank_seo_analytics__totals_<md5>`. | |
| 221 | 224 | $key_parts = [ |
| 222 | - $this->cache_prefix, | |
| 225 | + rtrim($this->cache_prefix, '_'), | |
| 223 | 226 | $endpoint, |
| 224 | - md5(serialize($params)) | |
| 227 | + md5(wp_json_encode($params)) | |
| 225 | 228 | ]; |
| 226 | 229 | |
| 227 | 230 | if ($user_id !== null) { |
| 228 | 231 | $key_parts[] = "user_{$user_id}"; |
| @@ -306,11 +309,37 @@ | ||
| 306 | 309 | } |
| 307 | 310 | |
| 308 | 311 | // Generate fresh response |
| 309 | 312 | $response = call_user_func($callback); |
| 310 | - | |
| 311 | - // Cache the response | |
| 312 | - $this->set_cached_response($endpoint, $response, $params, $duration, $user_id); | |
| 313 | - | |
| 313 | + | |
| 314 | + // Cache the response — but never cache a failure payload. Caching | |
| 315 | + // `success => false` / error responses pins a transient upstream | |
| 316 | + // failure (e.g. a momentary Google API error) for the full TTL, which | |
| 317 | + // shows up to users as screens that stay empty long after the | |
| 318 | + // underlying issue resolved. | |
| 319 | + if ($this->is_cacheable_response($response)) { | |
| 320 | + $this->set_cached_response($endpoint, $response, $params, $duration, $user_id); | |
| 321 | + } | |
| 322 | + | |
| 314 | 323 | return array_merge($response, ['cached' => false]); |
| 324 | + } | |
| 325 | + | |
| 326 | + /** | |
| 327 | + * Whether a response payload represents a success that is safe to cache. | |
| 328 | + * | |
| 329 | + * @since 1.0.0 | |
| 330 | + * | |
| 331 | + * @param array $response Response payload from the endpoint callback | |
| 332 | + * @return bool True when the payload should be cached | |
| 333 | + */ | |
| 334 | + private function is_cacheable_response(array $response): bool { | |
| 335 | + if (array_key_exists('success', $response) && $response['success'] === false) { | |
| 336 | + return false; | |
| 337 | + } | |
| 338 | + | |
| 339 | + if (!empty($response['error'])) { | |
| 340 | + return false; | |
| 341 | + } | |
| 342 | + | |
| 343 | + return true; | |
| 315 | 344 | } |
| 316 | 345 | } |