PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.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 / core / ReviewFeedback.php

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

424 lines 16.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 * 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
120 if (isset($_GET['abj404_review_response'])) {
121 self::handleReviewQualificationResponse();
122 return;
123 }
124
125 // An invalid leaving-review nonce falls through to the feedback check,
126 // matching the original control flow.
127 if (isset($_GET['abj404_leaving_review']) && self::handleLeavingReviewClick()) {
128 return;
129 }
130
131 self::handleFeedbackSubmission();
132 }
133
134 /**
135 * Decode and apply the review qualification response (yes / not_yet /
136 * ask_later / close_x / never), then redirect. Terminates this request leg.
137 *
138 * @return void
139 */
140 private static function handleReviewQualificationResponse(): void {
141 $rawResponseNonce = isset($_GET['_wpnonce']) ? $_GET['_wpnonce'] : '';
142 $responseNonce = sanitize_text_field(ABJ_404_Solution_RequestInputNormalizer::normalizeScalar($rawResponseNonce));
143 if ($responseNonce === '' || !wp_verify_nonce($responseNonce, 'abj404_review_response')) {
144 return;
145 }
146
147 $response = sanitize_text_field(ABJ_404_Solution_RequestInputNormalizer::normalizeScalar($_GET['abj404_review_response']));
148 $allowedResponses = array('yes', 'not_yet', 'ask_later', 'close_x', 'never');
149 if (!in_array($response, $allowedResponses, true)) {
150 return;
151 }
152
153 self::applyQualificationResponse($response);
154
155 wp_safe_redirect(remove_query_arg(array('abj404_review_response', '_wpnonce')));
156 exit;
157 }
158
159 /**
160 * Persist the state transition implied by a validated qualification
161 * response. (Persistence is delegated to the review-state repository.)
162 *
163 * @param string $response one of yes|not_yet|ask_later|close_x|never
164 * @return void
165 */
166 private static function applyQualificationResponse(string $response): void {
167 $repo = self::stateRepository();
168 switch ($response) {
169 case 'yes':
170 $repo->advanceToReviewLinkStep();
171 break;
172 case 'not_yet':
173 $repo->advanceToFeedbackStep();
174 break;
175 case 'ask_later':
176 $repo->snoozeReminderUntil(abj_clock()->now() + (self::ASK_LATER_DELAY_DAYS * 86400));
177 break;
178 case 'close_x':
179 $repo->snoozeReminderUntil(abj_clock()->now() + (self::CLOSE_X_SNOOZE_DAYS * 86400));
180 break;
181 case 'never':
182 $repo->dismissPermanently();
183 break;
184 }
185 }
186
187 /**
188 * Decode the leaving-review click; on a valid nonce permanently dismiss the
189 * request, render the review-redirect script, and redirect (terminating the
190 * request). Returns false on an invalid nonce so the caller falls through to
191 * the feedback check, preserving the original control flow.
192 *
193 * @return bool true when the click was handled, false to fall through
194 */
195 private static function handleLeavingReviewClick(): bool {
196 $rawLeavingReviewNonce = isset($_GET['_wpnonce']) ? $_GET['_wpnonce'] : '';
197 $leavingReviewNonce = sanitize_text_field(ABJ_404_Solution_RequestInputNormalizer::normalizeScalar($rawLeavingReviewNonce));
198 if ($leavingReviewNonce === '' || !wp_verify_nonce($leavingReviewNonce, 'abj404_leaving_review')) {
199 return false;
200 }
201
202 self::stateRepository()->dismissPermanently();
203 self::echoReviewRedirectScript();
204 wp_safe_redirect(remove_query_arg(array('abj404_leaving_review', '_wpnonce')));
205 exit;
206 }
207
208 /**
209 * Decode and persist a feedback-form submission (valid nonce only), email
210 * it to the maintainers, and permanently dismiss the review request.
211 *
212 * @return void
213 */
214 private static function handleFeedbackSubmission(): void {
215 $rawFeedbackNonce = isset($_POST['abj404_feedback_nonce']) ? $_POST['abj404_feedback_nonce'] : '';
216 $feedbackNonce = sanitize_text_field(ABJ_404_Solution_RequestInputNormalizer::normalizeScalar($rawFeedbackNonce));
217 if (!isset($_POST['abj404_submit_feedback']) ||
218 $feedbackNonce === '' ||
219 !wp_verify_nonce($feedbackNonce, 'abj404_submit_feedback')) {
220 return;
221 }
222
223 $issuesRaw = isset($_POST['feedback_issues']) ? $_POST['feedback_issues'] : array();
224 $issues = ABJ_404_Solution_RequestInputNormalizer::sanitizeFeedbackIssues($issuesRaw);
225
226 $feedbackDetailsRaw = isset($_POST['feedback_details']) ? $_POST['feedback_details'] : '';
227 $feedback_details = sanitize_textarea_field(ABJ_404_Solution_RequestInputNormalizer::normalizeScalar($feedbackDetailsRaw));
228
229 $feedback_data = array(
230 'timestamp' => abj_clock()->wpNowMysql(),
231 'user_id' => get_current_user_id(),
232 'site_url' => get_site_url(),
233 'issues' => $issues,
234 'details' => $feedback_details,
235 'wp_version' => get_bloginfo('version'),
236 'plugin_version' => ABJ404_VERSION,
237 'php_version' => PHP_VERSION
238 );
239
240 self::stateRepository()->appendFeedbackEntry($feedback_data);
241 self::emailFeedback($feedback_data);
242 self::stateRepository()->dismissPermanently();
243
244 self::$feedbackSubmitted = true;
245 }
246
247 /**
248 * Render the client-side script that redirects to the wordpress.org review
249 * page. The markup lives in an external template; this only fills it in.
250 *
251 * @return void
252 */
253 private static function echoReviewRedirectScript(): void {
254 $html = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/reviewRedirectScript.html");
255 $f = abj_service('functions');
256 $html = $f->str_replace('{review_url}', esc_js('https://wordpress.org/support/plugin/404-solution/reviews/#new-post'), $html);
257 echo $html;
258 }
259
260 /**
261 * Display a review request notification after sustained plugin use.
262 *
263 * @return void
264 */
265 private static function maybeShowReviewRequest() {
266 if (!isset($_GET['page']) || $_GET['page'] !== ABJ404_PP) {
267 return;
268 }
269
270 if (self::$feedbackSubmitted) {
271 $html = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/feedbackSuccessNotice.html");
272 echo $html;
273 return;
274 }
275
276 $repo = self::stateRepository();
277
278 if ($repo->isPermanentlyDismissed()) {
279 return;
280 }
281
282 $remindLaterUntil = $repo->getReminderTimestamp();
283 if ($remindLaterUntil > 0 && abj_clock()->now() < $remindLaterUntil) {
284 return;
285 }
286
287 $installedTime = $repo->getInstalledTime();
288 if ($installedTime <= 0) {
289 $repo->setInstalledTime(abj_clock()->now());
290 return;
291 }
292
293 $days_installed = (abj_clock()->now() - $installedTime) / 86400;
294 if ($days_installed < self::INITIAL_DELAY_DAYS) {
295 return;
296 }
297
298 $review_step = $repo->getCurrentStep();
299
300 if ($review_step === ABJ_404_Solution_ReviewStateRepository::STEP_REVIEW_LINK) {
301 self::showReviewLinkNotice();
302 } elseif ($review_step === ABJ_404_Solution_ReviewStateRepository::STEP_FEEDBACK) {
303 self::showFeedbackFormNotice();
304 } else {
305 self::showQualificationQuestion();
306 }
307 }
308
309 /**
310 * @param array<string, mixed> $feedback_data
311 * @return void
312 */
313 private static function emailFeedback($feedback_data) {
314 $to = '404solution@ajexperience.com';
315 $subject = '404 Solution Feedback from ' . get_bloginfo('name');
316
317 $message = "New feedback received from 404 Solution plugin\n\n";
318 $message .= "Site: " . $feedback_data['site_url'] . "\n";
319 $message .= "Date: " . $feedback_data['timestamp'] . "\n";
320 $message .= "WordPress Version: " . $feedback_data['wp_version'] . "\n";
321 $message .= "Plugin Version: " . $feedback_data['plugin_version'] . "\n";
322 $message .= "PHP Version: " . $feedback_data['php_version'] . "\n\n";
323
324 $message .= "Issues Selected:\n";
325 $feedbackIssues = isset($feedback_data['issues']) && is_array($feedback_data['issues']) ? $feedback_data['issues'] : array();
326 if (!empty($feedbackIssues)) {
327 foreach ($feedbackIssues as $issue) {
328 $issueStr = is_string($issue) ? $issue : (string)$issue;
329 $message .= " - " . ucfirst(str_replace('_', ' ', $issueStr)) . "\n";
330 }
331 } else {
332 $message .= " None selected\n";
333 }
334
335 $message .= "\nAdditional Details:\n";
336 $message .= $feedback_data['details'] ? $feedback_data['details'] : "(No additional details provided)\n";
337
338 $headers = array('Content-Type: text/plain; charset=UTF-8');
339
340 wp_mail($to, $subject, $message, $headers);
341 }
342
343 /** @return void */
344 private static function showQualificationQuestion() {
345 $yes_url = wp_nonce_url(
346 add_query_arg('abj404_review_response', 'yes'),
347 'abj404_review_response'
348 );
349 $not_yet_url = wp_nonce_url(
350 add_query_arg('abj404_review_response', 'not_yet'),
351 'abj404_review_response'
352 );
353 $ask_later_url = wp_nonce_url(
354 add_query_arg('abj404_review_response', 'ask_later'),
355 'abj404_review_response'
356 );
357 $never_url = wp_nonce_url(
358 add_query_arg('abj404_review_response', 'never'),
359 'abj404_review_response'
360 );
361 $close_url = wp_nonce_url(
362 add_query_arg('abj404_review_response', 'close_x'),
363 'abj404_review_response'
364 );
365
366 $html = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/reviewQualificationQuestion.html");
367 $f = abj_service('functions');
368 $html = $f->str_replace('{yes_url}', esc_attr($yes_url), $html);
369 $html = $f->str_replace('{not_yet_url}', esc_attr($not_yet_url), $html);
370 $html = $f->str_replace('{ask_later_url}', esc_attr($ask_later_url), $html);
371 $html = $f->str_replace('{never_url}', esc_attr($never_url), $html);
372 $html = $f->str_replace('{close_url}', esc_attr($close_url), $html);
373 echo $html;
374 }
375
376 /** @return void */
377 private static function showReviewLinkNotice() {
378 $review_link_url = wp_nonce_url(
379 add_query_arg('abj404_leaving_review', '1'),
380 'abj404_leaving_review'
381 );
382
383 $never_url = wp_nonce_url(
384 add_query_arg('abj404_review_response', 'never'),
385 'abj404_review_response'
386 );
387 $close_url = wp_nonce_url(
388 add_query_arg('abj404_review_response', 'close_x'),
389 'abj404_review_response'
390 );
391
392 $html = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/reviewLinkNotice.html");
393 $f = abj_service('functions');
394 $html = $f->str_replace('{review_link_url}', esc_attr($review_link_url), $html);
395 $html = $f->str_replace('{never_url}', esc_attr($never_url), $html);
396 $html = $f->str_replace('{close_url}', esc_attr($close_url), $html);
397 echo $html;
398 }
399
400 /** @return void */
401 private static function showFeedbackFormNotice() {
402 $never_url = wp_nonce_url(
403 add_query_arg('abj404_review_response', 'never'),
404 'abj404_review_response'
405 );
406 $close_url = wp_nonce_url(
407 add_query_arg('abj404_review_response', 'close_x'),
408 'abj404_review_response'
409 );
410
411 ob_start();
412 wp_nonce_field('abj404_submit_feedback', 'abj404_feedback_nonce');
413 $nonce_field = ob_get_clean();
414 if ($nonce_field === false) { $nonce_field = ''; }
415
416 $html = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/feedbackFormNotice.html");
417 $f = abj_service('functions');
418 $html = $f->str_replace('{nonce_field}', $nonce_field, $html);
419 $html = $f->str_replace('{never_url}', esc_attr($never_url), $html);
420 $html = $f->str_replace('{close_url}', esc_attr($close_url), $html);
421 echo $html;
422 }
423 }
424