PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.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 / SystemPage.php

SystemPage.php in 404 Solution 4.2.0, at includes/SystemPage.php

436 lines 14.2 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 * Manages the auto-created "Page Not Found" system page used by 404 Solution
9 * when the "Suggest similar pages" behavior is selected.
10 *
11 * The system page contains the [abj404_solution_page_suggestions] shortcode and
12 * renders through the theme's own page.php template, inheriting all theme fonts,
13 * colors, and layout automatically.
14 *
15 * The page is tagged with post meta _abj404_system_page = 1 so the plugin can
16 * find it reliably.
17 */
18 class ABJ_404_Solution_SystemPage {
19
20 /** Post meta key used to identify the system page. */
21 const META_KEY = '_abj404_system_page';
22
23 /** @var self|null */
24 private static $instance = null;
25
26 /** @return self */
27 public static function getInstance() {
28 if (self::$instance === null) {
29 self::$instance = new self();
30 }
31 return self::$instance;
32 }
33
34 /**
35 * Find the existing system page ID, or return 0 if none exists.
36 *
37 * @return int The page ID, or 0 if not found.
38 */
39 public function getSystemPageId(): int {
40 $pages = get_posts(array(
41 'post_type' => 'page',
42 'post_status' => array('publish', 'draft', 'private'),
43 'meta_key' => self::META_KEY,
44 'meta_value' => '1',
45 'posts_per_page' => 1,
46 'fields' => 'ids',
47 'no_found_rows' => true,
48 ));
49
50 if (!empty($pages) && is_array($pages)) {
51 return (int) $pages[0];
52 }
53
54 return 0;
55 }
56
57 /**
58 * Check whether the system page exists and is published.
59 *
60 * @return bool
61 */
62 public function systemPageExists(): bool {
63 $pageId = $this->getSystemPageId();
64 if ($pageId <= 0) {
65 return false;
66 }
67 return get_post_status($pageId) === 'publish';
68 }
69
70 /**
71 * Get or create the system page. If the page already exists and is published,
72 * return its ID. Otherwise, create a new one.
73 *
74 * @return int The page ID, or 0 on failure.
75 */
76 public function getOrCreateSystemPage(): int {
77 $existingId = $this->getSystemPageId();
78
79 if ($existingId > 0 && get_post_status($existingId) === 'publish') {
80 return $existingId;
81 }
82
83 // If the page exists but is trashed/drafted, try to republish it
84 if ($existingId > 0) {
85 $result = wp_update_post(array(
86 'ID' => $existingId,
87 'post_status' => 'publish',
88 ), true);
89 if (!is_wp_error($result)) {
90 return $existingId;
91 }
92 }
93
94 return $this->createSystemPage();
95 }
96
97 /**
98 * Create a fresh system page with the shortcode.
99 *
100 * @return int The new page ID, or 0 on failure.
101 */
102 public function createSystemPage(): int {
103 $title = __('Page Not Found', '404-solution');
104
105 $pageId = wp_insert_post(array(
106 'post_title' => $title,
107 'post_content' => '[abj404_solution_page_suggestions]',
108 'post_status' => 'publish',
109 'post_type' => 'page',
110 'post_author' => get_current_user_id() ?: 1,
111 'comment_status' => 'closed',
112 'ping_status' => 'closed',
113 ), true);
114
115 if (is_wp_error($pageId)) {
116 return 0;
117 }
118
119 // Tag with system page meta
120 update_post_meta($pageId, self::META_KEY, '1');
121
122 // Exclude from sitemaps
123 update_post_meta($pageId, '_yoast_wpseo_meta-robots-noindex', '1');
124 update_post_meta($pageId, 'rank_math_robots', array('noindex'));
125
126 return (int) $pageId;
127 }
128
129 /**
130 * Delete the system page permanently.
131 *
132 * @return bool True if deleted, false otherwise.
133 */
134 public function deleteSystemPage(): bool {
135 $pageId = $this->getSystemPageId();
136 if ($pageId <= 0) {
137 return false;
138 }
139
140 $result = wp_delete_post($pageId, true);
141 return $result !== false && $result !== null;
142 }
143
144 /**
145 * Check if a given post ID is the system page.
146 *
147 * @param int $postId
148 * @return bool
149 */
150 public static function isSystemPage(int $postId): bool {
151 if ($postId <= 0) {
152 return false;
153 }
154 return get_post_meta($postId, self::META_KEY, true) === '1';
155 }
156
157 /**
158 * When the system page is deleted or trashed externally, flip the behavior
159 * setting to 'theme_default' and set a transient for the admin notice.
160 *
161 * @return void
162 */
163 public function handleSystemPageDeleted(): void {
164 $logic = abj_service('plugin_logic');
165 $options = $logic->getOptions(true);
166
167 if (isset($options['dest404_behavior']) && $options['dest404_behavior'] === 'suggest') {
168 $options['dest404_behavior'] = 'theme_default';
169 $options['dest404page'] = '0|' . ABJ404_TYPE_404_DISPLAYED;
170 $logic->updateOptions($options);
171
172 set_transient('abj404_system_page_deleted', '1', DAY_IN_SECONDS);
173 }
174 }
175
176 /**
177 * On each 404 hit, verify that the system page still exists when behavior is 'suggest'.
178 * This catches bulk deletes, DB restores, cleanup plugins, etc.
179 *
180 * @return void
181 */
182 public function verifySystemPageOnRequest(): void {
183 $logic = abj_service('plugin_logic');
184 $options = $logic->getOptions(true);
185
186 if (!isset($options['dest404_behavior']) || $options['dest404_behavior'] !== 'suggest') {
187 return;
188 }
189
190 if (!$this->systemPageExists()) {
191 $this->handleSystemPageDeleted();
192 }
193 }
194
195 /**
196 * Hook: before_delete_post / wp_trash_post — detect when system page is trashed/deleted.
197 *
198 * @param int $postId
199 * @return void
200 */
201 public static function onPostDeleteOrTrash(int $postId): void {
202 if (!self::isSystemPage($postId)) {
203 return;
204 }
205
206 $instance = self::getInstance();
207 $instance->handleSystemPageDeleted();
208 }
209
210 /**
211 * Hook: admin_notices on plugin settings page — show notice if system page was deleted.
212 *
213 * @return void
214 */
215 public static function maybeShowDeletedPageNotice(): void {
216 if (get_transient('abj404_system_page_deleted') !== '1') {
217 return;
218 }
219
220 // Only show on our plugin's settings page
221 $page = isset($_GET['page']) ? sanitize_text_field($_GET['page']) : '';
222 if ($page !== ABJ404_PP) {
223 return;
224 }
225
226 $settingsUrl = admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options');
227 $recreateUrl = wp_nonce_url(
228 add_query_arg('abj404_recreate_system_page', '1', $settingsUrl),
229 'abj404_recreate_system_page'
230 );
231
232 echo '<div class="notice notice-warning is-dismissible">';
233 echo '<p>';
234 echo esc_html__('Your 404 suggestion page was deleted.', '404-solution');
235 echo ' <a href="' . esc_url($recreateUrl) . '">' . esc_html__('Recreate it', '404-solution') . '</a>';
236 echo ' ' . esc_html__('or choose a different option below.', '404-solution');
237 echo '</p>';
238 echo '</div>';
239
240 delete_transient('abj404_system_page_deleted');
241 }
242
243 /**
244 * Hook: admin_init — handle the recreate system page action.
245 *
246 * @return void
247 */
248 public static function handleRecreateAction(): void {
249 if (!isset($_GET['abj404_recreate_system_page'])) {
250 return;
251 }
252
253 if (!current_user_can('manage_options')) {
254 return;
255 }
256
257 if (!wp_verify_nonce(
258 isset($_GET['_wpnonce']) ? sanitize_text_field($_GET['_wpnonce']) : '',
259 'abj404_recreate_system_page'
260 )) {
261 return;
262 }
263
264 $instance = self::getInstance();
265 $pageId = $instance->createSystemPage();
266
267 if ($pageId > 0) {
268 $logic = abj_service('plugin_logic');
269 $options = $logic->getOptions(true);
270 $options['dest404_behavior'] = 'suggest';
271 $options['dest404page'] = $pageId . '|' . ABJ404_TYPE_POST;
272 $logic->updateOptions($options);
273 }
274
275 // Redirect back to settings page (without the action param)
276 $settingsUrl = admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options');
277 wp_safe_redirect($settingsUrl);
278 exit;
279 }
280
281 /**
282 * Hook: edit_form_after_title — show admin notice when editing the system page.
283 *
284 * @param \WP_Post $post
285 * @return void
286 */
287 public static function showEditorNotice($post): void {
288 if (!self::isSystemPage($post->ID)) {
289 return;
290 }
291
292 $settingsUrl = admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options');
293 echo '<div class="notice notice-info inline" style="margin: 12px 0;">';
294 echo '<p>';
295 echo esc_html__('This page is used by 404 Solution to display suggested pages to visitors.', '404-solution');
296 echo ' <a href="' . esc_url($settingsUrl) . '">' . esc_html__('Learn more', '404-solution') . '</a>';
297 echo '</p>';
298 echo '</div>';
299 }
300
301 /**
302 * Hook: wp_robots — add noindex to the system page.
303 *
304 * @param array<string, bool|string> $robots
305 * @return array<string, bool|string>
306 */
307 public static function addNoindexToSystemPage($robots) {
308 if (!is_page()) {
309 return $robots;
310 }
311
312 $postId = get_the_ID();
313 if ($postId && self::isSystemPage($postId)) {
314 $robots['noindex'] = true;
315 }
316
317 return $robots;
318 }
319
320 /**
321 * Hook: wp_page_menu_args / wp_get_nav_menu_items — exclude system page from nav menus.
322 *
323 * @param array<string, mixed> $args
324 * @return array<string, mixed>
325 */
326 public static function excludeFromPageMenu($args) {
327 $pageId = self::getInstance()->getSystemPageId();
328 if ($pageId > 0) {
329 $existing = isset($args['exclude']) ? $args['exclude'] : '';
330 $excludeList = $existing !== '' ? $existing . ',' . $pageId : (string) $pageId;
331 $args['exclude'] = $excludeList;
332 }
333 return $args;
334 }
335
336 /**
337 * Hook: template_redirect — show admin-only banner when system page is deleted
338 * and an admin visits a 404 page.
339 *
340 * @return void
341 */
342 public static function maybeShowAdminFrontend404Banner(): void {
343 if (!is_404()) {
344 return;
345 }
346
347 if (!is_user_logged_in() || !current_user_can('manage_options')) {
348 return;
349 }
350
351 // Check if behavior was 'suggest' but page was deleted
352 if (get_transient('abj404_system_page_deleted') !== '1') {
353 return;
354 }
355
356 $settingsUrl = admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options');
357
358 add_action('wp_footer', function() use ($settingsUrl) {
359 echo '<div style="position:fixed;top:0;left:0;right:0;z-index:999999;background:#fff3cd;border-bottom:2px solid #ffc107;padding:12px 20px;font-family:-apple-system,BlinkMacSystemFont,sans-serif;font-size:14px;">';
360 echo esc_html__('Your 404 Solution suggestion page was deleted. Visitors are seeing this default 404 page instead.', '404-solution');
361 echo ' <a href="' . esc_url($settingsUrl) . '" style="color:#0073aa;text-decoration:underline;">';
362 echo esc_html__('Go to settings', '404-solution');
363 echo '</a>';
364 echo '</div>';
365 });
366 }
367
368 /**
369 * Hook: enqueue_block_editor_assets — show notice in the block editor when editing the system page.
370 *
371 * Uses the wp.data notices store to create an info notice at the top of the block editor.
372 *
373 * @return void
374 */
375 public static function enqueueBlockEditorNotice(): void {
376 $screen = function_exists('get_current_screen') ? get_current_screen() : null;
377 if (!$screen || $screen->base !== 'post') {
378 return;
379 }
380
381 // Check if the current post is the system page.
382 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
383 $postId = isset($_GET['post']) ? (int) $_GET['post'] : 0;
384 if (!$postId || !self::isSystemPage($postId)) {
385 return;
386 }
387
388 $settingsUrl = admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options');
389 $message = esc_html__('This page is used by 404 Solution to display suggested pages to visitors.', '404-solution');
390 $linkText = esc_html__('Learn more', '404-solution');
391
392 wp_add_inline_script('wp-edit-post', sprintf(
393 'wp.domReady(function(){' .
394 'wp.data.dispatch("core/notices").createNotice("info",%s+%s,{id:"abj404-system-page-notice",isDismissible:false,' .
395 'actions:[{label:%s,url:%s}]});' .
396 '});',
397 wp_json_encode($message . ' '),
398 wp_json_encode(''),
399 wp_json_encode($linkText),
400 wp_json_encode($settingsUrl)
401 ));
402 }
403
404 /**
405 * Register all WordPress hooks for system page management.
406 *
407 * @return void
408 */
409 public static function registerHooks(): void {
410 // Deletion protection
411 add_action('before_delete_post', array(__CLASS__, 'onPostDeleteOrTrash'));
412 add_action('wp_trash_post', array(__CLASS__, 'onPostDeleteOrTrash'));
413
414 // Admin notices
415 add_action('admin_notices', array(__CLASS__, 'maybeShowDeletedPageNotice'));
416
417 // Recreate action
418 add_action('admin_init', array(__CLASS__, 'handleRecreateAction'));
419
420 // Editor notice (classic editor)
421 add_action('edit_form_after_title', array(__CLASS__, 'showEditorNotice'));
422
423 // Editor notice (block editor / Gutenberg)
424 add_action('enqueue_block_editor_assets', array(__CLASS__, 'enqueueBlockEditorNotice'));
425
426 // Exclude from sitemaps (WordPress 5.5+ native robots)
427 add_filter('wp_robots', array(__CLASS__, 'addNoindexToSystemPage'));
428
429 // Exclude from page menus
430 add_filter('wp_page_menu_args', array(__CLASS__, 'excludeFromPageMenu'));
431
432 // Admin-only frontend 404 banner
433 add_action('template_redirect', array(__CLASS__, 'maybeShowAdminFrontend404Banner'));
434 }
435 }
436