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 / frontend / SystemPage.php

SystemPage.php in 404 Solution trunk, at includes/frontend/SystemPage.php

467 lines 15.9 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 * Test seam: install or clear the cached singleton instance without
27 * private-field reflection. Pass null to reset between tests; pass a
28 * configured instance (or double) to install it. Mirrors the setInstance()
29 * contract on DataAccess / PluginLogic (M105 singleton-reset seam).
30 *
31 * @param self|null $instance
32 * @return void
33 */
34 public static function setInstance($instance) {
35 self::$instance = $instance;
36 }
37
38
39 /** @return self */
40 public static function getInstance() {
41 if (self::$instance === null) {
42 self::$instance = new self();
43 }
44 return self::$instance;
45 }
46
47 /**
48 * Find the existing system page ID, or return 0 if none exists.
49 *
50 * @return int The page ID, or 0 if not found.
51 */
52 public function getSystemPageId(): int {
53 $pages = get_posts(array(
54 'post_type' => 'page',
55 'post_status' => array('publish', 'draft', 'private'),
56 'meta_key' => self::META_KEY,
57 'meta_value' => '1',
58 'posts_per_page' => 1,
59 'fields' => 'ids',
60 'no_found_rows' => true,
61 ));
62
63 if (!empty($pages) && is_array($pages)) {
64 return (int) $pages[0];
65 }
66
67 return 0;
68 }
69
70 /**
71 * Check whether the system page exists and is published.
72 *
73 * @return bool
74 */
75 public function systemPageExists(): bool {
76 $pageId = $this->getSystemPageId();
77 if ($pageId <= 0) {
78 return false;
79 }
80 return get_post_status($pageId) === 'publish';
81 }
82
83 /**
84 * Ensure the system page exists and is published. If the page already
85 * exists and is published, return its ID. Otherwise, republish or create it.
86 *
87 * @return int The page ID, or 0 on failure.
88 */
89 public function ensureSystemPage(): int {
90 $existingId = $this->getSystemPageId();
91
92 if ($existingId > 0 && get_post_status($existingId) === 'publish') {
93 return $existingId;
94 }
95
96 // If the page exists but is trashed/drafted, try to republish it
97 if ($existingId > 0) {
98 $result = wp_update_post(array(
99 'ID' => $existingId,
100 'post_status' => 'publish',
101 ), true);
102 if (!is_wp_error($result)) {
103 return $existingId;
104 }
105 }
106
107 return $this->createSystemPage();
108 }
109
110 /**
111 * Legacy alias for callers that still use the old read-like name.
112 *
113 * @return int The page ID, or 0 on failure.
114 */
115 public function getOrCreateSystemPage(): int {
116 return $this->ensureSystemPage();
117 }
118
119 /**
120 * Create a fresh system page with the shortcode.
121 *
122 * @return int The new page ID, or 0 on failure.
123 */
124 public function createSystemPage(): int {
125 $title = __('Page Not Found', '404-solution');
126
127 $pageId = wp_insert_post(array(
128 'post_title' => $title,
129 'post_content' => '[abj404_solution_page_suggestions]',
130 'post_status' => 'publish',
131 'post_type' => 'page',
132 'post_author' => get_current_user_id() ?: 1,
133 'comment_status' => 'closed',
134 'ping_status' => 'closed',
135 ), true);
136
137 if (is_wp_error($pageId)) {
138 return 0;
139 }
140
141 // Tag with system page meta
142 update_post_meta($pageId, self::META_KEY, '1');
143
144 // Exclude from sitemaps
145 update_post_meta($pageId, '_yoast_wpseo_meta-robots-noindex', '1');
146 update_post_meta($pageId, 'rank_math_robots', array('noindex'));
147
148 return (int) $pageId;
149 }
150
151 /**
152 * Delete the system page permanently.
153 *
154 * @return bool True if deleted, false otherwise.
155 */
156 public function deleteSystemPage(): bool {
157 $pageId = $this->getSystemPageId();
158 if ($pageId <= 0) {
159 return false;
160 }
161
162 $result = wp_delete_post($pageId, true);
163 return $result !== false && $result !== null;
164 }
165
166 /**
167 * Check if a given post ID is the system page.
168 *
169 * @param int $postId
170 * @return bool
171 */
172 public static function isSystemPage(int $postId): bool {
173 if ($postId <= 0) {
174 return false;
175 }
176 return get_post_meta($postId, self::META_KEY, true) === '1';
177 }
178
179 /**
180 * When the system page is deleted or trashed externally, flip the behavior
181 * setting to 'theme_default' and set a transient for the admin notice.
182 *
183 * @return void
184 */
185 public function handleSystemPageDeleted(): void {
186 $logic = abj_service('plugin_logic');
187 $options = abj_service('options_repository')->getOptions(true);
188
189 if (isset($options['dest404_behavior']) && $options['dest404_behavior'] === 'suggest') {
190 $options['dest404_behavior'] = 'theme_default';
191 $options['dest404page'] = '0|' . ABJ404_TYPE_404_DISPLAYED;
192 abj_service('options_repository')->updateOptions($options);
193
194 set_transient('abj404_system_page_deleted', '1', DAY_IN_SECONDS);
195 }
196 }
197
198 /**
199 * On each 404 hit, verify that the system page still exists when behavior is 'suggest'.
200 * This catches bulk deletes, DB restores, cleanup plugins, etc.
201 *
202 * @return void
203 */
204 public function verifySystemPageOnRequest(): void {
205 $logic = abj_service('plugin_logic');
206 $options = abj_service('options_repository')->getOptions(true);
207
208 if (!isset($options['dest404_behavior']) || $options['dest404_behavior'] !== 'suggest') {
209 return;
210 }
211
212 if (!$this->systemPageExists()) {
213 $this->handleSystemPageDeleted();
214 }
215 }
216
217 /**
218 * Hook: before_delete_post / wp_trash_post - detect when system page is trashed/deleted.
219 *
220 * @param int $postId
221 * @return void
222 */
223 public static function onPostDeleteOrTrash(int $postId): void {
224 if (!self::isSystemPage($postId)) {
225 return;
226 }
227
228 $instance = self::getInstance();
229 $instance->handleSystemPageDeleted();
230 }
231
232 /**
233 * Hook: admin_notices on plugin settings page - show notice if system page was deleted.
234 *
235 * @return void
236 */
237 public static function maybeShowDeletedPageNotice(): void {
238 if (get_transient('abj404_system_page_deleted') !== '1') {
239 return;
240 }
241
242 // Only show on our plugin's settings page
243 $page = ABJ_404_Solution_RequestInputNormalizer::readText($_GET, array('name' => 'page'));
244 if ($page !== ABJ404_PP) {
245 return;
246 }
247
248 $settingsUrl = admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options');
249 $recreateUrl = wp_nonce_url(
250 add_query_arg('abj404_recreate_system_page', '1', $settingsUrl),
251 'abj404_recreate_system_page'
252 );
253
254 $tpl = ABJ_404_Solution_FileSystemService::readFileContents(
255 dirname(__DIR__) . '/html/systemPageDeletedAdminNotice.html'
256 );
257 $body = esc_html__('Your 404 suggestion page was deleted.', '404-solution')
258 . ' <a href="' . esc_url($recreateUrl) . '">' . esc_html__('Recreate it', '404-solution') . '</a>'
259 . ' ' . esc_html__('or choose a different option below.', '404-solution');
260 echo strtr($tpl, ['{message_with_link}' => $body]);
261
262 delete_transient('abj404_system_page_deleted');
263 }
264
265 /**
266 * Hook: admin_init - handle the recreate system page action.
267 *
268 * @return void
269 */
270 public static function handleRecreateAction(): void {
271 if (!isset($_GET['abj404_recreate_system_page'])) {
272 return;
273 }
274
275 if (!ABJ_404_Solution_PluginAdminAccessPolicy::currentUserCanAccessPluginAdmin()) {
276 return;
277 }
278
279 if (!wp_verify_nonce(
280 ABJ_404_Solution_RequestInputNormalizer::readText($_GET, array('name' => '_wpnonce')),
281 'abj404_recreate_system_page'
282 )) {
283 return;
284 }
285
286 $instance = self::getInstance();
287 $pageId = $instance->createSystemPage();
288
289 if ($pageId > 0) {
290 $logic = abj_service('plugin_logic');
291 $options = abj_service('options_repository')->getOptions(true);
292 $options['dest404_behavior'] = 'suggest';
293 $options['dest404page'] = $pageId . '|' . ABJ404_TYPE_POST;
294 abj_service('options_repository')->updateOptions($options);
295 }
296
297 // Redirect back to settings page (without the action param)
298 $settingsUrl = admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options');
299 wp_safe_redirect($settingsUrl);
300 exit;
301 }
302
303 /**
304 * Hook: edit_form_after_title - show admin notice when editing the system page.
305 *
306 * @param \WP_Post $post
307 * @return void
308 */
309 public static function showEditorNotice($post): void {
310 if (!self::isSystemPage($post->ID)) {
311 return;
312 }
313
314 $settingsUrl = admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options');
315 $tpl = ABJ_404_Solution_FileSystemService::readFileContents(
316 dirname(__DIR__) . '/html/systemPageEditorNotice.html'
317 );
318 $body = esc_html__('This page is used by 404 Solution to display suggested pages to visitors.', '404-solution')
319 . ' <a href="' . esc_url($settingsUrl) . '">' . esc_html__('Learn more', '404-solution') . '</a>';
320 echo strtr($tpl, ['{message_with_link}' => $body]);
321 }
322
323 /**
324 * Hook: wp_robots - add noindex to the system page.
325 *
326 * @param array<string, bool|string> $robots
327 * @return array<string, bool|string>
328 */
329 public static function addNoindexToSystemPage($robots) {
330 if (!is_page()) {
331 return $robots;
332 }
333
334 $postId = get_the_ID();
335 if ($postId && self::isSystemPage($postId)) {
336 $robots['noindex'] = true;
337 }
338
339 return $robots;
340 }
341
342 /**
343 * Hook: wp_page_menu_args / wp_get_nav_menu_items - exclude system page from nav menus.
344 *
345 * @param array<string, mixed> $args
346 * @return array<string, mixed>
347 */
348 public static function excludeFromPageMenu($args) {
349 $pageId = self::getInstance()->getSystemPageId();
350 if ($pageId > 0) {
351 $existing = isset($args['exclude']) ? $args['exclude'] : '';
352 $excludeList = $existing !== '' ? $existing . ',' . $pageId : (string) $pageId;
353 $args['exclude'] = $excludeList;
354 }
355 return $args;
356 }
357
358 /**
359 * Hook: template_redirect - show admin-only banner when system page is deleted
360 * and an admin visits a 404 page.
361 *
362 * @return void
363 */
364 public static function maybeShowAdminFrontend404Banner(): void {
365 if (!is_404()) {
366 return;
367 }
368
369 if (!is_user_logged_in() || !ABJ_404_Solution_PluginAdminAccessPolicy::currentUserCanAccessPluginAdmin()) {
370 return;
371 }
372
373 // Check if behavior was 'suggest' but page was deleted
374 if (get_transient('abj404_system_page_deleted') !== '1') {
375 return;
376 }
377
378 $settingsUrl = admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options');
379
380 add_action('wp_footer', function() use ($settingsUrl) {
381 /* allow-hardcoded-color: wp_footer front-end banner; admin theme vars are not loaded on the public site */
382 $tpl = ABJ_404_Solution_FileSystemService::readFileContents(
383 dirname(__DIR__) . '/html/systemPageAdminFrontend404Banner.html'
384 );
385 echo strtr($tpl, [
386 '{message}' => esc_html__('Your 404 Solution suggestion page was deleted. Visitors are seeing this default 404 page instead.', '404-solution'),
387 '{settings_url}' => esc_url($settingsUrl),
388 '{settings_label}' => esc_html__('Go to settings', '404-solution'),
389 ]);
390 });
391 }
392
393 /**
394 * Hook: enqueue_block_editor_assets - show notice in the block editor when editing the system page.
395 *
396 * Uses the wp.data notices store to create an info notice at the top of the block editor.
397 *
398 * @return void
399 */
400 public static function enqueueBlockEditorNotice(): void {
401 $screen = function_exists('get_current_screen') ? get_current_screen() : null;
402 if (!$screen || $screen->base !== 'post') {
403 return;
404 }
405
406 // Check if the current post is the system page.
407 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
408 $postId = isset($_GET['post']) ? (int) $_GET['post'] : 0;
409 if (!$postId || !self::isSystemPage($postId)) {
410 return;
411 }
412
413 $settingsUrl = admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options');
414 $message = esc_html__('This page is used by 404 Solution to display suggested pages to visitors.', '404-solution');
415 $linkText = esc_html__('Learn more', '404-solution');
416
417 wp_add_inline_script('wp-edit-post', sprintf(
418 'wp.domReady(function(){' .
419 'wp.data.dispatch("core/notices").createNotice("info",%s+%s,{id:"abj404-system-page-notice",isDismissible:false,' .
420 'actions:[{label:%s,url:%s}]});' .
421 '});',
422 // Encoded through ABJ_404_Solution_JsonResponseEncoder rather than
423 // wp_json_encode(): $message and $linkText are __() results, so a
424 // co-plugin hooked on `gettext` can make wp_json_encode() return
425 // false. sprintf('%s') renders false as '', and the emitted line
426 // becomes createNotice("info",+,{...}) -- a syntax error that takes
427 // the whole block editor script with it.
428 ABJ_404_Solution_JsonResponseEncoder::encode($message . ' ')->json(),
429 ABJ_404_Solution_JsonResponseEncoder::encode('')->json(),
430 ABJ_404_Solution_JsonResponseEncoder::encode($linkText)->json(),
431 ABJ_404_Solution_JsonResponseEncoder::encode($settingsUrl)->json()
432 ));
433 }
434
435 /**
436 * Register all WordPress hooks for system page management.
437 *
438 * @return void
439 */
440 public static function registerHooks(): void {
441 // Deletion protection
442 add_action('before_delete_post', array(__CLASS__, 'onPostDeleteOrTrash'));
443 add_action('wp_trash_post', array(__CLASS__, 'onPostDeleteOrTrash'));
444
445 // Admin notices
446 add_action('admin_notices', array(__CLASS__, 'maybeShowDeletedPageNotice'));
447
448 // Recreate action
449 add_action('admin_init', array(__CLASS__, 'handleRecreateAction'));
450
451 // Editor notice (classic editor)
452 add_action('edit_form_after_title', array(__CLASS__, 'showEditorNotice'));
453
454 // Editor notice (block editor / Gutenberg)
455 add_action('enqueue_block_editor_assets', array(__CLASS__, 'enqueueBlockEditorNotice'));
456
457 // Exclude from sitemaps (WordPress 5.5+ native robots)
458 add_filter('wp_robots', array(__CLASS__, 'addNoindexToSystemPage'));
459
460 // Exclude from page menus
461 add_filter('wp_page_menu_args', array(__CLASS__, 'excludeFromPageMenu'));
462
463 // Admin-only frontend 404 banner
464 add_action('template_redirect', array(__CLASS__, 'maybeShowAdminFrontend404Banner'));
465 }
466 }
467