PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.0
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / FeedbackTransport.php

FeedbackTransport.php in 404 Solution 4.2.0, at includes/FeedbackTransport.php

1,329 lines 54.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 require_once dirname(__FILE__) . '/FeedbackEnvironmentExtras.php';
8 require_once dirname(__FILE__) . '/PayloadSchema.php';
9
10 /**
11 * HTTP-first transport for plugin feedback reports.
12 *
13 * Replaces direct wp_mail() sends with a POST to a central reports endpoint,
14 * keeping wp_mail() only as a last-resort fallback when HTTP fails. The class
15 * has two modes:
16 *
17 * queue($payload, $type): interactive paths (deactivate AJAX). Stores
18 * the payload in a transient and schedules a single-shot cron event so
19 * the user's click is never blocked on the network.
20 *
21 * sendNow($payload, $type): already-async paths (nightly cron).
22 * Synchronously POSTs the payload, falls back to wp_mail() on non-2xx
23 * or WP_Error.
24 *
25 * handleQueuedSend($uuid): cron handler. Loads transient, calls
26 * sendNow(), deletes transient regardless of outcome.
27 *
28 * buildPayload($type, $extra): centralised payload assembly. All required
29 * server fields (plugin_version, db_type, site_id, is_uninstall, etc.)
30 * are derived here.
31 *
32 * Callers are wired in subsequent tasks (c347/c348). This class is dormant
33 * until then.
34 */
35 class ABJ_404_Solution_FeedbackTransport {
36
37 const TRANSIENT_PREFIX = 'abj404_pending_report_';
38 const TRANSIENT_TTL = 86400; // 24 hours
39 const CRON_HOOK = 'abj404_send_queued_report';
40 const HTTP_TIMEOUT = 10;
41 const DEBUG_LOG_MAX_BYTES = 262144;
42
43 /**
44 * Records whether the most recent sendNow() call fell back to wp_mail()
45 * after the HTTP POST failed. Read-only for callers that need to surface
46 * "we sent via email instead of HTTP" in their own response (e.g. the
47 * support-request AJAX handler returning {fallback_used: true}). Reset
48 * at the top of every sendNow() call so concurrent reads don't see a
49 * stale value from a previous unrelated send.
50 *
51 * @var bool
52 */
53 private static $lastSendUsedFallback = false;
54
55 /**
56 * Diagnostic details from the most recent sendNow() call. Populated
57 * unconditionally so callers (e.g. the support-request AJAX handler)
58 * can surface the actual failure code and reason to the user instead
59 * of a generic "could not send" message.
60 *
61 * Shape:
62 * http_status: int|null HTTP status code from the developer
63 * endpoint when the wp_remote_post()
64 * call completed, or null when the
65 * request never reached HTTP.
66 * http_reason: string Short slug (json_encode_failed,
67 * gzencode_failed, wp_error,
68 * http_<code>) usable for log greps.
69 * http_detail: string Free-form context (WP_Error message,
70 * etc). May be empty.
71 * email_attempted: bool true when HTTP failed and the email
72 * fallback ran.
73 * email_ok: bool|null Result of the email fallback when it
74 * ran; null when not attempted.
75 *
76 * @var array{http_status: int|null, http_reason: string, http_detail: string, email_attempted: bool, email_ok: bool|null}
77 */
78 private static $lastSendDiagnostics = array(
79 'http_status' => null,
80 'http_reason' => '',
81 'http_detail' => '',
82 'email_attempted' => false,
83 'email_ok' => null,
84 );
85
86 /**
87 * Queue a payload for asynchronous send. Used by interactive paths
88 * (deactivate AJAX). Returns immediately; the actual send happens in a
89 * single-shot cron event.
90 *
91 * Schedules the cron event and then kicks WP-Cron via spawn_cron() so the
92 * send happens on the next request cycle instead of waiting for a natural
93 * cron tick. On low-traffic sites a natural tick can be hours away, which
94 * is long enough for the deactivate flow to forget about the report.
95 *
96 * @param array<string, mixed> $payload
97 * @param string $type
98 * @return void
99 */
100 public static function queue(array $payload, string $type): void {
101 self::validatePayloadContract($payload, $type);
102 $uuid = self::generateUuid();
103 $envelope = array(
104 'payload' => $payload,
105 'type' => $type,
106 );
107 // allow-cache-empty: feedback envelope is generated locally and may contain an intentionally empty payload.
108 set_transient(self::TRANSIENT_PREFIX . $uuid, $envelope, self::TRANSIENT_TTL);
109 wp_schedule_single_event(time(), self::CRON_HOOK, array($uuid));
110
111 // Trigger spawn_cron so the listener runs on the next request rather
112 // than waiting for the next page load on a logged-in admin. spawn_cron
113 // is a no-op when DISABLE_WP_CRON is true or a cron is already running.
114 if (function_exists('spawn_cron')) {
115 spawn_cron();
116 }
117 }
118
119 /**
120 * Synchronously POST and fall back to wp_mail() on failure. Used by paths
121 * already in cron context (nightly maintenance).
122 *
123 * @param array<string, mixed> $payload
124 * @param string $type
125 * @return bool true if any transport (HTTP or email) succeeded.
126 */
127 public static function sendNow(array $payload, string $type): bool {
128 self::$lastSendUsedFallback = false;
129 self::validatePayloadContract($payload, $type);
130 $payload = self::redactPayloadStrings($payload);
131 $started = microtime(true);
132 $result = self::httpSend($payload);
133 $elapsedMs = (int) round((microtime(true) - $started) * 1000);
134
135 $statusStr = isset($result['status']) && is_scalar($result['status']) ? (string)$result['status'] : '';
136 $reasonStr = isset($result['reason']) && is_scalar($result['reason']) ? (string)$result['reason'] : '';
137 $detailStr = isset($result['detail']) && is_scalar($result['detail']) ? (string)$result['detail'] : '';
138
139 self::$lastSendDiagnostics = array(
140 'http_status' => $statusStr !== '' ? (int)$statusStr : null,
141 'http_reason' => $reasonStr,
142 'http_detail' => $detailStr,
143 'email_attempted' => false,
144 'email_ok' => null,
145 );
146
147 if (!empty($result['ok'])) {
148 self::log('info', sprintf(
149 'abj404_transport: type=%s http_status=%s fallback_used=false ms_elapsed=%d',
150 $type,
151 $statusStr !== '' ? $statusStr : 'ok',
152 $elapsedMs
153 ));
154 return true;
155 }
156
157 $statusLabel = $statusStr !== '' ? $statusStr : ($reasonStr !== '' ? $reasonStr : 'unknown');
158 self::log('warn', sprintf(
159 'abj404_transport: type=%s http_status=%s fallback_used=true ms_elapsed=%d detail=%s',
160 $type,
161 $statusLabel,
162 $elapsedMs,
163 $detailStr
164 ));
165
166 self::$lastSendUsedFallback = true;
167 self::$lastSendDiagnostics['email_attempted'] = true;
168 $emailOk = self::emailFallback($payload, $type);
169 self::$lastSendDiagnostics['email_ok'] = $emailOk;
170 return $emailOk;
171 }
172
173 /**
174 * Diagnostic context from the most recent sendNow() call. Callers
175 * that surface a user-facing failure message must include the
176 * http_status / http_reason here so the message is actionable.
177 * "Could not send" alone is the diagnostic black-hole this method
178 * exists to prevent (CLAUDE.md > Error visibility).
179 *
180 * @return array{http_status: int|null, http_reason: string, http_detail: string, email_attempted: bool, email_ok: bool|null}
181 */
182 public static function lastSendDiagnostics(): array {
183 return self::$lastSendDiagnostics;
184 }
185
186 /**
187 * Whether the most recent sendNow() call used the wp_mail() fallback
188 * after the HTTP POST failed. Callers (e.g. the support-request AJAX
189 * handler) read this immediately after sendNow() to surface the
190 * transport result to the user.
191 *
192 * @return bool
193 */
194 public static function lastSendUsedFallback(): bool {
195 return self::$lastSendUsedFallback;
196 }
197
198 /**
199 * Cron handler for queued sends. Loads payload from transient, calls
200 * sendNow(), deletes transient regardless of outcome (24h TTL still
201 * cleans up if anything throws before the delete).
202 *
203 * @param string $uuid
204 * @return void
205 */
206 public static function handleQueuedSend(string $uuid): void {
207 $key = self::TRANSIENT_PREFIX . $uuid;
208 $envelope = get_transient($key);
209 if (!is_array($envelope) || !isset($envelope['payload']) || !is_array($envelope['payload'])) {
210 // Transient expired before WP-Cron fired, or the cron event fired
211 // twice and the second invocation found the key already cleared.
212 // Log so the data loss is visible to admins; 24h TTL means this
213 // path is reachable on sites where WP-Cron is broken or paused.
214 self::log('warn', sprintf(
215 'abj404_transport: queued send missed - transient absent or malformed (key=%s). ' .
216 'Most commonly: WP-Cron did not fire within the %d second TTL.',
217 $key,
218 self::TRANSIENT_TTL
219 ));
220 delete_transient($key);
221 return;
222 }
223 /** @var array<string, mixed> $payload */
224 $payload = $envelope['payload'];
225 $type = isset($envelope['type']) && is_string($envelope['type']) ? $envelope['type'] : 'unknown';
226
227 try {
228 self::sendNow($payload, $type);
229 } catch (\Throwable $e) {
230 // sendNow() must be defensive, but if anything escapes we still
231 // log and let the transient be cleared so cron doesn't loop on it.
232 self::log('warn', 'abj404_transport: sendNow threw: ' . $e->getMessage());
233 }
234
235 delete_transient($key);
236 }
237
238 /**
239 * Build a payload from current site state. $extra carries type-specific
240 * fields (uninstall_reason, debug_log, error_signature, etc.).
241 *
242 * @param string $type One of 'error', 'heartbeat', 'uninstall', 'support_request'.
243 * @param array<string, mixed> $extra
244 * @return array<string, mixed>
245 */
246 public static function buildPayload(string $type, array $extra = array()): array {
247 global $wpdb;
248
249 $dbVersion = '';
250 if (isset($wpdb) && is_object($wpdb) && method_exists($wpdb, 'db_version')) {
251 $raw = $wpdb->db_version();
252 $dbVersion = is_scalar($raw) ? (string)$raw : '';
253 }
254 // db_version() typically returns the numeric portion only; for
255 // mariadb detection we also probe the full VERSION() string.
256 $fullVersion = $dbVersion;
257 if (isset($wpdb) && is_object($wpdb) && method_exists($wpdb, 'get_var')) {
258 // DAO-bypass-approved: SELECT VERSION() is a parameterless server-introspection probe with no plugin tables involved; routing through queryAndGetResults() would force a missing-table-repair detour for a query that cannot fail with that error class
259 $probed = $wpdb->get_var('SELECT VERSION()');
260 if (is_string($probed) && $probed !== '') {
261 $fullVersion = $probed;
262 }
263 }
264 $dbType = (stripos($fullVersion, 'mariadb') !== false) ? 'mariadb' : 'mysql';
265
266 $tablePrefix = '';
267 if (isset($wpdb) && is_object($wpdb) && isset($wpdb->prefix) && is_string($wpdb->prefix)) {
268 $tablePrefix = $wpdb->prefix;
269 }
270
271 $payload = array(
272 'plugin_version' => defined('ABJ404_VERSION') ? ABJ404_VERSION : '',
273 'db_type' => $dbType,
274 'db_version' => $fullVersion,
275 'wp_version' => function_exists('get_bloginfo') ? (string)get_bloginfo('version') : '',
276 'php_version' => PHP_VERSION,
277 'is_multisite' => function_exists('is_multisite') ? (bool)is_multisite() : false,
278 'is_uninstall' => ($type === 'uninstall'),
279 'report_type' => $type,
280 'site_url' => function_exists('home_url') ? (string)home_url() : '',
281 'locale' => function_exists('get_locale') ? (string)get_locale() : '',
282 'resource_limits' => self::resourceLimits(),
283 'wp_memory_limit_bytes' => self::tryInt(function () { return self::memoryLimitBytes(); }),
284 'extensions' => self::loadedExtensionsMap(),
285 'active_plugins' => self::activePlugins(),
286 'active_theme' => self::activeTheme(),
287 // Server schema declares object_cache as string. "external" when
288 // a drop-in is installed (W3 Total Cache, Redis Object Cache),
289 // "default" when WordPress is using its in-process cache.
290 'object_cache' => (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) ? 'external' : 'default',
291 'table_prefix' => $tablePrefix,
292 'wp_debug' => defined('WP_DEBUG') && WP_DEBUG,
293 'server_software' => self::sanitizeServerSoftware(
294 isset($_SERVER['SERVER_SOFTWARE']) && is_scalar($_SERVER['SERVER_SOFTWARE']) ? (string)$_SERVER['SERVER_SOFTWARE'] : ''
295 ),
296 );
297
298 // Null (not 0) on lookup failure so the server can distinguish
299 // "actually zero" from "we don't know" per server-schema contract.
300 $payload['published_posts_count'] = self::tryInt(function () { return self::countPublishedPosts(); });
301 $payload['published_pages_count'] = self::tryInt(function () { return self::countPublishedPages(); });
302 $payload['categories_count'] = self::tryInt(function () { return self::countCategories(); });
303 $payload['tags_count'] = self::tryInt(function () { return self::countTags(); });
304
305 // Server schema flattens the DAO's ['all','manual','auto','regex','trash']
306 // map. 'redirects_active_total' is the DAO 'all' (manual+auto+regex).
307 $redirectCounts = self::tryArray(function () { return self::redirectCountsRaw(); });
308 $payload['redirects_active_total'] = self::pluckInt($redirectCounts, 'all');
309 $payload['redirects_manual_count'] = self::pluckInt($redirectCounts, 'manual');
310 $payload['redirects_automatic_count'] = self::pluckInt($redirectCounts, 'auto');
311 $payload['redirects_regex_count'] = self::pluckInt($redirectCounts, 'regex');
312 $payload['redirects_trashed_count'] = self::pluckInt($redirectCounts, 'trash');
313
314 // DAO key 'captured' is the "new" status; server renames it.
315 $capturedCounts = self::tryArray(function () { return self::capturedCountsRaw(); });
316 $payload['captured_404s_active_total'] = self::pluckInt($capturedCounts, 'all');
317 $payload['captured_404s_new_count'] = self::pluckInt($capturedCounts, 'captured');
318 $payload['captured_404s_ignored_count'] = self::pluckInt($capturedCounts, 'ignored');
319 $payload['captured_404s_later_count'] = self::pluckInt($capturedCounts, 'later');
320 $payload['captured_404s_trashed_count'] = self::pluckInt($capturedCounts, 'trash');
321
322 $payload['log_entries_count'] = self::tryInt(function () { return self::logEntriesCount(); });
323 $payload['log_table_size_bytes'] = self::tryInt(function () { return self::logTableSizeBytes(); });
324 $payload['error_count_in_log'] = self::tryInt(function () { return self::errorCountInLog(); });
325 $payload['debug_file_size_bytes'] = self::tryInt(function () { return self::debugFileSizeBytes(); });
326 $payload += self::debugLogPayload($type);
327 $payload['environment_extras'] = (new ABJ_404_Solution_FeedbackEnvironmentExtras())->collect();
328
329 if (self::isDevelopmentEnvironment()) {
330 $payload['environment_type'] = 'development';
331 }
332
333 // Type-specific extras override base fields where appropriate
334 // (uninstall adds uninstall_reason / contact_email, error adds
335 // error_signature / debug_log).
336 foreach ($extra as $k => $v) {
337 $payload[(string)$k] = $v;
338 }
339
340 return $payload;
341 }
342
343 /**
344 * Build a schema-conforming payload with diagnostic and site-identifying
345 * fields stripped. Used by the uninstall flow when the user unchecks the
346 * "Include technical details" opt-in (docs/diagnostic-catalog.md F1):
347 * the server still needs a well-formed payload to record the feedback,
348 * but the modal text presents that checkbox as the diagnostic opt-in,
349 * so unchecking it must actually suppress site_url, environment_extras,
350 * counts, server_software, active_plugins, etc.
351 *
352 * Routing-only fields (plugin_version, report_type, is_uninstall) stay
353 * at their real values; everything else gets the schema-allowed empty /
354 * null / enum-default. Type-specific extras from `$extra` are merged on
355 * top so the user's actual feedback (uninstall_reason, contact_email,
356 * followup_details) still rides through.
357 *
358 * @param string $type One of 'error', 'heartbeat', 'uninstall', 'support_request'.
359 * @param array<string, mixed> $extra
360 * @return array<string, mixed>
361 */
362 public static function buildMinimalPayload(string $type, array $extra = array()): array {
363 $payload = array(
364 // Routing fields - kept at real values so the server can route
365 // and version-tag the report.
366 'plugin_version' => defined('ABJ404_VERSION') ? ABJ404_VERSION : '',
367 'report_type' => $type,
368 'is_uninstall' => ($type === 'uninstall'),
369
370 // Site-identifying fields - blanked.
371 'site_url' => '',
372 'locale' => '',
373 'db_type' => 'mysql',
374 'db_version' => '',
375 'table_prefix' => '',
376 'wp_version' => '',
377 'is_multisite' => false,
378 'wp_debug' => false,
379 'php_version' => '',
380 'server_software' => '',
381
382 // Environment fields - empty / null defaults that still satisfy
383 // the schema (object/array shapes and int|null nullability).
384 'resource_limits' => array(),
385 'wp_memory_limit_bytes' => null,
386 'extensions' => array(),
387 'active_plugins' => array(),
388 'active_theme' => '',
389 'object_cache' => 'default',
390
391 // Content counts - null (the "unknown" sentinel).
392 'published_posts_count' => null,
393 'published_pages_count' => null,
394 'categories_count' => null,
395 'tags_count' => null,
396
397 // Redirect counts - null.
398 'redirects_active_total' => null,
399 'redirects_manual_count' => null,
400 'redirects_automatic_count' => null,
401 'redirects_regex_count' => null,
402 'redirects_trashed_count' => null,
403
404 // Captured-404 counts - null.
405 'captured_404s_active_total' => null,
406 'captured_404s_new_count' => null,
407 'captured_404s_ignored_count' => null,
408 'captured_404s_later_count' => null,
409 'captured_404s_trashed_count' => null,
410
411 // Log / debug file health - null.
412 'log_entries_count' => null,
413 'log_table_size_bytes' => null,
414 'error_count_in_log' => null,
415 'debug_file_size_bytes' => null,
416
417 // JSON passthrough - empty.
418 'environment_extras' => array(),
419 );
420
421 // Type-specific extras the user explicitly opted in to. These ride
422 // through unchanged so the feedback text/email survives the redaction.
423 foreach ($extra as $k => $v) {
424 $payload[(string)$k] = $v;
425 }
426
427 return $payload;
428 }
429
430 /**
431 * HTTP transport. Returns ['ok' => bool, 'status' => int|null,
432 * 'reason' => string|null, 'detail' => string|null].
433 *
434 * @param array<string, mixed> $payload
435 * @return array<string, mixed>
436 */
437 private static function httpSend(array $payload): array {
438 $endpoint = self::resolveEndpoint();
439 $json = function_exists('wp_json_encode') ? wp_json_encode($payload) : json_encode($payload);
440 if (!is_string($json) || $json === '') {
441 return array('ok' => false, 'reason' => 'json_encode_failed');
442 }
443
444 $body = function_exists('gzencode') ? gzencode($json, 6) : false;
445 if ($body === false) {
446 return array('ok' => false, 'reason' => 'gzencode_failed');
447 }
448
449 $response = wp_remote_post($endpoint, array(
450 'timeout' => self::HTTP_TIMEOUT,
451 'redirection' => 0,
452 'blocking' => true,
453 'headers' => array(
454 'Content-Type' => 'application/json',
455 'Content-Encoding' => 'gzip',
456 ),
457 'body' => $body,
458 ));
459
460 if (function_exists('is_wp_error') && is_wp_error($response)) {
461 // is_wp_error() narrowing guarantees get_error_message() exists
462 // on both real WP_Error and the test stub.
463 $raw = $response->get_error_message();
464 $msg = is_scalar($raw) ? (string)$raw : '';
465 return array('ok' => false, 'reason' => 'wp_error', 'detail' => $msg);
466 }
467
468 $code = function_exists('wp_remote_retrieve_response_code') ? (int)wp_remote_retrieve_response_code($response) : 0;
469 if ($code >= 200 && $code < 300) {
470 return array('ok' => true, 'status' => $code);
471 }
472 // Surface the server's structured error message in `detail`. The dev
473 // endpoint's setErrorHandler returns
474 // {statusCode, error: 'validation_failed', message: '<human>', field?}
475 // on schema rejections; without this extraction the admin only sees
476 // "HTTP 400" and has no way to tell which field was wrong.
477 $rawBody = function_exists('wp_remote_retrieve_body') ? wp_remote_retrieve_body($response) : '';
478 $detail = self::extractServerErrorDetail(is_string($rawBody) ? $rawBody : '');
479 return array('ok' => false, 'reason' => 'http_' . $code, 'status' => $code, 'detail' => $detail);
480 }
481
482 /**
483 * Pull the response body off a wp_remote_post() result and, when it's a
484 * JSON error envelope, extract a one-line "<message> [field=<path>]"
485 * detail. Falls back to a short truncated body when the response isn't
486 * structured JSON, so opaque HTML error pages from a misrouted endpoint
487 * still leave a fingerprint in the admin notice.
488 *
489 * @param string $body Raw response body from wp_remote_retrieve_body().
490 * @return string Empty string if no useful detail could be extracted.
491 */
492 private static function extractServerErrorDetail(string $body): string {
493 if ($body === '') {
494 return '';
495 }
496 $decoded = json_decode($body, true);
497 if (is_array($decoded)) {
498 $message = '';
499 if (isset($decoded['message']) && is_scalar($decoded['message'])) {
500 $message = trim((string)$decoded['message']);
501 }
502 $field = '';
503 if (isset($decoded['field']) && is_scalar($decoded['field'])) {
504 $field = trim((string)$decoded['field']);
505 }
506 if ($message !== '' && $field !== '') {
507 return $message . ' [field=' . $field . ']';
508 }
509 if ($message !== '') {
510 return $message;
511 }
512 }
513 // Non-JSON body (HTML error page, plain text). Trim to a sane size
514 // so a 1 MB rendered 502 page can't blow up the admin notice.
515 $trimmed = trim($body);
516 if ($trimmed === '') {
517 return '';
518 }
519 return strlen($trimmed) > 240 ? substr($trimmed, 0, 240) . '...' : $trimmed;
520 }
521
522 /**
523 * Resolve the endpoint URL, allowing override via the
524 * 'abj404_report_endpoint' filter. Falls back to a string default if
525 * the constant is not defined yet (e.g. before Loader.php boots).
526 *
527 * @return string
528 */
529 private static function resolveEndpoint(): string {
530 $default = defined('ABJ404_REPORT_ENDPOINT')
531 ? ABJ404_REPORT_ENDPOINT
532 : 'https://404solution.ajexperience.com/api/v1/reports';
533 if (function_exists('apply_filters')) {
534 $filtered = apply_filters('abj404_report_endpoint', $default);
535 // Reject anything that isn't a non-empty http(s) URL so a buggy
536 // filter callback can never coerce wp_remote_post into a SSRF /
537 // file:// / data: request, or block on a malformed host.
538 if (is_string($filtered) && $filtered !== '' && self::isHttpUrl($filtered)) {
539 return $filtered;
540 }
541 if ($filtered !== $default) {
542 self::log('warn', sprintf(
543 'abj404_transport: abj404_report_endpoint filter returned an invalid value; using default. got=%s',
544 is_scalar($filtered) ? (string)$filtered : gettype($filtered)
545 ));
546 }
547 }
548 return $default;
549 }
550
551 /**
552 * Strict-shape URL check: only accept http:// or https:// with a host
553 * component. parse_url('http://') yields a parseable structure with no
554 * host, so we test for that explicitly.
555 *
556 * @param string $url
557 * @return bool
558 */
559 private static function isHttpUrl(string $url): bool {
560 $scheme = function_exists('wp_parse_url')
561 ? wp_parse_url($url, PHP_URL_SCHEME)
562 : parse_url($url, PHP_URL_SCHEME);
563 if (!is_string($scheme) || ($scheme !== 'http' && $scheme !== 'https')) {
564 return false;
565 }
566 $host = function_exists('wp_parse_url')
567 ? wp_parse_url($url, PHP_URL_HOST)
568 : parse_url($url, PHP_URL_HOST);
569 return is_string($host) && $host !== '';
570 }
571
572 /**
573 * Last-resort wp_mail() fallback. Routes to the type-specific email body
574 * builder so HTTP and email transports share a single source of truth:
575 * - 'uninstall' delegates to UninstallModal::sendFeedbackEmail($payload)
576 * - 'error' / 'heartbeat' delegates to Logging::emailLogFileToDeveloper($payload)
577 * (attaches a zip of the current debug log and renders the same HTML
578 * body the email transport used pre-migration).
579 * - 'support_request' is built inline (subject + body include the
580 * user's message and reply email at the top, then the standard
581 * diagnostic block + log excerpt).
582 *
583 * If the type-specific delegate is unavailable (class not loaded, service
584 * container empty), falls back to a generic JSON dump so the data is at
585 * least preserved somewhere.
586 *
587 * @param array<string, mixed> $payload
588 * @param string $type
589 * @return bool
590 */
591 private static function emailFallback(array $payload, string $type): bool {
592 if (!function_exists('wp_mail')) {
593 return false;
594 }
595 if ($type === 'uninstall' && class_exists('ABJ_404_Solution_UninstallModal')) {
596 return ABJ_404_Solution_UninstallModal::sendFeedbackEmail($payload);
597 }
598 if ($type === 'support_request') {
599 return self::supportRequestEmailFallback($payload);
600 }
601 if (($type === 'error' || $type === 'heartbeat') && function_exists('abj_service')) {
602 try {
603 $logger = abj_service('logging');
604 if (is_object($logger) && method_exists($logger, 'emailLogFileToDeveloper')) {
605 return (bool) $logger->emailLogFileToDeveloper($payload);
606 }
607 } catch (\Throwable $e) {
608 @error_log('404 Solution: FeedbackTransport email-fallback delegate (' . $type . ') failed: ' . $e->getMessage());
609 }
610 }
611 $to = defined('ABJ404_AUTHOR_EMAIL') ? ABJ404_AUTHOR_EMAIL : '404solution@ajexperience.com';
612 $version = defined('ABJ404_VERSION') ? ABJ404_VERSION : '';
613 $subject = sprintf('[404 Solution] %s report (HTTP fallback) v%s', $type, $version);
614 $json = function_exists('wp_json_encode') ? wp_json_encode($payload, JSON_PRETTY_PRINT) : json_encode($payload, JSON_PRETTY_PRINT);
615 $body = is_string($json) ? $json : '';
616 $headers = array('Content-Type: text/plain; charset=UTF-8');
617 $result = wp_mail($to, $subject, $body, $headers);
618 return (bool)$result;
619 }
620
621 /**
622 * Build the wp_mail() body for type='support_request' fallbacks. The
623 * user-facing fields (their message and reply address) lead the body so
624 * a reviewer can act on the request without scrolling past the
625 * diagnostic block. The standard payload dump and any captured log
626 * excerpt follow as appendices.
627 *
628 * @param array<string, mixed> $payload
629 * @return bool
630 */
631 private static function supportRequestEmailFallback(array $payload): bool {
632 $to = defined('ABJ404_AUTHOR_EMAIL') ? ABJ404_AUTHOR_EMAIL : '404solution@ajexperience.com';
633 $version = defined('ABJ404_VERSION') ? ABJ404_VERSION : '';
634 $subject = sprintf('[404 Solution] Support request v%s', $version);
635
636 $userMessage = isset($payload['user_message']) && is_scalar($payload['user_message'])
637 ? (string)$payload['user_message'] : '';
638 $replyEmail = isset($payload['reply_email']) && is_scalar($payload['reply_email'])
639 ? (string)$payload['reply_email'] : '';
640 $triggeredFrom = isset($payload['triggered_from']) && is_scalar($payload['triggered_from'])
641 ? (string)$payload['triggered_from'] : '';
642 $logExcerpt = isset($payload['debug_log_excerpt']) && is_scalar($payload['debug_log_excerpt'])
643 ? (string)$payload['debug_log_excerpt'] : '';
644
645 $bodyLines = array();
646 $bodyLines[] = '=== USER SUPPORT REQUEST ===';
647 $bodyLines[] = '';
648 $bodyLines[] = 'Reply-To: ' . ($replyEmail !== '' ? $replyEmail : '(not provided)');
649 $bodyLines[] = 'Triggered from: ' . ($triggeredFrom !== '' ? $triggeredFrom : '(unknown)');
650 $bodyLines[] = '';
651 $bodyLines[] = '--- User message ---';
652 $bodyLines[] = $userMessage !== '' ? $userMessage : '(no message provided)';
653 $bodyLines[] = '';
654 $bodyLines[] = '=== DIAGNOSTICS ===';
655 $bodyLines[] = '';
656 $scalar = static function ($v, string $fallback = ''): string {
657 return is_scalar($v) ? (string)$v : $fallback;
658 };
659 $bodyLines[] = 'Plugin version: ' . $scalar($payload['plugin_version'] ?? null);
660 $bodyLines[] = 'PHP version: ' . $scalar($payload['php_version'] ?? null, PHP_VERSION);
661 $bodyLines[] = 'WP version: ' . $scalar($payload['wp_version'] ?? null);
662 $bodyLines[] = 'DB: ' . $scalar($payload['db_type'] ?? null) . ' ' . $scalar($payload['db_version'] ?? null);
663 $bodyLines[] = 'Site URL: ' . $scalar($payload['site_url'] ?? null);
664 $bodyLines[] = 'Multisite: ' . (!empty($payload['is_multisite']) ? 'yes' : 'no');
665 $bodyLines[] = '';
666 if ($logExcerpt !== '') {
667 $bodyLines[] = '=== DEBUG LOG EXCERPT ===';
668 $bodyLines[] = '';
669 $bodyLines[] = $logExcerpt;
670 $bodyLines[] = '';
671 }
672 $bodyLines[] = '=== FULL PAYLOAD (JSON) ===';
673 $json = function_exists('wp_json_encode') ? wp_json_encode($payload, JSON_PRETTY_PRINT) : json_encode($payload, JSON_PRETTY_PRINT);
674 $bodyLines[] = is_string($json) ? $json : '';
675
676 $body = implode("\n", $bodyLines);
677 $headers = array('Content-Type: text/plain; charset=UTF-8');
678 if ($replyEmail !== '') {
679 $headers[] = 'Reply-To: ' . $replyEmail;
680 }
681 $result = wp_mail($to, $subject, $body, $headers);
682 return (bool)$result;
683 }
684
685 /**
686 * Detect a development host so the server can filter local traffic out
687 * of production reports.
688 *
689 * @return bool
690 */
691 private static function isDevelopmentEnvironment(): bool {
692 $home = function_exists('home_url') ? (string)home_url() : '';
693 $host = '';
694 if (function_exists('wp_parse_url')) {
695 $parsed = wp_parse_url($home, PHP_URL_HOST);
696 $host = is_string($parsed) ? $parsed : '';
697 } else {
698 $parsed = parse_url($home, PHP_URL_HOST);
699 $host = is_string($parsed) ? $parsed : '';
700 }
701 if ($host === '') {
702 return false;
703 }
704 if ($host === 'localhost') {
705 return true;
706 }
707 if (preg_match('/\.(test|local|dev|localhost)$/i', $host) === 1) {
708 return true;
709 }
710 // home_url() returning host:port: wp_parse_url strips the port, so
711 // dev-only ports (8888 MAMP, etc.) are caught by the WP_DEBUG plus
712 // localhost check on the raw home_url() string below.
713 if (defined('WP_DEBUG') && WP_DEBUG && strpos((string)$home, 'localhost') !== false) {
714 return true;
715 }
716 return false;
717 }
718
719 /**
720 * @return array<string, mixed>
721 */
722 private static function resourceLimits(): array {
723 // Server schema (see /api/v1/reports) requires every resource_limits
724 // value to be an integer. ini_get() returns shorthand strings like
725 // "256M" or "30s"; converting here keeps the server validator and the
726 // database column types aligned (BIGINT for bytes, INT for seconds).
727 return array(
728 'php_memory' => function_exists('ini_get') ? self::iniSizeToBytes((string)ini_get('memory_limit')) : 0,
729 'wp_memory' => defined('WP_MEMORY_LIMIT') ? self::iniSizeToBytes((string)WP_MEMORY_LIMIT) : 0,
730 'php_max_execution_seconds' => function_exists('ini_get') ? (int)ini_get('max_execution_time') : 0,
731 'php_post_max_size' => function_exists('ini_get') ? self::iniSizeToBytes((string)ini_get('post_max_size')) : 0,
732 'php_upload_max_size' => function_exists('ini_get') ? self::iniSizeToBytes((string)ini_get('upload_max_filesize')) : 0,
733 );
734 }
735
736 /**
737 * Convert PHP's shorthand byte notation ("256M", "1G", "1024K", "-1") to
738 * an integer count of bytes. Returns 0 for empty input or the special
739 * "no limit" value -1, since the server schema requires non-negative
740 * integers. Plain-numeric strings ("512") are treated as already-bytes.
741 *
742 * @param string $value
743 * @return int
744 */
745 private static function iniSizeToBytes(string $value): int {
746 $str = trim($value);
747 if ($str === '' || $str === '-1' || $str === '0') {
748 return 0;
749 }
750 $unit = strtolower(substr($str, -1));
751 $num = (int) $str;
752 switch ($unit) {
753 case 'g': return $num * 1024 * 1024 * 1024;
754 case 'm': return $num * 1024 * 1024;
755 case 'k': return $num * 1024;
756 default: return $num;
757 }
758 }
759
760 /**
761 * Resolve the WordPress memory limit constant to bytes. Mirrors the
762 * existing email transport, which exposes this as a string; servers
763 * receive a stable integer so they can sort or compare across sites
764 * without re-parsing "256M" / "512M".
765 *
766 * @return int Byte count, or 0 when undefined.
767 */
768 private static function memoryLimitBytes(): int {
769 if (!defined('WP_MEMORY_LIMIT')) {
770 return 0;
771 }
772 return self::iniSizeToBytes((string)WP_MEMORY_LIMIT);
773 }
774
775 /**
776 * Strip site-identifying noise from the SERVER_SOFTWARE banner before
777 * shipping it. Apache's mod_status footer (ServerSignature) writes
778 * "Server at <hostname> Port <n>" onto SERVER_SOFTWARE on hosts that
779 * never disabled the banner, leaking the site's internal hostname into
780 * telemetry. Cut at that literal marker (case-insensitive) and cap the
781 * result so a multi-line or otherwise verbose banner cannot smuggle
782 * additional context through. Useful prefix (software + version, e.g.
783 * "Apache/2.4.41 (Ubuntu)") is preserved.
784 *
785 * @param string $raw
786 * @return string
787 */
788 private static function sanitizeServerSoftware(string $raw): string {
789 if ($raw === '') {
790 return '';
791 }
792 $marker = stripos($raw, ' Server at ');
793 $clean = $marker === false ? $raw : substr($raw, 0, $marker);
794 $clean = trim($clean);
795 if (strlen($clean) > 100) {
796 $clean = substr($clean, 0, 100);
797 }
798 return $clean;
799 }
800
801 /**
802 * Call an int-returning helper, returning null if it throws. Lets
803 * buildPayload() assemble a partial report when one count source fails.
804 *
805 * @param callable $fn
806 * @return int|null
807 */
808 private static function tryInt(callable $fn): ?int {
809 try {
810 $v = $fn();
811 return is_int($v) ? $v : null;
812 } catch (\Throwable $e) {
813 @error_log('404 Solution: FeedbackTransport count lookup failed: ' . $e->getMessage());
814 return null;
815 }
816 }
817
818 /**
819 * Call a string-returning helper, returning an empty string if it throws.
820 *
821 * @param callable $fn
822 * @return string
823 */
824 private static function tryString(callable $fn): string {
825 try {
826 $v = $fn();
827 return is_string($v) ? $v : '';
828 } catch (\Throwable $e) {
829 @error_log('404 Solution: FeedbackTransport string lookup failed: ' . $e->getMessage());
830 return '';
831 }
832 }
833
834 /**
835 * Call an array-returning helper, returning [] if it throws.
836 *
837 * @param callable $fn
838 * @return array<string, int>
839 */
840 private static function tryArray(callable $fn): array {
841 try {
842 $v = $fn();
843 if (!is_array($v)) {
844 return array();
845 }
846 /** @var array<string, int> $coerced */
847 $coerced = array();
848 foreach ($v as $k => $val) {
849 if (is_string($k) && is_int($val)) {
850 $coerced[$k] = $val;
851 }
852 }
853 return $coerced;
854 } catch (\Throwable $e) {
855 @error_log('404 Solution: FeedbackTransport array lookup failed: ' . $e->getMessage());
856 return array();
857 }
858 }
859
860 /**
861 * Pull a single int from a count map, returning null when the key is
862 * absent. Distinguishes "DAO returned an empty array" (failure, null)
863 * from "DAO returned 0 for this status" (real zero) in the payload.
864 *
865 * @param array<string, mixed> $map
866 * @param string $key
867 * @return int|null
868 */
869 private static function pluckInt(array $map, string $key): ?int {
870 if (!array_key_exists($key, $map)) {
871 return null;
872 }
873 $v = $map[$key];
874 return is_scalar($v) ? (int)$v : null;
875 }
876
877 /**
878 * @return int wp_count_posts('post')->publish.
879 */
880 private static function countPublishedPosts(): int {
881 if (!function_exists('wp_count_posts')) {
882 throw new \RuntimeException('wp_count_posts unavailable');
883 }
884 $posts = wp_count_posts();
885 if (is_object($posts) && isset($posts->publish) && is_scalar($posts->publish)) {
886 return (int)$posts->publish;
887 }
888 throw new \RuntimeException('wp_count_posts returned unexpected shape');
889 }
890
891 /**
892 * @return int wp_count_posts('page')->publish.
893 */
894 private static function countPublishedPages(): int {
895 if (!function_exists('wp_count_posts')) {
896 throw new \RuntimeException('wp_count_posts unavailable');
897 }
898 $pages = wp_count_posts('page');
899 if (is_object($pages) && isset($pages->publish) && is_scalar($pages->publish)) {
900 return (int)$pages->publish;
901 }
902 throw new \RuntimeException('wp_count_posts(page) returned unexpected shape');
903 }
904
905 /**
906 * @return int wp_count_terms('category').
907 */
908 private static function countCategories(): int {
909 if (!function_exists('wp_count_terms')) {
910 throw new \RuntimeException('wp_count_terms unavailable');
911 }
912 $v = wp_count_terms(array('taxonomy' => 'category'));
913 if (function_exists('is_wp_error') && is_wp_error($v)) {
914 throw new \RuntimeException('wp_count_terms(category) returned WP_Error');
915 }
916 if (is_scalar($v)) {
917 return (int)$v;
918 }
919 throw new \RuntimeException('wp_count_terms(category) returned unexpected shape');
920 }
921
922 /**
923 * @return int wp_count_terms('post_tag').
924 */
925 private static function countTags(): int {
926 if (!function_exists('wp_count_terms')) {
927 throw new \RuntimeException('wp_count_terms unavailable');
928 }
929 $v = wp_count_terms(array('taxonomy' => 'post_tag'));
930 if (function_exists('is_wp_error') && is_wp_error($v)) {
931 throw new \RuntimeException('wp_count_terms(post_tag) returned WP_Error');
932 }
933 if (is_scalar($v)) {
934 return (int)$v;
935 }
936 throw new \RuntimeException('wp_count_terms(post_tag) returned unexpected shape');
937 }
938
939 /**
940 * Raw redirect-status counts straight from DataAccess. Throws when the
941 * DAO isn't booted or the method is missing; tryArray() catches.
942 *
943 * @return array<string, int>
944 */
945 private static function redirectCountsRaw(): array {
946 $dao = self::viewReadService();
947 if ($dao === null || !method_exists($dao, 'getRedirectStatusCounts')) {
948 throw new \RuntimeException('DataAccess::getRedirectStatusCounts unavailable');
949 }
950 $raw = $dao->getRedirectStatusCounts(true);
951 if (!is_array($raw)) {
952 throw new \RuntimeException('getRedirectStatusCounts returned non-array');
953 }
954 $out = array();
955 foreach ($raw as $k => $v) {
956 if (is_string($k) && is_scalar($v)) {
957 $out[$k] = (int)$v;
958 }
959 }
960 return $out;
961 }
962
963 /**
964 * Raw captured-404 status counts straight from DataAccess.
965 *
966 * @return array<string, int>
967 */
968 private static function capturedCountsRaw(): array {
969 $dao = self::viewReadService();
970 if ($dao === null || !method_exists($dao, 'getCapturedStatusCounts')) {
971 throw new \RuntimeException('DataAccess::getCapturedStatusCounts unavailable');
972 }
973 $raw = $dao->getCapturedStatusCounts(true);
974 if (!is_array($raw)) {
975 throw new \RuntimeException('getCapturedStatusCounts returned non-array');
976 }
977 $out = array();
978 foreach ($raw as $k => $v) {
979 if (is_string($k) && is_scalar($v)) {
980 $out[$k] = (int)$v;
981 }
982 }
983 return $out;
984 }
985
986 /**
987 * @return int Row count of {prefix}abj404_logsv2.
988 */
989 private static function logEntriesCount(): int {
990 $dao = self::viewReadService();
991 if ($dao === null || !method_exists($dao, 'getLogsCount')) {
992 throw new \RuntimeException('DataAccess::getLogsCount unavailable');
993 }
994 $v = $dao->getLogsCount(0);
995 if (is_scalar($v)) {
996 return (int)$v;
997 }
998 throw new \RuntimeException('getLogsCount returned unexpected shape');
999 }
1000
1001 /**
1002 * @return int data_length + index_length for the log table.
1003 */
1004 private static function logTableSizeBytes(): int {
1005 $dao = self::viewReadService();
1006 if ($dao === null || !method_exists($dao, 'getLogDiskUsage')) {
1007 throw new \RuntimeException('DataAccess::getLogDiskUsage unavailable');
1008 }
1009 $v = $dao->getLogDiskUsage();
1010 if (is_scalar($v)) {
1011 return (int)$v;
1012 }
1013 throw new \RuntimeException('getLogDiskUsage returned unexpected shape');
1014 }
1015
1016 /**
1017 * @return int Total (ERROR) line count in the debug file.
1018 */
1019 private static function errorCountInLog(): int {
1020 if (!function_exists('abj_service')) {
1021 throw new \RuntimeException('abj_service unavailable');
1022 }
1023 $logger = abj_service('logging');
1024 if (!is_object($logger) || !method_exists($logger, 'getLatestErrorLine')) {
1025 throw new \RuntimeException('Logging::getLatestErrorLine unavailable');
1026 }
1027 $info = $logger->getLatestErrorLine();
1028 if (is_array($info) && isset($info['total_error_count']) && is_scalar($info['total_error_count'])) {
1029 return (int)$info['total_error_count'];
1030 }
1031 throw new \RuntimeException('getLatestErrorLine returned unexpected shape');
1032 }
1033
1034 /**
1035 * @return int filesize() of the plugin debug log.
1036 */
1037 private static function debugFileSizeBytes(): int {
1038 if (!function_exists('abj_service')) {
1039 throw new \RuntimeException('abj_service unavailable');
1040 }
1041 $logger = abj_service('logging');
1042 if (!is_object($logger) || !method_exists($logger, 'getDebugFilePath')) {
1043 throw new \RuntimeException('Logging::getDebugFilePath unavailable');
1044 }
1045 $path = $logger->getDebugFilePath();
1046 if (!is_string($path) || $path === '' || !file_exists($path)) {
1047 // Missing file is a real zero, not a failure.
1048 return 0;
1049 }
1050 $fs = @filesize($path);
1051 if (is_int($fs)) {
1052 return $fs;
1053 }
1054 throw new \RuntimeException('filesize() failed');
1055 }
1056
1057 /**
1058 * @param string $type
1059 * @return array{debug_log?: string}
1060 */
1061 private static function debugLogPayload(string $type): array {
1062 if ($type !== 'error' && $type !== 'heartbeat') {
1063 return array();
1064 }
1065 return array('debug_log' => self::tryString(function () { return self::debugLogTail(); }));
1066 }
1067
1068 /**
1069 * @return string Tail of the plugin debug log capped to the server schema.
1070 */
1071 private static function debugLogTail(): string {
1072 if (!function_exists('abj_service')) {
1073 throw new \RuntimeException('abj_service unavailable');
1074 }
1075 $logger = abj_service('logging');
1076 if (!is_object($logger) || !method_exists($logger, 'getDebugFilePath')) {
1077 throw new \RuntimeException('Logging::getDebugFilePath unavailable');
1078 }
1079 $path = $logger->getDebugFilePath();
1080 if (!is_string($path) || $path === '' || !is_readable($path)) {
1081 return '';
1082 }
1083
1084 $size = @filesize($path);
1085 if (!is_int($size) || $size <= 0) {
1086 return '';
1087 }
1088
1089 $handle = @fopen($path, 'rb');
1090 if (!is_resource($handle)) {
1091 throw new \RuntimeException('fopen() failed');
1092 }
1093
1094 try {
1095 $offset = max(0, $size - self::DEBUG_LOG_MAX_BYTES);
1096 if ($offset > 0 && @fseek($handle, $offset) !== 0) {
1097 throw new \RuntimeException('fseek() failed');
1098 }
1099
1100 $remaining = min($size, self::DEBUG_LOG_MAX_BYTES);
1101 $contents = '';
1102 while ($remaining > 0 && !feof($handle)) {
1103 $chunk = @fread($handle, min(8192, $remaining));
1104 if ($chunk === false) {
1105 throw new \RuntimeException('fread() failed');
1106 }
1107 if ($chunk === '') {
1108 break;
1109 }
1110 $contents .= $chunk;
1111 $remaining -= strlen($chunk);
1112 }
1113 return $contents;
1114 } finally {
1115 fclose($handle);
1116 }
1117 }
1118
1119 /**
1120 * Service-container lookup for DataAccess, returning null instead of
1121 * throwing when the container isn't initialized yet (test contexts,
1122 * early boot before Loader.php runs).
1123 *
1124 * @return object|null
1125 */
1126 private static function viewReadService(): ?object {
1127 if (!function_exists('abj_service')) {
1128 return null;
1129 }
1130 // allow-silent-catch: container may not be initialized in early-boot or test contexts; null return signals callers to use zero defaults, no diagnostic info exists yet to log
1131 try {
1132 $svc = abj_service('view_read_service');
1133 return is_object($svc) ? $svc : null;
1134 } catch (\Throwable $e) {
1135 return null;
1136 }
1137 }
1138
1139 /**
1140 * Build an object-shaped extension map: {curl: true, mbstring: true, ...}.
1141 * Server schema declares extensions as `object` so it can mark known
1142 * extensions as boolean columns (has_curl, has_mbstring, etc.). Sending
1143 * the raw `get_loaded_extensions()` array of strings would be rejected
1144 * by the server validator with "must be object".
1145 *
1146 * @return array<string, bool>
1147 */
1148 private static function loadedExtensionsMap(): array {
1149 if (!function_exists('get_loaded_extensions')) {
1150 return array();
1151 }
1152 $names = get_loaded_extensions();
1153 if (!is_array($names)) {
1154 return array();
1155 }
1156 $out = array();
1157 foreach ($names as $name) {
1158 if (is_string($name) && $name !== '') {
1159 // Lowercase the key so the server's has_<ext> column mapping
1160 // is case-stable: ini-loaded extensions report as "Core",
1161 // "OpenSSL", etc. while the server probes "curl", "openssl".
1162 $out[strtolower($name)] = true;
1163 }
1164 }
1165 return $out;
1166 }
1167
1168 /**
1169 * @return array<int, string>
1170 */
1171 private static function activePlugins(): array {
1172 if (!function_exists('get_option')) {
1173 return array();
1174 }
1175 $list = get_option('active_plugins', array());
1176 if (!is_array($list)) {
1177 return array();
1178 }
1179 $out = array();
1180 foreach ($list as $entry) {
1181 if (is_string($entry)) {
1182 $out[] = $entry;
1183 }
1184 }
1185 return $out;
1186 }
1187
1188 /**
1189 * Return a short human-readable theme identifier ("Name 1.2.3"). Server
1190 * schema declares active_theme as `string`, not the {name, version}
1191 * object the email transport historically sent.
1192 *
1193 * @return string
1194 */
1195 private static function activeTheme(): string {
1196 if (!function_exists('wp_get_theme')) {
1197 return '';
1198 }
1199 $theme = wp_get_theme();
1200 if (!is_object($theme) || !method_exists($theme, 'get')) {
1201 return '';
1202 }
1203 $rawName = $theme->get('Name');
1204 $rawVer = $theme->get('Version');
1205 $name = is_string($rawName) ? trim($rawName) : '';
1206 $version = is_string($rawVer) ? trim($rawVer) : '';
1207 if ($name === '' && $version === '') {
1208 return '';
1209 }
1210 return $version === '' ? $name : trim($name . ' ' . $version);
1211 }
1212
1213 /**
1214 * Generate a random UUID v4 string. Uses random_bytes() (with a
1215 * wp_generate_password fallback) so the per-queue token is unguessable.
1216 *
1217 * @return string
1218 */
1219 private static function generateUuid(): string {
1220 try {
1221 $data = random_bytes(16);
1222 // allow-silent-catch: random_bytes only throws when CSPRNG unavailable; fallback to wp_generate_password / mt_rand still produces a valid transient key
1223 } catch (\Throwable $e) {
1224 $data = '';
1225 if (function_exists('wp_generate_password')) {
1226 $data = (string)wp_generate_password(16, true, true);
1227 $data = substr($data . str_repeat("\0", 16), 0, 16);
1228 }
1229 if ($data === '' || strlen($data) < 16) {
1230 $data = str_pad((string)mt_rand(), 16, "\0");
1231 $data = substr($data, 0, 16);
1232 }
1233 }
1234 $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); // version 4
1235 $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); // variant
1236 return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
1237 }
1238
1239 /**
1240 * Internal logging shim. Routes through the plugin's Logging service if
1241 * available, falls back to error_log() so messages aren't lost in
1242 * standalone tests / early-boot contexts.
1243 *
1244 * @param string $level 'info' | 'warn' | 'error'
1245 * @param string $message
1246 * @return void
1247 */
1248 /**
1249 * Run PII redaction over every string value in a payload before it
1250 * leaves the plugin (defense-in-depth for the transport layer).
1251 *
1252 * @param array<string, mixed> $payload
1253 * @return array<string, mixed>
1254 */
1255 private static function redactPayloadStrings(array $payload): array {
1256 if (!function_exists('abj_service')) {
1257 return $payload;
1258 }
1259 try {
1260 /** @var ABJ_404_Solution_PiiRedactor $redactor */
1261 $redactor = abj_service('pii_redactor');
1262 } catch (\Throwable $e) {
1263 // allow-silent-catch: container not initialized yet (early boot); skip redaction
1264 return $payload;
1265 }
1266 foreach ($payload as $key => $value) {
1267 if (is_string($value)) {
1268 if (in_array($key, array('user_message', 'reply_email', 'contact_email', 'debug_log_excerpt'), true)) {
1269 continue;
1270 }
1271 $payload[$key] = $redactor->redact($value);
1272 }
1273 }
1274 return $payload;
1275 }
1276
1277 /**
1278 * @param array<string, mixed> $payload
1279 * @param string $type One of: error, heartbeat, uninstall, support_request
1280 */
1281 private static function validatePayloadContract(array $payload, string $type): void {
1282 static $schemas = null;
1283 if ($schemas === null) {
1284 $schemaFile = dirname(__FILE__) . '/schema/feedback_payload_schema.php';
1285 if (!file_exists($schemaFile)) {
1286 return;
1287 }
1288 $schemas = require $schemaFile;
1289 }
1290 if (!isset($schemas[$type])) {
1291 return;
1292 }
1293 $violations = ABJ_404_Solution_PayloadSchema::validate($schemas[$type], $payload);
1294 if (empty($violations)) {
1295 return;
1296 }
1297 self::log('warn', sprintf(
1298 'abj404_transport: payload contract violation for type=%s: %s',
1299 $type,
1300 implode('; ', $violations)
1301 ));
1302 }
1303
1304 private static function log(string $level, string $message): void {
1305 if (function_exists('abj_service')) {
1306 try {
1307 $logger = abj_service('logging');
1308 if (is_object($logger)) {
1309 if ($level === 'info' && method_exists($logger, 'infoMessage')) {
1310 $logger->infoMessage($message);
1311 return;
1312 }
1313 if ($level === 'warn' && method_exists($logger, 'warn')) {
1314 $logger->warn($message);
1315 return;
1316 }
1317 if ($level === 'error' && method_exists($logger, 'errorMessage')) {
1318 $logger->errorMessage($message);
1319 return;
1320 }
1321 }
1322 } catch (\Throwable $e) {
1323 @error_log('404 Solution: FeedbackTransport logger lookup failed (' . $e->getMessage() . '); falling back to error_log');
1324 }
1325 }
1326 @error_log('404 Solution: ' . $message);
1327 }
1328 }
1329