@@ -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 // -------------------------------------------------------------------------
0 commit comments