| @@ -66,17 +66,13 @@ | ||
| 66 | 66 | |
| 67 | 67 | // Get the response body. |
| 68 | 68 | $response_body = wp_remote_retrieve_body( $response ); |
| 69 | 69 | |
| 70 | - // If the response body is not a JSON, then abandon ship. | |
| 71 | - if ( empty( $response_body ) || ! json_decode( $response_body ) ) { | |
| 72 | - return [ | |
| 73 | - 'error' => __( 'The SureForms AI Middleware encountered an error.', 'sureforms' ), | |
| 74 | - ]; | |
| 75 | - } | |
| 76 | - | |
| 77 | - // Return the response body. | |
| 78 | - return json_decode( $response_body, true ); | |
| 70 | + return self::decode_json_response( | |
| 71 | + $response_body, | |
| 72 | + wp_remote_retrieve_response_code( $response ), | |
| 73 | + 'generate/form' | |
| 74 | + ); | |
| 79 | 75 | } |
| 80 | 76 | |
| 81 | 77 | /** |
| 82 | 78 | * Get the SureForms Token from the SureForms AI Settings. |
| @@ -141,17 +137,14 @@ | ||
| 141 | 137 | |
| 142 | 138 | // Get the response body. |
| 143 | 139 | $response_body = wp_remote_retrieve_body( $response ); |
| 144 | 140 | |
| 145 | - // If the response body is not a JSON, then abandon ship. | |
| 146 | - if ( empty( $response_body ) || ! json_decode( $response_body ) ) { | |
| 147 | - return [ | |
| 148 | - 'error' => __( 'The SureForms API server encountered an error.', 'sureforms' ), | |
| 149 | - ]; | |
| 150 | - } | |
| 151 | - | |
| 152 | - // Return the response body. | |
| 153 | - return json_decode( $response_body, true ); | |
| 141 | + return self::decode_json_response( | |
| 142 | + $response_body, | |
| 143 | + wp_remote_retrieve_response_code( $response ), | |
| 144 | + 'usage', | |
| 145 | + __( 'The SureForms API server encountered an error.', 'sureforms' ) | |
| 146 | + ); | |
| 154 | 147 | } |
| 155 | 148 | |
| 156 | 149 | /** |
| 157 | 150 | * Get the Error Message. |
| @@ -226,8 +219,145 @@ | ||
| 226 | 219 | return ''; |
| 227 | 220 | } |
| 228 | 221 | // Check if the SureForms Pro license is active. |
| 229 | 222 | return $licensing->is_license_active(); |
| 223 | + } | |
| 224 | + | |
| 225 | + /** | |
| 226 | + * Sanitize an upstream error message before returning it to the client. | |
| 227 | + * | |
| 228 | + * The OpenAI / SureForms middleware sometimes echoes infrastructure details | |
| 229 | + * (URLs, request IDs, model names, organization/user IDs, raw API keys) | |
| 230 | + * inside error messages. The endpoints surfacing these messages are | |
| 231 | + * capability-gated, but contributors-and-up shouldn't see infra leaks. | |
| 232 | + * | |
| 233 | + * Pass-through behaviour is preserved when the message has no sensitive | |
| 234 | + * tokens — only matched patterns are stripped. Returns an empty string | |
| 235 | + * if nothing useful remains, so callers can fall back to a canonical | |
| 236 | + * translated message. | |
| 237 | + * | |
| 238 | + * @param mixed $raw Raw upstream message; non-strings are coerced. | |
| 239 | + * @param string $endpoint Optional endpoint label; when set, the raw input | |
| 240 | + * is passed through {@see self::log_ai_response_failure()} | |
| 241 | + * so the unredacted form is preserved server-side | |
| 242 | + * (subject to the usual WP_DEBUG / WP_DEBUG_LOG gates). | |
| 243 | + * @param int|string $status_code Optional HTTP status, forwarded to the logger. | |
| 244 | + * @since 2.8.2 | |
| 245 | + * @return string Sanitized message safe to return to the client. | |
| 246 | + */ | |
| 247 | + public static function sanitize_ai_error_message( $raw, $endpoint = '', $status_code = '' ) { | |
| 248 | + if ( ! is_string( $raw ) ) { | |
| 249 | + return ''; | |
| 250 | + } | |
| 251 | + $raw = trim( $raw ); | |
| 252 | + if ( '' === $raw ) { | |
| 253 | + return ''; | |
| 254 | + } | |
| 255 | + | |
| 256 | + if ( '' !== $endpoint ) { | |
| 257 | + self::log_ai_response_failure( $endpoint, $status_code, 'upstream_error', $raw ); | |
| 258 | + } | |
| 259 | + | |
| 260 | + $patterns = [ | |
| 261 | + // URLs (http / https / protocol-relative). | |
| 262 | + '#https?://\S+#i', | |
| 263 | + '#(?<=\s)//\S+#i', | |
| 264 | + // OpenAI-shape opaque IDs: org-/user-/key-/sess-/req-/file-/chatcmpl-/asst-/run-/thread-. | |
| 265 | + // Both '-' and '_' separators are observed in the wild (e.g. req-… and req_…). | |
| 266 | + '/\b(?:org|user|key|sess|req|file|chatcmpl|asst|run|thread)[-_][A-Za-z0-9_]{6,}/i', | |
| 267 | + // Generic "request id: …" / "request-id …" trailers — require a separator and a substantive id. | |
| 268 | + '/\brequest[_\s-]?id[:\s]+[A-Za-z0-9_-]{4,}/i', | |
| 269 | + // Bearer / API-key shapes. | |
| 270 | + '/\bsk-[A-Za-z0-9_-]{12,}/i', | |
| 271 | + '/\bBearer\s+[A-Za-z0-9._-]+/i', | |
| 272 | + // Model identifiers that would otherwise leak the underlying provider. | |
| 273 | + '/\bgpt-[A-Za-z0-9.-]+/i', | |
| 274 | + ]; | |
| 275 | + $cleaned = (string) preg_replace( $patterns, '', $raw ); | |
| 276 | + // Collapse the gaps left by removed tokens. | |
| 277 | + $cleaned = (string) preg_replace( '/\s+/', ' ', $cleaned ); | |
| 278 | + return trim( $cleaned, " \t\n\r\0\x0B.,;:" ); | |
| 279 | + } | |
| 280 | + | |
| 281 | + /** | |
| 282 | + * Decode the response body from the SureForms AI Middleware, returning | |
| 283 | + * a structured error payload when the body is empty or invalid JSON. | |
| 284 | + * | |
| 285 | + * @param string $response_body Raw HTTP response body. | |
| 286 | + * @param int|string $status_code HTTP status code, used for debug logging. | |
| 287 | + * @param string $endpoint Short endpoint label, used for debug logging. | |
| 288 | + * @param string|null $error_fallback Translated fallback message on decode failure. | |
| 289 | + * @since 2.8.2 | |
| 290 | + * @return array<mixed> | |
| 291 | + */ | |
| 292 | + protected static function decode_json_response( $response_body, $status_code, $endpoint, $error_fallback = null ) { | |
| 293 | + if ( null === $error_fallback ) { | |
| 294 | + $error_fallback = __( 'The SureForms AI Middleware encountered an error.', 'sureforms' ); | |
| 295 | + } | |
| 296 | + | |
| 297 | + if ( '' === $response_body || null === $response_body ) { | |
| 298 | + self::log_ai_response_failure( $endpoint, $status_code, 'empty_body', '' ); | |
| 299 | + return [ 'error' => $error_fallback ]; | |
| 300 | + } | |
| 301 | + | |
| 302 | + $decoded = json_decode( $response_body, true ); | |
| 303 | + | |
| 304 | + if ( JSON_ERROR_NONE !== json_last_error() ) { | |
| 305 | + self::log_ai_response_failure( $endpoint, $status_code, 'invalid_json', $response_body ); | |
| 306 | + return [ 'error' => $error_fallback ]; | |
| 307 | + } | |
| 308 | + | |
| 309 | + if ( ! is_array( $decoded ) ) { | |
| 310 | + self::log_ai_response_failure( $endpoint, $status_code, 'non_array_json', $response_body ); | |
| 311 | + return [ 'error' => $error_fallback ]; | |
| 312 | + } | |
| 313 | + | |
| 314 | + return $decoded; | |
| 315 | + } | |
| 316 | + | |
| 317 | + /** | |
| 318 | + * Log an AI middleware response failure when WP_DEBUG and WP_DEBUG_LOG are both enabled. | |
| 319 | + * | |
| 320 | + * Newlines are collapsed to prevent log injection, and known sensitive JSON keys | |
| 321 | + * (email, token, license_key, prompt, query) are redacted before logging. | |
| 322 | + * | |
| 323 | + * @param string $endpoint Short endpoint label. | |
| 324 | + * @param int|string $status_code HTTP status code. | |
| 325 | + * @param string $reason Failure reason identifier. | |
| 326 | + * @param string $body Raw response body (will be truncated). | |
| 327 | + * @since 2.8.2 | |
| 328 | + * @return void | |
| 329 | + */ | |
| 330 | + protected static function log_ai_response_failure( $endpoint, $status_code, $reason, $body ) { | |
| 331 | + if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) { | |
| 332 | + return; | |
| 333 | + } | |
| 334 | + | |
| 335 | + // Only write to the debug log file when WP_DEBUG_LOG is also enabled. | |
| 336 | + // Without this guard, error_log() falls back to the host's PHP error log. | |
| 337 | + if ( ! defined( 'WP_DEBUG_LOG' ) || ! WP_DEBUG_LOG ) { | |
| 338 | + return; | |
| 339 | + } | |
| 340 | + | |
| 341 | + $snippet = is_string( $body ) ? substr( $body, 0, 500 ) : ''; | |
| 342 | + // Collapse all whitespace (including CR/LF) to a single space to prevent log injection. | |
| 343 | + $snippet = (string) preg_replace( '/\s+/', ' ', $snippet ); | |
| 344 | + // Redact known sensitive keys if echoed in the body. | |
| 345 | + $snippet = (string) preg_replace( | |
| 346 | + '/("(?:email|token|license_key|prompt|query)"\s*:\s*")[^"]*"/i', | |
| 347 | + '$1[redacted]"', | |
| 348 | + $snippet | |
| 349 | + ); | |
| 350 | + | |
| 351 | + error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug-only logging behind WP_DEBUG && WP_DEBUG_LOG. | |
| 352 | + sprintf( | |
| 353 | + '[SureForms AI] %s %s status=%s body=%s', | |
| 354 | + $endpoint, | |
| 355 | + $reason, | |
| 356 | + (string) $status_code, | |
| 357 | + $snippet | |
| 358 | + ) | |
| 359 | + ); | |
| 230 | 360 | } |
| 231 | 361 | |
| 232 | 362 | /** |
| 233 | 363 | * Get the User Token. |