PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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.1.19, at includes/FeedbackTransport.php

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