| @@ -88,9 +88,9 @@ | ||
| 88 | 88 | * |
| 89 | 89 | * @since 1.0.0 |
| 90 | 90 | */ |
| 91 | 91 | public function __construct() { |
| 92 | - $this->settings = new Settings(); | |
| 92 | + $this->settings = Settings::instance(); | |
| 93 | 93 | } |
| 94 | 94 | |
| 95 | 95 | /** |
| 96 | 96 | * Register API routes |
| @@ -164,10 +164,30 @@ | ||
| 164 | 164 | 'message' => 'Invalid settings data provided' |
| 165 | 165 | ], 400); |
| 166 | 166 | } |
| 167 | 167 | |
| 168 | - // Sanitize and save settings | |
| 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. | |
| 169 | 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 | + | |
| 170 | 190 | $success = $this->save_social_platform_settings($sanitized_settings); |
| 171 | 191 | |
| 172 | 192 | if ($success) { |
| 173 | 193 | return new WP_REST_Response([ |
| @@ -228,9 +248,9 @@ | ||
| 228 | 248 | // Save each setting individually using the Settings class |
| 229 | 249 | // This ensures proper encryption for sensitive verification codes |
| 230 | 250 | foreach ($settings as $key => $value) { |
| 231 | 251 | // Only save if the key is in our allowed lists and has a value |
| 232 | - if (in_array($key, array_merge($this->public_keys, $this->sensitive_keys)) && !empty($value)) { | |
| 252 | + if (in_array($key, array_merge($this->public_keys, $this->sensitive_keys), true) && !empty($value)) { | |
| 233 | 253 | if (!$this->settings->set($key, $value)) { |
| 234 | 254 | $success = false; |
| 235 | 255 | } |
| 236 | 256 | } |
| @@ -266,8 +286,90 @@ | ||
| 266 | 286 | return $sanitized; |
| 267 | 287 | } |
| 268 | 288 | |
| 269 | 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 | + /** | |
| 270 | 372 | * Mask verification code for security display (XXX pattern) |
| 271 | 373 | * |
| 272 | 374 | * @since 1.0.0 |
| 273 | 375 | * @param string $code Verification code to mask |
| @@ -308,9 +410,9 @@ | ||
| 308 | 410 | * @since 1.0.0 |
| 309 | 411 | * @return bool Permission status |
| 310 | 412 | */ |
| 311 | 413 | public function check_read_permissions(): bool { |
| 312 | - return current_user_can('manage_options'); | |
| 414 | + return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings'); | |
| 313 | 415 | } |
| 314 | 416 | |
| 315 | 417 | /** |
| 316 | 418 | * Check manage permissions |
| @@ -318,7 +420,7 @@ | ||
| 318 | 420 | * @since 1.0.0 |
| 319 | 421 | * @return bool Permission status |
| 320 | 422 | */ |
| 321 | 423 | public function check_manage_permissions(): bool { |
| 322 | - return current_user_can('manage_options'); | |
| 424 | + return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings'); | |
| 323 | 425 | } |
| 324 | 426 | } |