| 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 |
|