PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 / ajax / Ajax_SupportRequestPreview.php

Ajax_SupportRequestPreview.php in 404 Solution trunk, at includes/ajax/Ajax_SupportRequestPreview.php

225 lines 9.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 /**
8 * AJAX handler for the "Show what's in this report" expander on the
9 * support-request modal.
10 *
11 * Returns the same payload FeedbackTransport::buildPayload('support_request',
12 * [...]) would build, redacted for in-browser display:
13 *
14 * - reply_email is always blanked. Even though the field is admin-only,
15 * a coworker glancing over the admin's shoulder should not see the
16 * reply address typed into a *preview* before the report is even
17 * sent.
18 * - debug_log_excerpt is truncated to the last PREVIEW_LOG_BYTES so the
19 * modal stays small and the response fits in a normal AJAX budget.
20 * The actual send carries the full excerpt.
21 * - The handler does NOT call FeedbackTransport::sendNow() and does NOT
22 * touch the cooldown transient. It is read-only by design so users
23 * can preview the payload as many times as they want without
24 * consuming their per-5-minute send budget.
25 *
26 * Wired in WordPressHookRegistrar::registerAdminHooks() under the action
27 * name 'wp_ajax_abj404_support_request_preview'.
28 */
29 class ABJ_404_Solution_Ajax_SupportRequestPreview {
30
31 /** Nonce action used by both wp_create_nonce() and wp_verify_nonce(). */
32 const NONCE_ACTION = 'abj404_support_request_preview';
33
34 /** Maximum bytes of debug_log_excerpt returned in the preview. */
35 const PREVIEW_LOG_BYTES = 5120;
36
37 /** Hard cap on user_message length echoed back into the preview. */
38 const MAX_USER_MESSAGE_LENGTH = 2000;
39
40 /** @var array<int, string> */
41 const ALLOWED_TRIGGER_SOURCES = ABJ_404_Solution_Ajax_SupportRequest::ALLOWED_TRIGGER_SOURCES;
42
43 /** @var self|null */
44 private static $instance = null;
45 /**
46 * Test seam: install or clear the cached singleton instance without
47 * private-field reflection. Pass null to reset between tests; pass a
48 * configured instance (or double) to install it (M105 singleton-reset seam).
49 *
50 * @param self|null $instance
51 * @return void
52 */
53 public static function setInstance($instance) {
54 self::$instance = $instance;
55 }
56
57
58 /** @return self */
59 public static function getInstance(): self {
60 if (self::$instance === null) {
61 self::$instance = new self();
62 }
63 return self::$instance;
64 }
65
66 /**
67 * Register the AJAX action. Called from WordPress_Connector during admin hook setup.
68 *
69 * @return void
70 */
71 public static function init(): void {
72 $me = self::getInstance();
73 ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_abj404_support_request_preview',
74 array($me, 'handleRequest'));
75 }
76
77 /**
78 * Handle the AJAX request. Validates nonce + capability, validates the
79 * triggered_from slug, builds the payload, redacts the
80 * user-shouldn't-see-surprises bits, and returns the redacted payload.
81 *
82 * @return void
83 */
84 public function handleRequest(): void {
85 if (!ABJ_404_Solution_AjaxRequestContractValidator::requireValidCurrentRequest('ajax-support-request-preview')) {
86 return;
87 }
88
89 abj_service('ajax_security_gate')->requireAdminWithNonce(self::NONCE_ACTION);
90
91 $triggeredFromRaw = isset($_POST['triggered_from']) && is_scalar($_POST['triggered_from'])
92 ? (string)$_POST['triggered_from'] : '';
93 $triggeredFrom = sanitize_key($triggeredFromRaw);
94 if (!in_array($triggeredFrom, self::ALLOWED_TRIGGER_SOURCES, true)) {
95 wp_send_json_error(array(
96 'message' => __('Invalid support request source.', '404-solution'),
97 ), 400);
98 return; // @phpstan-ignore deadCode.unreachable
99 }
100
101 // The user_message is echoed back into the preview as-is so the
102 // user can review what they've typed. Read it exactly the way
103 // Ajax_SupportRequest does -- same normalizer, same length cap --
104 // so the text the admin approves here cannot diverge from the text
105 // that is actually sent.
106 $userMessage = ABJ_404_Solution_RequestInputNormalizer::readTextarea(
107 $_POST, array('name' => 'user_message'));
108 if (strlen($userMessage) > self::MAX_USER_MESSAGE_LENGTH) {
109 $userMessage = substr($userMessage, 0, self::MAX_USER_MESSAGE_LENGTH);
110 }
111
112 $debugLogExcerpt = self::resolveDebugLogExcerpt();
113
114 $extras = array(
115 'user_message' => $userMessage,
116 // Always redacted in preview. The admin can review the final
117 // value in the input field; we don't echo it back here.
118 'reply_email' => '',
119 'triggered_from' => $triggeredFrom,
120 'debug_log_excerpt' => self::truncateLogExcerpt($debugLogExcerpt),
121 );
122
123 $previousPreviewReadOnly = $GLOBALS['abj404_feedback_preview_readonly'] ?? null;
124 $GLOBALS['abj404_feedback_preview_readonly'] = true;
125 $buildError = null;
126 try {
127 $payload = ABJ_404_Solution_FeedbackTransport::buildPayload('support_request', $extras);
128 } catch (\Throwable $e) {
129 // allow-silent-catch: not swallowed, deferred. $e is captured into $buildError
130 // rather than embedded/logged inline because wp_send_json_error() below exits
131 // the request in production, which would skip this finally block (PHP does not
132 // run finally past an exit()/die()) and leave the global flag stuck set for the
133 // rest of the process; deferring the report until after the finally block keeps
134 // the cleanup unconditional. $buildError->getMessage() is embedded in the
135 // wp_send_json_error() call ~15 lines below, and the explicit return; immediately
136 // after that call halts execution even if a test double's wp_send_json_error()
137 // stub does not itself exit -- so the exception detail always reaches the caller.
138 $buildError = $e;
139 $payload = array();
140 } finally {
141 if ($previousPreviewReadOnly === null) {
142 unset($GLOBALS['abj404_feedback_preview_readonly']);
143 } else {
144 $GLOBALS['abj404_feedback_preview_readonly'] = $previousPreviewReadOnly;
145 }
146 }
147
148 if ($buildError !== null) {
149 // buildPayload() throws if the assembled payload fails its
150 // schema contract. This is a read-only preview the user is
151 // actively waiting on, so surface the real detail rather than
152 // letting an uncaught exception reach them as a generic error.
153 wp_send_json_error(array(
154 /* translators: %s = the underlying error message. */
155 'message' => sprintf(__('Could not prepare the preview (%s).', '404-solution'), $buildError->getMessage()),
156 ), 500);
157 return; // @phpstan-ignore deadCode.unreachable
158 }
159
160 // Defensive belt-and-braces: even if buildPayload changes later to
161 // surface PII (raw IPs, user emails), strip those keys here so the
162 // preview surface is allowlist-shaped.
163 $payload = self::redactForPreview($payload);
164
165 wp_send_json_success(array(
166 'payload' => $payload,
167 'preview_log_bytes' => self::PREVIEW_LOG_BYTES,
168 'is_truncated' => $debugLogExcerpt !== '' && strlen($debugLogExcerpt) > self::PREVIEW_LOG_BYTES,
169 ));
170 }
171
172 /**
173 * Truncate the debug log excerpt to the last PREVIEW_LOG_BYTES so the
174 * preview response stays small. We keep the *tail* (most recent log
175 * entries) because that's where the failure that motivated the
176 * support request will be. A leading "(truncated...)" marker tells
177 * the user this isn't the whole excerpt.
178 *
179 * @param string $excerpt
180 * @return string
181 */
182 private static function truncateLogExcerpt(string $excerpt): string {
183 if ($excerpt === '' || strlen($excerpt) <= self::PREVIEW_LOG_BYTES) {
184 return $excerpt;
185 }
186 $tail = substr($excerpt, -self::PREVIEW_LOG_BYTES);
187 return "(truncated for preview; full log is sent with the report)\n" . $tail;
188 }
189
190 /**
191 * Strip fields that the user should not see surprises about in the
192 * preview pane. Keys are removed (not blanked) so the modal renders a
193 * stable allowlist of fields rather than "this looks blank, what is
194 * it?". The real send still carries every field buildPayload returns.
195 *
196 * @param array<string, mixed> $payload
197 * @return array<string, mixed>
198 */
199 private static function redactForPreview(array $payload): array {
200 $surpriseKeys = array(
201 // Server-software string contains the hostname on some
202 // shared-hosting environments; not surprising to a sysadmin
203 // but the preview surface is the user-visible one.
204 'server_software',
205 );
206 foreach ($surpriseKeys as $key) {
207 unset($payload[$key]);
208 }
209 return $payload;
210 }
211
212 /**
213 * Lookup of a sanitized log excerpt. Shares one implementation with
214 * Ajax_SupportRequest so preview and send anchor on the same source of
215 * truth, including when there is nothing to show: an unreachable Logging
216 * service produces a stated reason, never a blank pane the admin has to
217 * guess at.
218 *
219 * @return string
220 */
221 private static function resolveDebugLogExcerpt(): string {
222 return ABJ_404_Solution_SupportLogExcerpt::resolve('Support request preview');
223 }
224 }
225