1,'time'=>time(),'user'=>get_current_user_id(),'outcome'=>$outcome,'reason'=>$reason); $rows=array_slice($rows,-20); update_option(self::AUDIT_OPTION,$rows,false); } private static function runningLegacyActions($wpdb): ?bool { $actions=$wpdb->prefix.'actionscheduler_actions';$groups=$wpdb->prefix.'actionscheduler_groups'; $wpdb->last_error=''; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- reads WooCommerce Action Scheduler's tables directly to prove legacy jobs are drained; no API gives an exact count; must see the current rows; caching would be wrong $found=$wpdb->get_col($wpdb->prepare('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (%s,%s)',$actions,$groups)); if($wpdb->last_error!=='' || !is_array($found) || count($found)===1){return null;} if(!$found){return false;} $like1=$wpdb->esc_like('orderguard_').'%';$like2=$wpdb->esc_like('og_').'%'; // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; reads WooCommerce Action Scheduler's tables directly to prove legacy jobs are drained; no API gives an exact count; must see the current rows; caching would be wrong $running=$wpdb->get_var($wpdb->prepare("SELECT a.action_id FROM `{$actions}` a LEFT JOIN `{$groups}` g ON g.group_id=a.group_id WHERE a.status='in-progress' AND (g.slug IN (%s,%s) OR a.hook LIKE %s OR a.hook LIKE %s) LIMIT 1",'orderguard','orderguard-privacy-review',$like1,$like2)); return $wpdb->last_error===''?$running!==null:null; } private static function proveDrained(): array { global $wpdb; if(!function_exists('orderguard_pending_gdpr_erasures')){return array(false,'legacy_erasure_inventory_unavailable');} $ok=false;$pending=orderguard_pending_gdpr_erasures(true,$ok); if(!$ok || !is_array($pending)){return array(false,'legacy_erasure_inventory_unavailable');} if($pending){return array(false,'legacy_erasure_pending');} $fault=get_option('orderguard_gdpr_erasure_protocol_fault',false); if($fault!==false && $fault!==array() && $fault!==''){return array(false,'legacy_erasure_protocol_fault');} $running=self::runningLegacyActions($wpdb); if($running===null){return array(false,'legacy_scheduler_inventory_unavailable');} if($running){return array(false,'legacy_worker_still_running');} return array(true,'legacy_writers_drained'); } public static function request($nonce,$confirmation): array { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- request method is only compared with the fixed string 'POST' if(($_SERVER['REQUEST_METHOD']??'')!=='POST' || !is_user_logged_in() || !current_user_can('activate_plugins') || !current_user_can('manage_options') || !is_string($nonce) || !wp_verify_nonce($nonce,'orderguard_owned_cutover') || $confirmation!=='workers_stopped'){ self::audit('refused','authorisation_or_confirmation');return array('status'=>'forbidden'); } if(OG_Native_Legacy_Gate::state()!=='prepared'){ self::audit('refused','lifecycle_not_prepared');return array('status'=>'unavailable','reason'=>'lifecycle_not_prepared'); } $lockReason=''; if(!function_exists('orderguard_acquire_gdpr_erasure_lifecycle_lock') || !orderguard_acquire_gdpr_erasure_lifecycle_lock($lockReason)){ self::audit('refused',$lockReason?:'legacy_lifecycle_busy');return array('status'=>'unavailable','reason'=>$lockReason?:'legacy_lifecycle_busy'); } try { if(OG_Native_Legacy_Gate::state()!=='prepared'){$result=array('status'=>'unavailable','reason'=>'lifecycle_changed');} else { [$drained,$reason]=self::proveDrained(); if(!$drained){$result=array('status'=>'unavailable','reason'=>$reason);} else { $readiness=OG_OrderGuard_Package_Bootstrap::nativeReadiness(true); if(!$readiness['ready']){$result=array('status'=>'unavailable','reason'=>$readiness['reason']);} else { $context=(new OG_Owned_WordPress_Context($GLOBALS['wpdb'],true))->context(); $result=$context['writer']->activateNativeLifecycle(array('install'=>$context['install'],'generation'=>$context['generation'],'suppression_namespace'=>$context['suppression_namespace']),true); } } } }catch(Throwable $e){$result=array('status'=>'unavailable','reason'=>'cutover_unconfirmed');} finally {orderguard_release_gdpr_erasure_lifecycle_lock();} $accepted=($result['status']??null)==='native_storage_active'; self::audit($accepted?'activated':'refused',$accepted?'native_storage_active':($result['reason']??'cutover_unconfirmed')); if($accepted){OG_OrderGuard_Package_Bootstrap::attachNativeEraser();} return $result; } public static function handle(): void { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- request() verifies POST, login, capabilities, wp_verify_nonce() and the exact worker-stop confirmation $result=self::request(isset($_POST['_wpnonce'])?sanitize_text_field(wp_unslash($_POST['_wpnonce'])):'',isset($_POST['workers_stopped'])?sanitize_text_field(wp_unslash($_POST['workers_stopped'])):''); $code=($result['status']??null)==='native_storage_active'?'activated':($result['reason']??$result['status']??'unavailable'); wp_safe_redirect(add_query_arg('orderguard_cutover',rawurlencode($code),admin_url('tools.php?page=orderguard-native-cutover')));exit; } /** Fixed text per result code. Unknown values never reach the page. */ public static function notice(string $code): string { switch($code){ case 'activated': return __('The new erasure engine is active.','orderguard'); case 'forbidden': return __('The switch was refused. Confirm the stop procedure and try again from this page.','orderguard'); case 'lifecycle_not_prepared': case 'lifecycle_changed': return __('This store is not ready for the switch. Reload this page to see its current state.','orderguard'); case 'legacy_lifecycle_busy': case 'legacy_worker_still_running': case 'legacy_scheduler_inventory_unavailable': return __('Old erasure workers are still running or could not be checked. Stop them using the procedure, then try again.','orderguard'); default: return __('The switch did not complete. Check the requirements above and try again, or contact OrderGuard support.','orderguard'); } } public static function page(): void { if(!current_user_can('manage_options')){wp_die(esc_html__('You do not have permission to access this page.','orderguard'));} echo '

'.esc_html__('OrderGuard erasure takeover','orderguard').'

'; echo '

'.esc_html__('Existing stores remain on the old erasure workers until this supervised switch is completed. Stop background PHP workers and scheduled jobs using the qualified procedure, then restart only the web PHP service needed for this administrator page.','orderguard').'

'; $procedure=plugins_url('docs/stop-workers.md',ORDERGUARD_PLUGIN_FILE); echo '

'.esc_html__('Open the stop-workers procedure','orderguard').'

'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only result code from our own redirect; mapped to fixed text, never echoed. if(isset($_GET['orderguard_cutover'])){echo '

'.esc_html(self::notice(sanitize_key(wp_unslash($_GET['orderguard_cutover'])))).'

';} echo '

'.esc_html__('Current state:','orderguard').' '.esc_html(OG_Native_Erasure_Text::lifecycle(OG_Native_Legacy_Gate::state())).'

'; echo '
'; wp_nonce_field('orderguard_owned_cutover'); echo ''; submit_button(__('Switch to the new erasure engine','orderguard'),'primary','submit',true,array('onclick'=>"return confirm('".esc_js(__('This switch is permanent. Continue only after the host-specific stop procedure has been completed.','orderguard'))."');")); echo '
'; } public static function register(): void { add_action('admin_post_orderguard_native_cutover',array(self::class,'handle')); add_action('admin_menu',static function(){add_management_page(__('OrderGuard erasure takeover','orderguard'),__('OrderGuard takeover','orderguard'),'manage_options','orderguard-native-cutover',array(self::class,'page'));}); } }