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 / core / ReviewFeedback.php

ReviewFeedback.php in 404 Solution trunk, at includes/core/ReviewFeedback.php

427 lines 16.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 /**
8 * Review request and user feedback flow.
9 *
10 * Manages the multi-step review solicitation: qualification question,
11 * review link for satisfied users, feedback form for unsatisfied users,
12 * and permanent dismissal after any terminal action.
13 */
14 class ABJ_404_Solution_ReviewFeedback {
15
16 private const INITIAL_DELAY_DAYS = 30;
17 private const ASK_LATER_DELAY_DAYS = 7;
18 private const CLOSE_X_SNOOZE_DAYS = 14;
19
20 /** Set to true by handleResponseRedirects() when feedback POST is processed. */
21 private static bool $feedbackSubmitted = false;
22
23 /** @var ABJ_404_Solution_ReviewStateRepository|null Lazily-created persistence layer. */
24 private static ?ABJ_404_Solution_ReviewStateRepository $stateRepository = null;
25
26 /** Reset static state between tests. */
27 public static function resetForTests(): void {
28 self::$feedbackSubmitted = false;
29 self::$stateRepository = null;
30 }
31
32 /**
33 * Review-state persistence layer (user meta + feedback option store).
34 *
35 * The repository is stateless, so a single lazily-created instance is reused
36 * across this request. resetForTests() clears it for isolation.
37 *
38 * @return ABJ_404_Solution_ReviewStateRepository
39 */
40 private static function stateRepository(): ABJ_404_Solution_ReviewStateRepository {
41 if (self::$stateRepository === null) {
42 self::$stateRepository = new ABJ_404_Solution_ReviewStateRepository();
43 }
44 return self::$stateRepository;
45 }
46
47 /**
48 * Display an admin dashboard notification (captured-404 count + review request).
49 *
50 * @return void
51 */
52 static function echoDashboardNotification() {
53 $connector = abj_service('wordpress_connector');
54
55 if (!is_admin() || !abj_service('admin_access_policy')->isPluginAdmin()) {
56 $connector->getLogger()->logUserCapabilities("echoDashboardNotification");
57 return;
58 }
59
60 ABJ_404_Solution_AdminRuntimeErrorNotice::echoAdminRuntimeErrorNotice();
61
62 global $pagenow;
63 global $abj404view;
64
65 $isPluginPage = array_key_exists('page', $_GET) && $_GET['page'] == ABJ404_PP;
66 $isDashboard = $pagenow == 'index.php' && !isset($_GET['page']);
67
68 if ($isPluginPage) {
69 $dbNotice = get_transient('abj404_plugin_db_notice');
70 if (is_array($dbNotice)) {
71 $type = isset($dbNotice['type']) && is_string($dbNotice['type']) ? $dbNotice['type'] : 'warning';
72 // Per owner directive: collation issues must NEVER surface as user notices.
73 if ($type === 'collation') {
74 // intentionally do not render
75 } else {
76 $message = isset($dbNotice['message']) && is_string($dbNotice['message']) ? $dbNotice['message'] : '';
77 if ($message !== '') {
78 $warningTypes = array(
79 'stale_permalink_cache',
80 'warning',
81 'read_only',
82 'disk_full',
83 'query_quota',
84 );
85 $cssClass = in_array($type, $warningTypes, true) ? 'notice-warning' : 'notice-error';
86 $html = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/notice.html");
87 $f = abj_service('functions');
88 $html = $f->str_replace('{class}', esc_attr('notice ' . $cssClass), $html);
89 $html = $f->str_replace('{message}', esc_html($message), $html);
90 echo $html;
91 }
92 }
93 }
94 }
95
96 if ($isPluginPage || $isDashboard) {
97 $captured404Count = $connector->getCapturedCountForNotification();
98 if ($connector->getPluginLogic()->pageOrdering()->shouldNotifyAboutCaptured404s($captured404Count)) {
99 $msg = $abj404view->getDashboardNotificationCaptured($captured404Count);
100 echo $msg;
101 }
102
103 self::maybeShowReviewRequest();
104 }
105 }
106
107 /**
108 * Handle review GET redirects and feedback POST submission on admin_init.
109 *
110 * @return void
111 */
112 static function handleResponseRedirects() {
113 if (!is_admin()) {
114 return;
115 }
116 if (!isset($_GET['page']) || $_GET['page'] !== ABJ404_PP) {
117 return;
118 }
119 if (!ABJ_404_Solution_PluginAdminAccessPolicy::currentUserCanAccessPluginAdmin()) {
120 return;
121 }
122
123 if (isset($_GET['abj404_review_response'])) {
124 self::handleReviewQualificationResponse();
125 return;
126 }
127
128 // An invalid leaving-review nonce falls through to the feedback check,
129 // matching the original control flow.
130 if (isset($_GET['abj404_leaving_review']) && self::handleLeavingReviewClick()) {
131 return;
132 }
133
134 self::handleFeedbackSubmission();
135 }
136
137 /**
138 * Decode and apply the review qualification response (yes / not_yet /
139 * ask_later / close_x / never), then redirect. Terminates this request leg.
140 *
141 * @return void
142 */
143 private static function handleReviewQualificationResponse(): void {
144 $rawResponseNonce = isset($_GET['_wpnonce']) ? $_GET['_wpnonce'] : '';
145 $responseNonce = sanitize_text_field(ABJ_404_Solution_RequestInputNormalizer::normalizeScalar($rawResponseNonce));
146 if ($responseNonce === '' || !wp_verify_nonce($responseNonce, 'abj404_review_response')) {
147 return;
148 }
149
150 $response = sanitize_text_field(ABJ_404_Solution_RequestInputNormalizer::normalizeScalar($_GET['abj404_review_response']));
151 $allowedResponses = array('yes', 'not_yet', 'ask_later', 'close_x', 'never');
152 if (!in_array($response, $allowedResponses, true)) {
153 return;
154 }
155
156 self::applyQualificationResponse($response);
157
158 wp_safe_redirect(remove_query_arg(array('abj404_review_response', '_wpnonce')));
159 exit;
160 }
161
162 /**
163 * Persist the state transition implied by a validated qualification
164 * response. (Persistence is delegated to the review-state repository.)
165 *
166 * @param string $response one of yes|not_yet|ask_later|close_x|never
167 * @return void
168 */
169 private static function applyQualificationResponse(string $response): void {
170 $repo = self::stateRepository();
171 switch ($response) {
172 case 'yes':
173 $repo->advanceToReviewLinkStep();
174 break;
175 case 'not_yet':
176 $repo->advanceToFeedbackStep();
177 break;
178 case 'ask_later':
179 $repo->snoozeReminderUntil(abj_clock()->now() + (self::ASK_LATER_DELAY_DAYS * 86400));
180 break;
181 case 'close_x':
182 $repo->snoozeReminderUntil(abj_clock()->now() + (self::CLOSE_X_SNOOZE_DAYS * 86400));
183 break;
184 case 'never':
185 $repo->dismissPermanently();
186 break;
187 }
188 }
189
190 /**
191 * Decode the leaving-review click; on a valid nonce permanently dismiss the
192 * request, render the review-redirect script, and redirect (terminating the
193 * request). Returns false on an invalid nonce so the caller falls through to
194 * the feedback check, preserving the original control flow.
195 *
196 * @return bool true when the click was handled, false to fall through
197 */
198 private static function handleLeavingReviewClick(): bool {
199 $rawLeavingReviewNonce = isset($_GET['_wpnonce']) ? $_GET['_wpnonce'] : '';
200 $leavingReviewNonce = sanitize_text_field(ABJ_404_Solution_RequestInputNormalizer::normalizeScalar($rawLeavingReviewNonce));
201 if ($leavingReviewNonce === '' || !wp_verify_nonce($leavingReviewNonce, 'abj404_leaving_review')) {
202 return false;
203 }
204
205 self::stateRepository()->dismissPermanently();
206 self::echoReviewRedirectScript();
207 wp_safe_redirect(remove_query_arg(array('abj404_leaving_review', '_wpnonce')));
208 exit;
209 }
210
211 /**
212 * Decode and persist a feedback-form submission (valid nonce only), email
213 * it to the maintainers, and permanently dismiss the review request.
214 *
215 * @return void
216 */
217 private static function handleFeedbackSubmission(): void {
218 $rawFeedbackNonce = isset($_POST['abj404_feedback_nonce']) ? $_POST['abj404_feedback_nonce'] : '';
219 $feedbackNonce = sanitize_text_field(ABJ_404_Solution_RequestInputNormalizer::normalizeScalar($rawFeedbackNonce));
220 if (!isset($_POST['abj404_submit_feedback']) ||
221 $feedbackNonce === '' ||
222 !wp_verify_nonce($feedbackNonce, 'abj404_submit_feedback')) {
223 return;
224 }
225
226 $issuesRaw = isset($_POST['feedback_issues']) ? $_POST['feedback_issues'] : array();
227 $issues = ABJ_404_Solution_RequestInputNormalizer::sanitizeFeedbackIssues($issuesRaw);
228
229 $feedbackDetailsRaw = isset($_POST['feedback_details']) ? $_POST['feedback_details'] : '';
230 $feedback_details = sanitize_textarea_field(ABJ_404_Solution_RequestInputNormalizer::normalizeScalar($feedbackDetailsRaw));
231
232 $feedback_data = array(
233 'timestamp' => abj_clock()->wpNowMysql(),
234 'user_id' => get_current_user_id(),
235 'site_url' => get_site_url(),
236 'issues' => $issues,
237 'details' => $feedback_details,
238 'wp_version' => get_bloginfo('version'),
239 'plugin_version' => ABJ404_VERSION,
240 'php_version' => PHP_VERSION
241 );
242
243 self::stateRepository()->appendFeedbackEntry($feedback_data);
244 self::emailFeedback($feedback_data);
245 self::stateRepository()->dismissPermanently();
246
247 self::$feedbackSubmitted = true;
248 }
249
250 /**
251 * Render the client-side script that redirects to the wordpress.org review
252 * page. The markup lives in an external template; this only fills it in.
253 *
254 * @return void
255 */
256 private static function echoReviewRedirectScript(): void {
257 $html = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/reviewRedirectScript.html");
258 $f = abj_service('functions');
259 $html = $f->str_replace('{review_url}', esc_js('https://wordpress.org/support/plugin/404-solution/reviews/#new-post'), $html);
260 echo $html;
261 }
262
263 /**
264 * Display a review request notification after sustained plugin use.
265 *
266 * @return void
267 */
268 private static function maybeShowReviewRequest() {
269 if (!isset($_GET['page']) || $_GET['page'] !== ABJ404_PP) {
270 return;
271 }
272
273 if (self::$feedbackSubmitted) {
274 $html = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/feedbackSuccessNotice.html");
275 echo $html;
276 return;
277 }
278
279 $repo = self::stateRepository();
280
281 if ($repo->isPermanentlyDismissed()) {
282 return;
283 }
284
285 $remindLaterUntil = $repo->getReminderTimestamp();
286 if ($remindLaterUntil > 0 && abj_clock()->now() < $remindLaterUntil) {
287 return;
288 }
289
290 $installedTime = $repo->getInstalledTime();
291 if ($installedTime <= 0) {
292 $repo->setInstalledTime(abj_clock()->now());
293 return;
294 }
295
296 $days_installed = (abj_clock()->now() - $installedTime) / 86400;
297 if ($days_installed < self::INITIAL_DELAY_DAYS) {
298 return;
299 }
300
301 $review_step = $repo->getCurrentStep();
302
303 if ($review_step === ABJ_404_Solution_ReviewStateRepository::STEP_REVIEW_LINK) {
304 self::showReviewLinkNotice();
305 } elseif ($review_step === ABJ_404_Solution_ReviewStateRepository::STEP_FEEDBACK) {
306 self::showFeedbackFormNotice();
307 } else {
308 self::showQualificationQuestion();
309 }
310 }
311
312 /**
313 * @param array<string, mixed> $feedback_data
314 * @return void
315 */
316 private static function emailFeedback($feedback_data) {
317 $to = '404solution@ajexperience.com';
318 $subject = '404 Solution Feedback from ' . get_bloginfo('name');
319
320 $message = "New feedback received from 404 Solution plugin\n\n";
321 $message .= "Site: " . $feedback_data['site_url'] . "\n";
322 $message .= "Date: " . $feedback_data['timestamp'] . "\n";
323 $message .= "WordPress Version: " . $feedback_data['wp_version'] . "\n";
324 $message .= "Plugin Version: " . $feedback_data['plugin_version'] . "\n";
325 $message .= "PHP Version: " . $feedback_data['php_version'] . "\n\n";
326
327 $message .= "Issues Selected:\n";
328 $feedbackIssues = isset($feedback_data['issues']) && is_array($feedback_data['issues']) ? $feedback_data['issues'] : array();
329 if (!empty($feedbackIssues)) {
330 foreach ($feedbackIssues as $issue) {
331 $issueStr = is_string($issue) ? $issue : (string)$issue;
332 $message .= " - " . ucfirst(str_replace('_', ' ', $issueStr)) . "\n";
333 }
334 } else {
335 $message .= " None selected\n";
336 }
337
338 $message .= "\nAdditional Details:\n";
339 $message .= $feedback_data['details'] ? $feedback_data['details'] : "(No additional details provided)\n";
340
341 $headers = array('Content-Type: text/plain; charset=UTF-8');
342
343 wp_mail($to, $subject, $message, $headers);
344 }
345
346 /** @return void */
347 private static function showQualificationQuestion() {
348 $yes_url = wp_nonce_url(
349 add_query_arg('abj404_review_response', 'yes'),
350 'abj404_review_response'
351 );
352 $not_yet_url = wp_nonce_url(
353 add_query_arg('abj404_review_response', 'not_yet'),
354 'abj404_review_response'
355 );
356 $ask_later_url = wp_nonce_url(
357 add_query_arg('abj404_review_response', 'ask_later'),
358 'abj404_review_response'
359 );
360 $never_url = wp_nonce_url(
361 add_query_arg('abj404_review_response', 'never'),
362 'abj404_review_response'
363 );
364 $close_url = wp_nonce_url(
365 add_query_arg('abj404_review_response', 'close_x'),
366 'abj404_review_response'
367 );
368
369 $html = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/reviewQualificationQuestion.html");
370 $f = abj_service('functions');
371 $html = $f->str_replace('{yes_url}', esc_attr($yes_url), $html);
372 $html = $f->str_replace('{not_yet_url}', esc_attr($not_yet_url), $html);
373 $html = $f->str_replace('{ask_later_url}', esc_attr($ask_later_url), $html);
374 $html = $f->str_replace('{never_url}', esc_attr($never_url), $html);
375 $html = $f->str_replace('{close_url}', esc_attr($close_url), $html);
376 echo $html;
377 }
378
379 /** @return void */
380 private static function showReviewLinkNotice() {
381 $review_link_url = wp_nonce_url(
382 add_query_arg('abj404_leaving_review', '1'),
383 'abj404_leaving_review'
384 );
385
386 $never_url = wp_nonce_url(
387 add_query_arg('abj404_review_response', 'never'),
388 'abj404_review_response'
389 );
390 $close_url = wp_nonce_url(
391 add_query_arg('abj404_review_response', 'close_x'),
392 'abj404_review_response'
393 );
394
395 $html = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/reviewLinkNotice.html");
396 $f = abj_service('functions');
397 $html = $f->str_replace('{review_link_url}', esc_attr($review_link_url), $html);
398 $html = $f->str_replace('{never_url}', esc_attr($never_url), $html);
399 $html = $f->str_replace('{close_url}', esc_attr($close_url), $html);
400 echo $html;
401 }
402
403 /** @return void */
404 private static function showFeedbackFormNotice() {
405 $never_url = wp_nonce_url(
406 add_query_arg('abj404_review_response', 'never'),
407 'abj404_review_response'
408 );
409 $close_url = wp_nonce_url(
410 add_query_arg('abj404_review_response', 'close_x'),
411 'abj404_review_response'
412 );
413
414 ob_start();
415 wp_nonce_field('abj404_submit_feedback', 'abj404_feedback_nonce');
416 $nonce_field = ob_get_clean();
417 if ($nonce_field === false) { $nonce_field = ''; }
418
419 $html = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/feedbackFormNotice.html");
420 $f = abj_service('functions');
421 $html = $f->str_replace('{nonce_field}', $nonce_field, $html);
422 $html = $f->str_replace('{never_url}', esc_attr($never_url), $html);
423 $html = $f->str_replace('{close_url}', esc_attr($close_url), $html);
424 echo $html;
425 }
426 }
427