PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 All 49 releases
← All changes | includes/api/class-content-brief-endpoint.php +123 -77 1.0.02.7.0 View file →
@@ -16,8 +16,13 @@
16 16 use WP_REST_Request;
17 17 use WP_REST_Response;
18 18 use WP_Error;
19 19
20 +// Prevent direct access
21 +if (!defined('ABSPATH')) {
22 + exit;
23 +}
24 +
20 25 /**
21 26 * Content Brief Endpoint class
22 27 */
23 28 class Content_Brief_Endpoint {
@@ -34,8 +39,15 @@
34 39 */
35 40 private ?Content_Brief_Generator $generator = null;
36 41
37 42 /**
43 + * Generator instance for read-only work (no AI client attached)
44 + *
45 + * @var Content_Brief_Generator|null
46 + */
47 + private ?Content_Brief_Generator $storage_generator = null;
48 +
49 + /**
38 50 * Constructor
39 51 */
40 52 public function __construct() {
41 53 // Don't instantiate generator here - do it lazily when needed
@@ -43,8 +55,11 @@
43 55
44 56 /**
45 57 * Get generator instance (lazy loading)
46 58 *
59 + * Only for routes that actually call the AI provider. Storage-only routes
60 + * must use get_storage_generator() instead.
61 + *
47 62 * @return Content_Brief_Generator
48 63 * @throws \Exception If generator cannot be created
49 64 */
50 65 private function get_generator(): Content_Brief_Generator {
@@ -58,8 +73,25 @@
58 73 return $this->generator;
59 74 }
60 75
61 76 /**
77 + * Get a generator for storage-only work (list / export / delete).
78 + *
79 + * These routes only read and write the briefs table, so they must not
80 + * require an AI client: on a site with no API key configured — the default
81 + * for a fresh install — building one throws and turns a plain database read
82 + * into a 500.
83 + *
84 + * @return Content_Brief_Generator
85 + */
86 + private function get_storage_generator(): Content_Brief_Generator {
87 + if ($this->storage_generator === null) {
88 + $this->storage_generator = new Content_Brief_Generator(null, null, false);
89 + }
90 + return $this->storage_generator;
91 + }
92 +
93 + /**
62 94 * Register REST API routes
63 95 *
64 96 * @return void
65 97 */
@@ -114,21 +146,8 @@
114 146 ]
115 147 ]
116 148 ]);
117 149
118 - // Save content brief
119 - register_rest_route(self::NAMESPACE, '/content-brief/save', [
120 - 'methods' => 'POST',
121 - 'callback' => [$this, 'save_brief'],
122 - 'permission_callback' => [$this, 'check_permissions'],
123 - 'args' => [
124 - 'brief_data' => [
125 - 'required' => true,
126 - 'type' => 'object'
127 - ]
128 - ]
129 - ]);
130 -
131 150 // Get user's content briefs
132 151 register_rest_route(self::NAMESPACE, '/content-brief/list', [
133 152 'methods' => 'GET',
134 153 'callback' => [$this, 'get_briefs'],
@@ -172,12 +191,14 @@
172 191 'required' => true,
173 192 'type' => 'integer',
174 193 'minimum' => 1
175 194 ],
195 + // Only plain-text export is implemented; keep the enum honest
196 + // rather than advertising pdf/docx that fall back to text.
176 197 'format' => [
177 198 'type' => 'string',
178 - 'default' => 'pdf',
179 - 'enum' => ['pdf', 'docx', 'txt']
199 + 'default' => 'txt',
200 + 'enum' => ['txt']
180 201 ]
181 202 ]
182 203 ]);
183 204 }
@@ -187,10 +208,20 @@
187 208 *
188 209 * @param WP_REST_Request $request Request object
189 210 * @return WP_REST_Response|WP_Error Response object
190 211 */
191 - public function generate_brief(WP_REST_Request $request): WP_REST_Response|WP_Error {
212 + public function generate_brief(WP_REST_Request $request) {
192 213 try {
214 + // Persistent per-user throttle on this paid AI-backed route (the other
215 + // AI endpoints do the same) to prevent an edit_posts user looping it.
216 + if (!$this->check_ai_rate_limit()) {
217 + return new WP_Error(
218 + 'rate_limit_exceeded',
219 + 'Rate limit exceeded. Please wait a few minutes before generating another content brief.',
220 + ['status' => 429]
221 + );
222 + }
223 +
193 224 $params = [
194 225 'target_keywords' => $request->get_param('target_keywords'),
195 226 'content_type' => $request->get_param('content_type'),
196 227 'target_audience' => $request->get_param('target_audience'),
@@ -217,46 +248,19 @@
217 248 }
218 249 }
219 250
220 251 /**
221 - * Save content brief
222 - *
223 - * @param WP_REST_Request $request Request object
224 - * @return WP_REST_Response|WP_Error Response object
225 - */
226 - public function save_brief(WP_REST_Request $request): WP_REST_Response|WP_Error {
227 - try {
228 - $brief_data = $request->get_param('brief_data');
229 -
230 - // This would typically save an existing brief or update it
231 - // For now, we'll return success as the generation already saves
232 -
233 - return new WP_REST_Response([
234 - 'success' => true,
235 - 'message' => 'Content brief saved successfully'
236 - ], 200);
237 -
238 - } catch (\Exception $e) {
239 - return new WP_Error(
240 - 'brief_save_failed',
241 - $e->getMessage(),
242 - ['status' => 500]
243 - );
244 - }
245 - }
246 -
247 - /**
248 252 * Get user's content briefs
249 253 *
250 254 * @param WP_REST_Request $request Request object
251 255 * @return WP_REST_Response|WP_Error Response object
252 256 */
253 - public function get_briefs(WP_REST_Request $request): WP_REST_Response|WP_Error {
257 + public function get_briefs(WP_REST_Request $request) {
254 258 try {
255 259 $limit = $request->get_param('limit');
256 260 $offset = $request->get_param('offset');
257 261
258 - $briefs = $this->get_generator()->get_user_briefs($limit, $offset);
262 + $briefs = $this->get_storage_generator()->get_user_briefs($limit, $offset);
259 263
260 264 return new WP_REST_Response([
261 265 'success' => true,
262 266 'data' => $briefs,
@@ -263,17 +267,8 @@
263 267 'total' => count($briefs)
264 268 ], 200);
265 269
266 270 } catch (\Exception $e) {
267 - // If no API key is configured, return empty list instead of error
268 - if (strpos($e->getMessage(), 'Please configure your AI provider') !== false) {
269 - return new WP_REST_Response([
270 - 'success' => true,
271 - 'data' => [],
272 - 'total' => 0
273 - ], 200);
274 - }
275 -
276 271 return new WP_Error(
277 272 'briefs_fetch_failed',
278 273 $e->getMessage(),
279 274 ['status' => 500]
@@ -286,12 +281,12 @@
286 281 *
287 282 * @param WP_REST_Request $request Request object
288 283 * @return WP_REST_Response|WP_Error Response object
289 284 */
290 - public function delete_brief(WP_REST_Request $request): WP_REST_Response|WP_Error {
285 + public function delete_brief(WP_REST_Request $request) {
291 286 try {
292 287 $brief_id = $request->get_param('id');
293 - $success = $this->get_generator()->delete_brief($brief_id);
288 + $success = $this->get_storage_generator()->delete_brief($brief_id);
294 289
295 290 if ($success) {
296 291 return new WP_REST_Response([
297 292 'success' => true,
@@ -319,17 +314,16 @@
319 314 *
320 315 * @param WP_REST_Request $request Request object
321 316 * @return WP_REST_Response|WP_Error Response object
322 317 */
323 - public function export_brief(WP_REST_Request $request): WP_REST_Response|WP_Error {
318 + public function export_brief(WP_REST_Request $request) {
324 319 try {
325 - $brief_id = $request->get_param('id');
320 + $brief_id = (int) $request->get_param('id');
326 321 $format = $request->get_param('format');
327 322
328 - // For now, return a simple text export
329 - // In a full implementation, this would generate PDF/DOCX files
330 - $briefs = $this->get_generator()->get_user_briefs(1, 0);
331 - $brief = $briefs[0] ?? null;
323 + // Fetch the requested brief, scoped to the current user. Returns null
324 + // (→ 404) when the id doesn't exist or belongs to another user.
325 + $brief = $this->get_storage_generator()->get_brief($brief_id);
332 326
333 327 if (!$brief) {
334 328 return new WP_Error(
335 329 'brief_not_found',
@@ -362,28 +356,46 @@
362 356 * @param string $format Export format
363 357 * @return string Formatted content
364 358 */
365 359 private function format_brief_for_export(array $brief, string $format): string {
366 - $brief_data = $brief['brief_data'];
367 -
368 - $content = "Content Brief: " . $brief['title'] . "\n\n";
369 - $content .= "Target Keywords: " . implode(', ', $brief['target_keywords']) . "\n";
370 - $content .= "Content Type: " . $brief['content_type'] . "\n\n";
371 -
372 - if (!empty($brief_data['outline'])) {
360 + // Every read here is a field of json_decode() output, so nothing about
361 + // its shape is guaranteed. implode() on null and str_repeat() on a
362 + // negative count are a TypeError and a ValueError respectively, and
363 + // neither is an \Exception — so the catch around this call never
364 + // matched and an export of a malformed brief was a fatal (#394).
365 + $brief_data = is_array($brief['brief_data'] ?? null) ? $brief['brief_data'] : [];
366 + $keywords = is_array($brief['target_keywords'] ?? null) ? $brief['target_keywords'] : [];
367 +
368 + $content = "Content Brief: " . (string) ($brief['title'] ?? '') . "\n\n";
369 + $content .= "Target Keywords: " . implode(', ', array_map('strval', $keywords)) . "\n";
370 + $content .= "Content Type: " . (string) ($brief['content_type'] ?? '') . "\n\n";
371 +
372 + if (!empty($brief_data['outline']) && is_array($brief_data['outline'])) {
373 373 $content .= "Content Outline:\n";
374 +
374 375 foreach ($brief_data['outline'] as $item) {
375 - $indent = str_repeat(' ', $item['level'] - 1);
376 - $content .= $indent . "H{$item['level']}: " . $item['heading'];
377 - if ($item['word_count'] > 0) {
378 - $content .= " ({$item['word_count']} words)";
376 + if (!is_array($item)) {
377 + continue;
379 378 }
379 +
380 + // Clamped: a level of 0 or a missing one made the repeat count
381 + // negative.
382 + $level = max(1, min(6, (int) ($item['level'] ?? 1)));
383 + $word_count = (int) ($item['word_count'] ?? 0);
384 + $indent = str_repeat(' ', $level - 1);
385 +
386 + $content .= $indent . "H{$level}: " . (string) ($item['heading'] ?? '');
387 +
388 + if ($word_count > 0) {
389 + $content .= " ({$word_count} words)";
390 + }
391 +
380 392 $content .= "\n";
381 393 }
382 394 }
383 -
384 - $content .= "\nGenerated on: " . $brief['created_at'];
385 -
395 +
396 + $content .= "\nGenerated on: " . (string) ($brief['created_at'] ?? '');
397 +
386 398 return $content;
387 399 }
388 400
389 401 /**
@@ -391,9 +403,20 @@
391 403 *
392 404 * @param array $keywords Keywords to validate
393 405 * @return bool|WP_Error Validation result
394 406 */
395 - public function validate_keywords(array $keywords): bool|WP_Error {
407 + public function validate_keywords($keywords) {
408 + // A custom validate_callback replaces WP's array type-coercion, so the
409 + // raw param arrives here as-is; reject non-arrays instead of letting a
410 + // strict array type hint throw an uncaught TypeError during dispatch.
411 + if (!is_array($keywords)) {
412 + return new WP_Error(
413 + 'invalid_keywords',
414 + 'Keywords must be provided as an array',
415 + ['status' => 400]
416 + );
417 + }
418 +
396 419 if (empty($keywords)) {
397 420 return new WP_Error(
398 421 'invalid_keywords',
399 422 'At least one keyword is required',
@@ -420,6 +443,29 @@
420 443 * @return bool Permission status
421 444 */
422 445 public function check_permissions(): bool {
423 446 return current_user_can('edit_posts');
447 + }
448 +
449 + /**
450 + * Persistent per-user rate limit for the AI-backed generate route.
451 + *
452 + * Transient-backed (survives across requests) and keyed per user, mirroring
453 + * the llms-txt endpoint's AI throttle but with its own bucket so the two
454 + * features don't share a budget.
455 + *
456 + * @return bool True if the request is within the limit.
457 + */
458 + private function check_ai_rate_limit(): bool {
459 + $user_id = get_current_user_id();
460 + $rate_key = "thinkrank_ai_rate_content_brief_{$user_id}";
461 +
462 + $requests = (int) get_transient($rate_key);
463 +
464 + if ($requests >= 5) { // Max 5 content briefs per 10 minutes.
465 + return false;
466 + }
467 +
468 + set_transient($rate_key, $requests + 1, 10 * MINUTE_IN_SECONDS);
469 + return true;
424 470 }
425 471 }