dbh instanceof mysqli)) { throw new RuntimeException('reviewed_database_provider_required'); } foreach (array('DB_HOST','DB_NAME','DB_USER','DB_PASSWORD') as $constant) { if (!defined($constant) || !is_string(constant($constant))) { throw new RuntimeException('database_configuration_unavailable'); } } if ($wpdb->dbhost!==DB_HOST || $wpdb->dbname!==DB_NAME || $wpdb->dbuser!==DB_USER || $wpdb->dbpassword!==DB_PASSWORD) { throw new RuntimeException('reviewed_database_provider_required'); } $this->wpdb=$wpdb; $this->blog=get_current_blog_id(); $this->wpPrefix=$wpdb->prefix; $this->basePrefix=$wpdb->base_prefix; if (!is_string($this->basePrefix) || !preg_match('/^[a-zA-Z0-9_]{1,24}$/D',$this->basePrefix)) { throw new RuntimeException('storage_prefix_unsupported'); } $this->tables=$wpdb->prefix.'og_owned_'; if (!preg_match('/^[a-zA-Z0-9_]{1,32}$/D',$this->tables)) { throw new RuntimeException('storage_prefix_unsupported'); } if (strpos(DB_HOST,'p:')===0) { throw new RuntimeException('persistent_connection_provider_required'); } $parts=$wpdb->parse_db_host(DB_HOST); if (!is_array($parts) || count($parts)!==4 || $parts[0]==='' || strpos($parts[0],'p:')===0) { throw new RuntimeException('database_host_unsupported'); } [$host,$port,$socket,$ipv6]=$parts; if ($ipv6 && extension_loaded('mysqlnd')) { $host='['.$host.']'; } $port=$port ?? (int)ini_get('mysqli.default_port'); if ($port===0) { $port=3306; } $socket=$socket ?? ''; $flags=defined('MYSQL_CLIENT_FLAGS') ? MYSQL_CLIENT_FLAGS : 0; if (!is_int($flags) || ($flags & ~(MYSQLI_CLIENT_SSL | MYSQLI_CLIENT_SSL_DONT_VERIFY_SERVER_CERT))!==0) { throw new RuntimeException('reviewed_transport_provider_required'); } $identity=$this->read('SELECT DATABASE() AS database_name'); if (count($identity)!==1 || $identity[0]['database_name']!==DB_NAME) { throw new RuntimeException('database_identity_mismatch'); } $tls=$this->read("SHOW SESSION STATUS LIKE 'Ssl_cipher'"); if (count($tls)!==1 || !is_string($tls[0]['Value']??null)) { throw new RuntimeException('transport_unavailable'); } $encrypted=$tls[0]['Value']!==''; if (!$encrypted && ($flags & MYSQLI_CLIENT_SSL)) { throw new RuntimeException('configured_tls_not_negotiated'); } // Same mysqli defaults and client flags as core. If core negotiated TLS // implicitly, require it explicitly on the extra connection: no downgrade. $this->profile=array('database'=>DB_NAME,'user'=>DB_USER,'password'=>DB_PASSWORD,'host'=>$host, 'port'=>$port,'socket'=>$socket,'tls'=>$encrypted ? 'wordpress' : 'plain'); if ($encrypted) { $this->profile['client_flags']=$flags | MYSQLI_CLIENT_SSL; } // Constructor validates without opening the additional connection. new OG_Privacy_Case_Store($this->profile,$this->tables); if ($existing) { $state=$this->state(); $this->install=$state['install_id']; $this->generation=$state['generation']; $this->namespace=$state['suppression_namespace']; } } /** Existing WordPress handle, no wpdb reconnect/bail loop and no extra * connection. Failure is caught by the admitted caller, never wp_die here. */ private function read(string $sql): array { try { $result=@$this->wpdb->dbh->query($sql); if (!($result instanceof mysqli_result)) { throw new RuntimeException('bootstrap_read_unavailable'); } try { return $result->fetch_all(MYSQLI_ASSOC); } finally { $result->free(); } } catch (Throwable $e) { throw new RuntimeException('bootstrap_read_unavailable'); } } private function state(): array { $rows=$this->read('SELECT c.install_id,c.generation,c.state,r.revision,a.suppression_namespace FROM `'.$this->tables.'control` c '. 'JOIN `'.$this->tables.'block_revision` r ON r.singleton=c.singleton JOIN `'.$this->tables.'subject_authority_state` a ON a.singleton=c.singleton AND a.install_id=c.install_id AND a.generation=c.generation WHERE c.singleton=1 LIMIT 2'); if (count($rows)!==1 || $rows[0]['state']!=='open') { throw new RuntimeException('storage_not_admitted'); } foreach (array('install_id','generation','revision','suppression_namespace') as $key) { if (!is_string($rows[0][$key]??null) || !preg_match('/^[a-f0-9]{32}$/D',$rows[0][$key])) { throw new RuntimeException('storage_not_admitted'); } } return $rows[0]; } private function assertSite(): void { global $wpdb; if (!OG_Checkout_Database_Provider::accepts($wpdb)) { throw new RuntimeException('reviewed_database_provider_required'); } if ($wpdb!==$this->wpdb || get_current_blog_id()!==$this->blog || $wpdb->prefix!==$this->wpPrefix || $wpdb->base_prefix!==$this->basePrefix) { throw new RuntimeException('wordpress_site_changed'); } $identity=$this->read('SELECT DATABASE() AS database_name'); if (count($identity)!==1 || $identity[0]['database_name']!==$this->profile['database']) { throw new RuntimeException('database_identity_mismatch'); } } private static function baseSuffixes(): array { return array('control','operations','receipts','flags','block_revision','block_backup', 'privacy_cases','privacy_case_links','privacy_case_keys','privacy_namespaces', 'transport_operations','transport_subjects','transport_generations','transport_disposals', 'subject_authority_state','subject_authority'); } /** Case-insensitive inventory for lower_case_table_names=1 hosts. */ private function existingOwnedTables(): array { $names=array_map(function($suffix){return $this->tables.$suffix;},OG_Privacy_Case_Store::nativeLifecycleTableSuffixes()); $quoted=array_map(function($name){return "'".$this->wpdb->dbh->real_escape_string($name)."'";},$names); $rows=$this->read('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('.implode(',',$quoted).')'); $found=array();foreach($rows as $row){if(!is_string($row['TABLE_NAME']??null)){throw new RuntimeException('storage_inventory_unavailable');}$found[strtolower($row['TABLE_NAME'])]=true;} return $found; } private function groupState(array $suffixes,array $found): string { $present=0;foreach($suffixes as $suffix){if(isset($found[strtolower($this->tables.$suffix)])){$present++;}} return $present===0?'missing':($present===count($suffixes)?'ready':'partial'); } private function lifecycleContext(): array { $state=$this->state(); return array('install'=>$state['install_id'],'generation'=>$state['generation'],'suppression_namespace'=>$state['suppression_namespace']); } /** Same database-wide mutex used by the legacy erasure workers and * uninstall.php. Holding it across the final inventory and publication * prevents a legacy worker from being accepted in that gap. */ private function acquireLegacyLifecycleLock(): bool { $name='orderguard_gdpr_'.substr(hash('sha256',(string)$this->wpdb->dbhost.'|'.(string)$this->wpdb->dbname),0,40); $wpdb=$this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong $claimed=$wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 0)',$name)); return (string)$claimed==='1' && $this->wpdb->last_error===''; } private function releaseLegacyLifecycleLock(): void { $name='orderguard_gdpr_'.substr(hash('sha256',(string)$this->wpdb->dbhost.'|'.(string)$this->wpdb->dbname),0,40); $wpdb=$this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)',$name)); } /** Install or prepare every owned table. Upgrade deliberately stays * PREPARED; only the later composition cutover may activate it. */ private function installLifecycle(string $mode): array { $this->assertSite();$found=$this->existingOwnedTables();$base=self::baseSuffixes();$baseState=$this->groupState($base,$found); if($baseState==='partial'){return array('status'=>'unavailable','reason'=>'partial_owned_schema','dispatch_enabled'=>false);} if($mode==='fresh' && $baseState==='ready' && !isset($found[strtolower($this->tables.'native_lifecycle_v1')])){ return array('status'=>'unavailable','reason'=>'existing_composition_refused','dispatch_enabled'=>false); } $store=new OG_Privacy_Case_Store($this->profile,$this->tables); if($baseState==='missing'){ $created=$store->installComposition(bin2hex(random_bytes(16)),bin2hex(random_bytes(16)),bin2hex(random_bytes(16)),$mode==='fresh'?$this->wpPrefix:null,$mode==='fresh'?$this->basePrefix:null); if(($created['status']??null)!=='installed'){return $created;} } $context=$this->lifecycleContext(); $prepared=$store->prepareNativeLifecycle($context,$mode); if(($prepared['status']??null)!=='native_storage_prepared'){return $prepared;} $groups=array( array(array('historical_fences'),'installHistoricalSchema',false), array(array('native_erasure_bindings'),'installNativeFenceSchema',false), array(array('native_erasure_attention','native_attention_authority'),'installNativeAttentionSchema',true), array(array('native_scope_candidates'),'installNativeScopeSchema',false), array(array('native_scope_pages'),'installNativePageSchema',false), array(array('native_order_fences'),'installNativeOrderFenceSchema',false), array(array('native_remote_operations_v4'),'installNativeRemoteSchema',false), array(array('native_remote_outcomes_v4'),'installNativeOutcomeSchema',false), array(array('native_receipt_acks_v1'),'installNativeAckSchema',false), array(array('native_core_journal_v1'),'installNativeCoreSchema',false), array(array('native_send_attempts_v4'),'installNativeAttemptSchema',false), array(array('native_scheduled_attempts_v1'),'installNativeScheduleSchema',false), array(array('native_retry_resumes_v1','native_clock_observations_v1'),'installNativeResumeSchema',false), array(array('native_local_cleanup_v1'),'installNativeLocalSchema',false), array(array('native_local_flag_manifests_v1'),'installNativeLocalFlagsSchema',false), array(array('native_flag_projection_v1','native_block_touches_v1'),'installNativeFlagProjectionSchema',false), array(array('native_takeover_progress_v1'),'installNativeTakeoverProgressSchema',false), array(array('native_legal_epoch_v1','native_legal_results_v1','native_legal_holds_v1','native_legal_releases_v1'),'installNativeLegalCache',true), array(array('native_restart_requests_v1','native_operation_archive_v1','native_operation_audit_v1','native_operation_attention_v1'),'installNativeRestartSchema',false), ); foreach($groups as $group){ $found=$this->existingOwnedTables();$state=$this->groupState($group[0],$found); if($state==='partial'){return array('status'=>'unavailable','reason'=>'partial_owned_schema','group'=>$group[1],'dispatch_enabled'=>false);} if($state==='ready'){continue;} $result=$group[2]?$store->{$group[1]}($context):$store->{$group[1]}(); if(in_array(($result['status']??null),array('unavailable','unknown'),true)){return array_merge($result,array('group'=>$group[1],'dispatch_enabled'=>false));} if($this->groupState($group[0],$this->existingOwnedTables())!=='ready'){return array('status'=>'unavailable','reason'=>'installation_unconfirmed','group'=>$group[1],'dispatch_enabled'=>false);} } if($mode==='fresh'){ // Re-prove the absence of legacy data under the lifecycle mutex. // Setup may have stopped after PREPARED while old code kept writing. if(!$this->acquireLegacyLifecycleLock()){ return array('status'=>'unavailable','reason'=>'legacy_lifecycle_busy','dispatch_enabled'=>false); } try {$active=$store->activateFreshNativeLifecycle($context,$this->wpPrefix,$this->basePrefix);} finally {$this->releaseLegacyLifecycleLock();} if(($active['status']??null)!=='native_storage_active'){return $active;} return array('status'=>'installed','lifecycle'=>'active','authority_ready'=>false,'dispatch_enabled'=>false); } if(($prepared['state']??null)==='active'){ return array('status'=>'active','lifecycle'=>'active','authority_ready'=>false,'dispatch_enabled'=>false); } return array('status'=>'prepared','lifecycle'=>'prepared','authority_ready'=>false,'dispatch_enabled'=>false); } /** Internal fresh-install route; the controller must authorise its caller. */ public function installFresh(): array {return $this->installLifecycle('fresh');} /** Existing stores prepare storage without switching away from legacy writers. */ public function prepareUpgrade(): array {return $this->installLifecycle('upgrade');} public function context(): array { $this->assertSite(); $state=$this->state(); if ($state['install_id']!==$this->install || $state['generation']!==$this->generation || $state['suppression_namespace']!==$this->namespace) { throw new RuntimeException('installation_changed'); } // A stale wpdb snapshot can at most supply a stale expected revision. // The private writer rechecks under its lock; this is NOT checkout authority. return array('writer'=>new OG_Privacy_Case_Store($this->profile,$this->tables), 'blog_id'=>$this->blog,'install'=>$this->install,'generation'=>$this->generation,'revision'=>$state['revision'],'suppression_namespace'=>$state['suppression_namespace']); } /** Derived on demand, never included in the serializable context or saved * as an option. Site/installation are rechecked before configuration use. */ public function coreJournalSecret(): string { return OG_Native_Core_Secret::fromWordPress($this->context()); } /** Diagnostic context only. Construct with existing=false; no customer-data * admission is granted. Private attention entry rechecks under its lock. */ public function attentionContext(): array { $this->assertSite(); $rows=$this->read('SELECT c.install_id,c.generation,c.state,a.namespace,a.accepting FROM `'.$this->tables.'control` c JOIN `'.$this->tables.'native_attention_authority` a ON a.singleton=c.singleton AND a.install_id=c.install_id WHERE c.singleton=1 LIMIT 2'); if(count($rows)!==1 || !in_array($rows[0]['state'],array('open','closed'),true) || (string)$rows[0]['accepting']!=='1'){throw new RuntimeException('attention_not_admitted');} foreach(array('install_id','generation','namespace') as $key){if(!is_string($rows[0][$key]) || !preg_match('/^[a-f0-9]{32}$/D',$rows[0][$key])){throw new RuntimeException('attention_not_admitted');}} return array('writer'=>new OG_Privacy_Case_Store($this->profile,$this->tables),'install'=>$rows[0]['install_id'],'generation'=>$rows[0]['generation'],'suppression_namespace'=>$rows[0]['namespace']); } /** Sanitised admission result. No credentials or driver messages escape. */ public static function admit($wpdb): array { try { return array('status'=>'admitted','context'=>new self($wpdb)); } catch (Throwable $e) { return array('status'=>'unavailable','reason'=>'owned_storage_requires_attention'); } } /** Explicit registration by the future composition root; no write or repair. * The notice re-probes each render, so it survives a later request without * storing diagnostics or treating an option/cache as authority. */ public static function registerNotices(): void { static $registered=false; if ($registered) { return; } $registered=true; $notice=function() { if (!current_user_can('manage_woocommerce') && !current_user_can('manage_network_options')) { return; } global $wpdb; if (self::admit($wpdb)['status']==='admitted') { return; } echo '

'.esc_html(__('OrderGuard storage needs attention. Sharing is not ready. No data has been removed.','orderguard')).'

'; }; add_action('admin_notices',$notice); add_action('network_admin_notices',$notice); } }