PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.1.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.1.1
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 1.10.0 All 48 releases
thinkrank / includes / api / class-social-platforms-endpoint.php

class-social-platforms-endpoint.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.1.1, at includes/api/class-social-platforms-endpoint.php

427 lines 13.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Social Platforms API Endpoints Class
4 *
5 * Provides REST API endpoints for social platform verification codes and IDs
6 * with selective encryption for sensitive verification codes.
7 *
8 * @package ThinkRank\API
9 * @since 1.0.0
10 */
11
12 declare(strict_types=1);
13
14 namespace ThinkRank\API;
15
16 use WP_REST_Controller;
17 use WP_REST_Request;
18 use WP_REST_Response;
19 use WP_Error;
20 use ThinkRank\Core\Settings;
21
22 // Prevent direct access
23 if (!defined('ABSPATH')) {
24 exit;
25 }
26
27 /**
28 * Social Platforms API Endpoints Class
29 *
30 * Handles social platform verification codes and IDs with selective encryption.
31 * Encrypts sensitive verification codes while keeping public IDs visible.
32 *
33 * @since 1.0.0
34 */
35 class Social_Platforms_Endpoint extends WP_REST_Controller {
36
37 /**
38 * API namespace
39 *
40 * @since 1.0.0
41 * @var string
42 */
43 protected $namespace = 'thinkrank/v1';
44
45 /**
46 * REST base
47 *
48 * @since 1.0.0
49 * @var string
50 */
51 protected $rest_base = 'social-platforms';
52
53 /**
54 * Settings instance
55 *
56 * @since 1.0.0
57 * @var Settings
58 */
59 private Settings $settings;
60
61 /**
62 * Sensitive keys that should be encrypted
63 *
64 * @since 1.0.0
65 * @var array
66 */
67 private array $sensitive_keys = [
68 'pinterest_site_verification',
69 'instagram_verification',
70 'tiktok_verification'
71 ];
72
73 /**
74 * Public keys that remain visible
75 *
76 * @since 1.0.0
77 * @var array
78 */
79 private array $public_keys = [
80 'facebook_app_id',
81 'facebook_admins',
82 'youtube_channel_id',
83 'whatsapp_business_id'
84 ];
85
86 /**
87 * Constructor
88 *
89 * @since 1.0.0
90 */
91 public function __construct() {
92 $this->settings = Settings::instance();
93 }
94
95 /**
96 * Register API routes
97 *
98 * @since 1.0.0
99 */
100 public function register_routes(): void {
101 // Social platform settings management
102 register_rest_route(
103 $this->namespace,
104 '/' . $this->rest_base . '/settings',
105 [
106 [
107 'methods' => 'GET',
108 'callback' => [$this, 'get_settings'],
109 'permission_callback' => [$this, 'check_read_permissions']
110 ],
111 [
112 'methods' => 'POST',
113 'callback' => [$this, 'update_settings'],
114 'permission_callback' => [$this, 'check_manage_permissions'],
115 'args' => $this->get_settings_args()
116 ]
117 ]
118 );
119 }
120
121 /**
122 * Get social platform settings
123 *
124 * @since 1.0.0
125 *
126 * @param WP_REST_Request $request Request object
127 * @return WP_REST_Response Response object
128 */
129 public function get_settings(WP_REST_Request $request): WP_REST_Response {
130 try {
131 $settings = $this->get_social_platform_settings();
132
133 return new WP_REST_Response([
134 'success' => true,
135 'data' => [
136 'settings' => $settings
137 ],
138 'message' => 'Social platform settings retrieved successfully'
139 ], 200);
140
141 } catch (\Exception $e) {
142 return new WP_REST_Response([
143 'success' => false,
144 'message' => 'Failed to retrieve social platform settings: ' . $e->getMessage()
145 ], 500);
146 }
147 }
148
149 /**
150 * Update social platform settings
151 *
152 * @since 1.0.0
153 *
154 * @param WP_REST_Request $request Request object
155 * @return WP_REST_Response Response object
156 */
157 public function update_settings(WP_REST_Request $request): WP_REST_Response {
158 try {
159 $settings = $request->get_param('settings');
160
161 if (empty($settings) || !is_array($settings)) {
162 return new WP_REST_Response([
163 'success' => false,
164 'message' => 'Invalid settings data provided'
165 ], 400);
166 }
167
168 // Sanitize input, then enforce the shared format rules before saving.
169 // Sanitization only strips unsafe characters; without this, malformed
170 // verification codes / IDs would be stored and later emitted verbatim.
171 $sanitized_settings = $this->sanitize_settings($settings);
172
173 // Drop sensitive verification codes that arrive still masked (the
174 // client resending the XXXX placeholder for an unchanged field).
175 // The server is the source of truth: a masked value is never a real
176 // edit, so removing it here both preserves the stored secret and
177 // keeps the untouched placeholder out of the format validator below
178 // (otherwise every save of this tab would 400 once a code is set).
179 $sanitized_settings = $this->strip_masked_sensitive_values($sanitized_settings);
180
181 $validation_errors = $this->validate_settings_format($sanitized_settings);
182 if (!empty($validation_errors)) {
183 return new WP_REST_Response([
184 'success' => false,
185 'message' => 'One or more social platform values are in an invalid format',
186 'errors' => $validation_errors
187 ], 400);
188 }
189
190 $success = $this->save_social_platform_settings($sanitized_settings);
191
192 if ($success) {
193 return new WP_REST_Response([
194 'success' => true,
195 'data' => [
196 'settings' => $this->get_social_platform_settings()
197 ],
198 'message' => 'Social platform settings saved successfully'
199 ], 200);
200 } else {
201 return new WP_REST_Response([
202 'success' => false,
203 'message' => 'Failed to save social platform settings'
204 ], 500);
205 }
206
207 } catch (\Exception $e) {
208 return new WP_REST_Response([
209 'success' => false,
210 'message' => 'Failed to update social platform settings: ' . $e->getMessage()
211 ], 500);
212 }
213 }
214
215 /**
216 * Get social platform settings from Settings class
217 *
218 * @since 1.0.0
219 * @return array Settings array
220 */
221 private function get_social_platform_settings(): array {
222 $settings = [];
223
224 // Get public IDs (visible)
225 foreach ($this->public_keys as $key) {
226 $settings[$key] = $this->settings->get($key, '');
227 }
228
229 // Get sensitive verification codes (encrypted, masked for display)
230 foreach ($this->sensitive_keys as $key) {
231 $value = $this->settings->get($key, '');
232 $settings[$key] = $this->mask_verification_code($value);
233 }
234
235 return $settings;
236 }
237
238 /**
239 * Save social platform settings using Settings class
240 *
241 * @since 1.0.0
242 * @param array $settings Settings to save
243 * @return bool Success status
244 */
245 private function save_social_platform_settings(array $settings): bool {
246 $success = true;
247
248 // Save each setting individually using the Settings class
249 // This ensures proper encryption for sensitive verification codes
250 foreach ($settings as $key => $value) {
251 // Only save if the key is in our allowed lists and has a value
252 if (in_array($key, array_merge($this->public_keys, $this->sensitive_keys), true) && !empty($value)) {
253 if (!$this->settings->set($key, $value)) {
254 $success = false;
255 }
256 }
257 }
258
259 return $success;
260 }
261
262 /**
263 * Sanitize settings data
264 *
265 * @since 1.0.0
266 * @param array $settings Raw settings
267 * @return array Sanitized settings
268 */
269 private function sanitize_settings(array $settings): array {
270 $sanitized = [];
271
272 // Sanitize public IDs (only if not empty)
273 foreach ($this->public_keys as $key) {
274 if (!empty($settings[$key])) {
275 $sanitized[$key] = sanitize_text_field($settings[$key]);
276 }
277 }
278
279 // Sanitize sensitive verification codes (only if not empty)
280 foreach ($this->sensitive_keys as $key) {
281 if (!empty($settings[$key])) {
282 $sanitized[$key] = sanitize_text_field($settings[$key]);
283 }
284 }
285
286 return $sanitized;
287 }
288
289 /**
290 * Remove sensitive verification codes that are still masked.
291 *
292 * The Social Platforms tab receives verification codes masked (e.g. `a1b2XXXX`)
293 * and binds them straight into their input fields. When the tab is saved
294 * without re-typing a code, that masked placeholder is sent back. Persisting
295 * it would overwrite the real encrypted secret, and — since 1.14.0 — it also
296 * fails format validation, causing the whole save (including unrelated fields)
297 * to 400. A masked value is never a genuine edit, so we drop it here and keep
298 * the currently stored secret untouched.
299 *
300 * @since 1.27.0
301 *
302 * @param array $settings Sanitized settings.
303 * @return array Settings with masked sensitive values removed.
304 */
305 private function strip_masked_sensitive_values(array $settings): array {
306 foreach ($this->sensitive_keys as $key) {
307 if (!isset($settings[$key]) || $settings[$key] === '') {
308 continue;
309 }
310
311 $incoming = (string) $settings[$key];
312 $stored = (string) $this->settings->get($key, '');
313
314 // Primary check: the incoming value is exactly the mask of the
315 // currently stored secret (the untouched field round-tripping).
316 // Fallback check: the value still matches a generic mask shape, so
317 // even without a stored value we never persist a bare placeholder.
318 if (
319 ($stored !== '' && $incoming === $this->mask_verification_code($stored))
320 || $this->is_masked_value($incoming)
321 ) {
322 unset($settings[$key]);
323 }
324 }
325
326 return $settings;
327 }
328
329 /**
330 * Determine whether a value looks like a masking placeholder.
331 *
332 * Mirrors the shapes produced by mask_verification_code(): `XXXX` for short
333 * codes and `<first 4 chars>XXXX` for longer ones. Genuine verification codes
334 * for the sensitive keys are never this short (Pinterest is 32 hex chars;
335 * Instagram/TikTok require 20+ chars), so a match is safe to treat as "unchanged".
336 *
337 * @since 1.27.0
338 *
339 * @param string $value Value to test.
340 * @return bool True when the value is a mask placeholder.
341 */
342 private function is_masked_value(string $value): bool {
343 return $value === 'XXXX' || (bool) preg_match('/^.{4}XXXX$/', $value);
344 }
345
346 /**
347 * Validate sanitized settings against the shared social platform format rules.
348 *
349 * Reuses Social_Meta_Manager's per-field rules (single source of truth) so the
350 * REST save path rejects malformed verification codes / IDs instead of storing
351 * them and letting them render as broken verification meta tags.
352 *
353 * @since 1.14.0
354 *
355 * @param array $settings Sanitized settings.
356 * @return array<string, string> Map of field key => error message; empty when all valid.
357 */
358 private function validate_settings_format(array $settings): array {
359 $errors = [];
360
361 foreach ($settings as $key => $value) {
362 $error = \ThinkRank\SEO\Social_Meta_Manager::validate_platform_field($key, $value);
363 if ($error !== null) {
364 $errors[$key] = $error;
365 }
366 }
367
368 return $errors;
369 }
370
371 /**
372 * Mask verification code for security display (XXX pattern)
373 *
374 * @since 1.0.0
375 * @param string $code Verification code to mask
376 * @return string Masked code or empty string
377 */
378 private function mask_verification_code(string $code): string {
379 if (empty($code)) {
380 return '';
381 }
382
383 // Show first 4 characters + XXXX suffix (like placeholders)
384 if (strlen($code) > 8) {
385 return substr($code, 0, 4) . 'XXXX';
386 }
387
388 return 'XXXX';
389 }
390
391 /**
392 * Get settings arguments for REST API
393 *
394 * @since 1.0.0
395 * @return array Settings arguments
396 */
397 private function get_settings_args(): array {
398 return [
399 'settings' => [
400 'required' => true,
401 'type' => 'object',
402 'description' => 'Social platform settings object'
403 ]
404 ];
405 }
406
407 /**
408 * Check read permissions
409 *
410 * @since 1.0.0
411 * @return bool Permission status
412 */
413 public function check_read_permissions(): bool {
414 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
415 }
416
417 /**
418 * Check manage permissions
419 *
420 * @since 1.0.0
421 * @return bool Permission status
422 */
423 public function check_manage_permissions(): bool {
424 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
425 }
426 }
427