Skip to content

Commit 45b10da

Browse files
authored
Merge pull request #1836 from rtCamp/fix/cpt-redirects
fix: Add 410 Gone redirect for CPT URLs
1 parent 4b96f3e commit 45b10da

2 files changed

Lines changed: 140 additions & 3 deletions

File tree

inc/classes/migrations/class-godam-cpt-cleanup.php

Lines changed: 139 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,113 @@ public static function register_hooks(): void {
107107
add_action( self::AS_HOOK, array( static::class, 'process_batch' ) );
108108
}
109109

110+
/**
111+
* Register the template_redirect handler that emits 410 Gone for old CPT URLs.
112+
*
113+
* Called unconditionally from Runner::init() on every request so the handler
114+
* is active on all front-end requests once the migration is complete. The
115+
* handler itself bails immediately unless OPTION_KEY === 'done'.
116+
*
117+
* Priority 1 ensures this runs before WordPress's redirect_canonical handler
118+
* (default priority 10). Without this, canonical trailing-slash redirects on
119+
* URLs like /videos would issue a 301 before our 410 ever fires.
120+
*
121+
* @return void
122+
*/
123+
public static function register_redirect_hooks(): void {
124+
add_action( 'template_redirect', array( static::class, 'maybe_send_410' ), 1 );
125+
}
126+
127+
/**
128+
* Emit HTTP 410 Gone for requests that match the discontinued godam-video CPT URL patterns.
129+
*
130+
* Runs on template_redirect at priority 1 (before redirect_canonical at 10).
131+
* Returns early unless all three conditions are met:
132+
* - WordPress has already determined the current request is a 404 (checked first
133+
* because it is a free in-memory query-var read — no DB cost).
134+
* - The cleanup migration is complete (OPTION_KEY === 'done').
135+
* - The request path, normalised relative to the home URL base path, matches
136+
* the CPT slug root or any sub-path beneath it (single posts, paginated
137+
* archives /page/N/, embeds, feeds, etc.).
138+
*
139+
* Normalising against the home path means subdirectory WordPress installs and
140+
* multisite subdirectory networks are handled correctly — the match is always
141+
* against the site-relative portion of the URL, not the server-absolute path.
142+
*
143+
* Responding with 410 Gone (instead of 404 Not Found) signals to search engines
144+
* that the removal is intentional, prompting faster de-indexing and preserving
145+
* crawl budget for the rest of the site. The theme's 404 template is loaded so
146+
* human visitors receive a helpful page rather than a blank response.
147+
*
148+
* 410 is a permanent status; a public Cache-Control header lets CDNs and reverse
149+
* proxies cache the response so repeated crawls do not hit the PHP origin.
150+
*
151+
* @return void
152+
*/
153+
public static function maybe_send_410(): void {
154+
// is_404() is a free in-memory check — run it first to avoid the
155+
// get_option() DB call on every non-404 front-end page load.
156+
if ( ! is_404() ) {
157+
return;
158+
}
159+
160+
if ( 'done' !== get_option( self::OPTION_KEY ) ) {
161+
return;
162+
}
163+
164+
$video_settings = get_option( 'rtgodam_video_post_settings', array() );
165+
$cpt_url_slug = ! empty( $video_settings['video_slug'] )
166+
? sanitize_title( $video_settings['video_slug'] )
167+
: 'videos';
168+
169+
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- path used for pattern matching only, never output.
170+
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
171+
172+
// rawurldecode() normalises percent-encoded slugs (e.g. /videos/my%20slug/)
173+
// so they match the sanitize_title()-processed value stored in the option.
174+
$path = rawurldecode( (string) wp_parse_url( $request_uri, PHP_URL_PATH ) );
175+
176+
// Strip the home-URL base path so matching is site-relative.
177+
// On a root install home_path is '/' and this is a no-op.
178+
// On a subdirectory install (e.g. example.com/wp/) the path prefix /wp
179+
// is removed before matching, so /wp/videos/slug/ correctly resolves to
180+
// /videos/slug/ and the regex fires as expected.
181+
$home_path = rtrim( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' );
182+
if ( '' !== $home_path && 0 === strpos( $path, $home_path ) ) {
183+
$path = substr( $path, strlen( $home_path ) );
184+
}
185+
$path = '/' . ltrim( $path, '/' );
186+
187+
// Match the archive root (/videos/), single-post permalinks (/videos/slug/),
188+
// and any deeper sub-paths (paginated archives /videos/page/2/, embed
189+
// endpoints /videos/slug/embed/, feed URLs /videos/slug/feed/, etc.)
190+
// that Google may have indexed from the now-deleted CPT.
191+
if ( ! preg_match( '#^/' . preg_quote( $cpt_url_slug, '#' ) . '(/.*)?$#', $path ) ) {
192+
return;
193+
}
194+
195+
status_header( 410 );
196+
// 410 Gone is a permanent status — allow CDNs and reverse proxies to cache
197+
// the response so repeated crawls do not generate PHP origin requests.
198+
header( 'Cache-Control: public, max-age=3600' );
199+
200+
// Render the theme's 404 template so human visitors receive a helpful page
201+
// rather than a blank response. The template is included after headers are
202+
// set so WordPress renders it under the already-emitted 410 status.
203+
$template = get_query_template( '404' );
204+
if ( ! empty( $template ) ) {
205+
// phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable -- template path resolved via get_query_template(), which validates against the active theme directory.
206+
include $template;
207+
exit;
208+
}
209+
210+
wp_die(
211+
esc_html__( 'This content has been permanently removed.', 'godam' ),
212+
esc_html__( 'Gone', 'godam' ),
213+
array( 'response' => 410 )
214+
);
215+
}
216+
110217
/**
111218
* Register the AS batch callback and schedule the migration if needed.
112219
*
@@ -190,7 +297,13 @@ public static function run() {
190297
// Mark as in-progress so maybe_run() does not re-trigger on the next request.
191298
update_option( self::OPTION_KEY, 'processing', false );
192299

193-
$action_id = as_enqueue_async_action( self::AS_HOOK, array(), self::AS_GROUP );
300+
// Pass the current blog ID as an AS argument so process_batch() can
301+
// switch_to_blog() before operating. On single-site this is a no-op.
302+
// On multisite with an external/system cron, AS jobs fire in the main-site
303+
// context regardless of which subsite queued them — without the blog ID the
304+
// batch would delete main-site posts and mark main-site done, never touching
305+
// the subsite that actually queued the migration.
306+
$action_id = as_enqueue_async_action( self::AS_HOOK, array( get_current_blog_id() ), self::AS_GROUP );
194307
self::release_lock();
195308

196309
if ( ! $action_id ) {
@@ -220,9 +333,25 @@ public static function run() {
220333
* Each job is independent — only one batch runs per AS execution, so PHP
221334
* memory and runtime are bounded regardless of total post count.
222335
*
336+
* The $blog_id parameter ensures correctness on multisite. Action Scheduler
337+
* fires callbacks in whatever blog context the cron request loaded (usually
338+
* the main site). Passing the queuing site's blog ID and calling
339+
* switch_to_blog() guarantees that $wpdb->posts, get_option(), and
340+
* wp_delete_post() all operate against the correct subsite's tables.
341+
*
342+
* @param int $blog_id Blog ID that queued this batch. 0 or 1 on single-site.
223343
* @return void
224344
*/
225-
public static function process_batch() {
345+
public static function process_batch( int $blog_id = 0 ) {
346+
// On multisite, switch to the blog that queued this batch so that all DB
347+
// operations ($wpdb->posts, get_option, wp_delete_post) target the correct
348+
// subsite. restore_current_blog() is called before every return path.
349+
$switched = false;
350+
if ( is_multisite() && $blog_id > 0 && get_current_blog_id() !== $blog_id ) {
351+
switch_to_blog( $blog_id );
352+
$switched = true;
353+
}
354+
226355
global $wpdb;
227356

228357
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
@@ -240,6 +369,9 @@ public static function process_batch() {
240369
self::flush_sitemap_caches();
241370
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
242371
error_log( '[GoDAM] CPT cleanup migration complete. All CPT posts deleted.' );
372+
if ( $switched ) {
373+
restore_current_blog();
374+
}
243375
return;
244376
}
245377

@@ -276,7 +408,7 @@ public static function process_batch() {
276408

277409
if ( $remaining > 0 ) {
278410
if ( function_exists( 'as_enqueue_async_action' ) ) {
279-
as_enqueue_async_action( self::AS_HOOK, array(), self::AS_GROUP );
411+
as_enqueue_async_action( self::AS_HOOK, array( $blog_id ), self::AS_GROUP );
280412
}
281413
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
282414
error_log(
@@ -291,6 +423,10 @@ public static function process_batch() {
291423
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
292424
error_log( '[GoDAM] CPT cleanup migration complete. All CPT posts deleted.' );
293425
}
426+
427+
if ( $switched ) {
428+
restore_current_blog();
429+
}
294430
}
295431

296432
// -------------------------------------------------------------------------

inc/classes/migrations/class-runner.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ class Runner {
8989
*/
9090
public static function init(): void {
9191
Godam_Cpt_Cleanup::register_hooks();
92+
Godam_Cpt_Cleanup::register_redirect_hooks();
9293

9394
// Trigger pending migrations on admin page loads after version changes.
9495
add_action( 'admin_init', array( self::class, 'maybe_run' ) );

0 commit comments

Comments
 (0)